📀

【Flutter】Hydrated BLoCで実現する状態の永続化

2024/12/21に公開

この記事は、Flutter大学アドベントカレンダー 2024 21日目の記事です。

はじめに

Flutterアプリ開発では、ユーザーがアプリを再起動しても前回の状態を保持したいケースが多々あります。たとえば、ユーザーが最後に見ていた画面、選択していたテーマモード、設定項目などを再起動後も保持することで、スムーズなUXを実現できます。

本記事では、状態管理手法であるBLoCと、状態の自動永続化を可能にするhydrated_blocパッケージを活用し、アプリ再起動後も状態を復元する方法をご紹介します。

なぜhydrated_blocを使うのか?

hydrated_blocは、blocパッケージ上で動作し、BLoCやCubitが持つ状態を自動的にJSON形式へシリアライズ・デシリアライズして、ローカルストレージへ保存してくれます。この仕組みにより、次のようなメリットが得られます。

  • 状態の自動復元:アプリを再起動しても、BLoCが管理する状態がそのまま復元され、ユーザーは前回の続きからスムーズに操作を再開できます。
  • 実装コストの軽減:状態の永続化に関する入出力処理やJSON化の手間を大幅に削減し、状態管理ロジックそのものに集中できます。
  • 汎用性toJsonfromJsonメソッドを実装すれば、任意の状態を簡単に永続化できるため、幅広いユースケースに対応できます。

shared_preferencesとの違いは?

shared_preferencesはキーと値のペアで軽量なデータを保存するためによく使われるパッケージです。ユーザー設定や小さなフラグなどを保持するには便利ですが、BLoCの状態全体を扱う場合、やや面倒な点が生じます。

  • シリアライズ処理の手動実装が必要shared_preferencesでBLoC状態を保存する場合、状態を自分でJSON文字列へ変換したり、逆に文字列から状態へ復元するコードを書く必要があります。
  • 状態が複雑になると負担増:状態が複雑になるほど、読み書き処理や変換コードが増え、メンテナンス性が低下してしまいます。
  • 状態管理との統合が必要shared_preferencesはあくまでも「ローカルストレージへデータを保存・読み込む機能」を提供するだけです。そのため、BLoCをはじめ、Riverpodなど他の状態管理手法を用いる場合でも、それぞれの状態をshared_preferencesと紐づけるためのコードを自分で実装しなければなりません。状態管理ロジックとストレージ処理を手動で結合する必要があるため、コードが複雑化しやすくなります。

これに対し、hydrated_blocはBLoCやCubitの状態を自動的に永続化することに特化しているため、最小限の実装で済みます。HydratedCubitHydratedBlocを継承し、toJsonfromJsonを用意するだけで、状態をシームレスに保存・復元してくれるのです。

必要なパッケージの導入

pubspec.yamlで以下のように依存関係を追加します。
https://pub.dev/packages/flutter_bloc
https://pub.dev/packages/hydrated_bloc

pubspec.yaml
dependencies:
  flutter_bloc: ^8.1.6
  hydrated_bloc: ^9.1.5

また、ローカルパス取得のためにpath_providerパッケージも用います。
https://pub.dev/packages/path_provider

pubspec.yaml
dependencies:
  path_provider: ^2.1.5

利用例①:カウンターアプリ

最初は、シンプルなカウンターアプリでhydrated_blocを利用する例を示します。カウンター値をhydrated_blocで保持し、再起動後も前回のカウントが復元されることを確認します。

HydratedBlocの初期化

main()HydratedStorageを初期化し、その上でアプリ全体をラップします。

main.dart
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を実装しておけば、より簡素化できます。

counter_cubit.dart
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では単純なカウント表示と増減ボタンを配置します。アプリを再起動しても最後のカウント値が表示されることを確認できます。

counter_page.dart
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の定義

ThemeCubitHydratedCubitを継承し、AppTheme.lightまたはAppTheme.darkを状態として管理します。toJsonfromJsonで列挙値を文字列と相互変換することで永続化を実現します。

theme_cubit.dart
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.dartThemeCubitを提供

BlocProviderを用いてThemeCubitをアプリ全体に注入し、BlocBuilderで現在のテーマをMaterialAppに適用します。

main.dart
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でスイッチを用いてテーマをトグルします。再起動しても最後に設定したテーマが反映されます。

settings_page.dart
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の状態管理についての記事を作成、、するかもしれないし、しないかもしれないです。。
不足事項等あれば教えていただけると幸いです!

お世話になっているコミュニティ

https://flutteruniv.com/
https://galirage.com/

Discussion