はじめに
Flutterで開発をしていると、誰もが一度は遭遇するエラーがあります。
setState() or markNeedsBuild() called during build.
こちらです。
これは、
「ビルド中に状態を更新するな」
というフレームワークからの警告であり、
これに対するある種の「おまじない」のようなものとして多用されがちなのが
WidgetsBinding.instance.addPostFrameCallback
このコードイディオムです。
この「おまじない」を何とはなしに使っていたところ、
BuildContextについて、
およびRiverpodの作者であるRemi氏の思想に触れる中で、
これがコードスメルの一つであることを学びました。
本記事では、なぜこの遅延実行による手法を避けるべきなのか、
そして BuildContext に基づく解決手法について記事にしてみたいと思います。
なぜ addPostFrameCallback は「Code Smell」なのか
Riverpodの作者・Remi氏は、ビルドフェーズ中での Future.microtask や
addPostFrameCallback による状態更新を推奨していません。
その理由は、Flutterの「宣言的な設計思想」にあります。
命令的プログラミングの混入
Flutterは「State -> UI」という一方向の流れを基本とします。
しかし、addPostFrameCallback は
「描画が終わったら、次にこれをして、その次に…」
という命令的な手順をコードに持ち込んでしまい、
宣言的UIのフレームワークであるFlutterの設計思想から乖離してしまいます。
1フレームの負債
そして、このコールバックを使うと、内部的には以下の挙動が発生します。
1.フレームA:不完全な(あるいは古い)状態で一度描画。
2.描画直後:コールバック内で状態を更新し、即座に再描画を要求。
3.フレームB:更新された状態で再度描画。
ユーザーの目には一瞬で見えませんが、常に「二度手間」が発生しており、
これがパフォーマンスジャンクの火種になります。
Remi氏が懸念するのは、この「場当たり的な修正」がプロダクトコード全体に積み重なることで、
データの流れ、状態の遷移が追えなくなることです。
「時間」で解決せず「空間」で解決する
このような遅延実行(時間的な解決)を使いたくなる場面では、
BuildContextの階層(空間的な解決)を正しく制御することで解消できることがあります。
ケース:Scaffold.of(context) が見つからない
同じ build メソッド内で Scaffold を作り、その直後で SnackBar を出そうとするとエラーになります。
Widget build(BuildContext context) { // ← この context は「親の住所」
return Scaffold( // ← 今から Scaffold という「自分の家」を建てようとしている
body: ElevatedButton(
onPressed: () {
// 「親の住所」から上の先祖を辿っても、
// まだ建っていない(あるいは自分の代の)家は見つからない
Scaffold.of(context).showSnackBar(...);
},
),
);
}
理由
Scaffoldのcontextはbuildのcontextと同じで、
そのcontextは親ウィジェットを参照するので、
無論そこにはScaffoldはないのでエラーになってしまうのです。
Flutterの「継承(InheritedWidget)のメカニズム」に即して言うと、
「自分自身(Scaffold)は、自分の持っている context の探索範囲(スコープ)に含まれない」
という仕様による問題です。
context は「親」への参照である
build(BuildContext context) の context は、
正確にはそのウィジェットが配置された場所(Element)を指しています。
この context を使って of(context) を呼ぶとき、
探索は「その場所の『親』」からスタートします。
自分は自分の「外」にいることになってしまう
Scaffold を return するコードを書いているとき、
その build メソッドが持っている context は、
まだ Scaffold の外側に位置しています。
どういうことかと言うと、
-
Scaffold.of(context)は「このcontextの親方向にScaffoldはいるか?」と聞きに行きます - しかし、
Scaffoldはこのcontextの 「中(子)」 にこれから作られるもの、あるいは 「自分自身」 です
Flutterの探索アルゴリズムにおいて、
「自分自身」や「自分の子」は探索対象に含まれません。
常に「親以上」しか見ないのです。
解決策:Builder ウィジェットでラップする
Builder ウィジェットを使えば、
新しい BuildContext を「その場」で生成できます。
Widget build(BuildContext context) {
return Scaffold(
body: Builder(
builder: (innerContext) { // ← ここで「Scaffoldの中」という新しい住所ができる
return ElevatedButton(
onPressed: () {
// 「Scaffoldの中」から上を辿れば、すぐ上に Scaffold が見つかる!
Scaffold.of(innerContext).showSnackBar(...);
},
);
},
),
);
}
「内側の context」が必要な理由
Builder を使う理由は、
「Scaffold の『子』として存在する新しい context」 を手に入れるためです。
どう言うことかと言うと、
Scaffold の 内側(子) で新たに発行された context(innerContext) であれば、
Scaffold.of(innerContext)で「親」を辿って自分を見つける際に、
先ほど作った return で返す Scaffold に行き当たるので、
エラーを回避することが出来るのです。
整理するとこういうことです。
- 通常時(エラー):
build(context)のcontextを使って探すと、探索スタート地点がScaffoldの「外(親)」にあるため、自分の作ったScaffoldを見つけられない -
Builder使用時(成功):BuilderをScaffoldのbodyなどに置くことで、Scaffoldの「内側(子)」という新しい住所(innerContext)を手に入れる - 解決:その
innerContextから「親」を辿れば、すぐ上にある 「先ほど作ったScaffold」 に行き当たる
【番外編】遅延実行 or 副作用
上記Builder以外のユースケースですと、
ref.listenによる回避策もあります。
ケース:addPostFrameCallback による遅延実行
「画面が表示された瞬間に、条件に応じてダイアログを出したい」
というケースでよく見かける実装です。
@override
void initState() {
super.initState();
// 描画が終わるのを待ってから、命令的に実行する
WidgetsBinding.instance.addPostFrameCallback((_) {
if (shouldShowDialog) {
showDialog(
context: context, // この時点のcontextは不安定な可能性がある
builder: (context) => AlertDialog(title: Text('Welcome')),
);
}
});
}
問題点
上記のコードには以下の問題点があります。
-
initState内で「未来の描画後」に依存している - ウィジェットがマウントされるタイミングと実行タイミングにズレがあり、不具合(メモリリークやクラッシュ)の温床になる
- 宣言的ではなく、「表示されたらこれをしろ」という命令的なコードになっている
解決策:副作用として管理
もしこれがデータ読み込み完了などの「状態変化」に伴うものであれば、
UIのビルドプロセスとは切り離し、
プロバイダーのリスナーなどで制御すべきです。
// Riverpodを使用している場合
ref.listen<AsyncValue<Data>>(provider, (previous, next) {
if (next is AsyncData && shouldShowDialog) {
showDialog(...); // 状態の変化(副作用)として正しく実行される
}
});
ウィジェット分割が求められる理由
これまでBuilderやref.listenによる解消法について紹介してきました。
その多くはBuildContextというオブジェクトの扱い方によるものでしたが、
ここで少し視点を変えてみます。
Builderによる解消法は参照可能なcontextを新規作成する手法でしたが、
これはつまり、
contextを新規で作れるのであれば別の代替手段もある、
と言うことです。
その方法が『ウィジェット分割』です。
これは単に「コードを短くするため」といった可読性、メンテナンス性の向上だけを目的にしているのではありません。
先述の通り、ウィジェットをクラス(カスタムウィジェット)として分割し、
新しい build(BuildContext context) を持たせることは、
「依存関係の解決ポイントを増やす」ことを意味します。
別の言い方をすると、
「新しい build(context) メソッドを手に入れることで(新しいcontextを手に入れることで)、探索のスタート地点を自由に増やせるから」
と言うことです。
「依存関係の解決ポイント」を増やすとは?
Flutterの context は、
その build メソッドが始まった瞬間の「場所(親が誰なのか、または誰の子どもなのか)」を指しています。
メソッド分割の場合
大きなウィジェットの中で _buildChild() のようにメソッドに分けるだけでは、
使われる context は親と同じままです。
これでは、先ほどの Scaffold.of(context) の例と同じで、
「今作っているものの内側」を指すコンテキストが手に入りません。
クラス分割の場合
そこで新しいウィジェットクラスとして切り出してあげると、
そこには専用の Widget build(BuildContext context) が生まれます。
この context は、親ウィジェットの「子」として新しく発行されたものです。
つまり、ウィジェットを分割するたびに、
ツリーのより深い位置から探索を開始できる「新しい目(context)」が増えていくことになります。
コードの結合度における優位点
上記の通りcontextの新規作成による参照範囲の確保、といった利点以外にも、
先述の通り、コードや設計をCleanにする、と言う利点もあります。
大きな build メソッドの中で全てを処理しようとすると、
親ウィジェットが全てのお膳立て(データの用意など)をして、
子に渡してあげる必要があります。
ウィジェット分割すると
子ウィジェット自身が、自分の context を使って勝手にツリーを遡り、
必要な情報(テーマ、ユーザー情報、ナビゲーターなど)を取ってこれます。
メリット
親ウィジェットは「子が何を必要としているか」を知る必要がなくなり、
コードの結合度が下がります。
最小限のリビルドスコープによるパフォーマンス性の担保
加えて、ウィジェット分割を行うことには、
不要なリビルドを削減するというパフォーマンス上の利点もあります。
ここでもこれまでの議論を踏襲して
BuildContextを用いて仕組みの提示をいたします。
依存関係の登録メカニズム 〜 BuildContextとdirtyについて
Flutter内部で context.watch<T>() や ref.watch(provider) が実行されると、
呼び出し元の BuildContext(Element) がデータの購読者として登録されます。
データ更新時、フレームワークは登録リストに基づき、
該当する BuildContext を dirty(再構築が必要) 状態としてマークします。
リビルドの範囲は、dirty マークが付与された Element の build メソッド全体に限定されます。
親ウィジェットでの購読による影響
単一の build メソッド内でデータを購読すると、
ウィジェットに対応する Element 全体が dirty 化の対象となります。
Widget build(BuildContext context) {
// 以下の Element 全体が dirty マークの対象
final data = ref.watch(dataProvider);
return Column(
children: [
const HeavyWidget(), // 親ウィジェットの dirty 化に伴い再構築が実行される
Text(data), // 更新対象の箇所
],
);
}
Flutterのレンダリングパイプラインでは、
dirty 状態の Element 配下にあるウィジェットは、
定数(const)指定による最適化を除き、
親ウィジェットのリビルドに合わせて再実行されます。
クラス分割によるリビルド範囲の局所化
特定のデータを使用する箇所を独立したウィジェットクラスに切り出し、
購読を行う BuildContext をツリーの末端へ配置します。
// 親ウィジェット:データを購読しないため dirty 状態にならない
Widget build(BuildContext context) {
return Column(
children: [
const HeavyWidget(),
_DataTextWidget(), // 子ウィジェットが独立して購読を実行
],
);
}
// 分割された子ウィジェット:最小単位の BuildContext で購読
class _DataTextWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final data = ref.watch(dataProvider);
return Text(data);
}
}
物理的なリビルドスキップの実現
クラス分割は、
「Elementの dirty 化を止める階層」
を制御する手法です。
分割により購読地点を末端に寄せることで、
データ更新時に親ウィジェットや兄弟ウィジェットの build メソッド実行をフレームワークレベルでスキップできます。
実行タイミングを調整する addPostFrameCallback ではなく、
ウィジェットツリー構造の設計によってパフォーマンスを担保するアプローチとなっているのです。
参照