システム開発部のTです。
私自身、趣味でアプリを開発しながらスキルアップしようとしておりますが、Flutterにおいては自分がこれだ!と思うアーキテクチャーが固まっていない状態です。

そういった中、試行錯誤しているうちに、ようやくスッキリとした形でコーディングできたので紹介します。

個人的にAndroidアプリの開発のなかでDataBindingを用いてのMVVM(Model-View-ViewModel)が主流ですが、基本的にそれと同等の動きを、Flutterでも目指してみました。

開発準備

それでは、以下のコマンドを実行し、プロジェクトを生成してください。
(以下、helloworldにしていますが、任意のプロジェクト名にしてください。)

$ flutter create helloworld

(※Flutterの開発環境は構築済みの前提で進めていきます。)

Riverpodの追加

本件では、グローバルに利用可能なProviderライブラリとしてRiverpodを利用いたします。
pubspec.yamlのdependensiesに以下の内容を追記してください。

dependencies:
  flutter:
    sdk: flutter


  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.2
  flutter_riverpod: ^1.0.0-dev.7 # <-- これを追加

その後、Pub getで反映してください。

main.dartの整理

プロジェクト作成直後に生成されているmain.dartを見てみましょう。
(以下、不要なコメントは削除済みです)

main.dart
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}

このままだと見栄えが悪いので、classごとにファイルを分けます。
分けたあとの内容が以下になります。

main.dart
import 'package:flutter/material.dart';
import 'package:helloworld/MyApp.dart';

void main() {
  runApp(MyApp());
}
MyApp.dart
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:helloworld/MyHomePage.dart';

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}
MyHomePage.dart
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}

動作確認

上記までコード分けしたら、動作確認してみます。
以下の画面が表示されることを確認してください。

これで開発準備は完了です。

概要

本件においては、デフォルトで作成されるカウンターアップの処理をそのままMVVMに置き換えていきます。
DBやAPIのアクセスは行わないので物足りないかもしれませんが、基本の実装を見てもらうことで、ご理解いただきたいです。

また、本件でのMVVM(+Repositoryパターン)のイメージは以下になります。

ProviderScopeの実装

本件においては、RiverpodのProviderScope配下で動くことを前提としているため、main.dartにて以下の実装をお願いします。

import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:helloworld/application/MyApp.dart';

void main() {
  runApp(ProviderScope(child: MyApp())); // <--- MyAppをProviderScopeで包む
}

Modelの実装

MVVMのModelに相当するクラスを実装します。
本件ではResultModelで定義し、インクリメントしたカウンターの内容を保持するクラスになります。

import 'package:flutter_riverpod/flutter_riverpod.dart';

final resultModelProvider = Provider((ref) => ResultModel());

class ResultModel {
  int counter = 0;
}

ResultModel自体はint属性のcounterを持っているだけです。
また、本件ではDIを意識した実装をしたかったので、ProviderにResultModelのインスタンスを保持するようにしています。要するにProvider=DIコンテナの役割を持たせました。

Repositoryの実装

Modelへのアクセスを隠蔽することを意識し、Repositoryパターンでの実装になります。
こちらもDIを意識して実装します。

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:helloworld/home/ResultModel.dart';

final myHomeRepositoryProvider = Provider((ref) => MyHomeRepositoryImpl(model: ref.read(resultModelProvider)));

abstract class MyHomeRepository {
  Future<ResultModel> incrementCounter();
}

class MyHomeRepositoryImpl implements MyHomeRepository {
  MyHomeRepositoryImpl({required ResultModel model}): _model = model;

  final ResultModel _model;

  @override
  Future<ResultModel> incrementCounter() {
    _model.counter = _model.counter + 1;
    return Future.value(_model);
  }
}

Unitテストを意識して、MyHomeRepositoryの抽象クラスを用意し、実際の処理はMyHomeRepositoryImplクラスに委ねます。コンストラクタの引数にResultModelの受け口を用意し、本クラスをDIコンテナ(Provider)に保持するときに、ref.readでModelの実装時にDIコンテナに保持しているResultModelのインスタンスを引数に渡しています。

本クラスでやっていることは単純にmodelのcounterをインクリメントし、model自体を呼び出し元に返すだけのものです。本アプリではAPIおよびDBにアクセスすることがないので、上記のように実装しています。

ViewModelの実装

MVVMのViewModelの実装になります。
本件のViewModelはChangeNotifierクラスを継承して実装します。

import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:helloworld/home/MyHomeRepository.dart';

// ViewModelの格納先はChangeNotifierProviderとなる
final myHomeViewModelProvider = ChangeNotifierProvider((ref) => MyHomeViewModel(repository: ref.read(myHomeRepositoryProvider)));

// ChangeNotifierを継承することで、呼び出し元に変更を通知することが可能
class MyHomeViewModel extends ChangeNotifier {

  int _counter = 0;

  MyHomeRepository? repository;

  MyHomeViewModel({this.repository});

  int getCounter() {
    return _counter;
  }

  void incrementCounter() {
    this.repository?.incrementCounter().then((resultModel) {
      _counter = resultModel.counter;
      // 以下を実行することで、呼び出し元に変更が通知され、setStateしたときと同様に画面がリビルドされる
      notifyListeners(); 
    });
  }
}

呼び出し元のWidgetはgetCounter()からインクリメントされる値を取得し、画面に表示します。
画面右下の「+」ボタンをタップすると、incrementCounter()を実行し、インクリメントされたカウンターを保持しているResultModelを受け取り、ResultModelのcounter値を本クラスの_counterに設定されます。
その後、notifyListeners()を実行すると、呼び出し元のWidgetがリビルドされるため、再度getCounter()がコールされ、更新後の_counter値を画面に表示することになります。

MyHomePageの実装

MVVMのViewに該当する処理の実装になります。
実装内容については、実際のコードを見てもらうほうが早いので、以下のコードを参照後に説明します。

import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:helloworld/home/MyHomeViewModel.dart';

class MyHomePage extends ConsumerWidget { // StatefulWidgetからConsumerWidgetに変更

  final String title;

  MyHomePage({Key? key, required this.title}) : super(key: key);

  @override   // 実装が強要されるため、_MyHomePageStateのreturn内容をそのまま入れる
  Widget build(BuildContext context, WidgetRef ref) { 
    // ref.watchメソッドのパラメータにChangeNotifierProviderに格納したMyHomeViewModelを取得する
    // これによって、_viewModelでnotifyListeners()が実行された場合、本Widgetのリビルドが走る
    final _viewModel = ref.watch(myHomeViewModelProvider);

    // _MyHomePageStateのreturn内容をそのまま入れる
    return Scaffold( 
      appBar: AppBar(
        title: Text(title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '${_viewModel.getCounter()}', // <-- _viewModelのgetCounter()に置き換え
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _viewModel.incrementCounter,// <-- _viewModelのincrementCounterに置き換え
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}

// Stateは削除
// class _MyHomePageState extends State<MyHomePage> {
//   int _counter = 0;
//
//   void _incrementCounter() {
//     setState(() {
//       _counter++;
//     });
//   }
//
//   @override
//   Widget build(BuildContext context) {
//     return Scaffold(
//       appBar: AppBar(
//         title: Text(widget.title),
//       ),
//       body: Center(
//         child: Column(
//           mainAxisAlignment: MainAxisAlignment.center,
//           children: <Widget>[
//             Text(
//               'You have pushed the button this many times:',
//             ),
//             Text(
//               '$_counter',
//               style: Theme.of(context).textTheme.headline4,
//             ),
//           ],
//         ),
//       ),
//       floatingActionButton: FloatingActionButton(
//         onPressed: _incrementCounter,
//         tooltip: 'Increment',
//         child: Icon(Icons.add),
//       ),
//     );
//   }
// }

既存のMyHomePageの親クラスStatefulWidgetをConsumerWidgetに変更します。

class MyHomePage extends StatefulWidget {
から
class MyHomePage extends ConsumerWidget {

上記に変更すると、build()メソッドの実装が強要されます。
buildメソッドのWidgetRefを利用し、ref.watch()メソッドの引数にViewModelのProvider「myHomeViewModelProvider」を指定することで、Providerに保持しているMyHomeViewModelを取得できます。
あとは、returnに_MyHomePageStateクラスのbuild()のreturnの内容を移植し、_counterはviewModel.getCounter()に、_incrementCounterはviewModel.incrementCounterに、それぞれ置き換えて実装完了です。

今までの_MyHomePageStateは不要なので削除します。

動作確認

ここまで実装完了したら、アプリを起動してください。
変更前と同様に動作することができればOKです。
変更が大きかったわりに今までと動作が変わらないので達成感無いかもしれませんが、API、およびDB等も含めて、アクセス時は同様の実装になるかと思います。

まとめ

アーキテクチャーの検討ということで、本件ではAndroidっぽい感じのMVVMで実装しましたがいかがだったでしょうか。MVVM以外にも検討するべきアーキテクチャーは存在しますので、今後も記事にあげていこうと思っております。

なお、開発者T的には本件のMVVMがシンプルでいいかな?と思っています。

以上、ありがとうございました。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる