🕌

【Flutter】Riverpod3.0からの永続化に独自の保存領域を使う

に公開

Riverpod 3.0ではProviderに永続化機構を統合できるようになりました。公式にはriverpod_sqfliteが存在しますが、sqfliteではなく独自の保存領域を使うこともそう難しくはありません。

ここではFlutterSecureStorageを例に、独自の保存領域を用いた永続化機構を作成してみます。

Storageクラスを実装する

Storageインターフェイスは、Riverpodの永続化機構においてCRUDを行う部分です。というよりこれしか基本的にはありません。

ジェネリクスとなっており、Storage<KeyT extends Object?, EncodedT extends Object?>となっています。1つ目の型引数にはキーを、2つめの型引数にはvalueを示します。要するにキーバリューです。

Storageインターフェイスではread, write, deleteの3つのメソッドを実装しますが、これには単なる値に加えて、キャッシュ戦略に係る情報も含まれているので、これも一緒に保存する必要があります。

パラメータ パラメータ説明
value 保存したい値
destroyKey このキーが更新された場合、状態は破棄する。例えばアプリに破壊的な変更が加わって永続化した情報に対して初期化が必要な場合や、セッション破棄後にセッション情報を削除したい場合など
cacheTime いつまで状態を保持するか

FlutterSecureStorageの場合、そのままFlutterSecureStorageのread, write, deleteが対応するので、このパラメータを含めて保持するようにしてみます。

// Riverpodの必要な情報を保持するためのfreezedデータクラス

abstract class FreezedPersistedData with _$FreezedPersistedData {
  const factory FreezedPersistedData({
    required String value,
    required String? destroyKey,
    required DateTime? expireAt,
  }) = _FreezedPersistedData;

  factory FreezedPersistedData.fromJson(Map<String, dynamic> json) =>
      _$FreezedPersistedDataFromJson(json);
}

class RiverpodSecureStorage implements Storage<String, Map<String, dynamic>?> {
  final _storage = FlutterSecureStorage();

  
  FutureOr<void> delete(String key) async {
    await _storage.delete(key: key);
  }

  
  FutureOr<PersistedData<Map<String, dynamic>>?> read(String key) async {
    final result = await _storage.read(key: key);

    if (result == null) return null;

    // まずRiverpodの必要な情報を含むクラスから型に落とし込んで、
    final data = FreezedPersistedData.fromJson(jsonDecode(result));

    return PersistedData(
      // さらにその内部のバリューをMap<String, dynamic>?に変換する
      jsonDecode(data.value),
      destroyKey: data.destroyKey,
      expireAt: data.expireAt,
    );
  }

  
  FutureOr<void> write(
    String key,
    Map<String, dynamic>? value,
    StorageOptions options,
  ) async {
    final duration = options.cacheTime.duration;
    await _storage.write(
      key: key,
      value: jsonEncode(
        FreezedPersistedData(
          value: jsonEncode(value),
          destroyKey: options.destroyKey,
          expireAt: duration == null ? null : DateTime.now().add(duration),
        ).toJson(),
      ),
    );
  }
}

使い方

このようにすれば、あとはこの機構を用いてNotifierのクラスを作成するだけです。

/// アカウント情報を保持するデータクラス(例)

abstract class AccountData with _$AccountData {
  const factory AccountData({
    required String token,
    required String userId,
    required String userName,
  }) = _AccountData;

  factory AccountData.fromJson(Map<String, dynamic> json) =>
      _$AccountDataFromJson(json);
}

/// ストレージにアクセスするためのProvider(シングルトン)
(keepAlive: true)
RiverpodSecureStorage riverpodSecureStorage(Ref ref) => RiverpodSecureStorage();


class AccountNotifier extends _$AccountNotifier
    with Persistable<AccountData?, String, Map<String, dynamic>?> {
  
  Future<AccountData?> build() async {
    // このNotifierクラスは、stateが変更された際に
    // Riverpodによって自動的に値が保存される。
    await persist(
      key: 'account',
      storage: ref.read(riverpodSecureStorageProvider),
      encode: (data) => data?.toJson(),
      decode: (json) => json == null ? null : AccountData.fromJson(json),
    );

    return state.value;
  }

  
  Future<void> login() async {
    await Future.delayed(const Duration(seconds: 3));
    state = AsyncData(
      AccountData(token: '1234567890', userId: 'userId', userName: 'userName'),
    );
  }

  
  Future<void> logout() async {
    await Future.delayed(const Duration(seconds: 3));
    state = AsyncData(null);
  }
}

class AccountExample extends ConsumerWidget {
  const AccountExample({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final account = ref.watch(accountNotifierProvider);
    final login = ref.watch(accountNotifierProvider.login);
    final logout = ref.watch(accountNotifierProvider.logout);

    return Scaffold(
      appBar: AppBar(title: const Text('Account Example')),
      body: Center(
        child: switch (account) {
          AsyncLoading() => const Center(child: CircularProgressIndicator()),
          AsyncData(:final value) =>
            value == null
                ? ElevatedButton(
                  onPressed: login.state is PendingMutation ? null : login.call,
                  child: const Text('Login'),
                )
                : Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Text('User ID: ${value.userId}'),
                    Text('User Name: ${value.userName}'),
                    ElevatedButton(
                      onPressed:
                          logout.state is PendingMutation ? null : logout.call,
                      child: const Text('Logout'),
                    ),
                  ],
                ),
          AsyncError(:final value) => Text('Error $value'),
        },
      ),
    );
  }
}

また、初期化処理に重い非同期の処理が必要な場合には、それこそriverpod_sqfliteのサンプルと実装が参考になります。上記の例で言えば、

(keepAlive: true)
RiverpodSecureStorage riverpodSecureStorage(Ref ref) => RiverpodSecureStorage();

の部分がFuture<RiverpodSecureStorage>となって、その中で初期化処理をします。
Notifier側ではref.watch(riverpodSecureStorageProvider)persistの引数に渡すことになります。

まとめ

永続化の機構がRiverpodに公式に統合されたことで、各Notifierはそのために色々な紆余曲折をすることなく本来の状態管理に専念できるようになりました。また、シンプルな機構のため、様々な永続化手法に応用が効きます。(というより今までなんでなかったんだろうという…)

Discussion