淘特 Flutter 流式場景的深度優化

阿里巴巴移動技術發表於2021-12-03

作者:江澤軍(眞意)

淘特在很多業務場景都使用了 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<int, Element> _childElements = SplayTreeMap<int, Element>();

  @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<Q, R>(isolates.ComputeCallback<Q, R> 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:https://files.flutter-io.cn/e...

[2]:閒魚雲從:他把閒魚APP長列表流暢度翻了倍

[3]:Google Android RecyclerView.ViewHolder:RecyclerView.Adapter#onCreateViewHolder:https://developer.android.com...(android.view.ViewGroup,%20int)

關注【阿里巴巴移動技術】官方公眾號,每週 3 篇移動技術實踐&乾貨給你思考!

相關文章