🐥

【Flutter】実務で使うMVVMの実装例

2023/03/24に公開

みなさんこんにちは。
これまで参画してきた案件の中で、MVVM(MVCの場合もありましたが)の実装例が自分の中で固まってきたと感じたので、こちらで記事をまとめてみようと思います。
個人開発でも使用できますが、スピードなどを考えると微妙な部分もありますのでご注意ください(個人的にはAPI連携も含めると保守性が上がるので個人開発でもアリです)。

何の記事か

  • FlutterのRiverpodを使用したMVVMの実装例を作ってみました。
  • API連携は除き、特に画面の状態管理についてまとめています。

前提

  • Flutter - V.3.3.4(fvmで管理しています)
  • Android Studio / VScode(どちらでもやることは一緒です)
  • modelクラスはfreezedでimmutableにします
  • 使用パッケージはflutter_riverpodです

https://pub.dev/packages/flutter_riverpod

はじめに

今回のMVVMについてですが、便宜上以下のような形でディレクトリを作成しています。

lib
 |-model
 |  |-sample_page_model.dart
 |          * 画面で使用するデータを扱います
 |-view_model
 |  |-sample_page_view_model.dart
 |          * viewのイベントを検知してmodelのデータを更新します
 |-view
 |  |-sample_page.dart
             * 画面です

また、扱う内容については以下のような状態管理です。

  • 単純な文字列や数値
  • 真偽値
  • TextEditingControllerなどのcontroller系
  • Listの管理(タップすると選択済みになるやつです)

イメージとしては、StatefulWidgetでプライベート変数を定義し、setStateで更新するものをRiverpodではどうやって実装するのか、というものを扱っています。

作成したもの

今回作成したものは以下のようなものとなります。
sample_page_1

パッケージインポート

必要なパッケージをインポートします。(それぞれのバージョンに準拠してください)

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  flutter_riverpod: ^1.0.3 # 追加
  freezed_annotation: ^2.2.0 # 追加

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
  freezed: ^2.3.2 # 追加
  build_runner: ^2.3.2 # 追加

modelの作成

freezedを使用して、modelクラスをimmutableにします

sample_page_model.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'sample_page_model.freezed.dart';

@freezed
class SamplePageModel with _$SamplePageModel {
  const SamplePageModel._();
  const factory SamplePageModel({
    @Default('') String email,
    @Default(false) bool isPolicyChecked,
    @Default([]) List<String> typeList,
  }) = _SamplePageModel;
}

build_runnerをターミナルで実行しましょう。

fvm flutter pub run build_runner build --delete-conflicting-outputs

さて、ここからですが、このfreezedでimmutableにしたmodelクラスの値を変更するために、作成したクラス内に関数を定義していきます。
ここで作成する関数を後ほどview_modelの方で呼び出します。
view_modelのみでの定義もできますが、あくまでview_modelでは「関数の呼び出し」という責務を実行してもらうような形を目指します。

クラス内に以下のように記述をします。

sample_page_model.dart
class SamplePageModel with _$SamplePageModel {
  const SamplePageModel._();
  const factory SamplePageModel({
    @Default('') String email,
    @Default(false) bool isPolicyChecked,
    @Default([]) List<String> typeList,
  }) = _SamplePageModel;

  // ここから追記
  SamplePageModel updateEmail(String value) => copyWith(
        email: value,
      );

  SamplePageModel updateIsPolicyChecked(bool value) => copyWith(
        isPolicyChecked: value,
      );

  SamplePageModel updateTypeList(List<String> list) => copyWith(
        typeList: list,
      );

この関数は、view_modelの中でstate.~という形で呼び出すことが可能になります。

view_modelの作成

次にview_modelを作成していきます。
freezedクラスとの相性を考えると、RiverpodのStateNotifierが良いと思います。
以下のように作成してみてください。

sample_page_view_model.dart
final samplePageViewModelProvider =
    StateNotifierProvider.autoDispose<SamplePageViewModel, SamplePageModel>(
        (ref) => SamplePageViewModel());

class SamplePageViewModel extends StateNotifier<SamplePageModel> {
  SamplePageViewModel() : super(const SamplePageModel());
}

view側で状態を参照する際に、ref.watch(samplePageViewModelProvider)でstateを参照することができます。

modelクラスの関数を呼び出す処理を定義

view_modelからmodelの更新処理を呼び出します。
以下のようにStateNotifierクラス内に追記します。

sample_page_view_model.dart
class SamplePageViewModel extends StateNotifier<SamplePageModel> {
  SamplePageViewModel() : super(const SamplePageModel());

  void updateEmail(String value){
    state = state.updateEmail(value);
  }
  
  void updateIsPolicyChecked(bool value) {
    state = state.updateIsPolicyChecked(value);
  }
  
  void updateTypeList(String value){
    final listToUpdate = List.of(state.typeList);
    if(listToUpdate.contains(value)){
      listToUpdate.remove(value);
    } else {
      listToUpdate.add(value);
    }
    state = state.updateTypeList(listToUpdate);
  }
}

state = state.~とすることで、view_modelから状態更新を行うことができます。
また、typeListの追加と削除に関しては、state.typeListを直接コピーできないため、List.ofメソッドで値を変更できるリストを作成しています。
場合によっては配列内の型が変わったり、リストの上限値などが出てくるかと思いますので、その際には分岐を工夫することで対応できるかと思います。

emailを更新するため、TextEditingControllerを定義

今回はTextFieldを使用してemailを入力するWidgetを作成します。
その際にControllerとして値の変更を管理してあげる必要がありますので、view_model内に定義していきます。
また、注意点として、TextEditingControllerはSamplePageを抜けた際にdisposeしてあげることも忘れないようにしましょう。

sample_page_view_model.dart
class SamplePageViewModel extends StateNotifier<SamplePageModel> {
  SamplePageViewModel()
      : emailController = TextEditingController(),
        super(const SamplePageModel()) {
    emailController.addListener(() => updateEmail(emailController.text));
  }

  final TextEditingController emailController;
  
  @override
  void dispose(){
    emailController.dispose();
    super.dispose();
  }

  ...
}

このように記載することで、SamplePageに遷移した際に初期化処理としてemailControllerが定義され、先ほど定義したupdateEmailをcontrollerが実行してくれるようになります。
また、このstateを参照しなくなった際にcontrollerをdisposeする処理も追加しています。

viewの作成

これでUI以外の部分は作成することができました。
ここから実際の画面を考えて状態管理を実装してみます。

sample_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../view_model/sample_page_view_model.dart';

class SamplePage extends ConsumerWidget {
  const SamplePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(samplePageViewModelProvider);
    final viewModel = ref.watch(samplePageViewModelProvider.notifier);
    const types = ['科学・テクノロジー', '文化・芸術', 'スポーツ'];
    return GestureDetector(
      onTap: () {
        final FocusScopeNode currentScope = FocusScope.of(context);
        if (!currentScope.hasPrimaryFocus && currentScope.hasFocus) {
          FocusManager.instance.primaryFocus!.unfocus();
        }
      },
      child: Scaffold(
        appBar: AppBar(
          title: const Text('MVVMサンプル'),
        ),
        body: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 15),
          child: Column(
            children: [
              const SizedBox(height: 30),
              Row(
                children: const [
                  SizedBox(width: 10),
                  Text(
                    'メールアドレスを入力',
                    style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                  ),
                ],
              ),
              TextField(
                controller: viewModel.emailController,
                keyboardType: TextInputType.emailAddress,
              ),
              const SizedBox(height: 15),
              Row(children: [Text('※ state.email => ${state.email}')]),
              const SizedBox(height: 40),
              Row(
                children: const [
                  SizedBox(width: 10),
                  Text(
                    'あなたの興味を選んでください。',
                    style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                  ),
                ],
              ),
              const SizedBox(height: 10),
              for (final type in types)
                InkWell(
                  onTap: () => viewModel.updateTypeList(type),
                  child: Row(
                    children: [
                      const SizedBox(width: 60),
                      Icon(
                        Icons.circle_outlined,
                        color: state.typeList.contains(type)
                            ? Colors.red
                            : Colors.grey,
                      ),
                      const SizedBox(width: 10),
                      Text(type),
                    ],
                  ),
                ),
              const SizedBox(height: 15),
              Row(children: [Text('※ state.typeList => ${state.typeList}')]),
              const SizedBox(height: 40),
              Row(
                children: [
                  Checkbox(
                    value: state.isPolicyChecked,
                    onChanged: (bool? newValue) {
                      viewModel.updateIsPolicyChecked(newValue!);
                    },
                  ),
                  const SizedBox(width: 10),
                  const Text(
                    '利用規約に同意します。',
                    style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                  ),
                ],
              ),
              const SizedBox(height: 15),
              Row(children: [
                Text('※ state.isPolicyChecked => ${state.isPolicyChecked}'),
              ]),
              const SizedBox(height: 30),
            ],
          ),
        ),
      ),
    );
  }
}

ここではstateをmodelクラス内の状態として、notifierをviewModelを参照する変数として定義しています。viewModelで初めて定義した変数などは、notifierをつけないと参照できないため注意しましょう。名称としてはcontrollerなどもありかなと思います。

念の為main.dartも載せておきます。

main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sample_project/view/sample_page.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const _Home(),
    );
  }
}

class _Home extends StatelessWidget {
  const _Home({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('MVVMサンプル')),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            Navigator.of(context).push(
              MaterialPageRoute(
                builder: (context) => const SamplePage(),
              ),
            );
          },
          child: const Text('画面遷移'),
        ),
      ),
    );
  }
}

ここまでで、はじめにgifでお見せした内容と同じになっているかと思います。
敢えてボタン遷移にした理由がありますので、それをここから記載します。

view_modelの初期化処理

例えば、画面遷移時に非同期処理を行い、stateを更新した上で遷移したい場合があるとします。例としては、プロフィールの更新など初期値が空でないことを想定している場合です。
そのようなときは、まずviewModel内に任意の処理を記載し、初期化処理に追加して対応します。

sample_page_view_model.dart

class SamplePageViewModel extends StateNotifier<SamplePageModel> {
  SamplePageViewModel()
      : emailController = TextEditingController(),
        super(const SamplePageModel()) {
    emailController.addListener(() => updateEmail(emailController.text));
    initialize(); // 追記
  }

  final TextEditingController emailController;

  // 追記
  Future<void> initialize() async {
    // 何かしらの処理を行う
    state = state.copyWith(
      email: 'test@example.com',
      typeList: ['スポーツ'],
    );
    emailController.text = state.email;
  }
  
  ...
}

sample_page_2
これで画面遷移時に予め値を入れた状態にすることができます。

おわりに

Riverpodのパッケージを使用したアーキテクチャを考える際、かなり多くの議論がされているかと思いますし、色々あってわかりにくいですよね。
今回のサンプルでfreezed + StateNotifierの実装例がある程度参考になるかと思いますので、他にもデータを変えたりして試してみてください。

また良さそうな内容があれば更新します!

Discussion