概要
Flutter Hooksを使う機会があり、すごい便利だなと思っていたのですが、
内部的にどんな風に実装されているのか掘り下げてみようかと思い、今回色々調べてみました。
(何か間違っていたりしたらコメントいただけると嬉しいです )
Flutter Hooks とは?
React hooksをFlutterで実装したものになります。
作者はProvider等でおなじみのRemiさんです。
サンプルの実行環境
flutter: v1.20.3
flutter_hooks: 0.14.0
Flutter Hooksの基本的な仕組み
useMemoized
を掘り下げる
一番シンプルな useMemoized
というhookを例にFlutter Hooksがどのような仕組みになっているのか追ってみたいと思います。
そもそも useMemoized
とは?
useMemoized
は何回ビルドが走っても初期値をキャッシュしてくれるhookです。
↓簡単なサンプルとして現在時刻を useMemoized
でキャッシュし、Floating Action Button
をタップする度に
カウンターが増えて再ビルドが走るようなサンプルを作成してみました。
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
void main() {
runApp(MyApp());
}
class MyApp extends HookWidget {
@override
Widget build(BuildContext context) {
final DateTime now = useMemoized(() => DateTime.now()); // 初期値として現在日時を保存
final ValueNotifier<int> counter = useState<int>(0);
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
now.toString(),
style: TextStyle(fontSize: 25, fontWeight: FontWeight.bold),
),
Text(
counter.value.toString(),
style: TextStyle(fontSize: 25, fontWeight: FontWeight.bold),
)
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => counter.value++, // カウンターが増えWidgetのビルドが走る
child: Icon(Icons.add),
),
));
}
}
↑ counterの値が更新されても初期値として設定した現在日時の値は変更されていないのが分かるかと思います。
(2020-09-11 11:21:40.... の箇所)
useMemoized
の実装は?
こちら で実装されています。以下に関連箇所だけ抜き出しました。
T useMemoized<T>(T Function() valueBuilder,
[List<Object> keys = const <dynamic>[]]) {
return use(_MemoizedHook(
valueBuilder,
keys: keys,
));
}
class _MemoizedHook<T> extends Hook<T> {
const _MemoizedHook(
this.valueBuilder, {
List<Object> keys = const <dynamic>[],
}) : assert(valueBuilder != null, 'valueBuilder cannot be null'),
assert(keys != null, 'keys cannot be null'),
super(keys: keys);
final T Function() valueBuilder;
@override
_MemoizedHookState<T> createState() => _MemoizedHookState<T>();
}
class _MemoizedHookState<T> extends HookState<T, _MemoizedHook<T>> {
T value;
@override
void initHook() {
super.initHook();
value = hook.valueBuilder();
}
@override
T build(BuildContext context) {
return value;
}
@override
String get debugLabel => 'useMemoized<$T>';
}
より詳細に見ていこうと思います。
useメソッド
先ずは useMemoized
内で使用されている use(_MemoizedHook(...
の use
メソッド を掘り下げてみたいと思います。
この use
メソッド は flutter_hooks/lib/src/framework.dart で以下の様に実装されています。
R use<R>(Hook<R> hook) => Hook.use(hook);
Hook
というクラスのstatic メソッド use
に引数のhook(ここでは_MemoizedHook)を渡して呼んでいます。
ここで Hook
というクラスが出てきました。今度はこの Hook
に着目したいと思います。
Hookクラス
Hookクラスはこちらに実装されています。
以下に要約したものを抜き出してみました。
@immutable
abstract class Hook<R> with Diagnosticable {
const Hook({this.keys});
@Deprecated('Use `use` instead of `Hook.use`')
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);
}
final List<Object> keys;
@protected
HookState<R, Hook<R>> createState();
}
先程出てきた Hook.use
に着目したいと思います。
@Deprecated
となっていて直接 Hook.use
は呼ばずに先程の useメソッド
を呼ぶようにとなっています。
ここでは HookElement._currentHookElement._use(hook)
が呼ばれており、
HookElement._currentHookElement
は後でも出てきますが こちらにstatic変数として定義されています。
HookElement._currentHookElement._use
は別途掘り下げるとして createState
で生成される HookState
を見てみます。
HookStateクラス
こちらに実装されています。こちらも要約したものを以下に抜き出してみました。
abstract class HookState<R, T extends Hook<R>> with Diagnosticable {
@protected
BuildContext get context => _element;
HookElement _element;
T get hook => _hook;
T _hook;
@protected
void initHook() {}
@protected
void dispose() {}
@protected
R build(BuildContext context);
@protected
void didUpdateHook(T oldHook) {}
void deactivate() {}
void reassemble() {}
@protected
void setState(VoidCallback fn) {
fn();
_element
.._isOptionalRebuild = false
..markNeedsBuild();
}
}
こうして見ると Hook
と HookState
の関係が StatefulWidget
と State
の関係に似てますね
HookState
内に先程出てきた HookElement
を保持し、BuildContext
としてgetできるようにしています。
後で出てきますが、HookElement
は ComponentElement
をimplementしているので BuildContext
として扱う事ができます。
詳しくはこちらを参照して下さい。
FlutterのBuildContextとは何か - Qiita
HookElement mixin
こちらに実装されています。こちらも要約して抜き出してみました。
HookElement
の use
メソッドでの処理がFlutter Hooksのキモとなる処理になってきます。
mixin HookElement on ComponentElement {
static HookElement _currentHookElement;
_Entry<HookState> _currentHookState;
final LinkedList<_Entry<HookState>> _hooks = LinkedList();
LinkedList<_Entry<bool Function()>> _shouldRebuildQueue;
LinkedList<_Entry<HookState>> _needDispose;
bool _isOptionalRebuild = false;
Widget _buildCache;
@override
Widget build() {
// 色々な前処理 ...
_currentHookState = _hooks.isEmpty ? null : _hooks.first; // ①
HookElement._currentHookElement = this; // ②
try {
_buildCache = super.build();
} finally {
// 後処理 ....
}
return _buildCache;
}
R _use<R>(Hook<R> hook) {
if (_currentHookState == null) {
_appendHook(hook);
} else if (hook.runtimeType != _currentHookState.value.hook.runtimeType) { // ③
final previousHookType = _currentHookState.value.hook.runtimeType;
_unmountAllRemainingHooks();
if (kDebugMode && _debugDidReassemble) {
_appendHook(hook);
} else {
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
で出てきた HookElement._currentHookElement
が定義されています。
大まかな処理の流れ
-
_currentHookState
-
HookState
の LinkedListになっておりビルド中に useXXX で呼ばれた際の各HookStateの一覧を呼ばれた順で保持しています
-
-
build メソッド
- ① : 前回Widgetのビルドが走った際の
HookState
のLinkedList のキャッシュがあれば_currentHookState
にセットしています - ② : staticな領域に現在build中のHookElementをセットしています
HookWidget
やStatefulHookWidget
クラスを使ったWidgetのビルドでは内部的にHookElement
を使用しているのでHookElement
のbuild()
メソッドが呼ばれます。 - ① : 前回Widgetのビルドが走った際の
-
use メソッド
- ③ : 前回ビルドした時のHookと今回ビルド中のHookの
runtimeType
が異なっている場合- _currentHookStateに格納されているHookStateをすべてクリアします
- Debug中の場合(開発しててuseXXXを変更した等)今回Hookを新たに格納します
- ④ : 前回ビルド時のHookと異なるオブジェクトの場合
shouldPreserveState
メソッドでKeyが前回と異なっているかチェックを行います- 異なっている場合
- 一旦以前のHookStateは破棄して今回のHookStateに入れ替えます
- 異なっていない場合
-
HookState
のdidUpdateHook
が呼ばれます
-
- 異なっている場合
- ⑤ : 次に備えて、
_currentHookState.next
で次のHookStateにLinkedList内の位置を移動させています
- ③ : 前回ビルドした時のHookと今回ビルド中のHookの
LinkedListを使用して前回ビルドのHookStateと比較する処理は flutter_hooksのREADMEにも載っていますが
React hooks: not magic, just arrays | by Rudi Yardley | Medium
こちらを読むとさらに理解が深まりそうでした。
useMemoized
に立ち返って
ここで useMemoized
内で呼ばれていた Hook.use
に立ち返ってみると HookElement._currentHookElement._use(hook)
が呼ばれていました。
引数の hook
は _MemoizedHook
が設定され use
メソッドが呼ばれることになります。
_currentHookState
がnullの場合(初めてWidgetビルド中にuseXXXが呼ばれた場合)は _appendHook
が呼ばれてます。
_appendHook
は何をしているかというと
extension on HookElement {
HookState<R, Hook<R>> _createHookState<R>(Hook<R> hook) {
assert(() {
_debugIsInitHook = true;
return true;
}(), '');
final state = hook.createState()
.._element = this
.._hook = hook
..initHook();
assert(() {
_debugIsInitHook = false;
return true;
}(), '');
return state;
}
void _appendHook<R>(Hook<R> hook) {
final result = _createHookState<R>(hook);
_currentHookState = _Entry(result);
_hooks.add(_currentHookState);
}
}
Hookクラス
の createState
を呼び出して HookState
を作成し _currentHookState
に設定しています。
上記であった前回ビルド時のHookStateと比較する等の処理が終わったあと以下の処理が行われます。
final result = _currentHookState.value.build(this) as R;
_currentHookState = _currentHookState.next;
return result;
ここで HookState
の buildメソッドを呼び出して戻りuseメソッドの戻り値として返しています。
useMemoized
の場合だと _MemoizedHookState
の buildが呼ばれることになり、
_MemoizedHookState
の buildは単に保存した値を返しているだけなので、いくらWidgetのビルドが走っても
更新されない保存された値を返し続けるという仕組みになっているようです
@override
T build(BuildContext context) {
return value;
}
HookWidget
最後にhookを使う側で必要な HookWidget
クラスを見てみたいと思います。
abstract class HookWidget extends StatelessWidget {
const HookWidget({Key key}) : super(key: key);
@override
_StatelessHookElement createElement() => _StatelessHookElement(this);
}
class _StatelessHookElement extends StatelessElement with HookElement {
_StatelessHookElement(HookWidget hooks) : super(hooks);
}
すごいシンプルで、StatelessWidget
クラスを継承し、Elementを生成する際に
HookElement
を実装した _StatelessHookElement
を返すようになっています。
また、StatefulWidget
版も用意されている様でした。
abstract class StatefulHookWidget extends StatefulWidget {
const StatefulHookWidget({Key key}) : super(key: key);
@override
_StatefulHookElement createElement() => _StatefulHookElement(this);
}
class _StatefulHookElement extends StatefulElement with HookElement {
_StatefulHookElement(StatefulHookWidget hooks) : super(hooks);
}
ここまで仕組みがどうなっているのか超ざっくり説明しました。
主な登場人物とざっくり相関図
これまでで登場してきたクラスやmixinを相関図にしてみました。
- Hook
- HookElement
- HookState
- HookWidget
- StatefulHookWidget (※今回は省いています)
※ 間違っていたらご指摘いただけると嬉しいです
他のhooks達
ここまでで何となくでも仕組みが理解できたので、他のhooksも見てみたいと思います。
useContext
実装はこちら
これは至ってシンプルで以下の様に実装されています。
BuildContext useContext() {
assert(
HookElement._currentHookElement != null,
'`useContext` can only be called from the build method of HookWidget',
);
return HookElement._currentHookElement;
}
実装をみるとなぜbuild中じゃないと呼び出せないのか分かりますね
ちなみに HookElement._currentHookElement
が null になるタイミングはWidgetのビルドが終わったタイミングになります。
useEffect
抜き出したもの
void useEffect(Dispose Function() effect, [List<Object> keys]) {
use(_EffectHook(effect, keys));
}
class _EffectHook extends Hook<void> {
const _EffectHook(this.effect, [List<Object> keys])
: assert(effect != null, 'effect cannot be null'),
super(keys: keys);
final Dispose Function() effect;
@override
_EffectHookState createState() => _EffectHookState();
}
class _EffectHookState extends HookState<void, _EffectHook> {
Dispose disposer;
@override
void initHook() {
super.initHook();
scheduleEffect();
}
@override
void didUpdateHook(_EffectHook oldHook) {
super.didUpdateHook(oldHook);
if (hook.keys == null) {
if (disposer != null) {
disposer();
}
scheduleEffect();
}
}
@override
void build(BuildContext context) {}
@override
void dispose() {
if (disposer != null) {
disposer();
}
}
void scheduleEffect() {
disposer = hook.effect();
}
@override
String get debugLabel => 'useEffect';
@override
bool get debugSkipValue => true;
}
使い方としては useEffect
第一引数で渡された処理が初回呼ばれて、以降は第二引数のKeyに変更が無い限り
処理が呼ばれる事はありません。
useEffect(() {
print('useEffect');
return () => print('dispose');
}, const []);
↑の例だと第二引数のKeyに const []
を指定しているので初回だけしか print('useEffect');
は呼ばれません。
また第一引数の戻り値として終了処理を Function()
として返せるのでもう一度処理が呼ばれる前にクリアさせたい等に使えそうです。
Keyが変更されるサンプルとして useMemoized
のサンプルに useEffect
を呼ぶ処理を追加してみました。
// ... 省略
class MyApp extends HookWidget {
@override
Widget build(BuildContext context) {
final DateTime now = useMemoized(() => DateTime.now());
final ValueNotifier<int> counter = useState<int>(0);
// ☆ここから追加
useEffect(() {
print('useEffect');
return () => print('dispose');
}, [counter.value]);
// ... 省略
↑のサンプルを実行し + ボタンをタップすると print('useEffect');
と print('dispose');
が呼ばれるのが分かるかと思います。
useEffect
のしくみ
初回呼ばれる initHook
内で scheduleEffect
メソッドを呼び出しています。
scheduleEffect
メソッドがどうなっているかというと
void scheduleEffect() {
disposer = hook.effect();
}
useEffect
の第一引数で渡された Function()
を 呼び出し戻り値の dispose を内部で保存しています。
disposeはここでは typedef Dispose = void Function();
として定義されています。
このタイミングで初回の処理を呼び出しています。
次に第二引数のKeyが変更された時点の処理を見てみたいと思います。
} 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);
}
}
既に説明した通り、HookElement
の use
メソッド内でKeyが変更されたかの判定を shouldPreserveState
で行っており
Keyが変更されている場合、新たにHookStateを作り直しています。
作り直す際に initHook
が呼ばれ内部で scheduleEffect
を呼んでいます。
破棄された方のHookStateはbuildの最後で dispose
が呼ばれ、内部で保持していた disposer()
を呼び出しています。
useState
抜き出したもの
ValueNotifier<T> useState<T>([T initialData]) {
return use(_StateHook(initialData: initialData));
}
class _StateHook<T> extends Hook<ValueNotifier<T>> {
const _StateHook({this.initialData});
final T initialData;
@override
_StateHookState<T> createState() => _StateHookState();
}
class _StateHookState<T> extends HookState<ValueNotifier<T>, _StateHook<T>> {
ValueNotifier<T> _state;
@override
void initHook() {
super.initHook();
_state = ValueNotifier(hook.initialData)..addListener(_listener);
}
@override
void dispose() {
_state.dispose();
}
@override
ValueNotifier<T> build(BuildContext context) {
return _state;
}
void _listener() {
setState(() {});
}
@override
Object get debugValue => _state.value;
@override
String get debugLabel => 'useState<$T>';
}
こちらは既に useMemoized
のサンプルで使ってましたが、こちらは ValueNotifier
をラップし
扱いやすくしてくれているhooksになります。
useState
のしくみ
こちらはシンプルで initHook
時に ValueNotifier
を生成し、build時には生成した ValueNotifier
を返しています。
まとめ
基本的なhooksの仕組みを何となくでも把握しとけば、他のhooksもソースコードを読むことで
ある程度理解できるようになりました
今回のように一番シンプルなものから掘り下げていくのは余分なInputが少ない分理解しやすいですね。
内部的な処理が分かっていれば、useContext をWidgetのビルドタイミング以外で使用したらダメだとか
事前に分かるので、広範囲でお世話になるライブラリ等は事前に内部がどんな風になっているのか把握しておくと、
トータル的にはハマる時間が無くなってスムーズかもしれません
また次も機会があれば何か掘り下げようかと思います。