参考文献
はじめに
React Hooks と同じ書き味で書くことができ、StatefulWidget に比べて短い行数になるので、多くの人に受け入れられている印象があります。
Flutter Hooks の導入におけるメリットは語られても、デメリットについてまとめられた記事は多くありません。
なので本記事では、自分の経験と意見を踏まえながら、少し深めに Flutter Hooks を見ていき、導入すべきかを考えていきたいと思います。
当然ですが個人的な意見なので、プロジェクトの決断を第一に、ルール策定などに役立てれば嬉しいです。
Flutter Hooks の利点
記述量の低下
特に AnimationController を使う際に顕著です。
flutter_hooks の README にわかりやすくビジュアルで示されているメリットです。
class Example extends StatefulWidget {
final Duration duration;
const Example({Key? key, required this.duration})
: super(key: key);
@override
_ExampleState createState() => _ExampleState();
}
class _ExampleState extends State<Example> with SingleTickerProviderStateMixin {
AnimationController? _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: widget.duration);
}
@override
void didUpdateWidget(Example oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.duration != oldWidget.duration) {
_controller!.duration = widget.duration;
}
}
@override
void dispose() {
_controller!.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container();
}
}
class Example extends HookWidget {
const Example({Key? key, required this.duration})
: super(key: key);
final Duration duration;
@override
Widget build(BuildContext context) {
final controller = useAnimationController(duration: duration);
return Container();
}
}
AnimationController まわりの実装での効果が特に絶大です。
記述量の低下は、可読性や開発効率のアップという明確なメリットにつながり、これらは開発体験にも直結します。
カスタムフックに便利な機能が用意されている
Flutter Hooks は、基本となる Hook.use 関数をもとに、さまざまなカスタムフックが用意されています。
setState 実行漏れを防げる
シンプルに単一の状態を管理する useState では、Flutter の ValueNotifier が使われており、値の変更を検知して setState が自動で実行されるようになっています。
class _StateHookState<T> extends HookState<ValueNotifier<T>, _StateHook<T>> {
late final _state = ValueNotifier<T>(hook.initialData)
..addListener(_listener);
// 中略
void _listener() {
setState(() {});
}
ChangeNotifier の dispose 漏れを防げる
Flutter には、たくさんの ChangeNotifier が用意されています。
TextEditingControllerScrollControllerPageController-
AnimationController
...
それらを使う場合、意図しないリスナーの発火等を防止するために dispose を仕込んでいなければいけません。
class MyWidget extends StatefulWidget {
const MyWidget({super.key});
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
final _scrollController = ScrollController();
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}
しかし、flutter_hooks で用意されているカスタムフックスを使えば、勝手に dispose を実行してくれます。
class _ScrollControllerHookState
extends HookState<ScrollController, _ScrollControllerHook> {
late final controller = ScrollController(
// 中略
@override
void dispose() => controller.dispose();
useReducer
useReducer、という独自の強力なフックスが使えるのも利点です。
状態と、状態の変更を「定義」できるので、再利用性やテスタビリティの高い状態管理を実装することができます。
説明すると長くなるので、参考になる記事を貼っておきます。
mixin の命名の競合を防げる
mixin は多重継承です。なので、継承した2つの mixin で命名が競合する可能性があります。
そうなった場合、with で記述した後ろの方が優先されるという特殊な仕様があります。
ソースコード
mixin A_MIXIN {
void hello() {
print('Hello A');
}
}
mixin B_MIXIN {
void hello() {
print('Hello B');
}
}
class A_Imple with B_MIXIN, A_MIXIN {
const A_Imple();
}
class B_Imple with A_MIXIN, B_MIXIN {
const B_Imple();
}
void main() {
const a = A_Imple();
const b = B_Imple();
a.hello(); // 'Hello A'
b.hello(); // 'Hello B'
}
Flutter の実装においても、AutomaticKeepAlive や SingleTickerProviderStateMixin 等の mixin を使う機会があります。
自前で mixin を用意しているプロジェクトなどもあるかもしれません。
しかし、カスタムフックスで提供される useAnimationController、useSingleTickerProvider、useAutomaticKeepAlive は mixin を使わずに実装できるので、これらの多重継承の問題に遭遇することはありません。
(自分は遭遇したことありませんが…)
Flutter Hooks の欠点
次は、欠点や気になるところを挙げていきます。
その前に、Flutter Hooks の採用を検討する上で重要な Issue を共有しておきます。
Flutter Hooks の開発者である Remi さんが、hooks を公式へ導入するリクエストをしている Issue です。
Flutter Hooks の利点や、公式として導入する懸念などが挙げられているので、気になる人はぜひ目を通してみてください(クソ長いですが)。
Issue はクローズされていて、「Flutter Hooks は素晴らしいシステムだが、パッケージという距離感が一番適している」という感じに着地しています。
use~ という関数をリビルドのたびに実行するのは多少なりともコストがかかる
use~ 関数は build 関数内で実行、使用されます。
Widget build(BuildContext context) {
final a = useA();
final b = useB();
return Container();
}
しかし、build 関数は、Flutter において何度も実行される関数です。
たとえば、上のコードで useA の値が変更されて setState が行われた場合、再び useA や useB が実行されます。
use 関数ではそれなりにいろんなことが実行されています。
StatefulWidget では、値は State クラスのメンバ変数に保持されているので、use のような関数がリビルドのたびに実行されることはありません。
上であげた Issue でも一度話題には上がっています
https://github.com/flutter/flutter/issues/25280#issuecomment-591821514
ですが、かなり軽微であるので神経質になりすぎるほどでもないとは思います。
可読性・保守性が本当に高いか疑問がある
特に使われる useState を例に、StatefulWidget と HookWidget での実装を比較してみましょう。
まず、すでに1つの状態を管理しているウィジェットを用意します。
class MyWidget extends StatefulWidget {
const MyWidget({super.key});
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
int _count = 0;
void _increase() {
setState(() {
_count++;
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextButton.icon(
onPressed: _increase,
icon: Icon(Icons.add),
label: Text('${_count}'),
),
]
);
}
}
class MyWidget extends HookWidget {
const MyWidget({super.key});
@override
Widget build(BuildContext context) {
final count = useState(0);
void increase() {
count.value++;
}
return Column(
children: [
TextButton.icon(
onPressed: increase,
icon: Icon(Icons.add),
label: Text('${count.value}'),
),
]
);
}
}
完成したソースコードは StatefulWidget が27行、HookWidget が21行で、HookWidget の方が6行の差をつけて行数は少ないです。
これに、状態を1つ足して違いを比較してみます。
class MyWidget extends StatefulWidget {
const MyWidget({super.key});
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
int _count = 0;
void _increase() {
setState(() {
_count++;
});
}
+ bool _checked = false;
+
+ void _toggle() {
+ setState(() {
+ _checked = !_checked;
+ });
+ }
@override
Widget build(BuildContext context) {
return Column(
children: [
TextButton.icon(
onPressed: _increase,
icon: Icon(Icons.add),
label: Text('${_count}'),
),
+ Checkbox(
+ value: _checked,
+ onChanged: (_) => _toggle(),
+ ),
]
);
}
}
class MyWidget extends HookWidget {
const MyWidget({super.key});
@override
Widget build(BuildContext context) {
final count = useState(0);
void increase() {
count.value++;
}
+ final checked = useState(false);
+
+ void toggle() {
+ _checked.value = !_checked.value;
+ }
+
return Column(
children: [
TextButton.icon(
onPressed: increase,
icon: Icon(Icons.add),
label: Text('${count.value}'),
),
+ Checkbox(
+ value: checked.value,
+ onChanged: (_) => toggle(),
+ ),
]
);
}
}
値を足したソースコードは StatefulWidget が39行、HookWidget が31行です。
行数の増加量に関しては、StatefulWidget が+12行、HookWidget が+10行と、setState の差分による2行しか変わらず、大きな違いはありません。
StatefulWidget 自体と HookWidget 自体のコード量には差がありますが、状態を追加したときの手間はほとんど変わらないように感じられます。
おそらく、flutter_hooks の行数の少なさを一番感じる瞬間は、StatelessWidget から状態を追加した 0→1 の瞬間です。
エディタ補完などで StetefulWidget に変更したりせずとも、flutter_hooks はクラスを HookWidgetにリネームして useState 行を足すだけで済みます。
| 状態の数 | StetefulWidget | HookWidget |
|---|---|---|
| 0→1 | 面倒くさい | ⭕️ 楽 👈ここに大きな効果を感じている |
| 1→N | 普通 | 普通 |
「コード量を減らしたい、StatefulWidget を使いたくないから flutter_hooks を採用する」という決断は、一度立ち止まったほうがいいかもしれません。
数字で客観的な比較をしてるっぽい感じになってますが、結局「見た目の印象」みたいな主観の話をしていることをご留意ください。
StatefulWidget にしかできないことがある
StatefulWidget には、外部ウィジェットからのアクセスという点で HookWidget にはない強みがあります。
- 親から子の
Stateへアクセスする GlobalKey.currentState - 子から親の
Stateへアクセスする context.findAncestorStateOfType()
ほぼ使わない人もいるかもしれませんが、これらの機能で安全性や開発効率につながることがあります。
自分は割と使うので、少しだけ活用法を書いておきます。
GlobalKey.currentState
Scaffold や Form などでたまに使うので、存在を知ってる人は多いと思います。
たとえば、以下のように ListView を切り出した時に、親ウィジェットでもスクロールを実行したいとします。
本来は、親ウィジェットで ScrollController を持って渡す必要があります。
しかし、GlobalKey.currentState を使えば、子コンポーネントの ScrollControllerを直接使うことができます。
import 'package:flutter/material.dart';
class MyHorizontalListView extends StatefulWidget {
const MyPageView({super.key});
@override
State<StatefulWidget> createState() => MyHorizontalListViewState();
}
class MyHorizontalListViewState extends State<MyHorizontalListView> {
final _controller = ScrollController();
void scrollToTop() {
_controller.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 120,
child: ListView(
controller: _controller,
}
}
final _listViewKey = GlobalKey<MyHorizontalListViewState>();
FloatingActionButton(
onPressed: () {
_listViewKey.currentState!.scrollToTop();
}
)
上記の例に加えて、特にフォームの値を管理したい場合などに重宝します。
子ウィジェットに状態を持たせると、ListView のスクロール外で破棄される危険性があるので、ページ等の親ウィジェットに持たせるのが正解の場合もあります。
context.findAncestorStateOfType<T>()
子の BuildContext から、親の StatefulWidget(の State)にアクセスできます。
Navigator.of(context) もこれを使っています。
個人的には、GlobalKey.currentState よりもこちらを重宝しています。
class MyPage extends StatefulWidget {
const MyPage({super.key});
@override
State<MyPage> createState() => _MyPageState();
}
class _MyPageState extends State<MyPage> {
Future<void> refresh() async {
// リフレッシュ処理
}
@override
Widget build(BuildContext context) {
return Scaffold(
floationgActionButton: _MyPageFab(),
body : // ...
)
}
}
class _MyPageFab extends StatelessWidget {
const _MyPageFab({super.key});
@override
Widget build(BuildContext context) {
return FloatingActionButton(
onPressed:() {
// コールバックを渡さなくても実行できる
context.findAncestorStateOfType<_MyPageState>()!.refresh()
},
child: Icon(Icons.refresh),
);
}
}
ただし、公式ドキュメントに注意点がある通り、濫用していいものではありません。
In general, though, consider using a callback that triggers a stateful change in the ancestor rather than using the imperative style implied by this method. This will usually lead to more maintainable and reusable code since it decouples widgets from each other.
https://api.flutter.dev/flutter/widgets/BuildContext/findAncestorStateOfType.html
ウィジェット同士が密結合になってしまうので、コールバックを渡す形にして分離するのが一番安全です。
ですが、プライベートクラスでの使用等に限定すれば、冗長な関数の定義無くすことができたり、開発効率の向上につながります。
もちろん flutter_hooks を入れてもこれらは使えますが、1つのウィジェットで HookWidget と StatefulWidget を併用(StatefulHookWidget)するのは考えにくい、という前提で書いています。
HookWidget が放置された時の対応がめんどくさい
以下のようなウィジェットが、プロダクトのコードに含まれているとします。
class MyWidget extends HookWidget {
const MyWidget({super.key});
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () {},
icon: Icon(Icons.add),
);
}
}
use でフックスを使用していないのに、HookWidget としてしまっている状態です。
このコードに対して、あなたはどういうアプローチをかけるでしょうか?
このソースコードを洗練させるためには、できるだけシンプルな方が良いので、StatelessWidget へ変更するのが正解です。
しかし一方で、HookWidget として放置しておくと、後でフックスを追加しようとした時に、また HookWidget に戻さないといけない手間が省けます。
HookWidgetのままにしておいた方が楽みたいな罠も潜んでいます。
この辺をコード規約やレビューで指摘、あるいは静的解析を用いることで防ぐことができますが、その運用を追加するのもコストと言えるでしょう。
まとめ
あくまで状態管理のパッケージなので、flutter_hooks をわなければ何かしらアプリの機能が損なわれるようなことはありません。
- 採用する動機
-
AnimationControllerの記述量が減ることに魅力を感じる -
dispose漏れ、setState漏れ防止が便利なので、たまに使いたい - flutter_hooks を安全に運用できるルール等が、既に確立してある
-
useReducerを使って複雑な状態管理を安全に行いたい - 昔作ったカスタムフックスや、
useReducerの資産があって、それを使いたい
-
- 採用しない動機
- パッケージの依存をできるだけ減らしたい
-
HookWidgetorStatefulWidget等のルールを設けるのがコストに感じる
上記に加え、「コード量を減らしたい」という動機が本当に叶えられるのかというのは、一度立ち止まって考えるのがいいかと思います。
- 実際に減る行数は、 10 行にも満たないかも
- build 関数に処理が増えていくのは読みやすいか
flutter_hooks 採用の検討材料や、ルール策定の助けになれば幸いです。
じゃあ結局採用すべき?
以下は筆者の私見であり、プロジェクトや実装者の決断がいちばん重要です 🦑
自分がリーダーとして担当するプロジェクトならば、採用しません。
もし諸々の都合で採用することがあるとしたら、以下のようなルールを設けると思います。
- 基本的に、~Page/~Screen などのページウィジェットでのみ使用を許可する(パッケージ依存の排除)
- 使用するフックスを限定する
-
useAnimation等は積極的に使う -
useState、useEffectは禁止 -
useReducerやカスタムフックスのルールや実装の指標を設ける
-
加えて、もし Flutter に慣れてないのならば、採用しない方が賢明かもしれません。
画面が予期しない挙動をしたとき、Flutter 自体の理解だけでなく、追加で flutter_hooks の理解も迫られる可能性があります。
