MobX.dartはDart用の状態管理ライブラリで、React用のMobXの設計思想をもとにしています。
MobXの特徴
MobXの基本的なコンセプトは以下の3つです。
- observable (リアクティブな値)
- action (リアクティブな値の更新)
- reaction (更新をトリガーにした処理)
使い方をRiverpodと比較しつつ紹介します。
状態の参照と更新
状態はobservable、状態の更新をactionと定義しています。
observableの値を参照するのにriverpodでいうref.watchやref.readを経由する必要はありません。
更新を通知するには runInAction()を使います。
final anyState = Observable('');
Widget build(BuildContext context) {
return Column(
children: [
// 値を読む。リアクティブに反応しない
Text(anyState.value),
// 値を読む。更新されたらリビルドされる
Observer(builder:(_) => Text(anyState.value)),
ElevatedButton(
onPressed: () {
// 状態を更新して、observerに通知する
runInAction(() => anyState.value = DateTime.now().toString());
},
child: const Text('Update'),
)
],
);
}
リアクティブな処理の実行
状態の変化によって処理を実行するのはMobXではreactionと定義しています。
riverpodではref.listenですが、reaction、autorun、whenというラッパーを使います。
これらのラッパーで作ったreactionはアプリケーションコードで破棄を管理する必要があります。
final dispose = reaction(
(_) => anyState.value,
(stateValue) => debugPrint(stateValue),
);
dispose();
ReactionBuilderウィジェットから使う場合は管理不要です。
return ReactionBuilder(
builder: (_) {
return reaction(
(_) => anyState.value,
(stateValue) => debugPrint(stateValue),
);
},
child: Text('Hogehoge'),
);
autoDisposeはない
riverpodの便利(たまに罠)な機能としてライフサイクル管理がありますが、MobXにはありません。
状態への参照がなくなった時点で破棄したい場合は、アプリケーションコードとして実装する必要があります。
disposeの戦略としてはいくつかありそうです。
1. StatefulWidgetのStateにObservableなプロパティを定義する
一番簡単ですが、状態管理ライブラリを使わないときと同じような面倒くささが発生します。
2. 手動で初期化・破棄
個人的な感覚ですが、autoDisposeな状態の多くは使い始める起点になる画面があるように思います。
その起点になる画面でObservableを状態を初期化・破棄すれば上手くいきそうな予感があります。
3. 参照カウントを実装する
control_coreにあるReferenceCounter mixinを使うとそこそこ上手くできそうな感じはしますが、バグがあったときに頭が痛くなりそうです。
Observableの定義
実際の開発では適切な粒度のObservableとActionをまとめたクラスを定義すると思います。
ここでボイラープレートを避けるため、mobx_codegenを導入することになります。
mobx_codegenを使ったクラス定義ではアノテーションを使って、Observable・Actionを定義します。
計算で値を決めるリアクティブプロパティを作ることもできます。
@observable ... リアクティブなプロパティ
@compute ... 合成されたリアクティブなプロパティ
@action ... アクション相当のメソッド
import 'package:mobx/mobx.dart';
part 'score.g.dart';
/// スコア
class Score = ScoreBase with _$Score;
abstract class ScoreBase with Store {
/// スコア
@observable
var score = 0;
@observable
var highScore = 0;
/// 初期化する
@action
void init() {
score = 0;
}
/// スコアを更新する
@action
void addPoint(int point) {
score += point;
if(score > highScore) {
highScore = score;
}
}
}
_$ScoreクラスがScoreの実体になっていて、通知やアクションの実行がうまくプログラマから隠蔽されています。
@override
int get score {
_$scoreAtom.reportRead();
return super.score;
}
@override
set score(int value) {
_$scoreAtom.reportWrite(value, super.score, () {
super.score = value;
});
}
@override
void addPoint(int point) {
final _$actionInfo = _$ScoreBaseActionController.startAction(
name: 'ScoreBase.addPoint',
);
try {
return super.addPoint(point);
} finally {
_$ScoreBaseActionController.endAction(_$actionInfo);
}
}
非同期の取り扱い
ObserverbleFutureを使います。riverpodとriverpod_generatorを使ったFutureProviderとAsyncValueのほうがちょっとスッキリしている感じがしますね。
import 'package:mobx/mobx.dart';
part 'count.g.dart';
class Count = CountBase with _$Count;
abstract class CountBase with Store {
/// カウント値を非同期で保持するObservableFuture
@observable
var value = ObservableFuture.value(0);
/// ロード中かどうか
@computed
bool get isLoading => value.isLoading;
@action
Future<void> increment() async {
value = ObservableFuture(_incrementValue());
}
Future<int> _incrementValue() async {
await Future.delayed(const Duration(milliseconds: 100));
return (value.value ?? 0) + 1;
}
}
extension ObservableFutureExtension<T> on ObservableFuture<T> {
bool get isLoading => status == FutureStatus.pending;
}
何か作ってみる
メモアプリのようなものを作ってみます。やることはToDoアプリとほぼ同じです。
- メモ一覧
- メモ編集
ソースコード
以下のパッケージを追加します。
UUIDはmobxとは無関係で、メモページの識別子を生成するために組み込んでいます。
$ flutter pub add dev:build_runner dev:mobx_codegen mobx flutter_mobx uuid
以下が実行可能なソースコードです。
mobx_codegenを使っているので、ビルド前にbuild_runnerを実行してpage.g.dartとmemo.g.dartを生成しておきます。
$ dart pub run build_runner build
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
import 'package:mobx_test/memo.dart';
import 'page.dart' as model;
/// グローバルのタブインデックス(0: ダッシュボード, 1: ページ一覧)
final tabIndex = Observable(0);
/// グローバルのメモストア(アプリ全体で共有される状態)
final memo = Memo();
void main() {
runApp(const MyApp());
}
/// アプリケーションのルートウィジェット
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Memo',
home: const _HomeScreen(),
);
}
}
/// ホーム画面(ダッシュボードとページ一覧を切り替え可能)
class _HomeScreen extends StatelessWidget {
const _HomeScreen();
@override
Widget build(BuildContext _) {
return ReactionBuilder(
builder: (context) {
// メモの更新を監視して、更新時にSnackBar を表示
return reaction((_) => memo.lastUpdatedAt, (_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('メモが更新されました')),
);
});
},
// Observer でラップすることで、tabIndex の変更を監視して自動的に再描画
child: Observer(
builder: (context) {
final index = tabIndex.value;
return Scaffold(
appBar: AppBar(title: const Text('Flutter Memo')),
// IndexedStack で画面を切り替え(両方の状態を保持)
body: IndexedStack(
index: index,
children: const [_DashBoard(), _PageList()],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: index,
onTap: (index) {
// runInAction で Observable の値を更新
runInAction(() => tabIndex.value = index);
},
items: [
BottomNavigationBarItem(
icon: Icon(Icons.dashboard),
label: 'Dashboard',
),
BottomNavigationBarItem(icon: Icon(Icons.list), label: 'Pages'),
],
),
// ページ一覧画面のときのみ、新規作成ボタンを表示
floatingActionButton: index == 0
? null
: FloatingActionButton(
onPressed: () {
final page = model.Page();
_Editor.show(context, page);
},
child: const Icon(Icons.add),
),
);
},
),
);
}
}
/// ダッシュボード画面
class _DashBoard extends StatelessWidget {
const _DashBoard();
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: .start,
children: [
// Observer で memo の computed プロパティを監視
Observer(
builder: (_) {
// computed プロパティから値を取得
final numPages = memo.pageCount;
final lastModified = memo.lastUpdatedAt;
return Row(
mainAxisAlignment: .spaceAround,
children: [
Text('ページ数: $numPages'),
Text(
'最新: ${lastModified != null ? lastModified.toLocal().toString() : "なし"}',
),
],
);
},
),
],
);
}
}
/// ページ一覧画面
class _PageList extends StatelessWidget {
const _PageList();
@override
Widget build(BuildContext context) {
// memo.pages の変更を監視
return Observer(
builder: (_) {
final pages = memo.pages;
return ListView.builder(
itemCount: pages.length,
itemBuilder: (context, index) {
final page = pages[index];
// 各 ListTile を Observer でラップして、個別のページの変更を監視
return Observer(
builder: (_) => ListTile(
title: Text(page.title),
onTap: () {
_Editor.show(context, page.clone());
},
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () {
// アクションを実行してページを削除
memo.removePage(page);
},
),
),
);
},
);
},
);
}
}
/// ページエディタ画面(モーダルボトムシートで表示)
class _Editor extends StatelessWidget {
const _Editor({required this.page});
/// 編集対象のページ
final model.Page page;
/// エディタ画面を表示する
static void show(BuildContext context, model.Page page) {
showModalBottomSheet(
context: context,
builder: (_) {
return _Editor(page: page);
},
);
}
@override
Widget build(BuildContext context) {
return Container(
padding: .all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
),
child: Form(
child: Column(
children: [
/// タイトル入力欄
TextFormField(
initialValue: page.title,
decoration: InputDecoration(labelText: 'タイトル'),
onChanged: (value) {
// アクションを実行してタイトルを更新
page.setTitle(value);
},
),
const SizedBox(height: 16),
/// 内容入力欄
Expanded(
child: TextFormField(
initialValue: page.content,
decoration: InputDecoration(labelText: '内容'),
expands: true,
minLines: null,
maxLines: null,
onChanged: (value) {
// アクションを実行して内容を更新
page.setContent(value);
},
),
),
ElevatedButton(
onPressed: () {
// ページを保存して画面を閉じる
memo.addPage(page);
Navigator.of(context).pop();
},
child: Text(page.isNew ? '登録' : '更新'),
),
],
),
),
);
}
}
import 'package:mobx/mobx.dart';
import 'package:uuid/uuid.dart';
part 'page.g.dart';
/// メモ1件分
class Page = PageBase with _$Page;
abstract class PageBase with Store {
/// ページID
String? uuid;
/// 新規ページかどうか
bool get isNew => uuid == null;
/// ページタイトル
@observable
String title = '';
/// ページ内容
@observable
String content = '';
/// 最終更新日時
@observable
DateTime updatedAt = DateTime.now();
/// ページを複製する
Page clone() {
return Page()
..uuid = uuid
..title = title
..content = content
..updatedAt = updatedAt;
}
/// ページを保存する
@action
void save() {
uuid ??= const Uuid().v4();
updatedAt = DateTime.now();
}
/// タイトルを設定する
@action
void setTitle(String title) {
this.title = title;
updatedAt = DateTime.now();
}
/// 内容を設定する
@action
void setContent(String content) {
this.content = content;
updatedAt = DateTime.now();
}
}
import 'package:mobx/mobx.dart';
import 'page.dart';
part 'memo.g.dart';
/// メモ全件
class Memo = MemoBase with _$Memo;
abstract class MemoBase with Store {
/// ページリスト
@observable
var pages = ObservableList<Page>();
/// 最終更新日時
@computed
DateTime? get lastUpdatedAt {
if (pages.isEmpty) {
return null;
}
return pages.map((page) => page.updatedAt).reduce((a, b) => a.isAfter(b) ? a : b);
}
/// ページ数
@computed
int get pageCount => pages.length;
/// ページを追加する
@action
void addPage(Page page) {
if(page.uuid == null) {
page.save();
pages.add(page);
return;
}
final index = pages.indexWhere((p) => p.uuid == page.uuid);
if (index >= 0) {
pages[index] = page;
}
}
/// ページを削除する
@action
void removePage(Page page) {
final index = pages.indexWhere((p) => p.uuid == page.uuid);
if (index < 0) {
return;
}
pages.removeAt(index);
}
}
最後に
株式会社ボトルキューブではFlutterを使ったお仕事を募集中です。
お問い合わせは下記リンク先のフォームからご連絡ください。
