【Flutter】実務で使うMVVMの実装例
みなさんこんにちは。
これまで参画してきた案件の中で、MVVM(MVCの場合もありましたが)の実装例が自分の中で固まってきたと感じたので、こちらで記事をまとめてみようと思います。
個人開発でも使用できますが、スピードなどを考えると微妙な部分もありますのでご注意ください(個人的にはAPI連携も含めると保守性が上がるので個人開発でもアリです)。
何の記事か
- FlutterのRiverpodを使用したMVVMの実装例を作ってみました。
- API連携は除き、特に画面の状態管理についてまとめています。
前提
- Flutter - V.3.3.4(fvmで管理しています)
- Android Studio / VScode(どちらでもやることは一緒です)
- modelクラスはfreezedでimmutableにします
- 使用パッケージは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ではどうやって実装するのか、というものを扱っています。
作成したもの
今回作成したものは以下のようなものとなります。
パッケージインポート
必要なパッケージをインポートします。(それぞれのバージョンに準拠してください)
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にします
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では「関数の呼び出し」という責務を実行してもらうような形を目指します。
クラス内に以下のように記述をします。
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が良いと思います。
以下のように作成してみてください。
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クラス内に追記します。
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してあげることも忘れないようにしましょう。
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以外の部分は作成することができました。
ここから実際の画面を考えて状態管理を実装してみます。
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も載せておきます。
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内に任意の処理を記載し、初期化処理に追加して対応します。
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;
}
...
}
これで画面遷移時に予め値を入れた状態にすることができます。
おわりに
Riverpodのパッケージを使用したアーキテクチャを考える際、かなり多くの議論がされているかと思いますし、色々あってわかりにくいですよね。
今回のサンプルでfreezed + StateNotifierの実装例がある程度参考になるかと思いますので、他にもデータを変えたりして試してみてください。
また良さそうな内容があれば更新します!
Discussion