Flutter Shared Preferencesでユーザー情報をローカル保存

## まえがき

### このページについて
- Flutterアプリで、ユーザー設定などの簡易な情報を保存、呼び出しをする実装のサンプルを紹介しています
- アプリはAndroid, iOS, Linux, macOS, web, Windowsの全て対応だと思いますが、動作確認はiOS Simulatorでしか行っていません
- FlutterやDart、その他開発環境に関する基礎的な説明は対象範囲外です

### 記載のサンプルアプリについて
- `flutter create`で生成されるカウンターアプリをベースにしています
- `int`型の値を端末に保存し、次回アプリ起動時には値を自動で呼び出しビューに反映します
- パッケージ[`shared_preferences`](https://pub.dev/packages/shared_preferences)を利用します
- アプリ全体の[Githubリポジトリ](https://github.com/KazmaWed/sp_counter_app)、最下部には`main.dart`全文を記載してます
- 呼び出した値の反映には`FutureBuilder`を利用しています
- (他で紹介されている記事では`setState`のみで値をビューに反映している物がありますが、厳密には不正を起こす可能性があるのでオススメしません！)

### 完成イメージ

- 今回はFlutterの新規作成後のカウンターアプリで、タップした回数を`shared_preferences`で保存するという実装です
- 今回は`int`型の値を扱いますが、例えばダークモードの設定などを保存する場合は`bool`などに置き換える方法も考えられます
- 画像はiOSのSimulatorの画面で、再起動後もタップした回数が保持されている様子です

![Simulator Screen Shot - iPhone 13 mini - 2022-08-31 at 09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/c6fd00f7-3cd2-686c-b8f5-e29ada3bdb1f.png)

## 環境
- `flutter doctor`の出力

```zsh
[✓] Flutter (Channel stable, 3.0.5, on macOS 12.5.1 21G83 darwin-x64, locale en-JP)
[✓] Xcode - develop for iOS and macOS (Xcode 13.4.1)
[✓] Chrome - develop for the web
[✓] VS Code (version 1.70.2)
[✓] Connected device (1 available)
[✓] HTTP Host Availability
```

## `shared_preferences`について

- iOSやmacOSのNSUserDefaults、AndroidのSharedPreferencesに相当する機能を提供する[公式パッケージ](https://pub.dev/packages/shared_preferences)
- データをKeyとValueのセットで保存し、保存した値はKeyを指定して呼び出します
- 実装も簡単で、アプリのユーザー設定など簡易的なデータの読み書きに向いています
- 読み書きできる値の型は`int`, `double`, `bool`, `String`, `List<String>`です
- 通常のDBとしてのデータの管理には[SQLite](https://pub.dev/packages/sqflite)などを利用してください

## 実装

### 手順

1. `shared_preferences`の追加
1. `MyHomePage`の`FutureBuilder`化
1. `CounterView`Widgetの実装

### 1. `shared_preferences`の追加

- Flutterアプリのルートディレクトリから`flutter pub add`を実行
- yamlファイルに`dependencies`が追加される

```zsh
flutter pub add shared_preferences
```
```pubspec.yaml
# ...
dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  shared_preferences: ^2.0.15
# ...
```

- `shared_preferences`をdartファイルにimport

```main.dart
import 'package:shared_preferences/shared_preferences.dart';
```

### 2. `MyHomePage`の`FutureBuilder`化

#### ここで行うこと
- `MyHomePage`を`StatelessWidget`化
- 値の呼び出しメソッドを実装
- `MyHomePage`の`return`を`FutureBuilder`に変更

#### 詳しい手順
1. `MyHomePage`を`StatelessWidget`化
    - `FutureBuilder`は`setState`で再描画する必要がないため`StatelessWidget`化
    - エディタ上で`stles`と打ちエンターを押すと自動で雛形生成されます
    - 生成されたWidgetの名前を`MyHomePage`とし、先にあった`MyHomePage`は削除してください
1. 値呼び出しメソッドの実装
    - 非同期で値を呼び出す`_futureLoadedValue()`を実装
    - 戻り値は`Future<int>`型
    ```main.dart
    class MyHomePage extends StatelessWidget {
      const MyHomePage({Key? key}) : super(key: key);
      
      // ---------- 追加ここから ----------
      
      // 保存した値呼び出し
      Future<int> _futureLoadedValue() async {
        // SharedPreferencesインスタンスを生成
        SharedPreferences prefs = await SharedPreferences.getInstance();
        return prefs.getInt('counterValue') ?? 0; // 保存した値がなければゼロ
      }
    
      // ---------- ここまで ----------
    
      @override
      Widget build(BuildContext context) {
        // ...
      }
    }
    ```
3. `return`の`FutureBuilder`化
   - 上記メソッドの実行完了後にビルドするように`FutureBuilder`を利用します
   - `future:`には上記メソッドの戻り値
   - `builder:`以下に、処理状況に応じた分岐と、それぞれに`Widget`を実装

    ```main.dart
    class MyHomePage extends StatelessWidget {
      const MyHomePage({Key? key}) : super(key: key);
      // 保存した値ロード
      Future<int> _futureLoadedValue() async {
        // ...
      }    

      @override
      Widget build(BuildContext context) {

        // ---------- 追加ここから ----------

        // 保存した値呼び出し
        final futureLoadedValue = _futureLoadedValue();
    
        return FutureBuilder(
          future: futureLoadedValue,
          builder: (context, AsyncSnapshot<int> snapshot) {
            if (snapshot.connectionState != ConnectionState.done) {
              // 値呼び出し中プログレスバー
              return Scaffold(
                appBar: AppBar(title: const Text('SP COUNTER APP')),
                body: const Center(child: CircularProgressIndicator()),
              );
            } else if (snapshot.hasError) {
              // 呼び出し失敗
              return Scaffold(
                appBar: AppBar(title: const Text('SP COUNTER APP')),
                body: Center(child: Text(snapshot.error.toString())),
              );
            } else {
              // 呼び出し後
              return CounterView(initialValue: snapshot.data!); // このWidgetは未実装です
            }
          },
        );

        // ---------- ここまで ----------
      }
    }
    ```

### 3. `CounterView`Widgetの実装

#### ここで行うこと
1. 新しい`StatefulWidget`を生成する (デフォルトカウンターアプリの`MyHomePage`に相当)
1. エディタ上で`stful`と打ちエンターを押すと自動で雛形生成されます
1. 必要な変数と、インクリメント＆保存のメソッドを実装
1. `return`するWidget部分を実装 (カウンターアプリとほぼ同じ)

#### 詳しい手順
1. 新しい`StatefulWidget`を生成
    - `stful`で`StatefulWidget`の雛形を生成
    - `CounterView`と命名
    - 引数`initialValue`を定義 (呼び出し後の値を親から受け取るため)
    ```main.dart
    // 以下すべて追加

    // ロード後のメインのビュー
    class CounterView extends StatefulWidget {
      const CounterView({Key? key, required this.initialValue}) : super(key: key);
      // 親から受け取る初期値
      final int initialValue;
    
      @override
      State<CounterView> createState() => _CounterViewState();
    }
    
    class _CounterViewState extends State<CounterView> {
      @override
      Widget build(BuildContext context) {
        // ...
      }
    }
    
    ```

1. 必要な変数と、インクリメント＆保存のメソッドを実装
    - `counter`は`late`とし、初期値は初回ビルド時に代入
    - `firstBuild`で初回ビルドかどうかを判定する
    これにより、`setState`による再描画で`initialValue`が繰り返し代入されるのを防ぐ
    - 値の保存は非同期で行われます
    - 重要度や必要に応じて`await`による同期化や、エラー処理の追加をしてください
    - インクリメントは値を更新と`setState`に加えて、保存処理メソッド実行
    ```main.dart
    // ロード後のメインのビュー
    class CounterView extends StatefulWidget {
      // ...
    }
    
    class _CounterViewState extends State<CounterView> {
      // ---------- 追加ここから ----------
      bool firstBuild = true; // 初回ビルド判定
      late int counter; // カウンターとして表示する数 (ここでは初期値を定義しない)
      // ---------- ここまで ----------

      @override
      Widget build(BuildContext context) {

        // ---------- 追加ここから ----------
        
        // 初回ビルド時はcounterに初期値代入
        if (firstBuild) {
          counter = widget.initialValue;
          firstBuild = false;
        }
    
        // 値の保存
        void _saveValue() async {
          SharedPreferences prefs = await SharedPreferences.getInstance();
          prefs.setInt('counterValue', counter);
        }
    
        // 値のインクリメント＆保存まで実行
        void _incrementCounter() {
          setState(() {
            counter = counter + 1;
          });
          _saveValue();
        }

        // ---------- ここまで ----------
    
        // ...
      }
    }
    
    ```

1. Widget部分を実装
    - 初期カウンターアプリとほぼ同等の`Scaffold`部を実装

```main.dart
class CounterView extends StatefulWidget {
  // ...
}

class _CounterViewState extends State<CounterView> {
  int? counter;
  @override
  Widget build(BuildContext context) {

    bool firstBuild = true;
    late int counter;

    if (firstBuild) { // ... }
    void _saveValue() async { // ... }
    void _incrementCounter() { // ... }

    // ---------- 追加ここから ----------

    return Scaffold(
      appBar: AppBar(title: const Text('SP COUNTER APP')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );

    // ---------- ここまで ----------

  }
}

```

## あとがき

いかがだったでしょうか？

`shared_preferences`自体はとてもシンプルで使いやすいパッケージではあるのですが、初回の呼び出し時には`FutureBuilder`を使う点や、不要な再描画がかからないようにするガード的に実装などは、Flutterやアプリ開発に慣れてない開発者の方には参考にしていただける部分があったのではないかと思います

今回は`int`のみを扱いましたが、アプリ起動時に呼び出し必要なユーザー設定系は全て同様に実装可能だと思いますので、みなさんもぜひご自身のアプリでお試しください！

いいねとご質問お待ちしております^ ^

## `main.dart`のコード全体

- 上の解説パートとコメントなどが若干異なる部分があります
- ソースコードは[こちらのリポジトリ](https://github.com/KazmaWed/sp_counter_app)からご覧ください

```main.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.pink,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({Key? key}) : super(key: key);
  // 保存した値ロード
  Future<int> _futureLoadedValue() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    // 保存した値がなければゼロ
    return prefs.getInt('counterValue') ?? 0;
  }

  @override
  Widget build(BuildContext context) {
    // 保存した値ロード
    final futureLoadedValue = _futureLoadedValue();

    return FutureBuilder(
      future: futureLoadedValue,
      builder: (context, AsyncSnapshot<int> snapshot) {
        if (snapshot.connectionState != ConnectionState.done) {
          // 値ロード中プログレスバー
          return Scaffold(
            appBar: AppBar(title: const Text('SP COUNTER APP')),
            body: const Center(child: CircularProgressIndicator()),
          );
        } else if (snapshot.hasError) {
          // ロード失敗
          return Scaffold(
            appBar: AppBar(title: const Text('SP COUNTER APP')),
            body: Center(child: Text(snapshot.error.toString())),
          );
        } else {
          // ロード後
          return CounterView(initialValue: snapshot.data!);
        }
      },
    );
  }
}

// ロード後のメインのビュー
class CounterView extends StatefulWidget {
  const CounterView({Key? key, required this.initialValue}) : super(key: key);
  final int initialValue;

  @override
  State<CounterView> createState() => _CounterViewState();
}

class _CounterViewState extends State<CounterView> {
  // 初回ビルド判定
  bool firstBuild = true;
  late int counter;

  @override
  Widget build(BuildContext context) {
    if (firstBuild) {
      counter = widget.initialValue;
      firstBuild = false;
    }

    // 値の保存
    void _saveValue() async {
      SharedPreferences prefs = await SharedPreferences.getInstance();
      prefs.setInt('counterValue', counter);
    }

    // 値のインクリメント＆保存まで実行
    void _incrementCounter() {
      setState(() {
        counter = counter + 1;
      });
      _saveValue();
    }

    return Scaffold(
      appBar: AppBar(title: const Text('SP COUNTER APP')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}
```