【Flutter】SharedPreferencesの効果的な利用法：保存・取得とストリームの活用

## はじめに

アプリの設定やデータをデバイスに保存するための方法として、SharedPreferencesというパッケージがよく利用されます。

https://pub.dev/packages/shared_preferences

SharedPreferencesは、アプリの起動状態やユーザーの設定情報など、簡単なデータを保存するのに非常に便利です。例えば、以下のような情報を保存するのによく使われます。

- アプリの初回起動かどうかを示すbool値
- ユーザーの最高得点を記録するint値
- ユーザーのアカウント名を保存するString値

今回は、SharedPreferencesを効果的に使うための便利な方法を紹介していきます。

### 記事の対象者

• SharedPreferencesをより効果的に活用したい方
• Flutterアプリ開発におけるデータ保存の方法を学びたい方

### 記事を執筆時点での筆者の環境

```
[✓] Flutter (Channel stable, 3.22.1, on macOS 14.3.1 23D60 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2023.3)
[✓] VS Code (version 1.89.1)
```

## 1. サンプルプロジェクトの紹介

各種の値を保存、表示する単純なアプリです。
設定する値は以下の内容です

- bool値の設定
- intの設定
- Stringの設定
- CustomSetting型の設定（JSONに変換してStringで保存）


### Gif

![【Flutter】riverpodを使ったDIとレイヤードアーキテクチャ、特にデータ層について（仮）.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/69428b2b-a0c6-c2dd-9657-dd6ba009716f.gif)

### ソースコード

https://github.com/HaruhikoMotokawa/shared_preference_sample

### アーキテクチャ

このプロジェクトはriverpodを使ったレイヤードアーキテクチャをベースに作成しています。
詳しくは以下の記事で解説していますので、よろしければご覧ください。

https://qiita.com/Harx02663971/items/e04b059f5148c34f7a60

## 2. 保存と取得の便利メソッド

### 2-1. 前提

このプロジェクトではSharedPreferencesで実行する機能を`class KeyValueRepository implements KeyValueRepositoryBase`にまとめて定義しています。

しかしSharedPreferencesのインスタンスは`shared_preference.dart`でriverpodを使って別に定義しています。
```lib/data/local_sources/shared_preference.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preference_sample/applications/log/logger.dart';
import 'package:shared_preferences/shared_preferences.dart';

part 'shared_preference.g.dart';

/// SharedPreferencesのインスタンスを非同期に生成
@Riverpod(keepAlive: true)
Future<SharedPreferences> sharedPreferences(SharedPreferencesRef ref) async {
  try {
    // 全ての SharedPreferences のキーに接頭辞を設定
    SharedPreferences.setPrefix('shared_preference_sample');
  } catch (error, stackTrace) {
    logger.d(
      'SharedPreferences.setPrefixエラー',
      error: error,
      stackTrace: stackTrace,
    );
  }

  return SharedPreferences.getInstance();
}
```

よって、SharedPreferencesのインスタンスを呼び出して機能を定義する場合は基本的に以下の用に記述します。

```lib/data/repositories/key_value_repository/repository.dart
/// アプリケーションのキー・バリュー設定を管理するクラス
class KeyValueRepository implements KeyValueRepositoryBase {
  /// アプリケーションのキー・バリュー設定を管理するクラス
  KeyValueRepository(this.ref);

  /// レフ
  ///
  /// 今後の変更で変えられるように固定のレフではなくする
  final ProviderRef<dynamic> ref;

  // 省略

  @override
  Future<void> initData() async {
    // 💡 ここでインスタンスを呼び出す
    final pref = await ref.read(sharedPreferencesProvider.future);
    final result = await pref.clear();

  // 省略
  
```
:::note info
わざわざインスタンスを別に定義しているのは今後の変更でSharedPreferencesではない別のパッケージに切り替えたいとなった場合に、差し替えやすくするためです。

試しにサクッと作りたいサンプルなどであればKeyValueRepositoryで直接インスタンスを呼んでOKです。
:::

### 2-2. 保存

SharedPreferencesはkeyとvalueの組み合わせで値を保存、取得する仕組みです。
keyはStringで固定ですが、値の型は５つの選択肢があります。

1. int - 整数
2. double - 浮動小数点数
3. bool - 真偽値
4. String - 文字列
5. List<String> - 文字列のリスト

保存したい内容によって以下の用にそれぞれ専用のメソッドで行います。

SharedPreferences公式より
```dart

// 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']);

```

保存したい値ごとにこれはsetInt、これはsetBoolという用に呼び出してもいいのですが少々面倒です。
以下のメソッドをスニペットとして持っておき、使用場所でプライベートメソッドとして定義しておくとくとわざわざ切り替えずに済んで便利です。

```lib/data/repositories/key_value_repository/repository.dart

 Future<void> _set(String key, Object? value) async {
    final pref = await ref.read(sharedPreferencesProvider.future);

    switch (value) {
      case final int intValue:
        await pref.setInt(key, intValue);
      case final double doubleValue:
        await pref.setDouble(key, doubleValue);
      case final bool boolValue:
        await pref.setBool(key, boolValue);
      case final String stringValue:
        await pref.setString(key, stringValue);
      case final DateTime dateTimeValue:
        await pref.setString(key, dateTimeValue.toIso8601String());
      case final List<String> listStringValue:
        await pref.setStringList(key, listStringValue);
      case null:
        await pref.remove(key);
      case _:
        await pref.setString(key, jsonEncode(value));
    }

    _onValueChanged.add(key);
  }
  
```
`_set()`の引数`Object? value`にはどんな値でも入れることができます。
そしてその値にあったメソッドを`switch(value){}`の中でパターンマッチしてくれます。

:::note info
このメソッドの`_onValueChanged.add(key);`の説明は後述します。
:::

上記を使うとそれぞれが以下のように使うことができます。
*※ コードは部分的に抜粋*

```lib/data/repositories/key_value_repository/repository.dart

class KeyValueRepository implements KeyValueRepositoryBase {
  /// アプリケーションのキー・バリュー設定を管理するクラス
  KeyValueRepository(this.ref);

  /// アイコン設定のキー
  static const iconSettingKey = 'iconSetting';

  /// 背景色番号のキー
  static const backgroundColorNumberKey = 'backgroundColorNumber';

  /// タイトルのキー
  static const titleTextKey = 'titleText';

  @override
  Future<void> setIconSetting({bool? value}) => _set(iconSettingKey, value);

  @override
  Future<void> setBackgroundColorNumber(int? value) =>
      _set(backgroundColorNumberKey, value);

  @override
  Future<void> setTitleText(String? value) => _set(titleTextKey, value);
  
```

上記のようにどんな値であれ保存するkeyとvalueを渡してしまえば、内部で自動的に振り分けてくれるので
それぞれのメソッドの定義場所ではシンプルに書けます。

### 2-3. 取得

取得の場合も便利メソッドを使用することで、各メソッドをシンプルに記述できます。
基本的には保存と同じ考え方ですが、相違点として、便利メソッドの中で処理を切り替える対象がジェネリック型（T）である点が異なります。

```lib/data/repositories/key_value_repository/repository.dart

  Future<T?> _get<T>(String key) async {
    final pref = await ref.read(sharedPreferencesProvider.future);

    switch (T) {
      case int:
        return pref.getInt(key) as T?;
      case double:
        return pref.getDouble(key) as T?;
      case String:
        return pref.getString(key) as T?;
      case bool:
        return pref.getBool(key) as T?;
      case DateTime:
        return switch (pref.getString(key)) {
          final dateTimeString? => DateTime.parse(dateTimeString) as T,
          _ => null,
        };
      case const (List<dynamic>):
        final value = pref.get(key);
        if (value is List<String>) {
          return value as T?;
        }

        return switch (value) {
          final String stringValue => json.decode(stringValue) as T,
          _ => null,
        };
      case const (Map<dynamic, dynamic>):
        return switch (pref.getString(key)) {
          final value? => json.decode(value) as T,
          _ => null,
        };
      case _:
        throw UnsupportedError('対応していない型です');
    }
  }
  
```

保存の場合、対象の値はObject?型であり、値の型に応じて適切な処理を行っています。
一方、取得の場合、対象の値の型をジェネリック型（T）で定義しています。
これは、保存とは異なり取得した際に最終的に特定の型にする必要があります。
その型を呼び出し側で指定する必要があるためです。

例えば、保存する際には値そのものを保存します。
取得する際には保存された値を特定の型（int, double, String, など）に変換して返す必要があります。
このため、_getメソッドではジェネリック型を使用して、呼び出し側が期待する型に合わせて値を取得するようにしています。

使用する際は以下のようになります。
*※ コードは部分的に抜粋*
```lib/data/repositories/key_value_repository/repository.dart

class KeyValueRepository implements KeyValueRepositoryBase {
  /// アプリケーションのキー・バリュー設定を管理するクラス
  KeyValueRepository(this.ref);

  /// アイコン設定のキー
  static const iconSettingKey = 'iconSetting';

  /// 背景色番号のキー
  static const backgroundColorNumberKey = 'backgroundColorNumber';

  /// タイトルのキー
  static const titleTextKey = 'titleText';

  @override
  Future<bool?> getIconSetting() => _get(iconSettingKey);

  @override
  Future<int?> getBackgroundColorNumber() => _get(backgroundColorNumberKey);

  @override
  Future<String?> getTileText() => _get(titleTextKey);
  
```

メソッドの定義でFuture<bool?> getIconSetting()でbool?型を指定しています。
そのことで型推論が働き`_get<bool?>(iconSettingKey)`と書かなくて良くなっています。

## 3. JSONで複数の設定をまとめて保存

SharedPreferencesは冒頭でも説明したようにkeyとvalueの組み合わせで保存するため、シンプルな情報を保存することに長けています。
一方、複雑な内容を保存するのは不向きです。
しかし、そこまでシンプルではないが、複雑ではないちょっとした内容を保存したい場合もあると思います。
それがある項目の設定のまとまりなどです。

これをJSON形式で保存することによって実現できます。

### 3-1. 保存するオブジェクトを定義

まずは下準備として保存したい内容をfreezedを使ってクラスで定義します。

https://pub.dev/packages/freezed

必要なパッケージは以下です。

```pubspec.yaml
dependencies:
  freezed_annotation:
  json_annotation:

dev_dependencies:
  build_runner:
  freezed:
  json_serializable:
```

```lib/domains/custom_setting.dart

import 'package:freezed_annotation/freezed_annotation.dart';

part 'custom_setting.freezed.dart';
part 'custom_setting.g.dart';

/// カスタム設定
@freezed
class CustomSetting with _$CustomSetting {
  /// カスタム設定
  // ignore: invalid_annotation_target
  @JsonSerializable(fieldRename: FieldRename.snake)
  const factory CustomSetting({
    // アイコン設定
    bool? iconSetting,
    // 背景色番号
    int? backgroundColorNumber,
    // タイトル
    String? titleText,
  }) = _CustomSetting;

  /// jsonからCustomSetting型に変換
  factory CustomSetting.fromJson(Map<String, dynamic> json) =>
      _$CustomSettingFromJson(json);
}

```

上記を定義するとCustomSettingをJSON形式に変換、またはJSON形式からCustomSettingに変換することが可能になります。

### 3-2. 保存するメソッド

先ほどの *2-2. 保存* であげた便利メソッド部分では以下のところでjsonEncodeによって文字列に変換した値を`setString`メソッドで保存しています。

```lib/data/repositories/key_value_repository/repository.dart
  Future<void> _set(String key, Object? value) async {
    final pref = await ref.read(sharedPreferencesProvider.future);

    switch (value) {

     // 省略
     
      case _:
        await pref.setString(key, jsonEncode(value));
    }

    _onValueChanged.add(key);
  }
```

すると以下のようにして保存メソッドが定義できます。
引数にCusttomSettingを渡すだけで保存してくれます。

```lib/data/repositories/key_value_repository/repository.dart
  @override
  Future<void> setCustomSetting(CustomSetting? value) =>
      _set(customSettingKey, value);
```

### 3-3. 取得するメソッド

取得の場合は少々複雑です。
取得の場合は便利メソッド内では以下のケースによって処理されます。
文字列を取得して、`json.decode`メソッドでvalueをJSONに変換して返します。

```lib/data/repositories/key_value_repository/repository.dart

Future<T?> _get<T>(String key) async {
    final pref = await ref.read(sharedPreferencesProvider.future);

    switch (T) {

     // 省略
     
      case const (Map<dynamic, dynamic>):
        return switch (pref.getString(key)) {
          final value? => json.decode(value) as T,
          _ => null,
        };
      case _:
        throw UnsupportedError('対応していない型です');
    }
  }
```

取得メソッドでは以下の３ステップで処理します。

1. 便利メソッドでJSONEを取得を試みる
2. 取得できなかった場合はnullを返して処理を終了
3. JSONをCustomSettingに変換して返す


```lib/data/repositories/key_value_repository/repository.dart

  @override
  Future<CustomSetting?> getCustomSetting() async {
    final json = await _get<Map<dynamic, dynamic>>(customSettingKey);
    if (json == null) return null;
    return CustomSetting.fromJson(json.cast());
  }
  
```

## 4. ストリームで設定内容を配信し、受け取れるようにする

保存した値をStream型の値で状態として持つことで、わざわざ画面ごとにでgetXxxメソッドを実行しないようにします。
つまり、アプリを起動した最初にgetXxx()で値は取得しますが、以後は状態が変わったら勝手に内容が更新されるようになります。

### 4-1. `KeyValueRepository`にストリームを流す仕組みを作る

Stream型は理解が難しいのですが、今回のこの仕組み作ると理解がしやすいと思います。

まずはストリームを流す`StreamController`をプライベートな変数`_onValueChanged`で定義します。
`_onValueChanged`が流した値を`Stream<String> get onValueChange`で受け取ります。

```lib/data/repositories/key_value_repository/repository.dart

/// アプリケーションのキー・バリュー設定を管理するクラス
class KeyValueRepository implements KeyValueRepositoryBase {
  /// アプリケーションのキー・バリュー設定を管理するクラス
  KeyValueRepository(this.ref);

  // 省略

  /// 設定値の変更をアプリケーション全体にブロードキャストするための`StreamController`
  final _onValueChanged = StreamController<String>.broadcast();

  @override
  Stream<String> get onValueChange => _onValueChanged.stream;
```
`_onValueChanged`が実際にstreamを流しているのは保存の便利メソッド`_set`メソッドの最後です。
つまり、何かしらの値が保存されるたびに文字列のストリームが流れます。
その文字列はあらかじめ設定した保存に使うkey。
このkeyが何かによって、なんの値を保存したのかを判別しています。

*※ コードは部分的に抜粋*
```lib/data/repositories/key_value_repository/repository.dart

  /// アイコン設定のキー
  static const iconSettingKey = 'iconSetting';

  @override
  Future<void> setIconSetting({bool? value}) => _set(iconSettingKey, value);
  
  Future<void> _set(String key, Object? value) async {
    final pref = await ref.read(sharedPreferencesProvider.future);

    switch (value) {
      case final int intValue:
        await pref.setInt(key, intValue);

        // 省略
        
    }
    
    // key=文字列をストリームに流す
    _onValueChanged.add(key);
  }
  
```

### 4-2. ストリームを状態として受け取る仕組みを作る

以下のようにriverpodのproviderとして定義します。


```lib/data/repositories/key_value_repository/provider.dart

import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preference_sample/data/repositories/key_value_repository/repository.dart';
import 'package:shared_preference_sample/domains/custom_setting.dart';

part 'provider.g.dart';

/// `KeyValueRepositoryBase` のインスタンスを生成
@Riverpod(keepAlive: true)
KeyValueRepositoryBase keyValueRepository(KeyValueRepositoryRef ref) {
  return KeyValueRepository(ref);
  
@riverpod
Stream<bool?> iconSetting(IconSettingRef ref) async* {
  // キー値リポジトリのプロバイダーからリポジトリオブジェクトを取得
  final repository = ref.read(keyValueRepositoryProvider);

  // 最初のアイコン設定の値を取得し、yieldを使用してStreamに出力
  yield await repository.getIconSetting();

  // リポジトリの値変更通知を購読し、アイコン設定キーの変更のみにフィルターをかける
  await for (final _ in repository.onValueChange
      .where((key) => key == KeyValueRepository.iconSettingKey)) {
    // アイコン設定キーが変更されたとき、新しいアイコン設定の値を取得し、yieldでStreamに出力
    yield await repository.getIconSetting();
  }
}

```

コメントに記載してる部分もありますが、ストリームを定義する場合の注意点としては
1. Stream型のメソッドは`async*`をつける
2. ストリームを流す場合は行頭に`yield`をつける
3. ストリームの初期値は最初に一回流す
4. その後にawait fo()で（）内の条件に当てはまるストリームが流れてきた場合にトランザクション内の値を流す
5. 値を再び流すのでyieldをつける

今回でいくと`await for`の部分が少々理解しづらいかもしれません。
```dart
await for (final _ in repository.onValueChange
    .where((key) => key == KeyValueRepository.iconSettingKey)) {
  yield await repository.getIconSetting();
}
```
ここを分解して解説すると以下のようになっています。
1. ストリームを受け取るためにforでループする
 • await forを使用して、ストリームからのイベントを受け取る準備をします。
2. 全ての変更を監視する
 • repository.onValueChangeが全ての設定変更を通知するストリームです。
3. 特定のキーの変更のみをフィルターする
 • .where((key) => key == KeyValueRepository.iconSettingKey)で、特定のキー(iconSettingKey)の変更のみをフィルターします。これにより、アイコン設定に関する変更だけを受け取ることができます。
4. キーが一致する場合、新しい値を取得してストリームに流す
 • フィルター条件で合致した場合、repository.getIconSetting()を実行して最新のアイコン設定の値を取得し、それをyieldでストリームに流します。

### 4-3. 値を購読してWidgetに反映させる

`final iconSetting = ref.watch(iconSettingProvider);`で状態の購読を行なっています。
ここで状態が変わると自動的にWidgetが再ビルドされて内容が反映されます。

```lib/presentations/my_home_page/my_home_page.dart

              Flexible(
                child: Consumer(
                  builder: (context, ref, child) {
                    final iconSetting = ref.watch(iconSettingProvider);
                    return InfoListTile(
                      value: iconSetting.valueOrNull,
                      type: TileType.iconSetting,
                    );
                  },
                ),
              ),
              
```


```lib/presentations/shared/info_list_tile.dart

/// 現在の設定内容を表示するListTile
class InfoListTile extends StatelessWidget {
  /// 現在の設定内容を表示するListTile
  const InfoListTile({
    required this.value,
    required this.type,
    super.key,
  });

  /// 値のObject
  ///
  /// ここでは多様な型に対応できるようにObjectで定義している
  final Object? value;

  /// Tileのタイプ
  final TileType type;
  @override
  Widget build(BuildContext context) {
    logger.d('${type.title}のタイルをビルド');
    return switch (value) {
      null => ListTile(
          title: Text(type.title),
          trailing: const Text('値がnullです'),
        ),
      final bool boolValue => ListTile(
          title: Text(type.title),
          trailing: switch (boolValue) {
            true => const Icon(Icons.power),
            false => const Icon(Icons.power_off),
          },
        ),

     // 省略
    
      _ => ListTile(
          title: Text(type.title),
          trailing: const Text('対応しない型です'),
        ),
    };
  }
}

/// ListTileの種類
enum TileType {
  /// アイコン設定
  iconSetting(title: 'アイコンの設定'),

  /// 背景色の番号
  backgroundColorNumber(title: '背景色の番号'),

  /// タイトルのテキスト
  titleText(title: 'タイトルの文字'),

  /// CustomSetting
  customSetting(title: 'JSONで複数の設定'),
  ;

  const TileType({required this.title});

  /// ListTileのtitleWidgetに表示する文字列
  final String title;
}

```

## 終わりに

今回は、FlutterのアプリケーションでSharedPreferencesを利用してデータの保存や取得を行う方法について詳しく解説しました。
基本的な使い方から、便利なメソッドを利用した効率的な保存・取得の方法、さらにJSON形式で複数の設定を保存する方法までを紹介しました。
また、Streamを利用して設定の変更をリアルタイムに反映する方法についても説明しました。

これらの知識を活用することで、SharedPreferencesをより効果的に利用できるようになり、アプリケーションの設定管理が簡単になるでしょう。
特に、Streamを利用して設定変更をリアルタイムに反映させる手法は、ユーザーエクスペリエンスの向上に寄与する強力な技術です。

この記事の内容についてのフィードバックや、さらなる質問があれば、お気軽にお知らせください。
この記事がSharedPreferencesを使いこなすための一助となれば幸いです。

ご覧いただきありがとうございました。