【Flutter】flutter_hooks の use で起こっていることを、ざっくり把握する
Flutter Advent Calendar 2024 の 6日目のやつです。
直近でいちばん深いインプットについてまとめました。
はじめに
flutter_hooks は、Flutter 開発でメジャーな状態管理のパッケージです。
use~
という 関数を、HookWidget
(またはその継承)の build
関数で実行することで、リビルドされても保持される値を管理することができます。
class Example extends HookWidget {
Widget build(BuildContext context) {
final a = useA();
final b = useB();
return Container();
}
ですが、リビルドとはつまり build
関数の再実行です。
なので関数内で宣言された変数のスコープは、その関数の実行の間のみのはずです。
では、どうして値が保持されているのでしょうか。
Hooks には様々な機能が含まれていますが、「use~
がどうやって値を保持しているのか」に絞って内部ソースコードを追っていきたいと思います。
HookWidget
の正体
use~
関数は、setState
のようなプロパティ関数ではなく、どこからでもアクセスできる関数です。
しかし、HookWidget
の build
関数内で実行する前提の関数です。
そうしなければ、以下の AssertionError
が出ます。
════════ Exception caught by widgets library ═══════════════════════════════════
Hooks can only be called from the build method of a widget that mix-in `Hooks`.
Hooks should only be called within the build method of a widget.
Calling them outside of build method leads to an unstable state and is therefore prohibited.
abstract class Hook<R> with Diagnosticable {
static R use<R>(Hook<R> hook) {
assert(HookElement._currentHookElement != null, '''
Hooks can only be called from the build method of a widget that mix-in `Hooks`.
Hooks should only be called within the build method of a widget.
Calling them outside of build method leads to an unstable state and is therefore prohibited.
''');
return HookElement._currentHookElement!._use(hook);
}
なので、use~
関数の理解には HookWidget
の理解が必須事項です。
HookWidget
の実装はこうなっています。
abstract class HookWidget extends StatelessWidget {
const HookWidget({Key? key}) : super(key: key);
_StatelessHookElement createElement() => _StatelessHookElement(this);
}
class _StatelessHookElement extends StatelessElement with HookElement {
_StatelessHookElement(HookWidget hooks) : super(hooks);
}
HookElement
を持っただけの StatelessWidget
という感じです。
つまり、HookWidget
を知るには HookElement
知ればいけるということです。
HookElement
がやってること
HookElement
は、フックス値の保持を行っています。
use
を実行した順番に値を配列として保持し、リビルドした時、その use
の順番に保持した値をとっています。
公式の README に HookElement
を端的に表したソースコードが添付されています(引用元)。
class HookElement extends Element {
List<HookState> _hooks;
int _hookIndex;
T use<T>(Hook<T> hook) => _hooks[_hookIndex++].build(this);
performRebuild() {
_hookIndex = 0;
super.performRebuild();
}
}
このソースコードが表しているのは、HookElement
の _hooks という変数に配列として、フックスの値が保持されているということです。
_hookIndex++
としているように、順番でフックスの値を取得しているので、README には条件式で use~
を行ったり行わなかったりすることを禁止しています。
DON'T wrap use into a condition
Widget build(BuildContext context) { if (condition) { useMyHook(); } // .... }
実際のソースコード
めちゃくちゃ簡略化して HookElement
を追っていきます。
mixin HookElement on ComponentElement {
// どこでもアクセスできるように、static で HookElement を格納
static HookElement? _currentHookElement;
// Hook の本体を保持してる配列
final _hooks = LinkedList<_Entry<HookState<Object?, Hook<Object?>>>>();
Widget build() {
HookElement._currentHookElement = this;
return super.build();
}
やってることとしては、本家の build
関数(俺たちが実装するやつ)の実行前に、static
変数に現在実行中の HookElement
を格納します。
つまりこう言う感じです。
class MyWidget extends HookWidget {
Widget build(BuildContext context) {
// "mixin HookElement"
// HookElement._currentHookElement = this;
final a = useA();
final b = useB();
return Container();
}
}
static
の変数に格納することより、上位関数である use~
が現在実行中の HookElement
を参照することができるようになります。
use
関数がやってること
Flutter Hooks が提供している use~
関数は、漏れなく flutter_hooks
ライブラリ内の use
という関数を使用しています。
例
ValueNotifier<T> useState<T>(T initialData) {
return use(_StateHook(initialData: initialData));
}
ScrollController useScrollController({
//
}) {
return use(
_ScrollControllerHook(
initialScrollOffset: initialScrollOffset,
keepScrollOffset: keepScrollOffset,
debugLabel: debugLabel,
onAttach: onAttach,
onDetach: onDetach,
keys: keys,
),
);
}
use
関数は、辿れば Hook.use
。
もっと辿れば HookElement._currentHookElement._use(hook)
まで辿り着きます。
R use<R>(Hook<R> hook) => Hook.use(hook);
abstract class Hook<R> with Diagnosticable {
static R use<R>(Hook<R> hook) {
assert(HookElement._currentHookElement != null, '''
Hooks can only be called from the build method of a widget that mix-in `Hooks`.
Hooks should only be called within the build method of a widget.
Calling them outside of build method leads to an unstable state and is therefore prohibited.
''');
return HookElement._currentHookElement!._use(hook);
}
}
mixin HookElement on ComponentElement {
R _use<R>(Hook<R> hook) {
// 長い「同じ Hook かどうかの正誤判定」
// ...
if (_currentHookState == null) {
// ① 初めて描画される時
// 配列の _hooks へ Hook を格納
_appendHook(hook);
// ...
} else if (hook != _currentHookState!.value.hook) {
final previousHook = _currentHookState!.value.hook;
if (Hook.shouldPreserveState(previousHook, hook)) {
// ② リビルドの時
// _hooks に保持してあった Hook の再利用処理が書かれている
// ...
// Hook から、使いたい値を抜き出す
final result = _currentHookState!.value.build(this) as R;
// 次の use の実行するために _currentHookState を次に進める
_currentHookState = _currentHookState!.next;
return result;
}
_use
関数では、_hooks
変数に入っている値(実体はフックスの値を保持する HookState
というクラス)を順番に取得していきます。
runtimeType
が違った場合などエラーハンドリング周りを読みたい場合は、ソースコードを読んでいただくのがいいと思います。
イメージこんな感じです。
class MyWidget extends HookWidget {
Widget build(BuildContext context) {
// "mixin HookElement"
// HookElement._currentHookElement = this;
final a = useA(); // HookElement._currentHookElement._hooks の1番目に追加、または再利用
final b = useB(); // HookElement._currentHookElement._hooks の2番目に追加、または再利用
return Container();
}
}
HookState
とは
HookElement
が保持している _hooks
配列は、HookState
というクラスを保持しています。
つまり、フックスの値 = HookState
、use~
でフックスを使用するということは HookState
を生成するということです。
たとえば useState
では以下のような HookState
が実装されています。
ValueNotifier<T> useState<T>(T initialData) {
return use(_StateHook(initialData: initialData));
}
class _StateHook<T> extends Hook<ValueNotifier<T>> {
const _StateHook({required this.initialData});
final T initialData;
_StateHookState<T> createState() => _StateHookState();
}
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(() {});
}
}
この HookState
クラスのインスタンスを保持することで、メンバ変数で保持している ValueNotifier
を返すことが可能になっています。
その他のカスタムフックスも同様な実装になっています。
class _ScrollControllerHookState
extends HookState<ScrollController, _ScrollControllerHook> {
late final controller = ScrollController(
initialScrollOffset: hook.initialScrollOffset,
keepScrollOffset: hook.keepScrollOffset,
debugLabel: hook.debugLabel,
onAttach: hook.onAttach,
onDetach: hook.onDetach,
);
ScrollController build(BuildContext context) => controller;
そのほかには、initHook
での初期化、dispose
、unmount
、deactivate
、reassemble
などの関数が用意されています。
それらは HookElement
によって、 Element
のライフサイクルと同期させられています。
以上が HookState
の基本です。
自作カスタムフックス
せっかくなので、カスタムフックスを自作しようと思います。
作るのは、GlobalKey
を保持するフックスです。
GlobalKey<T> useGlobalKey<T extends State<StatefulWidget>>() {
return use(_GlobalKeyHook<T>());
}
class _GlobalKeyHook<T extends State<StatefulWidget>>
extends Hook<GlobalKey<T>> {
HookState<GlobalKey<T>, Hook<GlobalKey<T>>> createState() {
return _GlobalKeyHookState<T>();
}
}
class _GlobalKeyHookState<T extends State<StatefulWidget>>
extends HookState<GlobalKey<T>, Hook<GlobalKey<T>>> {
// メンバ変数で GlobalKey を保持
late final _key = GlobalKey<T>();
GlobalKey<T> build(BuildContext context) {
return _key;
}
}
ぶっちゃけ、以下のような形でも実現できます。
GlobalKey<T> useGlogalKey() {
return useState(GlogalKey()).value;
}
GlobalKey<T> useGlogalKey() {
return useMemoized(GlogalKey.new);
}
が、構成のシンプルさや拡張性の面で、カスタムフックスの方も悪くはないかなと思います。
これを知っていると、何かしらの外部パッケージの Controller などのカスタムフックスをサクッと作れるので、作れることは知っていていて損はなさそうです。
まとめ
-
HookWidget
でuse~
すると、その実行された順番の配列でHookState
を代入している -
HookState
の主な機能は以下の通り- メンバ変数で保持したい値を格納している
-
dispose
など、Element
のライフサイクルに同期して実行される関数がある
-
use~
はどこからでも使用できる上位関数で、そこでHookElement
を取得するために、static
な変数にHookElement
を代入している
本記事で完璧に理解するのは難しいかも知れませんが、Flutter Hooks の内部理解をしたい方の助けになれば幸いです。
flutter_hooks を導入するかどうかの個人的な私見をまとめているので、よければご覧ください。
Discussion