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()
でインスタンスを取得し、setDouble
、setInt
、setString
、setBool
、setStringList
でデータを保存します。
データを読み込む際は、getDouble、getInt
、getString
、getBool
、getStringList
を使用します。値がセットされていない場合にはnullがかえるので、上記例のように初期値をセットします。特定のKey-Valueを削除するにはremove
を、すべてのKey-Valueを削除するにはclear
を使用します。
Riverpod
flutterの状態管理は、学習者にとって最初のつまづきポイントかと思います。単純なアプリであれば不要なので避けて通ってきたのですが、とうとう避けることが出来なくなりました。「状態管理によって何ができるか?」を調べてもあまりピンとこないのですが、「状態管理を使わないと何が困るのか?」を実感すると学習のモチベーションが上がります。
具体的に、今回の例で説明します。
Widget間でデータの受け渡しをしたり、下位のWidgetのイベントを上位のWidgetで使うことは、状態管理を使わずとも出来ます。詳細は以下の記事に記載しております。
これが一対一の親子関係なら良いのですが、一対多の場合や何階層にも渡る場合には、延々とこの関係を引き継いていかなければなりません。
今回の場合、3つの画面で値を共有していたのですが、これらの画面は親子関係ではなく、ある画面での値の変更イベントを他画面に通知する方法が見つかりませんでした。頑張ればできなくはないのでしょうが、コードが煩雑になり断念することとしました。
状態管理とは
状態管理とはその名の通り、アプリケーション内のデータや状態を管理する仕組みです。最初にスコープを定義することで、スコープ内では簡単にデータや状態を共有することが出来ます。
他にも、関連するwidgetだけが再描画されるように効率的なリビルトができたり、状態やロジックをwidgetから分離して保守しやすくするなどの効果もある様ですが、現時点では、上記のような問題を解決するために、データや状態の共有にのみ着目したいと思います。
プロバイダ
flutter_riverpodの概念の中心はプロバイダオブジェクトです。providerパッケージと名前が被ってややこしいのですが、こちらは、widgetツリーの任意の場所から状態にアクセス可能にする機能を提供するオブジェクトです。
riverpodのプロバイダには以下のような種類があります。
https://riverpod.dev/ja/docs/concepts/providers
プロバイダの種類 生成されるステートの型 具体例 Provider 任意 サービスクラス / 算出プロパティ(リストのフィルタなど) StateProvider 任意 フィルタの条件 / シンプルなステートオブジェクト FutureProvider 任意の Future API の呼び出し結果 StreamProvider 任意の Stream API の呼び出し結果の Stream StateNotifierProvider StateNotifier のサブクラス イミュータブル(インタフェースを介さない限り)で複雑なステートオブジェクト ChangeNotifierProvider ChangeNotifier のサブクラス ミュータブルで複雑なステートオブジェクト
どのプロバイダを選択するのか、これだけでは分かりにくいので、公式の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);
},
),
],
),
);
}
}