🦅

Riverpodの次期バージョンでGenericsを利用する

2024/04/29に公開1

Riverpod の次期バージョン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に値が保存されていない場合はnullが返ります。自分が実装するケースではnullの利用を減らすため、値が保存されていない場合はデフォルト値を割り当てることが多いです。

keyとデフォルト値はペアで管理するのが望ましいため次のようなenum Preferepnce<T>を定義しています。

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

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

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

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

SharedPreferencesをRead/WriteするProviderの定義

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

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


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

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


class BoolPreference extends _$BoolPreference {
  
  bool build(Preferepnce<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を指定
    • 引数のPreferepnce<T>の型パラメタにboolを指定
  • update関数
    • 引数valueに対してboolを指定

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

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


class BoolPreference extends _$BoolPreference {
  
  bool build(Preferepnce<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(Preferepnce<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(Preferepnce<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(Preferepnce.shouldShowWalkthrough));

bool値を更新するコード

ref.read(boolPreferenceProvider(Preferepnce.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