淘特 Flutter 流式場景的深度優化
作者:江澤軍(眞意)
淘特在很多業務場景都使用了 Flutter,加上業務場景本身具有一定的複雜性,使得 Flutter 在低端機流式場景的滑動瀏覽過程中卡頓、跳幀對比使用原生(Android/iOS)開發明顯。通過分析業務層在 Flutter 渲染流程中的每個階段存在的性能問題進行了一系列的深度優化後,平均幀率已經達到50幀之上超越了原生的表現, 但卡頓率依然達不到最佳的體驗效果,遇到了難以突破的瓶頸和技術挑戰,需要進行技術嘗試和突破。
本文會從底層原理、優化思路、實際場景的優化策略、核心技術實現、優化成果等方面進行講述,期望可以為大家帶來一定的啟發和幫助,也歡迎多多交流與指正,共建美好的 Flutter 技術社區。
渲染機制
原生 vs Flutter
Flutter 本身是基於原生系統之上的,所以渲染機制和 Native 是非常接近的,引用 Google Flutter 團隊 Xiao Yu分享[1],如下圖所示:
渲染流程
如圖左中,Flutter 從接收到 VSync 信號之後整體經歷 8 個階段,其中 Compositing 階段後會將數據提交給GPU。
Semantics 階段會將 RenderObject marked 需要做語義化更新的信息傳遞給系統,實現輔助功能,通過語義化接口可以幫助有視力障礙的用户來理解UI內容,和整體繪製流程關聯不大。
Finalize Tree 階段會將所有添加到 _inactiveElements 的不活躍 Element 全部 unmount 掉,和整體繪製流程關聯不大。
所以,Flutter 整體渲染流程主要關注 上圖圖右 中的階段:
GPU Vsync
Flutter Engine 在收到垂直同步信號後,會通知 Flutter Framework 進行 beginFrame,進入 Animation 階段。
Animation
主要執行了 transientCallbacks 回調。Flutter Engine 會通知 Flutter Framework 進行 drawFrame,進入 Build 階段。
Build
構建要呈現的UI組件樹的數據結構,即創建對應的 Widget 以及對應的 Element。
Layout
目的是要計算出每個節點所佔空間的真實大小進行佈局,然後更新所有 dirty render objects 的佈局信息。
Compositing Bits
對需要更新的 RenderObject 進行 update 操作。
Paint
生成 Layer Tree,生成 Layer Tree 並不能直接使用,還需要 Compositing 合成為一個 Scene 並進行 Rasterize 光柵化處理。層級合併的原因是因為一般 Flutter 的層級很多,直接把每一層傳遞給 GPU 效率很低,所以會先做Composite 提高效率。光柵化之後才會交給 Flutter Engine 處理。
Compositing
將 Layout Tree 合成為 Scene,並創建場景當前狀態的柵格圖像,即進行 Rasterize 光柵化處理,然後提交給Flutter Engine,最後 Skia 通過 Open GL or Vulkan 接口提交數據給 GPU, GPU經過處理後進行顯示。
核心渲染階段
Widget
我們平時在寫的大都是 Widget,Widget 其實可以理解為是一個組件樹的數據結構,是 Build 階段的主要部分。其中 Widget Tree 的深度、 StatefulWidget 的 setState 合理性、build 函數中是否有不合理邏輯以及使用了調用saveLayer 的相關Widget往往會成為性能問題。
Element
關聯 Widget 和 RenderObject ,生成 Widget 對應的 Element 存放上下文信息,Flutter 通過遍歷 Element 來生成RenderObject 視圖樹支撐UI結構。
RenderObject
RenderObject 在 Layout 階段確定佈局信息,Paint 階段生成為對應的 Layer,可見其重要程度。所以 Flutter 中大部分的繪圖性能優化發生在這裏。RenderObject 樹構建的數據會被加入到 Engine 所需的 LayerTree 中。
性能優化思路
瞭解底層渲染機制和核心渲染階段,可以將優化分為三層:
這裏不具體展開講每一層的優化細節,本文主要從實際的場景來講述。
流式場景
流式組件原理
在原生開發下,通常使用 RecyclerView/UICollectionView 進行列表場景的開發;在Flutter開發下,Flutter Framework 也提供了ListView的組件,它的實質其實是 SliverList。
核心源碼
我們從 SliverList 的核心源碼來進行分析:
``` class SliverList extends SliverMultiBoxAdaptorWidget {
@override RenderSliverList createRenderObject(BuildContext context) { final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement; return RenderSliverList(childManager: element); } }
abstract class SliverMultiBoxAdaptorWidget extends SliverWithKeepAliveWidget {
final SliverChildDelegate delegate;
@override SliverMultiBoxAdaptorElement createElement() => SliverMultiBoxAdaptorElement(this);
@override RenderSliverMultiBoxAdaptor createRenderObject(BuildContext context); } ```
通過查看 SliverList 的源代碼可知,SliverList 是一個 RenderObjectWidget ,結構如下:
我們首先看它的 RenderObject 的核心源碼:
``` class RenderSliverList extends RenderSliverMultiBoxAdaptor {
RenderSliverList({ @required RenderSliverBoxChildManager childManager, }) : super(childManager: childManager);
@override void performLayout(){ ... //父節點對子節點的佈局限制 final SliverConstraints constraints = this.constraints; final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin; final double remainingExtent = constraints.remainingCacheExtent; final double targetEndScrollOffset = scrollOffset + remainingExtent; final BoxConstraints childConstraints = constraints.asBoxConstraints(); ... insertAndLayoutLeadingChild(childConstraints, parentUsesSize: true); ... insertAndLayoutChild(childConstraints,after: trailingChildWithLayout,parentUsesSize: true); ... collectGarbage(leadingGarbage, trailingGarbage); ... } }
abstract class RenderSliverMultiBoxAdaptor extends RenderSliver ...{ @protected RenderBox insertAndLayoutChild(BoxConstraints childConstraints, {@required RenderBox after,...}) { _createOrObtainChild(index, after: after); ... }
RenderBox insertAndLayoutLeadingChild(BoxConstraints childConstraints, {@required RenderBox after,...}) { _createOrObtainChild(index, after: after); ... }
@protected void collectGarbage(int leadingGarbage, int trailingGarbage) { _destroyOrCacheChild(firstChild); ... }
void _createOrObtainChild(int index, { RenderBox after }) { _childManager.createChild(index, after: after); ... }
void _destroyOrCacheChild(RenderBox child) { if (childParentData.keepAlive) { //為了更好的性能表現不會進行keepAlive,走else邏輯. ... } else { _childManager.removeChild(child); ... } } } ```
查看 RenderSliverList 的源碼發現,對於 child 的創建和移除都是通過其父類 RenderSliverMultiBoxAdaptor 進行。而 RenderSliverMultiBoxAdaptor 是通過 _childManager 即 SliverMultiBoxAdaptorElement 進行的,整個 SliverList繪製過程中佈局大小由父節點給出了限制。
在流式場景下:
- 在滑動過程中是通過 SliverMultiBoxAdaptorElement.createChild 進行對進入可視區新的 child 的創建;(即業務場景的每一個item卡片)
- 在滑動過程中是通過 SliverMultiBoxAdaptorElement.removeChild 進行對不在可視區舊的 child 的移除。
我們來看下 SliverMultiBoxAdaptorElement 的核心源碼:
```
class SliverMultiBoxAdaptorElement extends RenderObjectElement implements RenderSliverBoxChildManager {
final SplayTreeMap
@override void createChild(int index, { @required RenderBox after }) { ... Element newChild = updateChild(_childElements[index], _build(index), index); if (newChild != null) { _childElements[index] = newChild; } else { _childElements.remove(index); } ... }
@override void removeChild(RenderBox child) { ... final Element result = updateChild(_childElements[index], null, index); _childElements.remove(index); ... }
@override Element updateChild(Element child, Widget newWidget, dynamic newSlot) { ... final Element newChild = super.updateChild(child, newWidget, newSlot); ... } } ```
通過查看 SliverMultiBoxAdaptorElement 的源碼可以發現,對於 child 的操作其實都是通過父類 Element 的updateChild 進行的。
接下來,我們來看下 Element 的核心代碼:
``` abstract class Element extends DiagnosticableTree implements BuildContext { @protected Element updateChild(Element child, Widget newWidget, dynamic newSlot) { if (newWidget == null) { if (child != null) deactivateChild(child); return null; } Element newChild; if (child != null) { ... bool hasSameSuperclass = oldElementClass == newWidgetClass;; if (hasSameSuperclass && child.widget == newWidget) { if (child.slot != newSlot) updateSlotForChild(child, newSlot); newChild = child; } else if (hasSameSuperclass && Widget.canUpdate(child.widget, newWidget)) { if (child.slot != newSlot) updateSlotForChild(child, newSlot); child.update(newWidget); newChild = child; } else { deactivateChild(child); newChild = inflateWidget(newWidget, newSlot); } } else { newChild = inflateWidget(newWidget, newSlot); } ... return newChild; }
@protected Element inflateWidget(Widget newWidget, dynamic newSlot) { ... final Element newChild = newWidget.createElement(); newChild.mount(this, newSlot); ... return newChild; }
@protected void deactivateChild(Element child) { child._parent = null; child.detachRenderObject(); owner._inactiveElements.add(child); // this eventually calls child.deactivate() & child.unmount() ... } } ```
可以看到主要調用 Element 的 mount 和 detachRenderObject,這裏我們來看下 RenderObjectElement 的 這兩個方法的源碼:
``` abstract class RenderObjectElement extends Element { @override void mount(Element parent, dynamic newSlot) { super.mount(parent, newSlot); ... _renderObject = widget.createRenderObject(this); attachRenderObject(newSlot); ... }
@override void attachRenderObject(dynamic newSlot) { ... _ancestorRenderObjectElement = _findAncestorRenderObjectElement(); _ancestorRenderObjectElement?.insertChildRenderObject(renderObject, newSlot); ... }
@override void detachRenderObject() { if (_ancestorRenderObjectElement != null) { _ancestorRenderObjectElement.removeChildRenderObject(renderObject); _ancestorRenderObjectElement = null; } ... } } ```
通過查看上面源碼的追溯,可知:
在流式場景下:
- 在滑動過程中進入可視區新的 child 的創建,是通過創建全新的 Element 並 mount 掛載到 Element Tree;然後創建對應的 RenderObject,調用了 _ancestorRenderObjectElement?.insertChildRenderObject;
- 在滑動過程中不在可視區舊的 child 的移除,將對應的 Element 從 Element Tree unmount 移除掛載;然後調用了_ancestorRenderObjectElement.removeChildRenderObject。
其實這個 _ancestorRenderObjectElement 就是 SliverMultiBoxAdaptorElement,我們再來看下SliverMultiBoxAdaptorElement:
``` class SliverMultiBoxAdaptorElement extends RenderObjectElement implements RenderSliverBoxChildManager {
@override void insertChildRenderObject(covariant RenderObject child, int slot) { ... renderObject.insert(child as RenderBox, after: _currentBeforeChild); ... }
@override void removeChildRenderObject(covariant RenderObject child) { ... renderObject.remove(child as RenderBox); } } ```
其實調用的都是 ContainerRenderObjectMixin 的方法,我們再來看下 ContainerRenderObjectMixin:
``` mixin ContainerRenderObjectMixin<ChildType extends RenderObject, ... { void insert(ChildType child, { ChildType after }) { ... adoptChild(child);// attach render object _insertIntoChildList(child, after: after); }
void remove(ChildType child) { _removeFromChildList(child); dropChild(child);// detach render object } } ```
ContainerRenderObjectMixin 維護了一個雙向鏈表來持有當前 children RenderObject,所以在滑動過程中創建和移除都會同步在 ContainerRenderObjectMixin 的雙向鏈表中進行添加和移除。
最後總結下來:
- 在滑動過程中進入可視區新的 child 的創建,是通過創建全新的 Element 並 mount 掛載到 Element Tree;然後創建對應的 RenderObject, 通過調用 SliverMultiBoxAdaptorElement.insertChildRenderObject attach 到 Render Tree,並同步將 RenderObject 添加到 SliverMultiBoxAdaptorElement 所 mixin 的雙鏈表中;
- 在滑動過程中不在可視區舊的 child 的移除,將對應的 Element 從 Element Tree unmount 移除掛載;然後通過用SliverMultiBoxAdaptorElement.removeChildRenderObject 將對應的 RenderObject 從所 mixin 的雙鏈表中移除並同步將 RenderObject 從 Render Tree detach 掉。
渲染原理
通過核心源碼的分析,我們可以對流式場景的 Element 做如下分類:
下面我們來看用户向上滑動查看更多商品卡片並觸發加載下一頁數據進行展示時,整體的渲染流程和機制:
- 向上滑動時,頂部 0 和 1 的卡片移出 Viewport 區域(Visible Area + Cache Area),我們定義它為進入 Detach Area,進入 Detach Area 後將對應的 RenderObject 從 Render Tree detach 掉,並且將對應的 Element 從 Element Tree unmount 移除掛載,並同步從雙向鏈表中移除;
- 通過監聽 ScrollController 的滑動計算位置來判斷是否需要開始加載下一頁數據,然後底部 Loading Footer 組件會進入可視區 or 緩存區,需要對 SliverChildBuilderDelegate 的 childCount +1,最後一個 child 返回 Loading Footer組件,同時調用 setState 對整個 SliverList 刷新。update 會調用 performRebuild 進行重構建,中間部分在用户可視區會全部進行 update 操作;然後創建 Loading Footer 組件對應新的 Element 和 RenderObject,並同步添加到雙向鏈表中;
- 當 loading 結束數據返回後,會再次調用 setState 對整個 SliverList 刷新,update 會調用 performRebuild 進行重構建,中間部分在用户可視區會全部進行 update 操作;然後將 Loading Footer 組件將對應的 RenderObject 從Render Tree detach 掉,並且將對應的 Element 從 Element Tree unmount 移除掛載,並同步從雙向鏈表中移除;
- 底部新的 item 會進入可視區 or 緩存區,需要創建對應新的 Element 和 RenderObject,並同步添加到雙向鏈表中。
優化策略
上面用户向上滑動查看更多商品卡片並觸發加載下一頁數據進行展示的場景,可以從五個方向進行優化:
Load More
通過監聽 ScrollController 的滑動不斷進行計算,最好無需判斷,自動識別到需要加載下一頁數據然後發起loadMore() 回調。新建 ReuseSliverChildBuilderDelegate 增加 loadMore 以及和 item Builder 同級的footerBuilder,並默認包含 Loading Footer 組件,在 SliverMultiBoxAdaptorElement.createChild(int index,...) 判斷是否需要動態回調 loadMore() 並自動構建 footer 組件。
局部刷新
參考了閒魚之前在長列表的流暢度優化[2],在下一頁數據回來之後調用 setState 對整個 SliverList 刷新,導致中間部分在用户可視區會全部進行 update 操作,實際只需刷新新創建的部分,優化SliverMultiBoxAdaptorElement.update(SliverMultiBoxAdaptorWidget newWidget) 的部分實現局部刷新,如下圖:
Element & RenderObject 複用
參考了閒魚之前在長列表的流暢度優化[2] 和 Google Android RecyclerView ViewHolder 複用設計[3],在有新的item 創建時,可以做類似 Android RecyclerView 的 ViewHolder 對組件進行持有並複用。基於對渲染機制原理分析,在 Flutter 中 Widget 其實可以理解為是一個組件樹的數據結構,即更多是組件結構的數據表達。我們需要對移除的 item 的 Element 和 RenderObject 分組件類型進行緩存持有,在創建新的 item 的時候優先從緩存持有中取出進行復用。同時不破壞 Flutter 本身對 Key 的設計,當如果 item 有使用 Key 的時候,只複用和它 Key 相同的Element 和 RenderObject。但在流式場景列表數據都是不同的數據,所以在流式場景中使用了 Key,也就無法進行任何的複用。如果對 Element 和 RenderObject 進行復用,item 組件不建議使用 Key。
我們在對原有流式場景下 Element 的分類增加一個緩存態:
如下圖:
GC 抑制
Dart 自身有 GC 的機制,類似 Java 的分代回收,可以在滑動的過程中對 GC 進行抑制,定製 GC 回收的算法。針對這項和 Google 的 Flutter 專家討論,其實 Dart 不像 Java 會存在多線程切換進行垃圾回收的情況,單線程(主isolate)垃圾回收更快更輕量級,同時需要對 Flutter Engine 做深度的改造,考慮收益不大暫不進行。
異步化
Flutter Engine 限制非 Main Isolate 調用 Platform 相關 Api,將非跟 Platform Thread 交互的邏輯全部放至新的isolate中,頻繁 Isolate 的創建和回收也會對性能有一定的影響,Flutter compute(isolates.ComputeCallback
callback, Q message, { String debugLabel }) 每次調用會創建新的 Isolate,執行完任務後會進行回收,實現一個類似線程池的 Isolate 來進行處理非視圖任務。經過實際測試提升不明顯,不展開講述。
核心技術實現
我們可以將調用鏈路的代碼做如下分類:
所有渲染核心在繼承自 RenderObjectElement 的 SliverMultiBoxAdaptorElement 中,不破壞原有功能設計以及Flutter Framework 的結構,新增了 ReuseSliverMultiBoxAdaptorElement 的 Element 來進行優化策略的實現,並且可以直接搭配原有 SliverList 的 RenderSliverList 使用或者自定義的流式組件(例如:瀑布流組件)的RenderObject 使用。
局部刷新
調用鏈路優化
在 ReuseSliverMultiBoxAdaptorElement 的 update 方法做是否為局部刷新的判斷,如果不是局部刷新依然走performRebuild;如果是局部刷新,只創建新產生的 item。
核心代碼
@override
void update(covariant ReuseSliverMultiBoxAdaptorWidget newWidget) {
...
//是否進行局部刷新
if(_isPartialRefresh(oldDelegate, newDelegate)) {
...
int index = _childElements.lastKey() + 1;
Widget newWidget = _buildItem(index);
// do not create child when new widget is null
if (newWidget == null) {
return;
}
_currentBeforeChild = _childElements[index - 1].renderObject as RenderBox;
_createChild(index, newWidget);
} else {
// need to rebuild
performRebuild();
}
}
Element & RenderObject 複用
調用鏈路優化
- 創建:在 ReuseSliverMultiBoxAdaptorElement 的 createChild 方法讀取 _cacheElements 對應組件類型緩存的 Element 進行復用;如果沒有同類型可複用的 Element 則創建對應新的 Element 和 RenderObject。
- 移除:在 ReuseSliverMultiBoxAdaptorElement 的 removeChild 方法將移除的 RenderObject 從雙鏈表中移除,不進行Element 的 deactive 和 RenderObject 的 detach,並將對應的 Element 的 _slot 更新為null,使下次可以正常複用,然後將對應的 Element 緩存到 _cacheElements 對應組件類型的鏈表中。
注:不 deactive Element 其實不進行調用即可實現,但不 detach RenderObject 無法直接做到,需要在 Flutter Framework 層的 object.dart 文件中,新增一個方法 removeOnly 就是隻將 RenderObject 從雙鏈表中移除不進行detach。
核心代碼
- 創建
//新增的方法,createChild會調用到這個方法
_createChild(int index, Widget newWidget){
...
Type delegateChildRuntimeType = _getWidgetRuntimeType(newWidget);
if(_cacheElements[delegateChildRuntimeType] != null
&& _cacheElements[delegateChildRuntimeType].isNotEmpty){
child = _cacheElements[delegateChildRuntimeType].removeAt(0);
}else {
child = _childElements[index];
}
...
newChild = updateChild(child, newWidget, index);
...
}
- 移除
@override
void removeChild(RenderBox child) {
...
removeChildRenderObject(child); // call removeOnly
...
removeElement = _childElements.remove(index);
_performCacheElement(removeElement);
}
Load More
調用鏈路優化
在 createChild 時候判斷是否是構建 footer 來進行處理。
核心代碼
@override
void createChild(int index, { @required RenderBox after }) {
...
Widget newWidget;
if(_isBuildFooter(index)){ // call footerBuilder & call onLoadMore
newWidget = _buildFooter();
}else{
newWidget = _buildItem(index);
}
...
_createChild(index, newWidget);
...
}
整體結構設計
- 將核心的優化能力內聚在 Element 層,提供底層能力;
- 將 ReuseSliverMultiBoxAdaptorWidget 做為基類默認返回優化後的 Element;
- 將 loadMore 和 FooterBuilder 的能力統一由繼承自 SliverChildBuilderDelegate 的 ReuseSliverChildBuilderDelegate對上層暴露;
- 如有自己單獨定製的流式組件 Widget ,直接把繼承關係從 RenderObjectWidget 換為ReuseSliverMultiBoxAdaptorWidget 即可,例如自定義的單列表組件(ReuseSliverList)、瀑布流組件(ReuseWaterFall)等。
優化成果
基於在之前的一系列深度優化以及切換 Flutter Engine 為UC Hummer 之上,單獨控制流式場景的優化變量,使用PerfDog 獲取流暢度數據,進行了流暢度測試對比:
可以看到整體性能數據都有優化提升,結合替換 Engine 之前的測試數據平均來看,對幀率有 2-3 幀的提升,卡頓率下降 1.5 個百分點。
總結
使用方式
和原生 SliverList 的使用方式一樣,Widget 換成對應可以進行復用的組件 (ReuseSliverList/ReuseWaterFall/ CustomSliverList),delegate 如果需要 footer 和 loadMore 使用 ReuseSliverChildBuilderDelegate;如果不需要直接使用原生的 SliverChildBuilderDelegate 即可。
需要分頁場景
return ReuseSliverList( // ReuseWaterFall or CustomSliverList
delegate: ReuseSliverChildBuilderDelegate(
(BuildContext context, int index) {
return getItemWidget(index);
},
//構建footer
footerBuilder: (BuildContext context) {
return DetailMiniFootWidget();
},
//添加loadMore監聽
addUnderFlowListener: loadMore,
childCount: dataOfWidgetList.length
)
);
無需分頁場景
return ReuseSliverList( // ReuseWaterFall or CustomSliverList
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return getItemWidget(index);
},
childCount: dataOfWidgetList.length
)
);
注意點
使用的時候 item/footer 組件不要加 Key,否則認為只對同 Key 進行復用。因為複用了 Element,雖然表達組件樹數據結果的 Widget 會每次進行更新,但 StatefulElement 的 State 是在 Element 創建的時候生成的,同時也會被複用下來,和 Flutter 本身設計保持一致,所以需要在 didUpdateWidget(covariant T oldWidget) 將 State 緩存的數據重新從 Widget 獲取即可。
Reuse Element Lifecycle
將每個 item 的狀態進行回調,上層可以做邏輯處理和資源釋放等,例如之前在 didUpdateWidget(covariant T oldWidget) 將 State 緩存的數據重新從 Widget 獲取可以放置在 onDisappear裏或者自動播放的視頻流等;
``` /// 複用的生命週期 mixin ReuseSliverLifeCycle{
// 前台可見的 void onAppear() {}
// 後台不可見的 void onDisappear() {} } ```
參考資料
[1]:Google Flutter團隊 Xiao Yu:Flutter Performance Profiling and Theory:http://files.flutter-io.cn/events/gdd2018/Profiling_your_Flutter_Apps.pdf
[2]:閒魚雲從:他把閒魚APP長列表流暢度翻了倍
[3]:Google Android RecyclerView.ViewHolder:RecyclerView.Adapter#onCreateViewHolder:http://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.Adapter#onCreateViewHolder(android.view.ViewGroup,%20int)
關注【阿里巴巴移動技術】官方公眾號,每週 3 篇移動技術實踐&乾貨給你思考!
- 文本佈局性能提升 60%,Inline Text 技術原理與實現 | Cube 技術解讀
- Android Target 31 升級全攻略 —— 記阿里首個超級 App 的坎坷升級之路
- 系統困境與軟件複雜度,為什麼我們的系統會如此複雜
- 系統困境與軟件複雜度,為什麼我們的系統會如此複雜
- Cube 技術解讀 | Cube 渲染設計的前世今生
- 淘寶Native研發模式的演進與思考 | DX研發模式
- 大量模塊殼工程本地如何快速編譯?優酷 iOS 工程插件化實踐
- 大量模塊殼工程本地如何快速編譯?優酷 iOS 工程插件化實踐
- 從0到1,IDE如何提升端側研發效率?| DX研發模式
- 優酷移動端彈幕穿人架構設計與工程實戰總結
- 2022 支付寶五福 |“聯機版”打年獸背後的網絡技術 RTMS
- Flutter 圖片庫重磅開源!
- 如何持續突破性能表現?DX 性能優化策略詳解
- 淘寶Native研發模式的演進與思考 | DX研發模式
- 前車之鑑:聊聊釘釘 Flutter 落地桌面端踩過的“坑” | Dutter
- 釘釘 Flutter 跨四端方案設計與技術實踐 | Dutter
- 前車之鑑:聊聊釘釘 Flutter 落地桌面端踩過的“坑” | Dutter
- Dutter | 前車之鑑:聊聊釘釘 Flutter 落地桌面端踩過的“坑”
- Dutter | 釘釘 Flutter 跨四端方案設計與技術實踐
- Swift 首次調試斷點慢的問題解法 | 優酷 Swift 實踐