🪝

【Flutter】flutter_hooks の use で起こっていることを、ざっくり把握する

2024/12/06に公開

Flutter Advent Calendar 2024 の 6日目のやつです。

直近でいちばん深いインプットについてまとめました。

はじめに

flutter_hooks は、Flutter 開発でメジャーな状態管理のパッケージです。

https://pub.dev/packages/flutter_hooks

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 のようなプロパティ関数ではなく、どこからでもアクセスできる関数です。
しかし、HookWidgetbuild 関数内で実行する前提の関数です。
そうしなければ、以下の 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 の実装はこうなっています。

framework.dart
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 という関数を使用しています。

src/primitives.dart
ValueNotifier<T> useState<T>(T initialData) {
  return use(_StateHook(initialData: initialData));
}
src/scroll_controller.dart
ScrollController useScrollController({
  // 
}) {
  return use(
    _ScrollControllerHook(
      initialScrollOffset: initialScrollOffset,
      keepScrollOffset: keepScrollOffset,
      debugLabel: debugLabel,
      onAttach: onAttach,
      onDetach: onDetach,
      keys: keys,
    ),
  );
}

use 関数は、辿れば Hook.use
もっと辿れば HookElement._currentHookElement._use(hook) まで辿り着きます。

framework.dart
R use<R>(Hook<R> hook) => Hook.use(hook);
framework.dart
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);
  }
}
framework.dart
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 というクラスを保持しています。
つまり、フックスの値 = HookStateuse~ でフックスを使用するということは HookState を生成するということです。

たとえば useState では以下のような HookState が実装されています。

primitives.dart
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 を返すことが可能になっています。

その他のカスタムフックスも同様な実装になっています。

scroll_controller.dart
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 での初期化、disposeunmountdeactivatereassemble などの関数が用意されています。
それらは 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 などのカスタムフックスをサクッと作れるので、作れることは知っていていて損はなさそうです。

まとめ

  • HookWidgetuse~ すると、その実行された順番の配列で HookState を代入している
  • HookState の主な機能は以下の通り
    • メンバ変数で保持したい値を格納している
    • dispose など、Element のライフサイクルに同期して実行される関数がある
  • use~ はどこからでも使用できる上位関数で、そこで HookElement を取得するために、static な変数に HookElement を代入している

本記事で完璧に理解するのは難しいかも知れませんが、Flutter Hooks の内部理解をしたい方の助けになれば幸いです。

flutter_hooks を導入するかどうかの個人的な私見をまとめているので、よければご覧ください。
https://zenn.dev/zudah228/scraps/84fd7761b785bf

Discussion