Flutter Hooks 使用及原理

前言

Hooks,直译过来就是"钩子",是前端React框架加入的特性,用来分离状态逻辑和视图逻辑。现在这个特性并不只局限在于React框架中,其它前端框架也在借鉴。同样的,我们也可以在Flutter中使用Hooks。Hooks对于从事Native开发的开发者可能比较陌生。但Flutter的一大优势就是综合了H5,Native等开发平台的优势,对Native开发者和对H5开发者都比较友好。所以通过这篇文章来介绍Hooks,希望大家能对这一特性有所了解。

为什么引入Hooks

我们都知道在FLutter开发中的一大痛点就是业务逻辑和视图逻辑的耦合。这一痛点也是前端各个框架都有的痛点。所以大家就像出来各种办法来分离业务逻辑和视图逻辑,有MVP,MVVM,React中的Mixin,高阶组件(HOC),直到Hooks。Flutter中大家可能对Mixin比较熟悉,我之前写过一篇文章介绍使用Mixin这种方式来分离业务逻辑和视图逻辑。

Mixin的方式在实践中也会遇到一些限制:

  • Mixin之间可能会互相依赖。
  • Mixin之间可能存在冲突。

因此我们引入Hooks来看看能不能避免Mixin的这些限制。

Flutter Hooks使用

引入Hooks需要在pubspec.yaml加入以下内容

flutter_hooks: ^0.12.0

Hooks函数一般以use开头,格式为useXXX。React定义了一些常用的Hooks函数,如useState,useEffect等等。

useState

useState我们可能会比较常用,用来获取当前Widget所需要的状态。
我们以Flutter的计数器例子来介绍一下如何使用Hooks,代码如下:

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

void main() {
  runApp(MaterialApp(
    home: HooksExample(),
  ));
}

class HooksExample extends HookWidget {
  @override
  Widget build(BuildContext context) {
 
    final counter = useState(0);

    return Scaffold(
      appBar: AppBar(
        title: const Text('useState example'),
      ),
      body: Center(
        child: Text('Button tapped ${counter.value} times'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed:() => counter.value++,
        child: const Icon(Icons.add),
      ),
    );
  }
}

我们来看一下使用Hooks的计数器和原生的计数器例子源码有什么样的区别。

  • 首先原生的计数器因为要保存counter这个状态,所以使用的是一个StatefulWidgetcounter保存在对应的State中。而使用Hooks改造过的计数器却没有使用StatefulWidget,而是继承自HookWidget, 它其实是一个StatelessWidget
    class HooksExample extends HookWidget {
  • 其次我们看到计数器的状态counter是通过调用函数useState()获取到的。入参0代表初始值。
    final counter = useState(0);
  • 最后就是在点击事件的处理上,我们只是把计数器数值+1。并没有去调用setState(),计数器就会自动刷新。
    onPressed:() => counter.value++

可见相比于原生Flutter的模式,同样做到了将业务逻辑和视图逻辑分离。不需要再使用StatefulWidget,就可以做到对状态的访问和维护。

我们也可以在同一个Widget下引入多个Hooks:

final counter = useState(0);
final name = useState('张三');
final counter2 = useState(100);

这里要特别注意的一点是,使用Hooks的时候不可以在条件语句中调用useXXX,类似以下这样的代码要绝对避免。

    if(condition) {
        useMyHook();
    }

熟悉Hooks的同学可能会知道这是为什么。具体原因我会在下面的Flutter Hooks原理小结中做以说明。

useMemoized

当你使用了BLoC或者MobX,可能需要有一个时机来创建对应的store。这时你可以让useMemoized来为你完成这项工作。

class MyWidget extends HookWidget {
  @override
  Widget build(BuildContext context) {
 
    final store = useMemoized(() => MyStore());

    return Scaffold(...);
  }
}

useMemoized的入参是个函数,这个函数会返回MySotre实例。此函数在MyWidget的生命周期内只会被调用一次,得到的MySotre实例会被缓存起来,后续再次调用useMemoized会得到这一缓存的实例。

useEffect

在首次创建MySotre实例之后我们一般需要做一些初始化工作,例如开始加载数据之类。有时候或许在Widget生命周期结束的时候做一些清理工作。这些事情则会由useEffect这个Hook来做。

class MyWidget extends HookWidget {
  @override
  Widget build(BuildContext context) {
 
    final store = useMemoized(() => MyStore());
    useEffect((){
        store.init(); 
        return store.dispose;
    },const []);
    return Scaffold(...);
  }
}

useEffect的入参函数内可以做一些初始化的工作。如果需要在Widget生命周期结束的时候做一些清理工作,可以返回一个负责清理的函数,比如代码里的store.disposeuseEffect的第二个入参是一个空数组。这样就保证了初始化和清理函数只会在Widget生命周期开始和结束时各被调用一次。如果不传这个参数的话则会在每次build的时候都会被调用。

其他Hooks

除了以上这些Hooks,flutter_hooks还提供了一些可以节省我们代码量的Hooks。如useAnimationController,提供AnimationController直接用而不用去操心初始化以及释放资源的事情。还有useTabController,useTextEditingController等等,完整Hooks列表大家可以去flutter_hooks@github查看。

自定义Hooks

当以上Hooks不能满足需求时,我们也可以自定义Hooks。自定义Hooks有两种方式,一种是用函数来自定义自定义Hooks,如果需求比较复杂的话还可以用类来自定义Hooks。

Function

这种方式一般来讲就是用我们自定义的函数来包裹组合原生的Hooks。比如对前面的计数器那个例子。我们想在技术器增加的时候除了界面上有显示,还需要在日志里打出来。那么就可以这样来自定义一个Hook:

ValueNotifier<T> useLoggedState<T>(BuildContext context, [T initialData]) {
  final result = useState<T>(initialData);
  useValueChanged(result.value, (_, __) {
    print(result.value);
  });
  return result;
}

Class

如果需求比较复杂,需要在Widget的各个生命周期做处理,则可以用类的方式来自定义Hook。这里我们来自定义一个Hook,作用是作用是打印出Widget存活的时长。我们知道Hooks都是以useXXX作为名字的函数。所以我们先来给出这样的函数

Result useTimeAliveHook(BuildContext context) {
  return use(const _TimeAlive());
}

然后就是对应的类:

class _TimeAlive extends Hook<void> {
  const _TimeAlive();

  @override
  _TimeAliveState createState() => _TimeAliveState();
}

class _TimeAliveState extends HookState<void, _TimeAlive> {
  DateTime start;

  @override
  void initHook() {
    super.initHook();
    start = DateTime.now();
  }

  @override
  void build(BuildContext context) {}

  @override
  void dispose() {
    print(DateTime.now().difference(start));
    super.dispose();
  }
}

看起来是不是有一种很熟悉的感觉?这不就是一个StatefulWidget嘛。对的,flutter_hooks其实就是借鉴了Flutter自身的一些机制来达到Hooks的目的。那些自带的useState也都是这么写的。也就是看起来很复杂的需要StatefulWidget来完成的工作现在简化为一个useXXX的函数调用其实是因为flutter_hooks帮你把事情做了。至于这背后是怎样的一个机制,下一节我们通过源码来了解一下Flutter Hooks的原理。

Flutter Hooks原理

在了解Flutter Hooks原理之前我们要先提几个问题。在用Hooks改造计数器之后,就没有了StatefulWidget。那么计数器的状态放在哪里了呢?在状态发生变化之后界面又是如何响应的呢?带着这些问题让我们来探索Flutter Hooks的世界

HookWidget

首先来看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。并且重写了createElement。其对应的element_StatelessHookElement。而这个element只是继承了StatelessElement并且加上了HookElementmixin。所以关键的东西应该都是在HookElement里面。

HookElement

看一下HookElement:

mixin HookElement on ComponentElement {
   ...
  _Entry<HookState> _currentHookState;
  final LinkedList<_Entry<HookState>> _hooks = LinkedList();
  ...
  
    @override
  Widget build() {
    ...
    _currentHookState = _hooks.isEmpty ? null : _hooks.first;
    HookElement._currentHookElement = this;
    
    _buildCache = super.build();
    
    return _buildCache;
  }
}

HookElement有一个链表,_hooks保存着所有的HookState。还有一个指针_currentHookState指向当前的HookState。我们看一下build函数。在每次HookElementbuild的时候都会把_currentHookState指向_hooks链表的第一个元素。然后才走Widgetbuild函数。也就是说,每次重建Widget的时候都会重置_currentHookState。记住这一点。

另一个问题。我们不是在讨论Hooks吗?那这里的HookStateHook又是什么关系呢?

Hook

abstract class Hook<R> {
  const Hook({this.keys});

  @protected
  HookState<R, Hook<R>> createState();
}

Hook这个类就很简单了,而且看起来很像一个StatefulWidget。那么对应的State就是HookState了。

HookState

abstract class HookState<R, T extends Hook<R>> {
  @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 reassemble() {}

  /// Equivalent of [State.setState] for [HookState]
  @protected
  void setState(VoidCallback fn) {
    fn();
    _element
      .._isOptionalRebuild = false
      ..markNeedsBuild();
  }
}

简直和State一毛一样。我们可以直接拿StatefulWidgetState的关系来理解HookHookState的联系了。有一点区别是State.build返回值是个Widget。而HookState.build的返回值则是状态值。

另外,一个StatefulElement只会持有一个State。而HookElement则可能持有多个HookState,并且把这些HookState都放在_hooks这个链表里。如下图所示:

Hooks

use

至此我们知道了引入Hooks以后那些状态都放在哪里。那么这些状态又是何时被添加,何时被使用的呢?这就要说说那些useXXX函数了。从之前我们说的用类的方式来自定义Hook的时候了解到,每次调用useXXX都会新建一个Hooks实例。

Result useTimeAliveHook(BuildContext context) {
  return use(const _TimeAlive());
}

虽然Hook每次都是新的,但是HookState却还是原来那个。这个就参照StatefulWidget每次都是新的但State却不变来理解就是了。这个useXXX最终会调用到HookElement._use:

R _use<R>(Hook<R> hook) {
    
    if (_currentHookState == null) {
      _appendHook(hook);
    } else if (hook.runtimeType != _currentHookState.value.hook.runtimeType) {
      ...
      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;
  }

这个函数也是Hooks运行的核心,需要我们仔细去理解。

第一个分支,如果_currentHookState为空,说明此时_hook链表为空或者_currentHookState指向的是链表末尾元素的下一个。换而言之,当前调用use对应的HookState还不在链表中,那么就调用_appendHook来将其加入链表

  void _appendHook<R>(Hook<R> hook) {
    final result = _createHookState<R>(hook);
    _currentHookState = _Entry(result);
    _hooks.add(_currentHookState);
  }

在这里我们可以看到_createHookState被调用,生成的HookState实例被加入链表。

第二个分支,如果新Hook的运行时类型与当前Hook的运行时类型不一样,此时会抛出异常。

第三个分支,如果新老Hook类型一致,实例不一样,那么就要看是否保留状态,如果保留的话就先更新Hook,然后调用HookState.didUpdateHook。这个函数由其子类实现;如果不保留状态,那就调用_createHookState重新获取一个状态实例把原来的给替换掉。一般来讲我们都是想保留状态的,这也是Flutter Hooks的默认行为,具体判断呢则是在函数Hook.shouldPreserveState内:

static bool shouldPreserveState(Hook hook1, Hook hook2) {
    final p1 = hook1.keys;
    final p2 = hook2.keys;

    if (p1 == p2) {
      return true;
    }

    if ((p1 != p2 && (p1 == null || p2 == null)) || p1.length != p2.length) {
      return false;
    }

    final i1 = p1.iterator;
    final i2 = p2.iterator;

    while (true) {
      if (!i1.moveNext() || !i2.moveNext()) {
        return true;
      }
      if (i1.current != i2.current) {
        return false;
      }
    }
  }

这个函数就是在比较两个新老Hooks的keys。如果为空或者相等,那么就认为是要保留状态,否则不保留。

分支走完了最后就是通过HookState.build拿到状态值,然后把_currentHookState指向下一个

把整个流程串起来,就是:

  • HookElement.build首先将_currentHookState重置为指向链表第一个。
  • HookElement.build调用到HookWidget.build
  • HookWidget.build内按顺序调用useXXX,每调用一次useXXX就把_currentHookState指向下一个。
  • 等待下一次HookElement.build,返回第一条执行。

至此,我们就明白了为什么前面说不能出现用条件语句包裹的useXXX

useHook1();
if(condition){
   useHook2(); 
}
useHook3();

像上述代码。如果第一次调用conditiontrue。那么此后_hooks链表就按顺序保存着HookState1,HookState2,HookState3。那如果再次调用的时候conditionfalseuseHook2()被跳过,useHook3()被调用,但此时_currentHookState却指向HookState2,这就出问题了。如果Hook2Hook3类型不一致则会抛异常,如果不幸它们类型一致则取到了错误的状态,导致不易察觉的问题。所以我们一定要保证每次调用useXXX都是一致的。

总结

从以上对flutter_hooks的介绍可以看出,使用Hooks可以大大简化我们的开发工作,但是要注意一点,flutter_hooks并不能处理在Widget之间传递状态这种情况,这时就需要将Hooks和Provider等状态管理工具结合使用。

flutter_hooks将React中火爆的Hooks移植到Flutter。使广大Flutter开发者也能体会到Hooks概念的强大。大前端的趋势就是各个框架的技术理念相互融合,我希望通过阅读本文也能使大家对Hooks技术在Flutter中的应用有一些了解。如果文中有什么错漏之处,抑或大伙有什么想法,都请在评论中提出来。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349