更新於 2024/12/18閱讀時間約 18 分鐘

自製 Flutter Tab Bar - 深入底層更新機制


這個系列終於迎來最終回了,為了完成下圖這個特別的 Tab Bar 動畫效果,我們最初直接使用 CustomMultiChildLayout 來完成,但是也同時好奇為什麼 Row + Expanded + AnimatedSize 做不到一樣效果,所以在上一篇文章中分析了 Row 的佈局邏輯。

但是在研究 Row 的過程中,卻發現事情跟想像中的不同。以 Row 的佈局邏輯來說,應該要能完美的配合 AnimatedSize 的動畫,讓 AnimatedSize 中子 Widget 在大小有所變化時,可以順暢地以動畫的方式呈現。

但是當實際使用了 Row + Expanded 包在 AnimatedSize 外面時,效果並不如預期。當狀態改變時,AnimatedSize 中的 Tab 卻是一瞬間縮小,而不是以動畫形式變小。

Row(
children: [
for (int index = 0; index < tabLength; index++)
currentIndex == index
? AnimatedSize(
duration: const Duration(milliseconds: 300),
child: _SelectedTab(index: index),
)
: Expanded(
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: GestureDetector(
onTap: () => setState(() => currentIndex = index),
child: _UnselectedTab(index: index),
),
),
)
],
)

那這是為什麼呢?明明 Row 沒有限制子 Widget 大小,為什麼選中的 Tab 沒有按照預期的用動畫變大變小呢?

答案其實是:因為被選中的 Tab 沒被 Expanded 包住

對 Flutter 不熟悉的觀眾可能會滿頭問號,為什麼沒有被 Expanded 包住會造成問題?而且如果要用 Expanded 包住被選中的 Tab,我們又要如何不限制選中的 Tab 大小?

讓我們來一一解答這些問題吧。

Element Tree 的更新邏輯

首先,對 Flutter 有研究的觀眾多少都會知道,Flutter 框架底層維護了三顆樹:Widget Tree、Element Tree 與 RenderObject Tree,他們分別掌管了不同職責。

其中 Element 負責管理 Widget 與 RenderObject,當狀態改變時,能有效的因應變化,只調整有改變的部分,避免頻繁的重建 Element,進而提高效能。

當畫面需要更新時,Element 會從需要更新的 Element 開始,一路往子 Element 更新。

當更新某個 Element 時,Element 中的 updateChild 方法就會被呼叫,用來處理當前 Element 的下一層節點結構變化。在 updateChild 方法的上方也有一大段註釋在解釋其運作邏輯,其中包含了一段表格,用來展示幾種更新的狀況。

|                 |   newWidget == null    |       newWidget != null          |
| :-------------: | :--------------------- | :------------------------------- |
| child == null | Returns null. | Returns new [Element]. |
| | | |
| child != null | Old child is removed, | Old child updated if possible, |
| | returns null. | returns child or new [Element]. |

表格中的 child 是指當前更新中的 Element 的子節點,而 newWidget 則是打算新增進來的 Widget。讓我們看個簡單的例子,假設我們在畫面中存在著一個 Text,這個 Text 會隨著不同的狀態而出現底色。

class _MyTextState extends State<MyText> {
bool isHighlight = false;

@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => isHighlight = !isHighlight),
child: isHighlight
? Container(color: Colors.red, child: const Text("Hello World"))
: const Text("Hello World"),
);
}
}

若當前 isHighlight 為 false,畫面中的 Text("Hello World") 也沒有任何底色。接著下一秒程式呼叫了 setState 並將 isHighlight 更新為 true。GestureDetector 的子 Widget 也從 Text 改變成 Container。

此時 GestureDetector 的 Element 的 updateChild 方法就會被呼叫到,帶進來的 child 就是 Text,而 newWidget 則是 Container。(註:事實上,GestureDetector 並非 Text 的父母,而是祖父母,因為 GestureDetector 中還包了許多其他 Widget,這裡為了說明方便而簡化。)

Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) {
...
}

按照上面表格的邏輯 child 不為 null,newWidget 也不為 null,就會走到 Old child updated if possible, returns child or new [Element] 這段分支。這段邏輯分支主要由三個 if 判斷實現,第一個 if 會比較 child.widget 與 newWidget 是否相同,這裡比較的是兩者是否是相同實例。通常如果使用 const 的 Widget 就會直接落入這個 if 條件中。

if (hasSameSuperclass && child.widget == newWidget) {
if (child.slot != newSlot) {
updateSlotForChild(child, newSlot);
}
newChild = child;
}

若以上面的範例來說,child.widget 是 Text,而 newWidget 則是 Container。兩者類別都不同了,更別提是否是相同實例了。(註:以下 updateChild 方法邏輯經過簡化,原始碼可以參考這邊。)

接著看到下一段 if 判斷,判斷是否可以透過更新 Element 來避免建立新 Element。

if (hasSameSuperclass && child.widget == newWidget) {
// 省略...
} else if (hasSameSuperclass && Widget.canUpdate(child.widget, newWidget)) {
if (child.slot != newSlot) {
updateSlotForChild(child, newSlot);
}
child.update(newWidget);
newChild = child;
}

在這個 if 判斷中,最主要的判斷由 Widget.canUpdate 決定,來看一下 Widget.canUpdate 的邏輯。

abstract class Widget extends DiagnosticableTree {
static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}
}

帶入上面的範例後,我們會發現 oldWidget (Text) 的 runtimeType 與 newWidget (Container) 的 runtimeType 不同了,所以也無法直接更新 Element。

至此也只有最後一條路可以走,也就是建立新的 Element,這邊再往下走就會先停用當前的 Element,接著在 inflateWidget 方法中呼叫 Widget 身上的 createElement 方法來建立新的 Element。

if (hasSameSuperclass && child.widget == newWidget) {
// 省略...
} else if (hasSameSuperclass && Widget.canUpdate(child.widget, newWidget)) {
// 省略...
} else {
deactivateChild(child);
newChild = inflateWidget(newWidget, newSlot);
}

至此我們應該可以回答:為什麼被選中的 Tab 沒被 Expanded 包住會造成 AnimatedSize 失效,其實原因就是因為 AnimatedSize 重建了。那 AnimatedSize 被重建為什麼會造成動畫失效呢?其實原因也很好想像 AnimatedSize 若想要有動畫效果,他就必須有改變前的狀態改變後的狀態,這樣才能夠知道要變大還是變小。

假設當前 AnimatedSize 的寬度為 10,setState 後變成 20,AnimatedSize 就會知道要從寬度要從 10 慢慢變化成 20。但是如果是重建 AnimatedSize 的情況,AnimatedSize 就不會有 10 的資訊,而是重新建了一個寬度為 20 的 AnimatedSize,自然也就不會有動畫。

往後有機會的話,我們再來深入 Animated 系列 Widget 是怎麼製作的,現在先讓我們看看要怎麼解決這個問題呢?這邊有兩個方法。

使用 Expanded 包住被選中的 Tab

可能有觀眾會好奇,用 Expanded 包住被選中的 Tab 的話,那我們還怎麼實現想要的效果呢?答案其實在上一篇文章中也有提到,其實就把 flex 設為 0 就好。在 flex 設為 0 的狀態下,Row 就會給該 Widget 任意大小的空間,讓 Widget 佔住他需要的大小。

同時也因為包了 Expanded,所以在 Element 更新子節點的邏輯中,就能在 Widget.canUpdate 的邏輯中被判斷為可更新,進而避免重建。

Row(
children: [
for (int index = 0; index < tabLength; index++)
currentIndex == index
? Expanded(
flex: 0,
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: _SelectedTab(index: index),
),
)
: Expanded(
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: GestureDetector(
onTap: () => setState(() => currentIndex = index),
child: _UnselectedTab(index: index),
),
),
)
],
)

使用 GlobalObjectKey

除了使用 Expanded + flex 為 0 的設定之外,我們還能使用 GlobalKey 來避免 Widget 重建的問題。使用了 GlobalKey,在 Element 更新子節點時,就能透過 GlobalKey 來辨識 Element 是否已經建立過,進而重複使用先前已經建好的 Element。

Row(
children: [
for (int index = 0; index < tabLength; index++)
currentIndex == index
? AnimatedSize(
key: GlobalObjectKey(index),
duration: const Duration(milliseconds: 300),
child: _SelectedTab(index: index),
)
: Expanded(
child: AnimatedSize(
key: GlobalObjectKey(index),
duration: const Duration(milliseconds: 300),
child: GestureDetector(
onTap: () => setState(() => currentIndex = index),
child: _UnselectedTab(index: index),
),
),
)
],
)

當 Widget 使用了 GlobalKey 之後,雖然在 updateChild 方法中依舊會走到 inflateWidget 嘗試重建 Element,但是在 inflateWidget 方法中,其實還有一場敗部復活賽。Element 會從被停用的子節點中,找尋是否有與新子節點一樣的 GlobalKey 的節點,如果有的話,就把它拿回來重複利用。

小結

從使用 CustomMultiChildLayout 自製 Widget,再到探索 Row 的佈局邏輯,輾轉之後,還帶著觀眾一起看了部分 Element 更新機制。雖然我們可能不太有機會修改到底層框架的機制,但是認識這些機制有助於我們在開發的時候提高程式運行效能,也能在碰到問題的時候,有更多資訊可以提供解決思路。


分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.