【Flutter】Hydrated BLoCで実現する状態の永続化
この記事は、Flutter大学アドベントカレンダー 2024 21日目の記事です。
はじめに
Flutterアプリ開発では、ユーザーがアプリを再起動しても前回の状態を保持したいケースが多々あります。たとえば、ユーザーが最後に見ていた画面、選択していたテーマモード、設定項目などを再起動後も保持することで、スムーズなUXを実現できます。
本記事では、状態管理手法であるBLoCと、状態の自動永続化を可能にするhydrated_bloc
パッケージを活用し、アプリ再起動後も状態を復元する方法をご紹介します。
hydrated_bloc
を使うのか?
なぜhydrated_bloc
は、bloc
パッケージ上で動作し、BLoCやCubitが持つ状態を自動的にJSON形式へシリアライズ・デシリアライズして、ローカルストレージへ保存してくれます。この仕組みにより、次のようなメリットが得られます。
- 状態の自動復元:アプリを再起動しても、BLoCが管理する状態がそのまま復元され、ユーザーは前回の続きからスムーズに操作を再開できます。
- 実装コストの軽減:状態の永続化に関する入出力処理やJSON化の手間を大幅に削減し、状態管理ロジックそのものに集中できます。
-
汎用性:
toJson
とfromJson
メソッドを実装すれば、任意の状態を簡単に永続化できるため、幅広いユースケースに対応できます。
shared_preferences
との違いは?
shared_preferences
はキーと値のペアで軽量なデータを保存するためによく使われるパッケージです。ユーザー設定や小さなフラグなどを保持するには便利ですが、BLoCの状態全体を扱う場合、やや面倒な点が生じます。
-
シリアライズ処理の手動実装が必要:
shared_preferences
でBLoC状態を保存する場合、状態を自分でJSON文字列へ変換したり、逆に文字列から状態へ復元するコードを書く必要があります。 - 状態が複雑になると負担増:状態が複雑になるほど、読み書き処理や変換コードが増え、メンテナンス性が低下してしまいます。
-
状態管理との統合が必要:
shared_preferences
はあくまでも「ローカルストレージへデータを保存・読み込む機能」を提供するだけです。そのため、BLoCをはじめ、Riverpodなど他の状態管理手法を用いる場合でも、それぞれの状態をshared_preferences
と紐づけるためのコードを自分で実装しなければなりません。状態管理ロジックとストレージ処理を手動で結合する必要があるため、コードが複雑化しやすくなります。
これに対し、hydrated_bloc
はBLoCやCubitの状態を自動的に永続化することに特化しているため、最小限の実装で済みます。HydratedCubit
やHydratedBloc
を継承し、toJson
・fromJson
を用意するだけで、状態をシームレスに保存・復元してくれるのです。
必要なパッケージの導入
pubspec.yaml
で以下のように依存関係を追加します。
dependencies:
flutter_bloc: ^8.1.6
hydrated_bloc: ^9.1.5
また、ローカルパス取得のためにpath_provider
パッケージも用います。
dependencies:
path_provider: ^2.1.5
利用例①:カウンターアプリ
最初は、シンプルなカウンターアプリでhydrated_bloc
を利用する例を示します。カウンター値をhydrated_bloc
で保持し、再起動後も前回のカウントが復元されることを確認します。
HydratedBloc
の初期化
main()
でHydratedStorage
を初期化し、その上でアプリ全体をラップします。
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart';
import 'counter_cubit.dart';
import 'counter_page.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
HydratedBloc.storage = await HydratedStorage.build(
storageDirectory: await getApplicationDocumentsDirectory(),
);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Hydrated Bloc Demo',
home: BlocProvider(
create: (context) => CounterCubit(),
child: const CounterPage(),
),
);
}
}
HydratedCubit
の定義
CounterCubit
は整数状態を持つ単純なCubitです。HydratedCubit
を継承し、toJson
およびfromJson
を実装することで状態が自動的に永続化・復元されます。
Freezed等でtoJson
およびfromJson
を実装しておけば、より簡素化できます。
import 'package:hydrated_bloc/hydrated_bloc.dart';
class CounterCubit extends HydratedCubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
Map<String, dynamic>? toJson(int state) {
return {'value': state};
}
int fromJson(Map<String, dynamic> json) {
return json['value'] as int;
}
}
UI実装
CounterPage
では単純なカウント表示と増減ボタンを配置します。アプリを再起動しても最後のカウント値が表示されることを確認できます。
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'counter_cubit.dart';
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
Widget build(BuildContext context) {
final counterCubit = context.read<CounterCubit>();
return Scaffold(
appBar: AppBar(title: const Text('HydratedBloc Counter')),
body: Center(
child: BlocBuilder<CounterCubit, int>(
builder: (context, count) => Text(
'$count',
style: const TextStyle(fontSize: 48),
),
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
onPressed: counterCubit.increment,
child: const Icon(Icons.add),
),
const SizedBox(height: 8),
FloatingActionButton(
onPressed: counterCubit.decrement,
child: const Icon(Icons.remove),
),
],
),
);
}
}
利用例②:アプリ設定の状態保持
次に、アプリの設定(たとえばテーマモードなど)をhydrated_bloc
で永続化する例を示します。ユーザーがライト/ダークテーマを切り替えた場合、その設定が再起動後も保持されることでユーザーが常に好みのテーマでアプリを利用できます。
ThemeCubit
の定義
ThemeCubit
はHydratedCubit
を継承し、AppTheme.light
またはAppTheme.dark
を状態として管理します。toJson
とfromJson
で列挙値を文字列と相互変換することで永続化を実現します。
import 'package:hydrated_bloc/hydrated_bloc.dart';
enum AppTheme { light, dark }
class ThemeCubit extends HydratedCubit<AppTheme> {
ThemeCubit() : super(AppTheme.light);
void toggleTheme() {
emit(state == AppTheme.light ? AppTheme.dark : AppTheme.light);
}
Map<String, dynamic>? toJson(AppTheme state) {
return {'theme': state == AppTheme.light ? 'light' : 'dark'};
}
AppTheme fromJson(Map<String, dynamic> json) {
final themeString = json['theme'] as String;
return themeString == 'dark' ? AppTheme.dark : AppTheme.light;
}
}
main.dart
でThemeCubit
を提供
BlocProvider
を用いてThemeCubit
をアプリ全体に注入し、BlocBuilder
で現在のテーマをMaterialApp
に適用します。
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart';
import 'theme_cubit.dart';
import 'settings_page.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
HydratedBloc.storage = await HydratedStorage.build(
storageDirectory: await getApplicationDocumentsDirectory(),
);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ThemeCubit(),
child: BlocBuilder<ThemeCubit, AppTheme>(
builder: (context, theme) {
return MaterialApp(
title: 'Hydrated Bloc Theme Example',
theme: ThemeData(
brightness: theme == AppTheme.light ? Brightness.light : Brightness.dark,
),
home: const SettingsPage(),
);
},
),
);
}
}
SettingsPage
でテーマ切り替えUI
SettingsPage
でスイッチを用いてテーマをトグルします。再起動しても最後に設定したテーマが反映されます。
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'theme_cubit.dart';
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
Widget build(BuildContext context) {
final themeCubit = context.read<ThemeCubit>();
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: Center(
child: BlocBuilder<ThemeCubit, AppTheme>(
builder: (context, theme) {
final isDark = (theme == AppTheme.dark);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Current Theme: ${isDark ? "Dark" : "Light"}',
style: const TextStyle(fontSize: 24),
),
const SizedBox(height: 20),
Switch(
value: isDark,
onChanged: (value) {
themeCubit.toggleTheme();
},
),
],
);
},
),
),
);
}
}
最後までご覧いただきありがとうございました。
好評の場合は実際に案件で使用している、応用的なBLoCの状態管理についての記事を作成、、するかもしれないし、しないかもしれないです。。
不足事項等あれば教えていただけると幸いです!
お世話になっているコミュニティ
Discussion