🦅

Riverpod Generator V3でGenericsを利用する

2024/04/29に公開
1

Riverpod Generatorの次期バージョンV3ではGenericsへの対応が予定されています。

CHANGELOGによるとversion 3.0.0-dev.7 で実装されたようです。

https://github.com/rrousselGit/riverpod/blob/riverpod_generator-v3.0.0-dev.11/packages/riverpod_generator/CHANGELOG.md#300-dev7---2023-10-29

サンプルコードとして紹介されているコードはこちらで、Riverpod Generatorの関数およびClass(Notifier)のProviderの宣言で型パラメタ(下の例だと<T>)を指定することが可能になります。


List<T> example<T extends num>(ExampleRef<T> ref) {
  return <T>[];
}


class ClassExample<T> extends _$ClassExample<T> {
  
  List<T> build() => <T>[];
}

なお、作者のRemiさんのコメントによるとGenericsはコード生成のみでサポートするとのこと。

If we want generic providers, it'd most definitely be code-gen only.

https://github.com/rrousselGit/riverpod/issues/2150#issuecomment-1423032267

Genericsに限らず今後、新しい機能はコード生成のみでサポートされる可能性もあるため、従来スタイル(非コード生成)でRiverpodを利用している場合は移行を検討するのが良さそうです。

本記事ではFlutter SDK 3.19.6、および以下のバージョンを使用しました

flutter pub add \
    hooks_riverpod:3.0.0-dev.3 \
    riverpod_annotation:3.0.0-dev.3 \
    dev:riverpod_generator:3.0.0-dev.11 \
    dev:riverpod_lint:3.0.0-dev.4

具体的なユースケース

実際にGenericisを試してみるため shared_preferencesを利用するProviderをGenericsを使って実装してみました。

shared_preferenceはint, double, bool, String, List<String>などの複数の型に対応しているので、Genericsを利用してコードの重複を減らし型安全に利用することを試みます。

shared_preferences

shared_preferenceの特徴として

  • インスタンスの取得は非同期(awaitが必要)
  • 型毎にread/writeの関数が分かれている
  • readで値が保存されていない場合はnullが返る

などがあり、実装の際にはこれらを考慮する必要があります。

参考までにshared_preferencesの基本的な使用例はこちらです(公式のサンプルコードより)。

// Obtain shared preferences.
final SharedPreferences prefs = await SharedPreferences.getInstance();

// Try reading data from the 'counter' key. If it doesn't exist, returns null.
final int? counter = prefs.getInt('counter');
// Try reading data from the 'repeat' key. If it doesn't exist, returns null.
final bool? repeat = prefs.getBool('repeat');
// Try reading data from the 'decimal' key. If it doesn't exist, returns null.
final double? decimal = prefs.getDouble('decimal');
// Try reading data from the 'action' key. If it doesn't exist, returns null.
final String? action = prefs.getString('action');
// Try reading data from the 'items' key. If it doesn't exist, returns null.
final List<String>? items = prefs.getStringList('items');
// Obtain shared preferences.
final SharedPreferences prefs = await SharedPreferences.getInstance();

// Save an integer value to 'counter' key.
await prefs.setInt('counter', 10);
// Save an boolean value to 'repeat' key.
await prefs.setBool('repeat', true);
// Save an double value to 'decimal' key.
await prefs.setDouble('decimal', 1.5);
// Save an String value to 'action' key.
await prefs.setString('action', 'Start');
// Save an list of strings to 'items' key.
await prefs.setStringList('items', <String>['Earth', 'Moon', 'Sun']);

V2:Genericsを利用しない場合

最初に比較用としてRiverpodの現行版(V2)でshared_preferenceを利用している実装例を紹介します。V3でのGenericsを利用したコードを知りたい方は読み飛ばして構いません。

shared_preferenceの初期化

Genericsとは関係がありませんがProviderを使ってshared_preferenceのインスタンスの初期化を行います。

SharedPreferencesのインスタンスの取得は非同期のためawaitが必要です。

// Obtain shared preferences.
final SharedPreferences prefs = await SharedPreferences.getInstance();

// Try reading data from the 'counter' key. If it doesn't exist, returns null.
final int? counter = prefs.getInt('counter');

都度インスタンスを取得するとreadの際にもawaitが必要で扱いづらいので、予めインスタンスを生成してcacheしています。

まず、SharedPreferencesのインスタンスを管理する空のProviderを定義します。


SharedPreferences sharedPreferences(SharedPreferencesRef ref) =>
    throw UnimplementedError();

次にmain関数の中でSharedPreferences.getInstance()を呼び出してSharedPreferencesのインスタンスを取得します。取得したインスタンスの実体を先ほど定義したProviderに対してoverrideWithValue()を使って上書きします。

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final sharedPreferences = await SharedPreferences.getInstance();

  runApp(
    ProviderScope(
      overrides: [
        sharedPreferencesProvider.overrideWithValue(sharedPreferences),
      ],
      child: const MyApp(),
    ),
  );
}

これ以降、以下のようなコードで同期的にSharedPreferencesのインスタンスを使うことができます。

final sharedPreference = ref.watch(sharedPreferencesProvider);

final someBoolValue = sharedPreference.getBool(...)

keyとデフォルト値

shared_preferencesではkeyを使って値の読み書きを行います。keyに対して値が存在していない(保存されていない)場合のために、keyに対してデフォルト値をあらかじめ用意しておきます。keyとデフォルト値はペアで管理するのが望ましいため次のようなenum Preference<T>を定義しています。

enum Preference<T> {
  // bool用のcaseの例(デフォルト値がtrueの例)
  shouldShowWalkthrough('should_show_walkthrough', true),
  // int
  themeMode('theme_mode', 0),
  // String
  appColor('app_color', ''),
  ;

  const Preference(this.key, this.defaultValue);
  final String key;
  final T defaultValue;
}

上の例のshouldShowWalkthroughはウォークスルーの表示を制御するshould_show_walkthroughというkeyと、値が保存されていなかった時のデフォルト値trueを定義しています。

なお、今回の実装ではnullableな型(int?String?など)には対応しない方針とします。

話が少しややこしいですがGenericsを利用したenum Preference<T>はV2のRiverpodでも利用することが可能です(実際の利用例は後述)。

SharedPreferencesをRead/WriteするProviderの定義

SharedPreferencesから値を読み書きするNotifierを定義します。

本来であれば以下のようにNotifierのclass宣言で型パラメタ(<T>)を利用したいところですが、Riverpod Generator V2では対応していません。


class Preference<T> extends _$Preference<T> {
  
  T build(Preference<T> pref) => ...

ではどのようにしているかというと、例えばBoolを読み書きするProviderは以下のような実装をしています。


class BoolPreference extends _$BoolPreference {
  
  bool build(Preference<bool> pref) =>
      ref.read(sharedPreferencesProvider).getBool(pref.key) ??
      pref.defaultValue;

  // ignore: avoid_positional_boolean_parameters
  Future<void> update(bool value) async {
    await ref.read(sharedPreferencesProvider).setBool(pref.key, value);
    ref.invalidateSelf();
  }
}

上記のコードでは以下の場所で型を指定しています。

  • build関数
    • 戻り値の型にboolを指定
    • 引数のPreference<T>の型パラメタにboolを指定
  • update関数
    • 引数valueに対してboolを指定

このようにNotifierで具体的な型を明示する必要があるためSharedPreferencesで扱う型毎にNotifierの実装が必要で、コードが重複してしまう課題があります。

参考までにBool, Int, StringのNotifierを定義するとこちらのようになります。


class BoolPreference extends _$BoolPreference {
  
  bool build(Preference<bool> pref) =>
      ref.read(sharedPreferencesProvider).getBool(pref.key) ??
      pref.defaultValue;

  // ignore: avoid_positional_boolean_parameters
  Future<void> update(bool value) async {
    await ref.read(sharedPreferencesProvider).setBool(pref.key, value);
    ref.invalidateSelf();
  }
}


class IntPreference extends _$IntPreference {
  
  int build(Preference<int> pref) =>
      ref.read(sharedPreferencesProvider).getInt(pref.key) ??
      pref.defaultValue;

  Future<void> update(int value) async {
    await ref.read(sharedPreferencesProvider).setInt(pref.key, value);
    ref.invalidateSelf();
  }
}


class StringPreference extends _$StringPreference {
  
  String build(Preference<String> pref) =>
      ref.read(sharedPreferencesProvider).getString(pref.key) ??
      pref.defaultValue;

  Future<void> update(String value) async {
    await ref.read(sharedPreferencesProvider).setString(pref.key, value);
    ref.invalidateSelf();
  }
}

呼び出し側のコード

呼び出し側は次のようなコードになります。

bool値を参照するコード

final shouldShowWalkthrough = ref.watch(boolPreferenceProvider(Preference.shouldShowWalkthrough));

bool値を更新するコード

ref.read(boolPreferenceProvider(Preference.shouldShowWalkthrough).notifier).update(false);

なお、update()関数で自分自身をref.invalidateSelf();することで値の更新がリアクティブに反映される挙動になります。

  Future<void> update(String value) async {
    await ref.read(sharedPreferencesProvider).setString(pref.key, value);
    ref.invalidateSelf();
  }

ここまでがV2での実装例でした。

V3:Genericsを利用した実装

ではGenericsに対応したRiverpod V3ではどのような実装になったでしょうか。

結論から言うと、次のように型パラメータ<T>を指定したNotifierを定義することで複数の型に対応したNotifierを宣言することができます。


class PreferenceNotifier<T> extends _$PreferenceNotifier<T> {
  
  T build(Preference<T> pref) {
    return ref.watch(sharedPreferencesProvider).getValue(pref);
  }

  Future<void> update(T value) async {
    await ref.read(sharedPreferencesProvider).setValue(pref, value);
    ref.invalidateSelf();
  }
}

readおよびwriteはこちらのようなコードになります。

// read
ref.watch(preferenceNotifierProvider(Preference.shouldShowWalkthrough));

// write
ref.read(preferenceNotifierProvider(Preference.shouldShowWalkthrough).notifier).update(false);

このようにV2では変数の型毎にNotifierを用意していたものが、Genericsの対応によって共通のNotifierで実装することが可能になりました。

なお、getValue()setValue()はextensionを使ってSharedPreferencesに生やしたメソッドです。型に応じてSharedPreferences本体のgetter/setterを呼び出しています、煩雑ですが抽象化できないところなので仕方がありません。

extension on SharedPreferences {
  T getValue<T>(Preference<T> prefKey) {
    if (prefKey.defaultValue is bool) {
      final value = getBool(prefKey.key);
      return value == null ? prefKey.defaultValue : value as T;
    } else if (prefKey.defaultValue is int) {
      final value = getInt(prefKey.key);
      return value == null ? prefKey.defaultValue : value as T;
    } else if (prefKey.defaultValue is double) {
      final value = getDouble(prefKey.key);
      return value == null ? prefKey.defaultValue : value as T;
    } else if (prefKey.defaultValue is String) {
      final value = getString(prefKey.key);
      return value == null ? prefKey.defaultValue : value as T;
    } else if (prefKey.defaultValue is List<String>) {
      final value = getStringList(prefKey.key);
      return value == null ? prefKey.defaultValue : value as T;
    } else {
      throw UnimplementedError(
        '''SharedPreferencesExt.getValue: unsupported types ${prefKey.defaultValue.runtimeType}''',
      );
    }
  }

  Future<void> setValue<T>(Preference<T> prefKey, T value) {
    if (value is bool) {
      return setBool(prefKey.key, value);
    } else if (value is int) {
      return setInt(prefKey.key, value);
    } else if (value is double) {
      return setDouble(prefKey.key, value);
    } else if (value is String) {
      return setString(prefKey.key, value);
    } else if (value is List<String>) {
      return setStringList(prefKey.key, value);
    } else {
      throw UnimplementedError(
        '''SharedPreferencesExt.setValue: unsupported types ${prefKey.defaultValue.runtimeType}''',
      );
    }
  }
}

サンプルコード

flutter createで作成されるカウンターアプリをRiverpod V3のGenericsとSharedPreferencesを使って永続化したサンプルコードです。カウンターの値をSharedPreferencesに保存して、アプリを再起動したときに復元しています。

https://github.com/yorifuji/riverpod_generics_sandbox

Widget側でRiverpodを利用している箇所はこちらです

https://github.com/yorifuji/riverpod_generics_sandbox/blob/756f61e578552790679a10d97d0f84cd1bbc90e0/lib/main.dart#L42-L52

RiverpodとSharedPreferencesのコードはこちらです

https://github.com/yorifuji/riverpod_generics_sandbox/blob/8c47a32d3fe2f55d4b3246712dc1377b74eb3bc4/lib/preference.dart#L6-L73

下記のURLから実際の動作を試すことができます(Flutter Web)。
https://yorifuji.github.io/riverpod_generics_sandbox/

おわりに

Genericsを使ってSharedPreferencesの入出力を共通化するのは若干オーバースペックな感じもありましたが、実際に書いてみて便利であることも分かったのでリリースされたら積極的に使ってみようと思いました。

Discussion