Flutter 1.17 中的導航解密和效能提升

戀貓de小郭發表於2020-06-08

Flutter 1.17 對比上一個穩定版本,更多是帶來了效能上的提升,其中一個關鍵的優化點就是 Navigator 的內部邏輯,本篇將帶你解密 Navigator 從 1.12 到 1.17 的變化,並介紹 Flutter 1.17 上究竟優化了哪些效能。

一、Navigator 優化了什麼?

在 1.17 版本最讓人感興趣的變動莫過於:“開啟新的不透明頁面之後,路由裡的舊頁面不會再觸發 build

雖然之前介紹過 build 方法本身很輕,但是在“不需要”的時候“不執行”明顯更符合我們的預期,而這個優化的 PR 主要體現在 stack.dartoverlay.dart 兩個檔案上。

  • stack.dart 檔案的修改,只是為了將 RenderStack 的相關邏輯變為共享的靜態方法 getIntrinsicDimensionlayoutPositionedChild ,其實就是共享 Stack 的部分佈局能力給 Overlay

  • overlay.dart 檔案的修改則是這次的靈魂所在。

二、Navigator 的 Overlay

事實上我們常用的 Navigator 是一個 StatefulWidget, 而常用的 poppush 等方法對應的邏輯都是在 NavigatorState 中,而 NavigatorState 主要是通過 Overlay 來承載路由頁面,所以導航頁面間的管理邏輯主要在於 Overlay

2.1、Overlay 是什麼?

Overlay 大家可能用過,在 Flutter 中可以通過 Overlay 來向 MaterialApp 新增全域性懸浮控制元件,這是因為Overlay 是一個類似 Stack 層級控制元件,但是它可以通過 OverlayEntry 來獨立地管理內部控制元件的展示。

比如可以通過 overlayState.insert 插入一個 OverlayEntry 來實現插入一個圖層,而OverlayEntrybuilder 方法會在展示時被呼叫,從而出現需要的佈局效果。

    var overlayState = Overlay.of(context);
    var _overlayEntry = new OverlayEntry(builder: (context) {
      return new Material(
        color: Colors.transparent,
        child: Container(
          child: Text(
            "${widget.platform} ${widget.deviceInfo} ${widget.language} ${widget.version}",
            style: TextStyle(color: Colors.white, fontSize: 10),
          ),
        ),
      );
    });
    overlayState.insert(_overlayEntry);
複製程式碼

2.2、Overlay 如何實現導航?

Navigator 中其實也是使用了 Overlay 實現頁面管理,每個開啟的 Route 預設情況下是向 Overlay 插入了兩個 OverlayEntry

為什麼是兩個後面會介紹。

而在 Overlay 中, List<OverlayEntry> _entries 的展示邏輯又是通過 _Theatre 來完成的,在 _Theatre 中有 onstageoffstage 兩個引數,其中:

  • onstage 是一個 Stack,用於展示 onstageChildren.reversed.toList(growable: false) ,也就是可以被看到的部分;
  • offstage 是展示 offstageChildren 列表,也就是不可以被看到的部分;
    return _Theatre(
      onstage: Stack(
        fit: StackFit.expand,
        children: onstageChildren.reversed.toList(growable: false),
      ),
      offstage: offstageChildren,
    );
複製程式碼

簡單些說,比如此時有 [A、B、C] 三個頁面,那麼:

  • C 應該是在 onstage
  • A、B 應該是處於 offstage

當然,A、B、C 都是以 OverlayEntry 的方式被插入到 Overlay 中,而 A 、B、C 頁面被插入的時候預設都是兩個 OverlayEntry ,也就是 [A、B、C] 應該有 6 個 OverlayEntry

舉個例子,程式在預設啟動之後,首先看到的就是 A 頁面,這時候可以看到 Overlay

  • _entries 長度是 2,即 Overlay 中的列表總長度為2;
  • onstageChildren 長度是 2,即當前可見的 OverlayEntry 是2;
  • offstageChildren 長度是 0,即沒有不可見的 OverlayEntry

Flutter  1.17 中的導航解密和效能提升

這時候我們開啟 B 頁面,可以看到 Overlay 中:

  • _entries 長度是 4,也就是 Overlay 中多插入了兩個 OverlayEntry
  • onstageChildren 長度是 4,就是當前可見的 OverlayEntry 是 4 個;
  • offstageChildren 長度是 0,就是當前還沒有不可見的 OverlayEntry

Flutter  1.17 中的導航解密和效能提升

其實這時候 Overlay 處於頁面開啟中的狀態,也就是 A 頁面還可以被看到,B 頁面正在動畫開啟的過程。

Flutter  1.17 中的導航解密和效能提升

接著可以看到 Overlay 中的 build 又再次被執行:

  • _entries 長度還是 4;
  • onstageChildren 長度變為 2,即當前可見的 OverlayEntry 變成了 2 個;
  • offstageChildren 長度是 1,即當前有了一個不可見 OverlayEntry

Flutter  1.17 中的導航解密和效能提升

這時候 B 頁面其實已經開啟完畢,所以 onstageChildren 恢復為 2 的長度,也就是 B 頁面對應的那兩個 OverlayEntry;而 A 頁面不可見,所以 A 頁面被放置到了 offstageChildren

為什麼只把 A 的一個 OverlayEntry 放到 offstageChildren?這個後面會講到。

Flutter  1.17 中的導航解密和效能提升

接著如下圖所示,再開啟 C 頁面時,可以看到同樣經歷了這個過程:

  • _entries 長度變為 6;
  • onstageChildren 長度先是 4 ,之後又變成 2 ,因為開啟時有B 和 C 兩個頁面參與,而開啟完成後只剩下一個 C 頁面;
  • offstageChildren 長度是 1,之後又變為 2,因為最開始只有 A 不可見,而最後 A 和 B 都不可見;

Flutter  1.17 中的導航解密和效能提升

Flutter  1.17 中的導航解密和效能提升

所以可以看到,每次開啟一個頁面:

  • 先會向 _entries 插入兩個 OverlayEntry
  • 之後會先經歷 onstageChildren 長度是 4 的頁面開啟過程狀態;
  • 最後變為 onstageChildren 長度是 2 的頁面開啟完成狀態,而底部的頁面由於不可見所以被加入到 offstageChildren 中;

2.3、Overlay 和 Route

為什麼每次向 _entries 插入的是兩個 OverlayEntry

這就和 Route 有關,比如預設 Navigator 開啟新的頁面需要使用 MaterialPageRoute ,而生成 OverlayEntry 就是在它的基類之一的 ModalRoute 完成。

ModalRoutecreateOverlayEntries 方法中,通過 _buildModalBarrier_buildModalScope 建立了兩個 OverlayEntry ,其中:

  • _buildModalBarrier 建立的一般是蒙層;
  • _buildModalScope 建立的 OverlayEntry 是頁面的載體;

所以預設開啟一個頁面,是會存在兩個 OverlayEntry ,一個是蒙層一個是頁面

  @override
  Iterable<OverlayEntry> createOverlayEntries() sync* {
    yield _modalBarrier = OverlayEntry(builder: _buildModalBarrier);
    yield OverlayEntry(builder: _buildModalScope, maintainState: maintainState);
  }
複製程式碼

那麼一個頁面有兩個 OverlayEntry ,但是為什麼插入到 offstageChildren 中的數量每次都是加 1 而不是加 2?

如果單從邏輯上講,按照前面 [A、B、C] 三個頁面的例子,_entries 裡有 6 個 OverlayEntry, 但是 B、C 頁面都不可見了,把 B、C 頁面的蒙層也捎帶上不就純屬浪費了?

如從程式碼層面解釋,在 _entries 在倒序 for 迴圈的時候:

  • 在遇到 entry.opaqueture 時,後續的 OverlayEntry 就進不去 onstageChildren 中;
  • offstageChildren 中只有 entry.maintainStatetrue 才會被新增到佇列;
  @override
  Widget build(BuildContext context) {
    final List<Widget> onstageChildren = <Widget>[];
    final List<Widget> offstageChildren = <Widget>[];
    bool onstage = true;
    for (int i = _entries.length - 1; i >= 0; i -= 1) {
      final OverlayEntry entry = _entries[i];
      if (onstage) {
        onstageChildren.add(_OverlayEntry(entry));
        if (entry.opaque)
          onstage = false;
      } else if (entry.maintainState) {
        offstageChildren.add(TickerMode(enabled: false, child: _OverlayEntry(entry)));
      }
    }
    return _Theatre(
      onstage: Stack(
        fit: StackFit.expand,
        children: onstageChildren.reversed.toList(growable: false),
      ),
      offstage: offstageChildren,
    ); 
  }
複製程式碼

而在 OverlayEntry 中:

  • opaque 表示了 OverlayEntry 是不是“阻塞”了整個 Overlay,也就是不透明的完全覆蓋。
  • maintainState 表示這個 OverlayEntry 必須被新增到 _Theatre 中。

所以可以看到,當頁面完全開啟之後,在最前面的兩個 OverlayEntry

  • 蒙層 OverlayEntryopaque 會被設定為 true,這樣後面的 OverlayEntry 就不會進入到 onstageChildren,也就是不顯示;
  • 頁面 OverlayEntrymaintainState 會是 true ,這樣不可見的時候也會進入到 offstageChildren 裡;

Flutter  1.17 中的導航解密和效能提升

那麼 opaque 是在哪裡被設定的?

關於 opaque 的設定過程如下所示,在 MaterialPageRoute 的另一個基類 TransitionRoute 中,可以看到一開始蒙層的 opaque 會被設定為 false ,之後在 completed 會被設定為 opaque ,而 opaque 引數在 PageRoute 裡就是 @override bool get opaque => true;

PopupRouteopaque 就是 false ,因為 PopupRoute 一般是有透明的背景,需要和上一個頁面一起混合展示。

 void _handleStatusChanged(AnimationStatus status) {
    switch (status) {
      case AnimationStatus.completed:
        if (overlayEntries.isNotEmpty)
          overlayEntries.first.opaque = opaque;
        break;
      case AnimationStatus.forward:
      case AnimationStatus.reverse:
        if (overlayEntries.isNotEmpty)
          overlayEntries.first.opaque = false;
        break;
      case AnimationStatus.dismissed:
        if (!isActive) {
          navigator.finalizeRoute(this);
          assert(overlayEntries.isEmpty);
        }
        break;
    }
    changedInternalState();
  }
複製程式碼

到這裡我們就理清了頁面開啟時 Overlay 的工作邏輯,預設情況下:

  • 每個頁面開啟時會插入兩個 OverlayEntryOverlay
  • 開啟過程中 onstageChildren 是 4 個,因為此時兩個頁面在混合顯示;
  • 開啟完成後 onstageChildren 是 2,因為蒙層的 opaque 被設定為 ture ,後面的頁面不再是可見;
  • 具備 maintainStatetrueOverlayEntry 在不可見後會進入到 offstageChildren

額外介紹下,路由被插入的位置會和 route.install 時傳入的 OverlayEntry 有關,比如: push 傳入的是 _history(頁面路由堆疊)的 last 。

三、新版 1.17 中 Overlay

那為什麼在 1.17 之前,開啟新的頁面時舊的頁面會被執行 build 這裡面其實主要有兩個點:

  • OverlayEntry 都有一個 GlobalKey<_OverlayEntryState> 使用者表示頁面的唯一;
  • OverlayEntry_Theatre 中會有從 onstageoffstage 的過程;

3.1、為什麼會 rebuild

因為 OverlayEntryOverlay 內部是會被轉化為 _OverlayEntry 進行工作,而 OverlayEntry 裡面的 GlobalKey 自然也就用在了 _OverlayEntry 上,而當 Widget 使用了 GlobalKey,那麼其對應的 Element 就會是 "Global" 的。

Element 執行 inflateWidget 方法時,會判斷如果 Key 值是 GlobalKey,就會呼叫 _retakeInactiveElement 方法返回“已存在”的 Element 物件,從而讓 Element 被“複用”到其它位置,而這個過程 Element 會從原本的 parent 那裡被移除,然後新增到新的 parent 上。

這個過程就會觸發 Elementupdate ,而 _OverlayEntry 本身是一個 StatefulWidget ,所以對應的 StatefulElementupdate 就會觸發 rebuild

3.2、為什麼 1.17 不會 rebuild

那在 1.17 上,為了不出現每次開啟頁面後還 rebuild 舊頁面的情況,這裡取消了 _Theatreonstageoffstage ,替換為 skipCountchildren 引數。

並且 _TheatreRenderObjectWidget 變為了 MultiChildRenderObjectWidget,然後在 _RenderTheatre 中複用了 RenderStack 共享的佈局能力。

  @override
  Widget build(BuildContext context) {
    // This list is filled backwards and then reversed below before
    // it is added to the tree.
    final List<Widget> children = <Widget>[];
    bool onstage = true;
    int onstageCount = 0;
    for (int i = _entries.length - 1; i >= 0; i -= 1) {
      final OverlayEntry entry = _entries[i];
      if (onstage) {
        onstageCount += 1;
        children.add(_OverlayEntryWidget(
          key: entry._key,
          entry: entry,
        ));
        if (entry.opaque)
          onstage = false;
      } else if (entry.maintainState) {
        children.add(_OverlayEntryWidget(
          key: entry._key,
          entry: entry,
          tickerEnabled: false,
        ));
      }
    }
    return _Theatre(
      skipCount: children.length - onstageCount,
      children: children.reversed.toList(growable: false),
    );
  }
複製程式碼

這時候等於 Overlay 中所有的 _entries 都處理到一個 MultiChildRenderObjectWidget 中,也就是同在一個 Element 中,而不是之前控制元件需要在 onstageStackoffstage 列表下來回切換。

在新的 _Theatre 將兩個陣列合併成一個 children 陣列,然後將 onstageCount 之外的部分設定為 skipCount ,在佈局時獲取 _firstOnstageChild 進行佈局,而當 children 發生改變時,觸發的是 MultiChildRenderObjectElementinsertChildRenderObject ,而不會去“干擾”到之前的頁面,所以不會產生上一個頁面的 rebuild

  RenderBox get _firstOnstageChild {
    if (skipCount == super.childCount) {
      return null;
    }
    RenderBox child = super.firstChild;
    for (int toSkip = skipCount; toSkip > 0; toSkip--) {
      final StackParentData childParentData = child.parentData as StackParentData;
      child = childParentData.nextSibling;
      assert(child != null);
    }
    return child;
  }

  RenderBox get _lastOnstageChild => skipCount == super.childCount ? null : lastChild;
複製程式碼

最後如下圖所示,在開啟頁面後,children 會經歷從 4 到 3 的變化,而 onstageCount 也會從 4 變為 2,也印證了頁面開啟過程和關閉之後的邏輯其實並沒發生本質的變化。

Flutter  1.17 中的導航解密和效能提升

Flutter  1.17 中的導航解密和效能提升

從結果上看,這個改動確實對效能產生了不錯的提升。當然,這個改進主要是在不透明的頁面之間生效,如果是透明的頁面效果比如 PopModal 之類的,那還是需要 rebuild 一下。

Flutter  1.17 中的導航解密和效能提升

四、其他優化

Metal 是 iOS 上類似於 OpenGL ES 的底層圖形程式設計介面,可以在 iOS 裝置上通過 api 直接操作 GPU 。

而 1.17 開始,Flutter 在 iOS 上對於支援 Metal 的裝置將使用 Metal 進行渲染,所以官方提供的資料上看,這樣可以提高 50% 的效能。更多可見:github.com/flutter/flu…

Flutter  1.17 中的導航解密和效能提升

Android 上也由於 Dart VM 的優化,體積可以下降大約 18.5% 的大小。

1.17對於載入大量圖片的處理進行了優化,在快速滑動的過程中可以得到更好的效能提升(通過延時清理 IO Thread 的 Context),這樣理論上可以在原本基礎上節省出 70% 的記憶體。

Flutter  1.17 中的導航解密和效能提升

好了,這一期想聊的聊完了,最後容我“厚顏無恥”地推廣下鄙人最近剛剛上架的新書 《Flutter 開發實戰詳解》,感興趣的小夥伴可以通過以下地址瞭解:

Flutter  1.17 中的導航解密和效能提升

相關文章