Flutter 1.17 對比上一個穩定版本,更多是帶來了效能上的提升,其中一個關鍵的優化點就是 Navigator
的內部邏輯,本篇將帶你解密 Navigator
從 1.12 到 1.17 的變化,並介紹 Flutter 1.17 上究竟優化了哪些效能。
一、Navigator 優化了什麼?
在 1.17 版本最讓人感興趣的變動莫過於:“開啟新的不透明頁面之後,路由裡的舊頁面不會再觸發 build
”。
雖然之前介紹過 build
方法本身很輕,但是在“不需要”的時候“不執行”明顯更符合我們的預期,而這個優化的 PR 主要體現在 stack.dart
和 overlay.dart
兩個檔案上。
-
stack.dart
檔案的修改,只是為了將RenderStack
的相關邏輯變為共享的靜態方法getIntrinsicDimension
和layoutPositionedChild
,其實就是共享Stack
的部分佈局能力給Overlay
。 -
overlay.dart
檔案的修改則是這次的靈魂所在。
二、Navigator 的 Overlay
事實上我們常用的 Navigator
是一個 StatefulWidget
, 而常用的 pop
、push
等方法對應的邏輯都是在 NavigatorState
中,而 NavigatorState
主要是通過 Overlay
來承載路由頁面,所以導航頁面間的管理邏輯主要在於 Overlay
。
2.1、Overlay 是什麼?
Overlay
大家可能用過,在 Flutter 中可以通過 Overlay
來向 MaterialApp
新增全域性懸浮控制元件,這是因為Overlay
是一個類似 Stack
層級控制元件,但是它可以通過 OverlayEntry
來獨立地管理內部控制元件的展示。
比如可以通過 overlayState.insert
插入一個 OverlayEntry
來實現插入一個圖層,而OverlayEntry
的 builder
方法會在展示時被呼叫,從而出現需要的佈局效果。
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
中有 onstage
和 offstage
兩個引數,其中:
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
;
這時候我們開啟 B 頁面,可以看到 Overlay
中:
_entries
長度是 4,也就是Overlay
中多插入了兩個OverlayEntry
;onstageChildren
長度是 4,就是當前可見的OverlayEntry
是 4 個;offstageChildren
長度是 0,就是當前還沒有不可見的OverlayEntry
。
其實這時候 Overlay
處於頁面開啟中的狀態,也就是 A 頁面還可以被看到,B 頁面正在動畫開啟的過程。
接著可以看到 Overlay
中的 build
又再次被執行:
_entries
長度還是 4;onstageChildren
長度變為 2,即當前可見的OverlayEntry
變成了 2 個;offstageChildren
長度是 1,即當前有了一個不可見OverlayEntry
。
這時候 B 頁面其實已經開啟完畢,所以 onstageChildren
恢復為 2 的長度,也就是 B 頁面對應的那兩個 OverlayEntry
;而 A 頁面不可見,所以 A 頁面被放置到了 offstageChildren
。
為什麼只把 A 的一個
OverlayEntry
放到offstageChildren
?這個後面會講到。
接著如下圖所示,再開啟 C 頁面時,可以看到同樣經歷了這個過程:
_entries
長度變為 6;onstageChildren
長度先是 4 ,之後又變成 2 ,因為開啟時有B 和 C 兩個頁面參與,而開啟完成後只剩下一個 C 頁面;offstageChildren
長度是 1,之後又變為 2,因為最開始只有 A 不可見,而最後 A 和 B 都不可見;
所以可以看到,每次開啟一個頁面:
- 先會向
_entries
插入兩個OverlayEntry
; - 之後會先經歷
onstageChildren
長度是 4 的頁面開啟過程狀態; - 最後變為
onstageChildren
長度是 2 的頁面開啟完成狀態,而底部的頁面由於不可見所以被加入到offstageChildren
中;
2.3、Overlay 和 Route
為什麼每次向 _entries
插入的是兩個 OverlayEntry
?
這就和 Route
有關,比如預設 Navigator
開啟新的頁面需要使用 MaterialPageRoute
,而生成 OverlayEntry
就是在它的基類之一的 ModalRoute
完成。
在 ModalRoute
的 createOverlayEntries
方法中,通過 _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.opaque
為ture
時,後續的OverlayEntry
就進不去onstageChildren
中; offstageChildren
中只有entry.maintainState
為true
才會被新增到佇列;
@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
:
- 蒙層
OverlayEntry
的opaque
會被設定為 true,這樣後面的OverlayEntry
就不會進入到onstageChildren
,也就是不顯示; - 頁面
OverlayEntry
的maintainState
會是true
,這樣不可見的時候也會進入到offstageChildren
裡;
那麼 opaque
是在哪裡被設定的?
關於 opaque
的設定過程如下所示,在 MaterialPageRoute
的另一個基類 TransitionRoute
中,可以看到一開始蒙層的 opaque
會被設定為 false
,之後在 completed
會被設定為 opaque
,而 opaque
引數在 PageRoute
裡就是 @override bool get opaque => true;
在
PopupRoute
中opaque
就是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
的工作邏輯,預設情況下:
- 每個頁面開啟時會插入兩個
OverlayEntry
到Overlay
; - 開啟過程中
onstageChildren
是 4 個,因為此時兩個頁面在混合顯示; - 開啟完成後
onstageChildren
是 2,因為蒙層的opaque
被設定為ture
,後面的頁面不再是可見; - 具備
maintainState
為true
的OverlayEntry
在不可見後會進入到offstageChildren
;
額外介紹下,路由被插入的位置會和
route.install
時傳入的OverlayEntry
有關,比如:push
傳入的是_history
(頁面路由堆疊)的 last 。
三、新版 1.17 中 Overlay
那為什麼在 1.17 之前,開啟新的頁面時舊的頁面會被執行 build
? 這裡面其實主要有兩個點:
OverlayEntry
都有一個GlobalKey<_OverlayEntryState>
使用者表示頁面的唯一;OverlayEntry
在_Theatre
中會有從onstage
到offstage
的過程;
3.1、為什麼會 rebuild
因為 OverlayEntry
在 Overlay
內部是會被轉化為 _OverlayEntry
進行工作,而 OverlayEntry
裡面的 GlobalKey
自然也就用在了 _OverlayEntry
上,而當 Widget
使用了 GlobalKey
,那麼其對應的 Element
就會是 "Global" 的。
在 Element
執行 inflateWidget
方法時,會判斷如果 Key
值是 GlobalKey
,就會呼叫 _retakeInactiveElement
方法返回“已存在”的 Element
物件,從而讓 Element
被“複用”到其它位置,而這個過程 Element
會從原本的 parent
那裡被移除,然後新增到新的 parent
上。
這個過程就會觸發 Element
的 update
,而 _OverlayEntry
本身是一個 StatefulWidget
,所以對應的 StatefulElement
的 update
就會觸發 rebuild
。
3.2、為什麼 1.17 不會 rebuild
那在 1.17 上,為了不出現每次開啟頁面後還 rebuild
舊頁面的情況,這裡取消了 _Theatre
的 onstage
和 offstage
,替換為 skipCount
和 children
引數。
並且 _Theatre
從 RenderObjectWidget
變為了 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
中,而不是之前控制元件需要在 onstage
的 Stack
和 offstage
列表下來回切換。
在新的 _Theatre
將兩個陣列合併成一個 children
陣列,然後將 onstageCount
之外的部分設定為 skipCount
,在佈局時獲取 _firstOnstageChild
進行佈局,而當 children
發生改變時,觸發的是 MultiChildRenderObjectElement
的 insertChildRenderObject
,而不會去“干擾”到之前的頁面,所以不會產生上一個頁面的 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,也印證了頁面開啟過程和關閉之後的邏輯其實並沒發生本質的變化。
從結果上看,這個改動確實對效能產生了不錯的提升。當然,這個改進主要是在不透明的頁面之間生效,如果是透明的頁面效果比如 PopModal
之類的,那還是需要 rebuild
一下。
四、其他優化
Metal
是 iOS 上類似於 OpenGL ES
的底層圖形程式設計介面,可以在 iOS 裝置上通過 api 直接操作 GPU 。
而 1.17 開始,Flutter 在 iOS 上對於支援 Metal
的裝置將使用 Metal
進行渲染,所以官方提供的資料上看,這樣可以提高 50% 的效能。更多可見:github.com/flutter/flu…
Android 上也由於 Dart VM 的優化,體積可以下降大約 18.5% 的大小。
1.17對於載入大量圖片的處理進行了優化,在快速滑動的過程中可以得到更好的效能提升(通過延時清理 IO Thread 的 Context),這樣理論上可以在原本基礎上節省出 70% 的記憶體。
好了,這一期想聊的聊完了,最後容我“厚顏無恥”地推廣下鄙人最近剛剛上架的新書 《Flutter 開發實戰詳解》,感興趣的小夥伴可以通過以下地址瞭解: