Flutter iOS 音声メモアプリ開発(7)設定画面 SharedPreferences & Riverpod

音声メモアプリに「設定画面」を追加したいと思います。

設定画面で設定した値を、別の画面に反映させようと四苦八苦しましたが、だんだんとコードが複雑になってしまいました。値の受け渡しはできるのですが、どうしても画面を再描画させることができず、断念しました。

こんな時に、flutterでは「状態管理」をするための仕組みを使うことになります。いくつかのやり方が用意されている様ですが、直感的にRiverpodパッケージを使うこととしました。定番のProviderパッケージよりも後発であるということが一番の決め手です。

また、設定情報を保存するために、SharedPreferencesを使用します。これは以前、メモを保存するための「永続的なデータの保存方法」を調べた際に見つけていたものです。Key-Valueの形で値を保存できるので、今回の目的には向いているものと考えます。

Shared Preferences

Flutterのshared_preferencesは、簡単なデータの永続化を行うためのKey-Value型ストレージです。ネイティブアプリの開発経験はありませんが、AndroidのSharedPreferencesやiOSのNSUserDefaultsに相当する様です。主に、アプリの設定のような軽量なデータの保存に適しています。

データ型としてDouble、Int、文字列、ブール値、文字列リストをサポートしています。

// インスタンスの取得
SharedPreferences prefs = await SharedPreferences.getInstance();

//値の保存
await prefs.setDouble('decimal', 1.5);
await prefs.setInt('counter', 1);
await prefs.setString('username', 'John Doe');
await prefs.setBool('isLoggedIn', true);
await prefs.setStringList('favoriteColors', ['red', 'green', 'blue']);

//値の読込 
//キーが存在しなかった場合にはnullがかえるので、
// null判定 ”??” 以降で設定値がなかった場合の初期値を記述しておく
double decimal = prefs.getDouble('decimal') ?? 1.5;
int counter = prefs.getInt('counter') ?? 0;
String username = prefs.getString('username') ?? '';
bool isLoggedIn = prefs.getBool('isLoggedIn') ?? false;
List<String> favoriteColors = prefs.getStringList('favoriteColors') ?? [];

//削除
await prefs.remove('counter');

//全削除
await prefs.clear();

基本的な使用方法は、SharedPreferences.getInstance()でインスタンスを取得し、setDoublesetIntsetStringsetBoolsetStringListでデータを保存します。

データを読み込む際は、getDouble、getIntgetStringgetBoolgetStringListを使用します。値がセットされていない場合にはnullがかえるので、上記例のように初期値をセットします。特定のKey-Valueを削除するにはremoveを、すべてのKey-Valueを削除するにはclearを使用します。

Riverpod

flutterの状態管理は、学習者にとって最初のつまづきポイントかと思います。単純なアプリであれば不要なので避けて通ってきたのですが、とうとう避けることが出来なくなりました。「状態管理によって何ができるか?」を調べてもあまりピンとこないのですが、「状態管理を使わないと何が困るのか?」を実感すると学習のモチベーションが上がります。

具体的に、今回の例で説明します。

Widget間でデータの受け渡しをしたり、下位のWidgetのイベントを上位のWidgetで使うことは、状態管理を使わずとも出来ます。詳細は以下の記事に記載しております。

Flutter iOS 音声メモアプリ開発(6) 音声認識用TextField

Flutterの音声認識(speech_to_text)については、何度も利用してきました。今までは、音声認識結果をText Widgetに表示していましたが、これをTextFieldに変更したいと思い…

これが一対一の親子関係なら良いのですが、一対多の場合や何階層にも渡る場合には、延々とこの関係を引き継いていかなければなりません。

今回の場合、3つの画面で値を共有していたのですが、これらの画面は親子関係ではなく、ある画面での値の変更イベントを他画面に通知する方法が見つかりませんでした。頑張ればできなくはないのでしょうが、コードが煩雑になり断念することとしました。

状態管理とは

状態管理とはその名の通り、アプリケーション内のデータや状態を管理する仕組みです。最初にスコープを定義することで、スコープ内では簡単にデータや状態を共有することが出来ます。

他にも、関連するwidgetだけが再描画されるように効率的なリビルトができたり、状態やロジックをwidgetから分離して保守しやすくするなどの効果もある様ですが、現時点では、上記のような問題を解決するために、データや状態の共有にのみ着目したいと思います。

プロバイダ

flutter_riverpodの概念の中心はプロバイダオブジェクトです。providerパッケージと名前が被ってややこしいのですが、こちらは、widgetツリーの任意の場所から状態にアクセス可能にする機能を提供するオブジェクトです。

riverpodのプロバイダには以下のような種類があります。

プロバイダの種類生成されるステートの型具体例
Provider任意サービスクラス / 算出プロパティ(リストのフィルタなど)
StateProvider任意フィルタの条件 / シンプルなステートオブジェクト
FutureProvider任意の FutureAPI の呼び出し結果
StreamProvider任意の StreamAPI の呼び出し結果の Stream
StateNotifierProviderStateNotifier のサブクラスイミュータブル(インタフェースを介さない限り)で複雑なステートオブジェクト
ChangeNotifierProviderChangeNotifier のサブクラスミュータブルで複雑なステートオブジェクト
https://riverpod.dev/ja/docs/concepts/providers

どのプロバイダを選択するのか、これだけでは分かりにくいので、公式のAPIリファレンス>プロバイダを参照します。(公式はこちら

今回の様な場合、StateNotifierProviderを使っていれば問題なさそうです。

使い方

StateNotifierProviderを使う例は以下の通りです。

1)ProviderScopeを使って、アプリ全体でプロバイダが利用できる様にします。

import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(ProviderScope(child: MyApp()));
}

2)StateNotifierを継承したクラスを作成し、状態のロジックを実装します。

class SettingsNotifier extends StateNotifier<Settings> {
  SettingsNotifier() : super(Settings());

  Future<void> updateSettings(String newValue) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('someValue', newValue);
    state = Settings(someValue: newValue);
  }
}
class Settings {
  Settings({this.someValue = ''});
  final String someValue;
}

3)StateNotifierProviderを作成し、作成したSettingsNotifierクラスのインスタンスを返すようにします。

final settingsProvider = StateNotifierProvider<SettingsNotifier, Settings>(
    (ref) => SettingsNotifier());

4)ConsumerWidgetを継承したWigetに、プロバイダーの状態を変更するためのアクションを実装します。今回は、Setting画面でテキスト入力し、それをupdateSetting()に渡します。

class SettingScreen extends ConsumerWidget {
  const SettingScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final settings = ref.watch(settingsProvider);
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text('Settings: ${settings.someValue}'),
          TextFormField(
            initialValue: settings.someValue,
            decoration: const InputDecoration(labelText: 'Enter new value'),
            onChanged: (newValue) {
              ref.read(settingsProvider.notifier).updateSettings(newValue);
            },
          ),
        ],
      ),
    );
  }
}

5)ConsumerWidgetを継承したウィジェットを作成し、ref.watchで、プロバイダーの状態をリッスンします。

class ChatBotScreen extends ConsumerWidget {
  const ChatBotScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final settings = ref.watch(settingsProvider);
    return Center(
      child: Text('Chat Bot Screen: ${settings.someValue}'),
    );
  }
}

サンプルアプリ

サンプルアプリの実行結果です。

Setting画面で値を変更すれば、直ちにChatBot screenのTextが変更されます。そして、永続的なデータとして保存されていますので、アプリを再起動しても設定値は変わりません。

全体のコードは以下の様になりました。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
  late TabController _tabController;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(vsync: this, length: 3);
  }

  @override
  void dispose() {
    _tabController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Riverpod & SharedPreferences Demo'),
        bottom: TabBar(
          controller: _tabController,
          tabs: const [
            Tab(icon: Icon(Icons.home), text: 'Home'),
            Tab(icon: Icon(Icons.chat_bubble), text: 'Chat Bot'),
            Tab(icon: Icon(Icons.settings), text: 'Settings'),
          ],
        ),
      ),
      body: TabBarView(
        controller: _tabController,
        children: const [
          HomeScreen(),
          ChatBotScreen(),
          SettingScreen(),
        ],
      ),
    );
  }
}

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text('Home Screen'),
    );
  }
}

final settingsProvider = StateNotifierProvider<SettingsNotifier, Settings>(
    (ref) => SettingsNotifier());

class Settings {
  Settings({this.someValue = ''});
  final String someValue;
}

class SettingsNotifier extends StateNotifier<Settings> {
  SettingsNotifier() : super(Settings()) {
    loadSettings();
  }

  Future<void> loadSettings() async {
    final prefs = await SharedPreferences.getInstance();
    final savedValue = prefs.getString('someValue') ?? '';
    state = Settings(someValue: savedValue);
  }
  Future<void> updateSettings(String newValue) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('someValue', newValue);
    state = Settings(someValue: newValue);
  }
}

class ChatBotScreen extends ConsumerWidget {
  const ChatBotScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final settings = ref.watch(settingsProvider);
    return Center(
      child: Text('Chat Bot Screen: ${settings.someValue}'),
    );
  }
}

class SettingScreen extends ConsumerWidget {
  const SettingScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final settings = ref.watch(settingsProvider);
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text('Settings: ${settings.someValue}'),
          TextFormField(
            initialValue: settings.someValue,
            decoration: const InputDecoration(labelText: 'Enter new value'),
            onChanged: (newValue) {
              ref.read(settingsProvider.notifier).updateSettings(newValue);
            },
          ),
        ],
      ),
    );
  }
}