flutter_hooksを読む
flutter_hooksは、Flutterの世界にHooksを持ち込むことを目的としたライブラリです。ProviderやRiverpodの作者であるRemi Rousselet氏によって開発されています。
A Flutter implementation of React hooks: https://medium.com/@dan_abramov/making-sense-of-react-hooks-fdbde8803889
Hooks are a new kind of object that manage the life-cycle of a Widget. They exist for one reason: increase the code-sharing between widgets by removing duplicates.
flutter_hooksは、利用者側からすると「use
を使っていい感じにhookを呼び出す」ものです。
この仕組みは、「HookElement
によるStatelessElement
/StatefulElement
の拡張」により実現されます。HookElement
を理解できれば、flutter_hooksを理解できたといっても過言ではないでしょう。
この記事は、そんなノリでコードを読むものです。ライブラリのバージョンは、執筆時点の最新バージョンである0.21.0
を使用します。
HookElement
可読性のためにコードやコメントを省略するため、一通り把握してから大元のコードに戻って読むことをおすすめします。
buildメソッド関連
HookElement
は、ComponentElement
を対象としたmixinです。
次の4メソッドをbuild
メソッドに関連するものとして、抜き出して確認しましょう。
ドキュメントを確認する限り、update
とreassemble
はHot Reload時に呼ばれるメソッドです。実装したPRを見てみると、色々と動作を試しながら実装している様子が見て取れます。[1]
以上の文脈を踏まえ、HookElement
のdidChangeDependencies
とbuild
メソッドを整理したものが、次のコードです。[2]
mixin HookElement on ComponentElement {
static HookElement? _currentHookElement;
_Entry<HookState<Object?, Hook<Object?>>>? _currentHookState;
final _hooks = LinkedList<_Entry<HookState<Object?, Hook<Object?>>>>();
final _shouldRebuildQueue = LinkedList<_Entry<bool Function()>>();
LinkedList<_Entry<HookState<Object?, Hook<Object?>>>>? _needDispose;
bool? _isOptionalRebuild = false;
Widget? _buildCache;
void didChangeDependencies() {
_isOptionalRebuild = false;
super.didChangeDependencies();
}
Widget build() {
// Check whether we can cancel the rebuild (caused by HookState.mayNeedRebuild).
final mustRebuild = _isOptionalRebuild != true ||
_shouldRebuildQueue.any((cb) => cb.value());
_isOptionalRebuild = null;
_shouldRebuildQueue.clear();
if (!mustRebuild) {
return _buildCache!;
}
_currentHookState = _hooks.isEmpty ? null : _hooks.first;
HookElement._currentHookElement = this;
try {
_buildCache = super.build();
} finally {
_isOptionalRebuild = null;
_unmountAllRemainingHooks();
HookElement._currentHookElement = null;
if (_needDispose != null && _needDispose!.isNotEmpty) {
for (_Entry<HookState<dynamic, Hook<dynamic>>>? toDispose =
_needDispose!.last;
toDispose != null;
toDispose = toDispose.previous) {
toDispose.value.dispose();
}
_needDispose = null;
}
}
return _buildCache!;
}
void _unmountAllRemainingHooks() {
if (_currentHookState != null) {
_needDispose ??= LinkedList();
// Mark all hooks >= this one as needing dispose
while (_currentHookState != null) {
final previousHookState = _currentHookState!;
_currentHookState = _currentHookState!.next;
previousHookState.unlink();
_needDispose!.add(previousHookState);
}
}
}
}
mustRebuild
がtrue
の場合には、_buildCache
を再構築し返却します。mustRebuild
がfalse
の場合には、前回の_buildCache
を返却します。
このmustRebuild
は、つぎの2つの条件でtrue
になります。
-
_isOptionalRebuild
がnull
もしくはfalse
である -
_shouldRebuildQueue
の中にtrue
を返す関数がある
この条件はHookState
のmarkMayNeedRebuildを呼び出したとき、変化します。markMayNeedRebuild
は独自のhookを実装する際、利用できるメソッドの1つです。実装的にはHookState
のshouldRebuild
をtrue
にすることで、この条件を満たすことができます。
ということで、大半のhookはif(!mustRebuild)
の分岐に入りません。キャッシュを利用せず、新たにbuild
を実行することになります。
build
の処理を読むと「super.build
時にHookElement._currentHookElement
経由でHookElement
にアクセスできる」状態になります。super.build
はComponentElement.build
を指しますが、最終的にStatelessWidget
やState
のbuild
メソッドの呼び出しにつながります。こちらは説明不要かなと。
HookElement._currentHookElement
は、全面的にhookを導入した場合に利用されます。useContext
経由でアクセスするBuildContext
が、実はHookElement._currentHookElement
です。
BuildContext useContext() {
assert(
HookElement._currentHookElement != null,
'`useContext` can only be called from the build method of HookWidget',
);
return HookElement._currentHookElement!;
}
use関連
flutter_hooksにおけるuse
メソッドは下記のような実装となっており、最終的にHookElement
の_use
メソッドに辿り着きます。
use
メソッドの説明には、次の2文が記載されています。
use must be called within the build method of either HookWidget or StatefulHookWidget. All calls of use must be made outside of conditional checks and always in the same order.
前半部に対応するのが、HookElement._currentHookElement
です。
HookElement._currentHookElement
には、先ほどのbuild
メソッドにてthis
が代入されます。この代入はsuper.build()
の呼び出し前に行われ、null
で初期化されます。この処理は同期処理です。
後半部に対応するのが、_hooks
と_currentHookState
です。
_hooks
はLinkedList
で実装されており、_use
メソッドの呼び出し時に要素の追加が行われます。ここで重要なのは、LinkedList
で実装されているため、追加済みの要素は自身の次の要素を知っています。このため、複数回_use
が呼び出された際に、保存済みのlistを辿りながら処理を行うことができます。
この前提を踏まえると、_currentHookState
は、_hooks
の中で現在の位置を示すプロパティです。あるHookElement
に対して、初めてhookが追加される際には、_currentHookState
はnull
となります。その後は呼び出されたhookが_currentHookState
となり、hookの処理を終えたタイミングで_currentHookState!.next;
により次のhookを取得します。なお、リストの最後に辿り着いた場合には、_currentHookState
はnull
になります。
この処理は _use
を呼び出す順番に依存 するため、use
メソッドを呼び出す順番は一定である必要があります。
上記の説明を踏まえた上で、_use
メソッド周辺のコードを抜き出すと、次のようになります。
mixin HookElement on ComponentElement {
_Entry<HookState<Object?, Hook<Object?>>>? _currentHookState;
final _hooks = LinkedList<_Entry<HookState<Object?, Hook<Object?>>>>();
Widget build() {
// Check whether we can cancel the rebuild (caused by HookState.mayNeedRebuild).
final mustRebuild = _isOptionalRebuild != true ||
_shouldRebuildQueue.any((cb) => cb.value());
_isOptionalRebuild = null;
_shouldRebuildQueue.clear();
if (!mustRebuild) {
return _buildCache!;
}
_currentHookState = _hooks.isEmpty ? null : _hooks.first;
HookElement._currentHookElement = this;
try {
_buildCache = super.build();
} finally {
_isOptionalRebuild = null;
_unmountAllRemainingHooks();
HookElement._currentHookElement = null;
if (_needDispose != null && _needDispose!.isNotEmpty) {
for (_Entry<HookState<dynamic, Hook<dynamic>>>? toDispose =
_needDispose!.last;
toDispose != null;
toDispose = toDispose.previous) {
toDispose.value.dispose();
}
_needDispose = null;
}
}
return _buildCache!;
}
R _use<R>(Hook<R> hook) {
/// At the end of the hooks list
if (_currentHookState == null) {
_appendHook(hook);
} else if (hook.runtimeType != _currentHookState!.value.hook.runtimeType) {
final previousHookType = _currentHookState!.value.hook.runtimeType;
_unmountAllRemainingHooks();
throw StateError('''
Type mismatch between hooks:
- previous hook: $previousHookType
- new hook: ${hook.runtimeType}
''');
} else if (hook != _currentHookState!.value.hook) {
final previousHook = _currentHookState!.value.hook;
if (Hook.shouldPreserveState(previousHook, hook)) {
_currentHookState!.value
.._hook = hook
..didUpdateHook(previousHook);
} else {
_needDispose ??= LinkedList();
_needDispose!.add(_Entry(_currentHookState!.value));
_currentHookState!.value = _createHookState<R>(hook);
}
}
final result = _currentHookState!.value.build(this) as R;
_currentHookState = _currentHookState!.next;
return result;
}
HookState<R, Hook<R>> _createHookState<R>(Hook<R> hook) {
final state = hook.createState()
.._element = this
.._hook = hook
..initHook();
return state;
}
void _appendHook<R>(Hook<R> hook) {
final result = _createHookState<R>(hook);
_currentHookState = _Entry(result);
_hooks.add(_currentHookState!);
}
}
_createHookState
に関しては、後ほどHookState
の処理を見る中で詳しく見ていきます。今は、メソッドやプロパティがHookElement
内でアクセスされていることだけ、把握しておいてください。
処理の中で重要なのは、次のif文でしょう。筆者が把握した範囲で、コメントを追加します。
if (_currentHookState == null) {
// 正常系、hookの追加
} else if (hook.runtimeType != _currentHookState!.value.hook.runtimeType) {
// 異常系、_hooksに追加されているhookとアクセスしているhookの型が異なる
} else if (hook != _currentHookState!.value.hook) {
// 正常系、hookの更新
final previousHook = _currentHookState!.value.hook;
if (Hook.shouldPreserveState(previousHook, hook)) {
// hookを再利用する場合
} else {
// hookを破棄する場合
}
}
// _currentHookStateを_hooksから復旧した上で、HookState.buildの呼び出し
final result = _currentHookState!.value.build(this) as R;
return result;
use
はHookWidget
やStatefulHookWidget
のbuild
メソッド内で呼び出されることが前提となっています。build
メソッドはWidgetのリビルドが走るたびに呼び出されるため、何度_use
メソッドが呼び出されるかは確定していません。上記の実装は、何度呼び出されても正常に動作するよう設計されています。
unmountとdeactivate
HookElement
は、unmount
とdeactivate
をoverrideしています。これらのメソッドは、HookState
のdispose
とdeactivate
を呼び出しています。
mixin HookElement on ComponentElement {
final _hooks = LinkedList<_Entry<HookState<Object?, Hook<Object?>>>>();
void unmount() {
super.unmount();
if (_hooks.isNotEmpty) {
for (_Entry<HookState<dynamic, Hook<dynamic>>>? hook = _hooks.last;
hook != null;
hook = hook.previous) {
try {
hook.value.dispose();
} catch (exception, stack) {
FlutterError.reportError(
FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'hooks library',
context: DiagnosticsNode.message(
'while disposing ${hook.runtimeType}',
),
),
);
}
}
}
}
void deactivate() {
for (final hook in _hooks) {
try {
hook.value.deactivate();
} catch (exception, stack) {
FlutterError.reportError(
FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'hooks library',
context: DiagnosticsNode.message(
'while deactivating ${hook.runtimeType}',
),
),
);
}
}
super.deactivate();
}
}
unmount
がListの後ろからアクセスしているので、ちょっとだけ見慣れないfor文になっていますが、順繰りにdipose
を呼び出しています。deactivate
は、Listの要素を順繰りにdeactivate
を呼び出しています。
コードを読めばわかる通りなので、特にコメントはありません。
HookWidgetとStatefulHookWidget
hookを利用するには、上記のHookElement
へのアクセスが必要です。HookWidget
とStatefulHookWidget
は、HookElement
をmixinしたElement
をcreateElement
で返却するように実装されています。
この実装により、HookElement
が_hooks
にて任意のHookState
を保持できるようになります。生成や破棄がHookElement
で管理されるため、HookState
は通常のElementと同じようにElementによってStateが管理されることになります。
flutter_hooksはFlutterのWidgetにHookElement
を追加し、HookState
を管理できるようにしたものです。それぞれのHookState
は対応するHookElement
の参照を持っており、StatefulElement
の実装に近い形です。
Elementの振る舞いについては、次の記事が参考になります。該当箇所を確認したい方は、「初期のElementツリーが構築されるまでの流れ」を参照してください。
良い見方をすれば、HookWidget
は「StatelessElement
に対し、非常に少ない実装を足すことで、StatefulElement
相当の振る舞いを実現した」ものと言えます。他の見方をすると、StatefulElement
に対しては、State
の管理にHookState
の管理を追加したものと言えます。[3]
HookState
HookState
は、任意のhookを記述する際に、Hook.createState
が返却するクラスの継承元になります。文章で読むとわかりにくいので、適当なhookを見てみましょう。
useDebounced
はuse(_DebouncedHook(...))
を呼び出すことで利用できます。_DebouncedHook
はHook
を継承しており、createState
を実装しています。このcreateState
はHookState
を継承した_DebouncedHookState
を返却しています。
StatefulWidget
がState
を継承したクラスを、createState
で返却するのと同じです。この命名にする必要はないと思われますが、Flutterエンジニアに直感的に理解してもらいやすくなるので、このような命名になっていると筆者は考えています。
HookState
は、初回のアクセス時にcreateState
とinitHook
を実行します。これらはHookElement
の_use
メソッド内で呼び出されるため、HookWidget
やStatefulHookWidget
のbuild
メソッド内で呼び出されることになります。
HookState<R, Hook<R>> _createHookState<R>(Hook<R> hook) {
final state = hook.createState()
.._element = this
.._hook = hook
..initHook();
return state;
}
StatefulElement
の場合、Elementの生成時にcreateState
が実行されます。その後、Element
がmount
された際に_firstBuild
メソッドが呼び出され、initState
の処理が実行されます。なお、StatelessElement
の場合には、ComponentElement
の実装をoverrideしていないため、initState
の処理は実行されません。
Element
のmount
については、先ほどの初期のElementツリーが構築されるまでの流れを参照してください。
HookState
のinitHook
とbuild
は、State
のinitState
とbuild
に相当するものです。実装を見比べてみると、ある意味当然ではありますが、HookState
はStatefulState
に比べ実行が遅延されます。これはflutter_hooksが3rd party libraryであるため、致し方のないことだと言えます。
useState
useState
はflutter_hooksの中でも、特に利用されるhookです。一方で管理する値が更新された際、build
を呼び出す必要があるhookでもあります。
HookState
の理解を深めるためにも、useState
の実装を見てみましょう。
useState
メソッドはuse
メソッドを呼び出しています。use
の引数にはHook
を継承した_StateHook
が渡されます。字面はややこしいのですが、この_StateHook
はStatefulWidget
のStatefulWidget
クラスに相当するものです。
_StateHook.createState()
はHookState
を継承した_StateHookState
を返却します。この_StateHookState
がStatefulWidget
のState
に相当するものです。先の_createHookState
メソッドにて、このcreateState
メソッドが呼び出され、HookElement._hooks
に保持されます。
結果、見るべきは次のコードです。
class _StateHookState<T> extends HookState<ValueNotifier<T>, _StateHook<T>> {
late final _state = ValueNotifier<T>(hook.initialData)
..addListener(_listener);
void dispose() {
_state.dispose();
}
ValueNotifier<T> build(BuildContext context) => _state;
void _listener() {
setState(() {});
}
Object? get debugValue => _state.value;
String get debugLabel => 'useState<$T>';
}
ここで注意が必要なのは、setState
です。HookState
はState
ではないため、厳密には(我々がパッとイメージする)メソッドを持っていません。HookState
のコードを見てみましょう。
引数で取ったfunctionを実行したのちに、_isOptionalRebuild
にfalse
を追加します。この処理は、直前のキャッシュを利用せず、新規にbuild
が実行されます。なおHookState.markMayNeedRebuild
を呼び出すhookが存在する場合には、この限りではありません。[4]
HookState.setState
にてmarkNeedsBuild
を呼び出すのは、State
のsetState
と同じです。どちらもComponentElement
のmarkNeedsBuild
を呼び出すため、ようやく通常のWidgetに関する理解と話がつながります。
ValueNotifier
はsetされた値が同一でない場合、listenerを呼び出す仕組みになっています。結果、useState<T>
で作り出したValueNotifier<T>
の値を更新すると、Widgetのリビルドが走りUIに値が反映されます。
まとめ
flutter_hooksは、Flutterの仕組みを理解した上で、ComponentElement
をhook用に拡張するライブラリです。なお、hooks_riverpodもConsumerStatefulWidget
などにHookElement
をmixinすることで実現されています。
実装を追ってみると、HookElement
を理解することで、HookState
を適切に扱えるようになります。HookState
はベーシックな利用をする限りは、State
の知識があれば問題ありません。しかし、細かな調整を試みる場合には、HookElement
の理解が必要になります。
おまけ
flutter_hooksの実装を知っておくと、次のIssueが理解しやすくなります。議論がオープンなのは嬉しいですね。
Discussion