Flutter Hooks 使用及原理

ad6623發表於2020-07-22

前言

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中的應用有一些瞭解。如果文中有什麼錯漏之處,抑或大夥有什麼想法,都請在評論中提出來。

相關文章