この記事は Flutter Advent Calendar(カレンダー2)の11日目の記事です。
去年に引き続き今年も参加します。
去年分はこちらです。
前置き
最近、作業環境にトラックボールを導入したところよく起こっていた肩凝りが起きにくくなっていて快適になりました。
これまではMac付属のトラックパッドで頑張っていました。
どうやらこれはあまり肩に良くないらしいのでトラックボールを使うように変えました。
ちなみに次はキーボードを導入しようと目論んでいます。
今回はFlutterでのアプリ開発での実装時にハマった機能をランキング形式で紹介していこうと思ってます。3つ取り上げます。
ステート管理はRiverpodを前提にしています。
環境
| SDK version | >=2.12.0 <3.0.0 |
|---|---|
| flutter_riverpod | ^0.14.0 (0.14.0+3) |
| Dart | 2.13.3 |
$ flutter --version
Flutter 2.2.2 • channel stable • https://github.com/flutter/flutter.git
Framework • revision d79295af24 (6 months ago) • 2021-06-11 08:56:01 -0700
Engine • revision 91c9fc8fe0
Tools • Dart 2.13.3
第3位 ボタン連打防止対応
第3位はアプリのボタンの連打防止対応です。
iOSネイティブの時は指摘されることがあまりなく、Androidネイティブの時はよく指摘されていました。
おそらくマテリアルデザインが関係しているのかな、と勝手に思っています。
Androidネイティブの時はよく遅延処理で対応していた記憶があります。
今回は前提として、今使っているAPIは何秒後にリクエストが完了するか分からないことにします。
ボタンやListViewのitemをタップしたときにAPIリクエストを走らせるところを想定しています。
ここで、APIリクエスト中は処理が走らないようにします。
例えば、次のようにElevatedButtonのonPressedでAPIを呼び出すメソッドを叩くときを考えます。
ElevatedButton(
onPressed: () async {
// ここにリクエスト中のフラグを立てる
await requestAPI();
// リクエスト中のフラグを切る
},
child: const Text('ボタン'),
)
ここでの理想の対応はコメントアウトに書いた通りです。これをRiverpodを使っている前提でどう実現させようかで結構悩みました。
APIのリクエストが完了した後に連打防止のフラグをoffにするのが理想ですね。
対応方法
行う対応はAPIリクエスト中のフラグを用意し、そのフラグを監視する方針で対応します。
どこでもいいのでグローバル変数としてフラグを宣言します。
import 'package:flutter_riverpod/flutter_riverpod.dart';
// APIリクエストのフラグ
final isRequestingButtonStateProvider = StateProvider((ref) => false);
あとはボタンをタップするクラスで監視して、タップするタイミングでフラグのOn/Offの切り替えを行います。
class SampleView extends ConsumerWidget {
const SampleView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ScopedReader watch) {
// 監視用のフラグ
final isRequestingButtonProvider = watch(isRequestingButtonStateProvider);
return Container(
child: ElevatedButton(
onPressed: () async {
// リクエスト中は処理を中断させる
if (isRequestingButtonProvider.state) {
return;
}
// リクエストを開始するのでフラグを On に切り替える
isRequestingButtonProvider.state = true;
await requestAPI();
// リクエストが完了したのでフラグを Off に切り替える
isRequestingButtonProvider.state = false;
},
child: const Text('ボタン'),
));
}
}
これでAPIリクエスト中に再度APIをコールすることが起きないはずです。
本当は遅延処理を使って、例えば3秒経ったらフラグをOffにする方針も考えられますが、APIのリクエストで10秒かかったりと3秒を超える重いリクエストだと遅延処理では対応しきれません。
第2位 アプリのライフサイクル対応
第2位に選んだのは「アプリのライフサイクル」です。ネイティブ実装でもハンドリングが難しいところです。
ここではiOS端末での事象を想定しています。
iOSネイティブの場合は、次のようなライフサイクルのメソッドがあります。
func applicationWillResignActive(_ application: UIApplication) {
}
func applicationDidEnterBackground(_ application: UIApplication) {
}
func applicationWillEnterForeground(_ application: UIApplication) {
}
func applicationDidBecomeActive(_ application: UIApplication) {
}
func applicationWillTerminate(_ application: UIApplication) {
}
アプリ開発では、アプリ起動中にホーム画面に行ったあとにアプリに戻ったときに何かしらの対応をしたいことがよくあります。
該当アプリがホーム画面からアプリに戻ってくることを「バックグラウンド復帰」と呼ばれています。
これをFlutterのRiverpodを使っている前提でできる限り再現させて対応したいとします。
対応方法
今回はWidgetsBindingObserverクラスを使って対応します。
WidgetsBindingObserverクラスはドキュメントを見る限りStatefulWidgetを使うことを前提にしています。
幸い私が開発しているアプリでは画面とViewを分けてクラス管理しています。
- 画面 = Page (xxx_page.dart)
- コンポーネント = 画面を構成するView (xxx_view.dart)
簡単に言えば、アプリの画面に相当するところはページ管理として、画面を構成するViewはコンポーネント管理として管理しています。
もともとは、ページ管理しているクラスにはStatelessWidgetを、Viewに相当するクラスにはStatelessWidgetかConsumerWidgetを使う方針でした。
そこで、ページに相当するクラスにはStatefulWidgetを使うように方針を変えました。
これなら、画面単位でWidgetsBindingObserverクラスを利用することができます。
実際にWidgetsBindingObserverを使う際にはmixinして使います。
import 'package:flutter/material.dart';
class SamplePage extends StatefulWidget {
const SamplePage({Key? key}) : super(key: key);
@override
_SamplePageState createState() => _SamplePageState();
}
class _SamplePageState extends State<SamplePage> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance?.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance?.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
// アプリが paused 中
} else if (state == AppLifecycleState.resumed) {
// アプリが resumed 中
} else if (state == AppLifecycleState.inactive) {
// アプリが inactive 中
} else if (state == AppLifecycleState.detached) {
// アプリが detached 中
}
}
@override
Widget build(BuildContext context) {
return Container();
}
}
WidgetsBindingObserver を使うためには、該当のWidgetで監視を開始するaddObserverや監視を破棄するremoveObserverを設定する必要があります。
- addObserver: 監視の開始を登録
- removeObserver: 監視の終了を登録
ちょうど、iOSネイティブのNotificationCenterみたいなものと同じです。
これらをStatefulWidgetのどこかで登録する必要があります。
対して、StatefulWidgetにはStateを持つウィジェットでWidgetのライフサイクルを監視するメソッドがあります。
- initState() : Widgetが生成されたときに呼ばれる
- dispose() : Widgetが破棄されたときに呼ばれる
です。
この2つのメソッドでWidgetsBindingObserverの監視を登録すればOKです。
これを登録すれば、
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
// アプリが paused 中
} else if (state == AppLifecycleState.resumed) {
// アプリが resumed 中
} else if (state == AppLifecycleState.inactive) {
// アプリが inactive 中
} else if (state == AppLifecycleState.detached) {
// アプリが detached 中
}
}
のメソッド部分が機能するようになります。
ここの部分が該当アプリがバックグラウンドに行ったり、戻ってきたりしたときに走ります。
ここでのAppLifecycleStateはこちらの記事が大変参考になりました。
アプリの状態をそれぞれ表にすると次のようになります。
| 値 | アプリの状態 |
|---|---|
| inactive | アプリの停止時 |
| paused | アプリがバックグラウンドに行ったとき |
| resumed | アプリが復帰したとき |
| detached | アプリが終了したとき |
アプリがバックグラウンドに行ったときに何かしらの処理をしたいときには
stateがAppLifecycleState.pausedの箇所で処理を書きます。
アプリが復帰したときに何かしらの処理をしたいときには
stateがAppLifecycleState.resumedの箇所で処理を書きます。
これでアプリのライフサイクルに対する対応が行えます。
第1位 アプリのバックグラウンド復帰時の対応
第1位に選んだのは、「アプリのバックグラウンド復帰時の対応」です。
第2位と同じように思われるかもしれませんが、実装の実装時には想定していない動きがありました。そのため、難易度が上がった記憶があります。
ステート管理の次に難しいのは画面遷移じゃないのかというぐらいには画面遷移対応に悩まされました。
ネイティブだと簡単な実装なのに、という部分がFlutterだと激ムズでした。
まあ、Flutter力が足りないからでしょう。
どういう場面かといいますと、iOSネイティブであれば
func applicationWillEnterForeground(_ application: UIApplication)
のときに、何かしらの処理をしたい、と行った場面が想定されます。
特定の画面にいるときにホーム画面に行って、それからまたアプリに戻ったときに自然にアラートを表示させたり、トップの画面に遷移させたいときに使います。
Flutterだとどこでどんな風に書けばいいのか数時間ほど悩みました。
iOSアプリの場合は、AppDelegateというアプリ全体の制御に携わるクラスがありますが、Flutterの場合はトップはmain関数です。
また、Flutterでの画面遷移の場合は、routeを取得する必要がありますのでBuildContextのcontextが必要になります。
contextを取得できるところで画面遷移を書く必要があるなと思ってました。
さらにはRiverpodを使っている場合はConsumerWidgetを継承したクラスを使います。
ですが、ConsumerWidgetの場合はstateのライフサイクル
- initState()
- dispose()
などが使えません。
これらの要因がさらに難易度を引き上げました。
対応方法
Flutter + Riverpod ではどうすればいいのかを考えていきます。
StatefulWidget + WidgetsBindingObserver を使う方法を紹介します。
ちなみに、前節で紹介しました、
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
// アプリが paused 中
} else if (state == AppLifecycleState.resumed) {
// アプリが resumed 中
} else if (state == AppLifecycleState.inactive) {
// アプリが inactive 中
} else if (state == AppLifecycleState.detached) {
// アプリが detached 中
}
}
についてですが、実際に端末にてアプリ起動中にホーム画面に遷移して、またアプリに戻ったときの動きをprintなどでコンソールにログを残すと、ログでは次のように表示されます。
- アプリ起動中 から ホーム画面
AppLifecycleState.inactive
AppLifecycleState.paused
- ホーム画面 から アプリに戻る (バックグラウンド復帰)
AppLifecycleState.inactive
AppLifecycleState.resumed
つまり、1アクションだけでdidChangeAppLifecycleStateが「2回」呼ばれます。
どちらも共通して一度は「inactive」を経由します。
そのため、バックグラウンドに行った時のフラグを用意する時はフラグ管理に気をつける必要があります。
話を戻して、アプリのバックグラウンド復帰でどこかの画面に遷移させたい場合は、StatefulWidgetのWidget上で
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
}
のここで画面遷移の実装を書きます。
StatefulWidgetにはStateを持っていて、StateにはBuildContextを保持しています。
BuildContext get context {
assert(() {
if (_element == null) {
throw FlutterError(
'This widget has been unmounted, so the State no longer has a context (and should be considered defunct). \n'
'Consider canceling any active work during "dispose" or using the "mounted" getter to determine if the State is still active.',
);
}
return true;
}());
return _element!;
}
そのため、didChangeAppLifecycleState上でcontextにアクセスすることができます。
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final nav = Navigator.of(context);
nav.pushNamed('routeName');
}
これを組み合わせることでアプリがバックグラウンドから復帰したときに画面遷移させることができます。
ですが、Riverpodを使っているときにStatefulWidgetを使っていいのかなどで釈然としないまま実装しましたがアプリとしては正常に動いてくれています。
本当助かりましたが、この辺りはまだ正解を見つけられていません。
総論
この記事ではFlutterのアプリ開発で難しいところをフォーカスしましたが、それでもFlutterでのアプリ開発自体はとても楽しいです。
来年はよりFlutter力を向上させたいですね。
Flutter Webにも挑戦できたらなお理想と思っています。
参考文献