【Flutter】Riverpodを使ってSharedPreferencesの値の管理をシンプルに実現したい

Riverpodを使ってSharedPreferencesの値の管理をシンプルに実装する方法を考えました。

## 実装パターン1： setValues()、removeValues()関数で値を更新する

### SharedPreferencesを初期化するProviderを定義
```
final rawSharedPreferenceProvider = FutureProvider(
  (_) async => SharedPreferences.getInstance(),
);
```

### keyを定義
```
const sampleIntKey = 'sample_int';
const sampleBoolKey = 'sample_bool';
const sampleStringKey = 'sample_string';
```

### Preferencesクラスを定義
setValues()、removeValues()関数を定義します。
```
class Preferences {
  Preferences({
    required this.sampleInt,
    required this.sampleBool,
    required this.sampleString,
  });

  final int sampleInt;
  final bool sampleBool;
  final String sampleString;

  // 引数に値が渡された変数のみ更新する。nullのときは何もしない。
  Future<void> setValues({
    sampleInt? sampleInt,
    sampleBool? sampleBool,
    sampleString? sampleString,
  }) async {
    final sp = await SharedPreferences.getInstance();
    if (sampleInt != null) await sp.setInt(sampleIntKey, sampleInt);
    if (sampleBool != null) await sp.setBool(sampleBoolKey, sampleBool);
    if (sampleString != null) await sp.setString(sampleStringKey, sampleString);
    ref.invalidate(rawSharedPreferenceProvider);
  }

  // 引数にtrueが渡された項目を削除する
  Future<void> removeValues({
    bool? sampleInt,
    bool? sampleBool,
    bool? sampleString,
  }) async {
    final sp = await SharedPreferences.getInstance();
    if (sampleInt == true) await sp.remove(sampleIntKey);
    if (sampleBool == true) await sp.remove(sampleBoolKey);
    if (sampleString == true) await sp.remove(sampleStringKey);
    ref.invalidate(rawSharedPreferenceProvider);
  }
}
```

### Preferencesを提供するProviderを定義
```
final prefsProvider = Provider((ref) {
  final sp = ref.watch(rawSharedPreferenceProvider).value;
  if (sp == null) return null;

  return Preferences(
    sampleInt: sp.getInt(sampleIntKey) ?? 1,
    sampleBool: sp.getBool(sampleBoolKey) ?? true,
    sampleString: sp.getString(sampleStringKey) ?? "default value",
  );
});
```

### 値の取得、更新
```
final prefs = ref.watch(prefsProvider);
// 取得
final sampleInt = prefs.sampleInt);
// 更新
await prefs.setValues(sampleInt: 2, sampleBool: false);
// 削除
await prefs.removeValues(sampleInt: true, sampleBool: true);
```

### メリット
- SharedPreferenceの値の操作が型安全になる
- キーを意識しなくて済む（タイポも発生しない)
- ジャンプしやすい

### デメリット
- 各値にgetterとsetterをもたせるよりは直感的でない

## 実装パターン2： AsyncNotifierで値を管理する

### 値の型と参照キーを定義するオブジェクトの作成
```
class SpKey<T> {
  const SpKey(this.key);

  final String key;
}
```

### 値の型とキーを定義する
```
const sampleInt = SpKey<int>('sample_int');
const sampleBool = SpKey<bool>('sample_bool');
const sampleString = SpKey<String>('sample_string');
```

### Preferenceオブジェクトを定義する
```
part 'shared_preference.freezed.dart';

@freezed
class Preference with _$Preference {
  factory Preference({
    required int sampleInt,
    required bool sampleBool,
    required String sampleString
  }) = _Preference;
}
```

### AsyncNotifierで使うオブジェクトを定義する
```
part 'shared_preference.g.dart';

@riverpod
class SharedPreference extends _$SharedPreference {
  @override
  FutureOr<Preference> build() async {
    return await generateLatestPreference();
  }

  Future<Preference> generateLatestPreference() async {
    final sp = await SharedPreferences.getInstance();
    return Preference(
      sampleInt: sp.getInt('sample_int') ?? 2,
      sampleBool: sp.getBool('sample_bool') ?? false,
      sampleString: sp.getString('sample_string') ?? 'default',
    );
  }

  Future<void> setValue<T>(SpKey2<T> spKey, T value) async {
    final sp = await SharedPreferences.getInstance();
    if (T == int) {
      await sp.setInt(spKey.key, value as int);
    } else if (T == bool) {
      await sp.setBool(spKey.key, value as bool);
    } else if (T == String) {
      await sp.setString(spKey.key, value as String);
    } else {
      throw Exception('想定されていない型です。 spKey: ${spKey.key}');
    }
    state = AsyncValue.data(await generateLatestPreference());
  }
}
```

### メリット
- 値の複数更新をしてもエラーにならない
- SharedPreferenceの値の操作が型安全になる
- 項目を追加するときの手間が少ない

### デメリット
- 定義が面倒
- 生成するのが分かりづらい。ジャンプもしづらい
- 値の書き込みが直感的にできない(ずっと使っていても慣れない)

## 実装パターン3： getterとsetterで値を更新する

複数の値の更新時に問題が出るので没にしましたが、紹介だけさせていただきます。

### SharedPreferencesを初期化するProviderの作成
```
final rawSharedPreferenceProvider = FutureProvider(
  (_) async => SharedPreferences.getInstance(),
);
```

### 各設定を扱う型を定義
```
class Preference<T> {
  Preference({
    required this.ref,
    required this.key,
    required this.defaultValue,
  }) {
    value = _getValue();
  }

  final Ref ref;
  // SharedPreferenceで読み書きする際に使うキー
  final String key;
  // 設定がない場合の初期値
  final T defaultValue;
  // 設定値。コンストラクターで値を入れる
  late final T value;

  T _getValue() {
    final prefs = ref.watch(rawSharedPreferenceProvider).value;
    if (T == int) {
      return prefs?.getInt(key) as T? ?? defaultValue;
    } else if (T == bool) {
      return prefs?.getBool(key) as T? ?? defaultValue;
    } else if (T == String) {
      return prefs?.getString(key) as T? ?? defaultValue;
    } else {
      throw Exception('想定されていない型です。');
    }
  }

  Future<void> setValue(T newValue) async {
    final prefs = ref.watch(rawSharedPreferenceProvider).value;
    // 本当はnullチェックしたほうが良いと思います
    if (newValue is int) {
      await prefs?.setInt(key, newValue);
    } else if (newValue is bool) {
      await prefs?.setBool(key, newValue);
    } else if (newValue is String) {
      await prefs?.setString(key, newValue);
    } else {
      throw Exception('想定されていない型です。');
    }
    ref.invalidate(rawSharedPreferenceProvider);
  }
}
```

getValue, setValueを定義して、値の型に関係なく読み書きできるようにします。値の更新後にref.invalidate(rawSharedPreferenceProvider)を実行しプロバイダを読み込みなおします。

### 全設定値を扱う型を定義
```
class Preferences {
  Preferences({
    required this.sampleInt,
    required this.sampleBool,
    required this.sampleString,
  });

  final Preference<int> sampleInt;
  final Preference<bool> sampleBool;
  final Preference<String> sampleString;
}
```

Preference<T>で各項目を型付けできるようにします。

### SharedPreferenceを管理するproviderの作成
```
final sharedPreferenceProvider = Provider<Preferences>((ref) {
  return Preferences(
    sampleInt: Preference(
      ref: ref,
      key: 'sample_int',
      defaultValue: 0,
    ),
    sampleBool: Preference(
      ref: ref,
      key: 'sample_bool',
      defaultValue: false,
    ),
    sampleString: Preference(
      ref: ref,
      key: 'sample_string',
      defaultValue: 'sample',
    ),
  );
});
```
この際にSharedPreferenceで読み書きする際に使うキーと値が存在しない場合の初期値を定義します。

### 値の取得、更新
```
final prefs = ref.watch(sharedPreferenceProvider);
// 値の取得
final sampleInt = prefs.sampleInt.value;
// 値の更新
await prefs.sampleInt.setValue(5);
```

### メリット
- 値の読み書きが直感的にできる
- キーを意識しなくて済む（タイポも発生しない)
- SharedPreferenceの値の操作が型安全になる
- 項目を追加するときの手間が少ない

### デメリット
- 複数の値を更新しようとするとエラーになる。(ref.invalidate()の実行中にref.invalidate()を実行できないため。）

## おわりに

ここまで読んでいただけた方はいいね・ストックをよろしくお願いします。

Flutter製の便利すぎると好評の単語帳アプリ[Bestflip](https://study.bestop.jp/bestflip-product/)を配信しているのでそちらも見ていただけたら嬉しいです。