Riverpod Generator V3でGenericsを利用する
Riverpod Generatorの次期バージョンV3ではGenericsへの対応が予定されています。
CHANGELOGによるとversion 3.0.0-dev.7
で実装されたようです。
サンプルコードとして紹介されているコードはこちらで、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.
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に保存して、アプリを再起動したときに復元しています。
Widget側でRiverpodを利用している箇所はこちらです
RiverpodとSharedPreferencesのコードはこちらです
下記のURLから実際の動作を試すことができます(Flutter Web)。
おわりに
Genericsを使ってSharedPreferencesの入出力を共通化するのは若干オーバースペックな感じもありましたが、実際に書いてみて便利であることも分かったのでリリースされたら積極的に使ってみようと思いました。
Discussion
👏👏👏👏