TL;DR;
この記事は,RepositoryパターンとMVVMモデルの関係性を体感するためのTODOアプリ実装ハンズオンです
出来上がるもの
MVVMモデルとは
MVVMモデルとは
- View
- ViewModel
- Model
という3階層に分かれたアプリケーションモデルのことを指します.
これは従来のMVCモデルにおける
- ViewとControllerをできない場合がある
という問題点を解消するために提案されたモデル体系です.
具体的には,近代的なWebフレームワーク(React)などでは,Element自体が役割を持っており,そこで完結しているようなものが多く,それらは,ViewでありながらControllerの役割も担い,データのフローを行うといったパターンが発生するためです.
それらを避けるために登場したのがMVVMというわけです.
Repositoryパターン
データアクセスを抽象化し,コードの再利用生や,コードの利便性を向上させるためのデザインパターンです.
これは,これまでのControllerやViewModelが直接データにアクセスしていたという状況で真価を発揮します.
直接アクセスしていた場合,データのアクセス方法に変更が起こった場合,破壊的に元のメソッドなどを更新する必要がありました.
しかし,Repositoryパターンでは,これらのアクセス元は一度Repositoryクラスを経由するため,データの処理方法を知る必要がないのです.
つまり,データの取得方法が変更された場合,開発者は,データのアクセス方法だけを変える作業を行えば,良いということです.
従来の更新方法
データアクセス方法を更新するために,Controllerなどのオブジェクトをまとめて変更する必要がある
Repositoryパターンを採用した更新方法
データアクセス方法を更新するために,データにアクセスするだけのオブジェクトを変更する必要がある
MVVMとRepositoryパターンの接続
MVVMモデルでは,Viewに表示するデータを,状態という形でViewModelが保持します.
ですので,必然的にRepositoryパターンのオブジェクトはViewModelと関係を持つことになります.
実際には,ViewModelにRepositoryをDIして,ViewModelがRepositoryへアクセスすることになります.
この時,重要になることは,Repositoryのスレッド管理です.
ViewModelがトランザクションエラーを発生させないためには,Repositoryでの内部動作が確実に実行される必要があります.
また,Repositoryで行うデータアクセスが非常に重い処理(処理系のAPIへのアクセス)である場合は,それらがパフォーマンスにおけるボトルネックにならないように注意する必要があります.
これらを簡単に満たす方法は,Repositoryオブジェクトが持つインスタンスをシングルトンにし,非同期実行を可能にすることです.
このような動作を,今回はRiverpodをつかって実装しました.
実際には,ViewModelがRepositoryにアクセスし,Repositoryからデータを取得したことを,ViewModelがViewへ通知するといった流れです.
また,ViewModelからRepositoryへのデータ更新も随時行い,更新通知を行うことで,Viewとの同期が崩れないようにしました.
ここで,嬉しいのがViewModelの持つデータが直接キャッシュと同じ役割を果たすということです.
実際に実装したもの
コードの解説
まずフォルダの階層は次のように分かれています.
lib
├── main.dart : アプリケーションのエントリーポイント
├── models
│ └── todo.dart
├── providers
│ └── todo_di.dart : RepositoryをViewModelにDIする部分 Providerの集まり
├── repositories
│ ├── todo_repository.dart
│ └── todo_repository_prefs.dart
├── viewmodels
│ └── todo_viewmodel.dart
└── views
└── todo_screen.dart
main.dartがアプリケーションのエントリーポイントとなっており,modelsやviewmodels,viewsなどが続きます.またrepositoriesには今回Flutterで利用したSharedPreferencesにアクセスするためのRepositoryオブジェクトが入っており,providersにはDIのためのコードが入っています.
ここからは,各ファイルの解説です.
models/todo.dart
// Todoステータス列挙型
enum TodoStatus {
notStarted,
inProgress,
done,
}
// Todoデータクラス
class Todo {
final String id;
final String todo;
final TodoStatus status;
Todo({required this.id, required this.todo, this.status = TodoStatus.notStarted});
Map<String, dynamic> toJson() => {'id': id, 'todo': todo, 'status': status.name};
factory Todo.fromJson(Map<String, dynamic> json) => Todo(id: json['id'], todo: json['todo'], status: TodoStatus.values.firstWhere((e) => e.name == json['status'], orElse: () => TodoStatus.notStarted,));
Todo copyWith({String? id, String? todo, TodoStatus? status}) => Todo(id: id ?? this.id, todo: todo ?? this.todo, status: status ?? this.status);
}
このクラスは,1つのTodoに必要なデータがまとまっています.
toJsonクラスは後で使う,dart:convertライブラリのメソッドであるjsonEncordの実行に必要なため,Todoクラスが持つ情報をJSON形式で出力しています.
あとは,よくあるFactoryパターンのメソッドと,クラスオブジェクトをイミュータブルにするためのcopyWithメソッドです.
repositories/todo_repository.dart
import 'package:mvvm_repository_prefs/models/todo.dart';
// TodoRepositoryのインターフェース
abstract class TodoRepository {
Future<void> saveTodos(List<Todo> todos);
Future<List<Todo>> loadTodos();
}
これは,この後に制作するRepositoryオブジェクトが同じメソッドを持つためのインターフェース的抽象クラスです.
Dartには,Interfaceというオブジェクトがあり,こちらも実相を持たない関数を宣言することができます.
しかし,Interfaceは継承先に情報が残らず,様々な派生型が必要となるRepositoryオブジェクトに対して,型安全なプログラミングしにくいです.
そのため,継承先でもメソッドを持っていることを保証するためにTodoRepositoryという基底抽象クラスを定義しました.
repositories/todo_repository_prefs.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:mvvm_repository_prefs/models/todo.dart';
import 'package:mvvm_repository_prefs/repositories/todo_repository.dart';
import 'package:shared_preferences/shared_preferences.dart';
class TodoRepositoryPrefs extends TodoRepository{
final SharedPreferences _prefs;
static final String _key = 'todo_list';
TodoRepositoryPrefs(this._prefs);
@override
Future<void> saveTodos(List<Todo> todos) async {
final String res = jsonEncode(todos.map((e) => e.toJson(),).toList());
debugPrint(res);
await _prefs.setString(_key, res);
}
@override
Future<List<Todo>> loadTodos() async {
late String? encorded;
try {
encorded = _prefs.getString(_key);
} catch (e) {
debugPrint(e.toString());
}
if (encorded == null) return [];
final List<dynamic> tmp = jsonDecode(encorded);
return tmp.map((json) => Todo.fromJson(json),).toList();
}
}
このクラスはTodoRepositoryクラスを継承し,SharedPreferencesに対応したRepositoryオブジェクトです.
saveTodoメソッドで与えられたTodoの内容をJSON形式で保存し,loadTodoで保存されているかもしれないTodoの内容をJSONで読み込み,それをDartで扱うためのMap<String, dynamicに変換して保存しているだけです.
本当にこのクラスでは,SharedPreferencesに対する処理のみしか記述していません.
これがRepositoryパターンの強みです.
viewmodels/todo_viewmodel.dart
import 'package:flutter_riverpod/legacy.dart';
import 'package:mvvm_repository_prefs/models/todo.dart';
import 'package:mvvm_repository_prefs/repositories/todo_repository.dart';
class TodoViewModel extends StateNotifier<List<Todo>> {
final TodoRepository _todoRepository;
// superを呼び出すことで明示的にインスタンスの生成をアピール
TodoViewModel(this._todoRepository) : super([]){
_init();
}
// 起動時データを取得
Future<void> _init() async {
state = await _todoRepository.loadTodos();
}
// todo追加
Future<void> addTodo(Todo todo) async {
state = [...state, todo];
await _todoRepository.saveTodos(state);
}
// ステータス変更
Future<void> setStatus(String id, TodoStatus status) async {
state = [
for (Todo todo in state) if (todo.id.compareTo(id) == 0) todo.copyWith(status: status) else todo
];
await _todoRepository.saveTodos(state);
}
// todoを削除
Future<void> deleteTodo(String id) async {
state = [
for (Todo todo in state) if ( todo.id.compareTo(id) != 0) todo
];
await _todoRepository.saveTodos(state);
}
}
このクラスでは,Todoの追加,状態変更(TodoStatusの変更)と,Todoの削除という,Viewにある機能をハンドリングして,Todoの状態を管理する機能しか書いていません.
状態を管理し,その状態を保存するという記述があるが,これはあくまでもハンドリングした際のTodo状態をすぐにデータベースへ反映しています.
また,このクラスは,状態を扱い,その変化をViewに通知する必要があります.
そのため,StateNotifierクラスを用いています.
これは,Riverpod v2で推奨される書き方ではないものの,小規模コードであるため,今回は採用しました.
providers/todo_di.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'package:mvvm_repository_prefs/models/todo.dart';
import 'package:mvvm_repository_prefs/repositories/todo_repository.dart';
import 'package:mvvm_repository_prefs/repositories/todo_repository_prefs.dart';
import 'package:mvvm_repository_prefs/viewmodels/todo_viewmodel.dart';
import 'package:shared_preferences/shared_preferences.dart';
// getInstanceはmainのロジックでoverrideする
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) => throw UnimplementedError());
// DI テストしやすくなるように
final todoRepositoryProvider = Provider<TodoRepository>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return TodoRepositoryPrefs(prefs);
});
final todoViewModelProvider = StateNotifierProvider<TodoViewModel, List<Todo>>((ref) => TodoViewModel(ref.watch(todoRepositoryProvider)));
ここでは,SharedPreferencesオブジェクトをシングルトンにし,全てのWidgetで同一のインスタンスを参照するための状態管理Providerである,sharedPreferencesProviderを定義しています.
また,TodoRepositoryがSharedPreferencesを参照するために,TodoRepositoryにSharedPreferencesを注入しています.
さらに,TodoViewModelを管理するためのProviderをtodoViewModelProviderとして定義しており,SharedPreferencesを扱うための,todoRepositoryproviderを注入しています.
views/todo_view.daet
このコードは,実際のUIを作成しています.
特にきれなコードでもありませんが,重要なのは,todoViewModelProviderをwatchして,そのから各状態変更のためのアクションをハンドリングしているということです.
main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mvvm_repository_prefs/providers/todo_di.dart';
import 'package:mvvm_repository_prefs/views/todo_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
Future<void> main() async {
// Widgetツリーの確実な初期化
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
// sharedPreferencesProviderのstateをoverridesする
// これはSharedPreferencesのインスタンスを一つにしてトランザクションエラーなどを発生させないため
final app = ProviderScope(overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], child: MaterialApp(home: TodoScreen(),));
runApp(app);
}
このコードでは,WidgetsFlutterBinding.ensureInitialized()を使って,確実なWidgetツリーの初期化を行い,SharedPreferencesのインスタンスをProviderScopeでsharedPreferencesProviderにoverrideする形で注入しています.
参考
