グローバル変数の問題を解決する「状態管理の仕組み」を理解する
アプリの複数画面から使う「データベース」を、グローバル変数で管理するとこうなる:
// やってはいけない例
AppDatabase globalDb = AppDatabase();
// home_screen.dart から直接アクセス
final sessions = await globalDb.select(...).get();
// history_screen.dart からも直接アクセス
final history = await globalDb.select(...).get();
これの問題点:
筋トレメモの実際のコード:
lib/features/workout/workout_provider.dart@Riverpod(keepAlive: true)
AppDatabase appDatabase(Ref ref) {
return AppDatabase();
}
Provider は「データの受付窓口」。直接グローバル変数を触るのではなく、Provider を通じてアクセスする。
| Provider | グローバル変数 | |
|---|---|---|
| アクセス制御 | Riverpodが管理 | 誰でも直接触れる |
| テスト | overrideでモック注入可 | 本番DBに接続 |
| ライフサイクル | 自動管理(keepAlive/自動破棄) | 手動管理 |
| 変更通知 | 自動で画面が再描画 | 手動で setState が必要 |
筋トレメモでは、Provider が他の Provider を利用して「依存の連鎖」を形成している:
lib/features/workout/workout_provider.dart// 1. DB本体(全ての土台)
@Riverpod(keepAlive: true)
AppDatabase appDatabase(Ref ref) {
return AppDatabase();
}
// 2. Repository(DBを使う)
@riverpod
WorkoutRepository workoutRepository(Ref ref) {
return WorkoutRepository(ref.watch(appDatabaseProvider));
}
依存グラフを図で表すと:
AppDatabase Provider
|
v
WorkoutRepository Provider
|
v
ActiveWorkoutNotifier (画面の状態管理)
|
v
HomeScreen / WorkoutScreen (UI)
WorkoutRepository が「ドメインデータ(ActiveWorkoutState) と DB形式(WorkoutsCompanion) の変換」を担当しているから。UIがDB形式を知る必要がなくなる。筋トレメモのコードをよく見ると、大文字と小文字の2種類がある:
@Riverpod(keepAlive: true) // 大文字R + オプション指定
AppDatabase appDatabase(Ref ref) { ... }
@riverpod // 小文字r(デフォルト設定)
WorkoutRepository workoutRepository(Ref ref) { ... }
| @Riverpod(keepAlive: true) | @riverpod | |
|---|---|---|
| 寿命 | アプリ終了まで生存 | 参照がなくなると自動破棄 |
| 使い時 | DB接続など、常に必要なもの | 画面ごとのRepositoryなど |
| メモリ | ずっと占有 | 不要になったら解放 |
筋トレメモの App クラスでは setState を使っている箇所がある:
class _AppState extends ConsumerState<App> {
int _selectedIndex = 0; // setState で管理
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
bottomNavigationBar: NavigationBar(
onDestinationSelected: (index) {
setState(() => _selectedIndex = index);
},
),
),
);
}
}
判断フローチャート:
この状態を「他のWidget」で使う?
|
+-- YES --> Provider を使う
| 例: appDatabaseProvider(HomeScreen, HistoryScreen...)
| 例: activeWorkoutProvider(WorkoutScreen, 完了画面...)
|
+-- NO --> この Widget 内だけで完結する?
|
+-- YES --> setState で十分
| 例: _selectedIndex(ナビバーのタブ切替)
|
+-- NO --> Provider を使う
| 状態 | 選択 | 理由 |
|---|---|---|
_selectedIndex | setState | App Widget内だけで使うタブ番号 |
ActiveWorkoutState | Provider | WorkoutScreen, 完了画面, HomeScreenで共有 |
AppDatabase | Provider | 全Repository、全画面から参照 |
CalendarMonth | Provider | カレンダーと日詳細シートで共有 |
ref.watch(appDatabaseProvider) は何を返す?ref.watch() で取り出すと、Provider の build が返した値(ここでは AppDatabase())が得られる。_selectedIndex を Provider ではなく setState で管理している理由は?_selectedIndex はApp内のナビバー切り替えだけなので setState で十分。@Riverpod(keepAlive: true) と @riverpod の違いは?keepAlive: true は「常に生存」。DB接続のように常時必要なProviderに使う。@riverpod(自動破棄)はメモリ効率が良いが、毎回再生成コストがかかる。DB → Repository → Notifier → UI のレイヤー構造を表現するProvider の値を「いつ・どう取得するか」で使い分ける3つのメソッド
class HomeScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final routineListAsync = ref.watch(routineListProvider);
return Scaffold(
body: routineListAsync.when(
loading: () => CircularProgressIndicator(),
error: (e, _) => Text('エラー: $e'),
data: (list) => ListView(...),
),
);
}
}
ref.watch は build() メソッド内で使う。値が変わると自動で build() が再実行される。
Future<void> _onRoutineTap(
BuildContext context, WidgetRef ref,
RoutineWithExercises data,
) async {
await ref.read(activeWorkoutProvider.notifier).startSession(data);
if (!context.mounted) return;
Navigator.of(context).push(...);
}
| ref.watch | ref.read | |
|---|---|---|
| 用途 | 値の変化を監視して再描画 | 今この瞬間の値を1回取得 |
| 使う場所 | build() 内 | onTap / onPressed などのコールバック内 |
| 再実行 | 値が変わると build が再実行 | しない(スナップショット) |
実際の HomeScreen から、watch と read の使い分けを整理する:
class HomeScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// build 内 = ref.watch(画面の描画に必要なデータ)
final routineListAsync = ref.watch(routineListProvider);
return Scaffold(...);
}
Future<void> _onRoutineTap(...) async {
// コールバック内 = ref.read(アクションの実行)
await ref.read(activeWorkoutProvider.notifier).startSession(data);
}
}
覚え方:
ref.watchref.readref.listen は「値が変わった瞬間に何かを実行する」ためのもの。watch のように再描画はせず、コールバックを呼ぶだけ。
// 例: セッション完了時にスナックバーを表示する
ref.listen(activeWorkoutProvider, (prev, next) {
if (prev != null && next == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('セッション完了!')),
);
}
});
| メソッド | 再描画? | 副作用? | 使う場所 |
|---|---|---|---|
ref.watch | する | しない | build内 |
ref.read | しない | しない | コールバック内 |
ref.listen | しない | する | build内(で登録) |
筋トレメモの「ルーティン一覧表示」の流れを見てみよう:
lib/features/home/home_screen.dart → routine_provider.dart → workout_provider.dart// 実際の構造(Provider中間層あり)
HomeScreen
└─ ref.watch(routineListProvider)
└─ routineListProvider
└─ ref.watch(routineRepositoryProvider)
└─ routineRepositoryProvider
└─ ref.watch(appDatabaseProvider)
もし Provider を飛ばして直接 DB にアクセスしたら?
// NG: Provider を飛ばした例
class HomeScreen extends ConsumerWidget {
Widget build(context, ref) {
final db = ref.watch(appDatabaseProvider);
// 画面内で直接SQL相当の操作...
return FutureBuilder(
future: db.select(db.routines).get(),
...
);
}
}
失われるもの:
completeSession() 内の1RM計算と過去最高比較 — Repository/Notifier がないと UI に書くことにtotalVolume、totalFilledSetCount などの集計 — 全画面で重複コードになるref.watch を使うとどうなる?ref.watch は build メソッド内でのみ使用可能。コールバック内で使うと実行時エラーになる。コールバック内では ref.read を使う。ref.watch = build内で監視 / ref.read = コールバックで1回取得 / ref.listen = 変化時に副作用複雑な状態を「メソッドで操作する」仕組みを ActiveWorkoutNotifier から学ぶ
3-1で見た Provider は「値を返すだけ」だった。Notifier は「状態を持ち、メソッドで変更できる」Provider。
| Provider(関数型) | Notifier(クラス型) | |
|---|---|---|
| 状態変更 | できない(読み取り専用) | メソッドで変更可能 |
| 用途 | DB、Repository などの「供給」 | セッション状態など「管理」 |
| 例 | appDatabaseProvider | ActiveWorkoutNotifier |
@Riverpod(keepAlive: true)
class ActiveWorkoutNotifier extends _$ActiveWorkoutNotifier {
@override
ActiveWorkoutState? build() => null;
}
ポイント:
build() は Notifier の「初期状態」を返すメソッドActiveWorkoutState?) が、この Notifier が管理する state の型になるnull = 「セッション未開始」を表現(null safety の活用)keepAlive: true = 画面遷移しても状態を保持(ワークアウト途中で画面を閉じても消えない)Notifier では state = ... で状態を更新する。イミュータブルなので、毎回新しいオブジェクトを作る:
/// セットの重量を更新
void updateWeight(int exerciseIndex, int setIndex, double weight) {
if (!_isValidSetIndex(exerciseIndex, setIndex)) return;
final sets = state!.exercises[exerciseIndex].sets;
final newSets = [
for (var i = 0; i < sets.length; i++)
if (i == setIndex) sets[i].copyWith(weight: weight) else sets[i],
];
state = _withUpdatedSets(exerciseIndex, newSets);
}
state = 新しい値 を実行すると:
ref.watch している全Widget が自動で再描画されるstate!.exercises[0].sets[0].weight = 60 としないのか? freezed で不変(イミュータブル)にしているため、フィールドの直接変更はコンパイルエラーになる。copyWith で「一部だけ違う新しいオブジェクト」を作るのが正しいパターン。Notifier で最も重要なパターン: 外部から受け取ったデータを、内部の状態フィールドに変換する。
lib/features/workout/workout_provider.dartFuture<void> startSession(
RoutineWithExercises data, {
DateTime? targetDate, // 入力パラメータ
}) async {
state = ActiveWorkoutState(
routineId: data.routine.id,
routineName: data.routine.name,
startedAt: targetDate ?? DateTime.now(), // パラメータ → 状態フィールド
exercises: data.exercises.map((e) {
return ActiveExerciseData(
exerciseId: e.id,
name: e.name,
sets: List.generate(defaultSetCount, (_) => const SetData()),
);
}).toList(),
);
...
}
変換パターンの整理:
| 入力パラメータ | 変換 | 状態フィールド |
|---|---|---|
targetDate (DateTime?) | ?? DateTime.now() | startedAt (DateTime) |
data.exercises (List<Exercise>) | .map + デフォルト3セット生成 | exercises (List<ActiveExerciseData>) |
data.routine.id (String) | そのまま | routineId (String?) |
targetDate ?? DateTime.now()? 通常のセッション開始は「今この瞬間」が開始時刻。しかし「過去日付の記録追加」機能もあるため、nullable な targetDate で両方に対応する。null なら現在時刻、指定されればその日時を使う。ActiveWorkoutState は3層のネスト構造を持つ:
ActiveWorkoutState
├── routineName: String
├── startedAt: DateTime
└── exercises: List<ActiveExerciseData>
├── exerciseId: String
├── name: String
└── sets: List<SetData>
├── weight: double
├── reps: int
└── isPR: bool
「2番目の種目の1番目のセットの重量」を変えるには、3層すべてを新しく作り直す必要がある:
ActiveWorkoutState _withUpdatedSets(
int exerciseIndex, List<SetData> newSets,
) {
final currentState = state!;
return currentState.copyWith(
exercises: [
for (var i = 0; i < currentState.exercises.length; i++)
if (i == exerciseIndex)
currentState.exercises[i].copyWith(sets: newSets)
else
currentState.exercises[i],
],
);
}
ActiveWorkoutNotifier は keepAlive: true で定義されている。セッションのライフサイクル全体:
1. アプリ起動
└── build() → null (セッション未開始)
2. セッション開始
└── startSession() → state = ActiveWorkoutState(...)
3. データ入力(画面遷移しても state 保持)
├── updateWeight() → state = state.copyWith(...)
├── updateReps() → state = state.copyWith(...)
└── addSet() → state = state.copyWith(...)
4. セッション完了
└── completeSession() → Repository に保存 → state = null
5. セッション破棄
└── discardSession() → state = null
state = null で「セッション未開始」に戻る。keepAlive でもメモリリークしないのは、null に戻すことで実質データを解放しているため。
ActiveWorkoutNotifier の build() が null を返す理由は?startSession(data, targetDate: null) を呼ぶと startedAt はどうなる?targetDate ?? DateTime.now() の ?? 演算子により、targetDate が null のとき DateTime.now() がフォールバック値として使われる。これが「パラメータ→状態フィールドの変換」パターン。state = 新しいオブジェクト を代入することで Riverpod が変更を検知し、watch している Widget が再描画される。直接変更ではこの検知が働かない。targetDate ?? DateTime.now()、.map でデフォルト値生成copyWith を連鎖させる。freezed がイミュータブルを保証し、Riverpod が変更を検知非同期データの3状態を安全に扱い、パラメータ付きProviderで柔軟にデータ取得する
DB からデータを読むのは非同期処理。「読み込み中」「エラー」「成功」の3つの状態がある。
// FutureProvider は自動的に AsyncValue を返す
final routineListProvider = FutureProvider<List<RoutineWithExercises>>((ref) {
final repo = ref.watch(routineRepositoryProvider);
return repo.getAllRoutinesWithExercises();
});
lib/features/routine/routine_provider.dart
この Provider を ref.watch すると、AsyncValue<List<RoutineWithExercises>> が返る。
| 状態 | 意味 | UIの表示例 |
|---|---|---|
AsyncLoading | データ読み込み中 | くるくるインジケータ |
AsyncError | 読み込みエラー | エラーメッセージ |
AsyncData | データ取得成功 | ルーティンカード一覧 |
Widget build(BuildContext context, WidgetRef ref) {
final routineListAsync = ref.watch(routineListProvider);
return Scaffold(
body: ...
routineListAsync.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Text('エラー: $error'),
),
data: (routineList) => ListView.separated(
itemCount: routineList.length + 1,
itemBuilder: (context, index) { ... },
),
),
);
}
.when() を使う? 3状態すべてのハンドリングを 強制 してくれるから。if-else で書くと loading の処理を忘れるリスクがある。.when() なら書き忘れるとコンパイルエラーになる。| .when() | if-else | |
|---|---|---|
| 網羅性 | 3状態すべて必須 | 忘れてもコンパイル通る |
| 可読性 | 状態ごとに明確 | ネストが深くなる |
「月ごとのトレーニング日」を取得する Provider を見てみよう:
lib/features/home/home_provider.dart lib/features/home/home_provider.dartfinal sessionDatesForMonthProvider =
FutureProvider.family<List<DateTime>, ({int year, int month})>((
ref,
params,
) {
ref.watch(allWorkoutsProvider);
return ref
.watch(historyRepositoryProvider)
.getSessionDatesForMonth(params.year, params.month);
});
使う側:
// 2026年3月のトレーニング日を取得
final dates = ref.watch(
sessionDatesForMonthProvider((year: 2026, month: 3))
);
// 2026年4月のトレーニング日を取得(別のキャッシュ)
final nextDates = ref.watch(
sessionDatesForMonthProvider((year: 2026, month: 4))
);
.family の判断基準:
Provider に渡すパラメータが必要?
|
+-- NO --> 通常の Provider で十分
| 例: routineListProvider(パラメータなし)
|
+-- YES --> .family を使う
|
同じパラメータで複数回呼ばれる?
+-- YES --> キャッシュが効いて効率的
| 例: sessionDatesForMonthProvider(同じ月は再取得不要)
+-- NO --> それでも .family は有効
例: lastSessionDateProvider(routineId)
筋トレメモには3種類の .family Provider がある:
lib/features/home/home_provider.dart// 1. Record型パラメータ(複数値をまとめる)
FutureProvider.family<List<DateTime>, ({int year, int month})>
// 2. 単一パラメータ(DateTime)
FutureProvider.family<List<Workout>, DateTime>
// 3. 単一パラメータ(String)
FutureProvider.family<DateTime?, String>
lib/features/history/history_provider.dart
// 4. 詳細取得(sessionId で引く)
FutureProvider.family<WorkoutDetail, String>
| Provider | パラメータ | 用途 |
|---|---|---|
sessionDatesForMonthProvider | ({int year, int month}) | 月ごとのカレンダードット |
sessionsForDateProvider | DateTime | 日付タップ時の詳細 |
lastSessionDateProvider | String (routineId) | ルーティンカードの「前回」表示 |
workoutDetailProvider | String (sessionId) | 履歴詳細画面 |
筋トレメモでは、一部の Provider を @riverpod ではなく手動で定義している:
/// drift 生成型を riverpod_generator で扱えないため手動で定義
final routineListProvider = FutureProvider<List<RoutineWithExercises>>((ref) {
final repo = ref.watch(routineRepositoryProvider);
return repo.getAllRoutinesWithExercises();
});
コメントに「drift 生成型を riverpod_generator で扱えないため手動で定義」と書かれている。
@riverpod で書けない場合:
RoutineWithExercises は drift が生成した型。riverpod_generator がこの型を解析できない({int year, int month}) のような Record 型もコード生成が非対応| @riverpod(コード生成) | 手動定義 | |
|---|---|---|
| 使い時 | 標準的な型(String, int等)のProvider | drift生成型、DateTime、Record型パラメータ |
| 例 | workoutRepository, historyRepository | routineListProvider, sessionDatesForMonthProvider |
| メリット | ボイラープレート削減 | どんな型でも使える |
| デメリット | 一部の型が非対応 | 定義がやや冗長 |
riverpod_generator は Dart のソースコードを静的解析して Provider を生成する。drift が生成する型は別のコード生成器の出力なので、riverpod_generator が解析するタイミングでは「まだ存在しない型」になり、エラーが起きる。.when() の loading / error / data のうち、1つでも書き忘れるとどうなる?.when() は3つの引数すべてが必須。1つでも抜けるとコンパイルエラー。これが「状態の網羅漏れ」を防ぐ仕組み。sessionDatesForMonthProvider((year: 2026, month: 3)) と sessionDatesForMonthProvider((year: 2026, month: 4)) は同じキャッシュを使う?(year: 2026, month: 3) で呼べばキャッシュが効くが、別のパラメータなら別のインスタンスになる。routineListProvider を @riverpod ではなく手動で定義している理由は?.when() で網羅的にハンドリングするテストでの Provider 差し替え、N+1問題、レイヤー設計の価値を理解する
通常のアプリでは ProviderScope が Widget ツリーのトップにある。テストでは ProviderContainer を直接作る:
container = ProviderContainer(
overrides: [
appDatabaseProvider.overrideWithValue(db),
currentUserIdProvider.overrideWithValue(null),
],
);
テストで本番DBではなくインメモリDBを使う仕組み:
test/features/workout/workout_provider_test.dartlate AppDatabase db;
late ProviderContainer container;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
container = ProviderContainer(
overrides: [
appDatabaseProvider.overrideWithValue(db),
currentUserIdProvider.overrideWithValue(null),
],
);
});
何が起きているか:
AppDatabase.forTesting(NativeDatabase.memory()) でインメモリDB(テスト後に消える)を作成appDatabaseProvider.overrideWithValue(db) で、本番DBの代わりにインメモリDBを注入ref.watch(appDatabaseProvider) しているので、自動的にインメモリDBを使う| override あり | override なし | |
|---|---|---|
| DB | テスト専用のインメモリDB | 本番DBに接続 |
| テスト後 | DBが自動消去 | 本番データが汚染 |
| テスト速度 | 高速(メモリ上) | 低速(ファイルI/O) |
| 並列実行 | 各テストが独立 | テスト同士が干渉 |
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
container = ProviderContainer(
overrides: [
appDatabaseProvider.overrideWithValue(db),
currentUserIdProvider.overrideWithValue(null),
],
);
addTearDown(() async {
container.dispose();
await db.close();
});
notifier = container.read(activeWorkoutProvider.notifier);
});
テスト1回ごとのライフサイクル:
setUp
├── 1. インメモリDB作成
├── 2. Container作成(DBをoverride)
└── 3. Notifier取得
テスト本体
├── notifier.startSession(...)
├── notifier.updateWeight(...)
└── expect(...)
tearDown
├── 1. Container破棄(全Provider解放)
└── 2. DB接続を閉じる
テストが書きやすいのは、Repository パターンのおかげ。以下はDB操作のテスト:
test/features/workout/workout_repository_test.dartsetUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
repository = WorkoutRepository(db);
});
test('前回記録がない場合は空リストを返す', () async {
final result = await repository.getLastSessionSets(exerciseId);
expect(result, isEmpty);
});
Repository テストのポイント:
// テストの典型パターン: 保存 → 取得 → 検証
await saveTestSession( // 1. 保存
exerciseId: exerciseId,
exerciseName: 'ベンチプレス',
sets: [SetData(weight: 60, reps: 10)],
);
final result = await repository.getLastSessionSets(exerciseId); // 2. 取得
expect(result.length, 1); // 3. 検証
expect(result[0].weight, 60.0);
筋トレメモの getSessionDetail にはパフォーマンスの改善余地がある:
Future<WorkoutDetail> getSessionDetail(String workoutId) async {
// 1回目のクエリ: Workout を取得
final workout = await (...).getSingle();
// 2回目のクエリ: WorkoutExercises を取得
final workoutExercises = await (...).get();
// N回のクエリ: 各 Exercise の Sets を取得
for (final ex in workoutExercises) {
final sets = await (...).get(); // ここが N 回実行される!
exercisesWithSets.add(...);
}
}
N+1 問題とは:
種目が3つなら 4回のクエリ。10種目なら 11回。種目数が増えるほど遅くなる。
改善案: JOIN で一括取得
// 改善版: 1回のクエリで全データを JOIN で取得
final rows = await (select(workoutExercises)
.join([
innerJoin(workoutSets,
workoutSets.workoutExerciseId.equalsExp(workoutExercises.id)),
])
..where(workoutExercises.workoutId.equals(workoutId))
).get();
// → 1回のクエリで全データ取得。あとは Dart 側でグループ化
筋トレメモの全レイヤー構造を振り返ろう:
UI 層 (Flutter Widget)
HomeScreen, WorkoutScreen, HistoryScreen
├── ref.watch で Provider を監視
└── ref.read で Notifier のメソッドを呼ぶ
|
Provider 層 (Riverpod)
activeWorkoutProvider, routineListProvider, allWorkoutsProvider
├── Notifier: 状態管理 + ビジネスロジック
└── FutureProvider / StreamProvider: 非同期データ供給
|
Repository 層
WorkoutRepository, HistoryRepository, RoutineRepository
├── ドメイン ↔ DB 形式の変換
└── DB操作のカプセル化
|
DB 層 (drift / SQLite)
AppDatabase, テーブル定義, マイグレーション
各レイヤーの役割と、override によるテスト戦略:
| レイヤー | 責務 | テスト方法 |
|---|---|---|
| UI | 表示 + ユーザー操作 | Widget テスト(今後) |
| Provider/Notifier | 状態管理 + ロジック | ProviderContainer + DB override |
| Repository | ドメイン↔DB変換 | インメモリDB直接注入 |
| DB | データ永続化 | マイグレーションテスト |
appDatabaseProvider.overrideWithValue(db) を使う目的は?