【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