Chapter 08

Step7: 設定の変更が即時反映されるようにしよう

すぎっと@フルペラットエンジニア
すぎっと@フルペラットエンジニア
2021.12.07に更新

Step7 の概要

Step7 では、設定の変更をアプリに対して即時反映するための仕組みを導入します。

本章で学べること

  • Providerの使い方
  • ChangeNotifierの使い方

ChangeNotifierの導入

ThemeModeの設定が HotRestart した時にしか反映されない問題を解決しましょう。

まず、FlutterのWidgetはその内容を更新する必要がなければ再ビルドは実行されません。今回は Settings Widget で行われた変更は MyApp Widget からすれば全く関係のないことですので、MyApp が更新されることはなく、テーマが更新されることはありません。あくまで、Shared Preferencesという形でアプリの外の永続化されたデータが更新されただけです。

Settingsでの変更を即座にMyAppに適用するには以下の方法があります。

  • 共通の State にする
  • 変更を検知する仕組みを導入する (Notifier)

共通の State にするには StatefulWidget の State をバケツリレーすることになります。値をセットするなら、setStateをラップしたSetterも一緒にバケツリレーすることになります。

2~3階層を超えるとちょっと耐えられない実装になってくるのでこれはやめたいですね。

この煩わしさに出会った時がStatefulWidget以外の手法を学ぶべきタイミングです。

StatefulWidgetの次なので、InheritedWidget あたりを触りたい気持ちもあるのですが、InheritedWidgetを直接触ることはもう2度とないのではないかと思っているので、大人しく Provider を使用します。Provider は Flutter official 推奨ですので、とりあえずこれでいいかな、という感じです。(もちろん他にもいいライブラリはたくさんあります)

provider | Flutter Package

Providerを使う場合、基本的にはChangeNotifierとセットで使用します。ChangeNotifierはFlutter SDKに含まれるクラスですが、Providerは外部から追加するものですね。

ProviderはChangeNotifierをいい感じに使えるようにするChangeNotifierProviderというものを持っています。これを使います。

ChangeNotifierはNotifierという名の通り、"通知" する仕組みを持ったクラスです。正確には Listenerがベースなので、 "聞き手"を保持する仕組みといったほうが良いかもしれません。

import 'package:flutter/material.dart';
import '../utils/theme_mode.dart';

class ThemeModeNotifier extends ChangeNotifier {
  late ThemeMode _themeMode;

  ThemeModeNotifier() {
    _init();
  }

  ThemeMode get mode => _themeMode;

  void _init() async {
    _themeMode = await loadThemeMode();
    notifyListeners();
  }

  void update(ThemeMode nextMode) {
    _themeMode = nextMode;
    saveThemeMode(nextMode);
    notifyListeners();
  }
}

ChangeNotifierをモデルとして定義します。

コンストラクタで初期化しつつ、get で返します。その後の変更は update で反映しています。

ChangeNotifierProviderでリッスンしよう

次に ChangeNotifierProviderでラップし

void main() {
  runApp(ChangeNotifierProvider(
    create: (context) => ThemeModeNotifier(),
    child: const MyApp(),
  ));
}

使いたいところで Consumerを使用します。

class _MyAppState extends State<MyApp> {
  
  Widget build(BuildContext context) {
    return Consumer<ThemeModeNotifier>(
      builder: (context, mode, child) => MaterialApp(
        title: 'Pokemon Flutter',
        theme: ThemeData.light(),
        darkTheme: ThemeData.dark(),
        themeMode: mode.mode,
        home: const TopPage(),
      ),
    );
  }
}

この実装の美味しいところはこの mode に対するアクセスが完全に同期的であり、かつその設定値がどこに保存されているのか、どうやって取り出されるのかが全てブラックボックス化されているところです。MaterialAppとしては、「ThemeModeNotifierがもっているmodeを取得した」という認知しかなく、その裏でSharedPreferencesが使われているか、SQLiteか、Firebaseかといったことは全く気にしなくて良いのです。

このような設計はViewとModelの切り離しとしてよく知られており、MVVMと呼ばれています。

これ、とっっっっっても大事です。

はい、本題に戻ります。

とりあえずこれで動きそうに見えるのですが、起動時の初期値をとるタイミングが非同期なので、同期的にビルド処理に入ってしまう MaterialAppに対して、タイミング的にエラーが発生してしまいます。

The following LateError was thrown building Consumer<ThemeModeNotifier>(dirty, dependencies:
[_InheritedProviderScope<ThemeModeNotifier?>]):
LateInitializationError: Field '_themeMode@204158082' has not been initialized.

The relevant error-causing widget was:
  Consumer<ThemeModeNotifier>
  ....

起動直後にnullエラーが出て、その後すぐに非同期処理の結果が得られるので、エラーが出ていてもなんやかんや動きます。なんやかんや動いてしまうので、「まぁ、動いているしええんかな?」という危険な判断をしてしまうかもしれません。注意しましょう。

これはなぜエラーが出ているかというと、ThemeModeNotifierの初期化処理で実行している、SharedPreferencesの読み込み処理が非同期だからです。非同期なので、結果が得られるのはちょっと時間がたった後です。一方、MaterialAppの方はそんなの関係なしにWidgetの構築にかかります。結果、初期化が完了していない null のままの値が MaterialApp の themeMode にセットされ、エラーとなります。

これを解決する方法としては runAppより前の段階で SharedPreferencesの読み込み処理を実行してしまうことです。

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final SharedPreferences pref = await SharedPreferences.getInstance();
  final themeModeNotifier = ThemeModeNotifier(pref);
  runApp(ChangeNotifierProvider(
    create: (context) => themeModeNotifier,
    child: const MyApp(),
  ));
}

こんな風に、mainの中で SharedPreferences のインスタンス取得(非同期部分)を実施してしまい、ThemeModeNotifierのコンストラクタに提供します。コンストラクタは初期化処理においてそのインスタンスを使用することで、同期的に初期化を終えることができます。

ポイントは, WidgetFlutterBiniding.ensureInitialized() です。

  • WidgetsFlutterBinding class - widgets library - Dart API

https://api.flutter.dev/flutter/widgets/WidgetsFlutterBinding-class.html

これは、runApp()を実行する前に Flutter Engine の機能を使いたい場合に呼び出しておくおまじないコードです。Shared Preferences は iOS、Androidそれぞれ異なるネイティブの機能を駆使して Key-Value-Store を実現しています。こういうときには MethodChannel という仕組みが使われているのですが、こういった仕組みは Flutter Engine の機能です。これを runApp 以前に使う場合はこのおまじないコードを実行してください。

Flutter Engineについてはこちらを参照してください。

  • Flutter architectural overview

https://flutter.dev/docs/resources/architectural-overview#architectural-layers