淘特 Flutter 流式场景的深度优化

淘特 Flutter 流式场景的深度优化_第1张图片

作者:江泽军(眞意)

淘特在很多业务场景都使用了 Flutter,加上业务场景本身具有一定的复杂性,使得 Flutter 在低端机流式场景的滑动浏览过程中卡顿、跳帧对比使用原生(Android/iOS)开发明显。通过分析业务层在 Flutter 渲染流程中的每个阶段存在的性能问题进行了一系列的深度优化后,平均帧率已经达到50帧之上超越了原生的表现, 但卡顿率依然达不到最佳的体验效果,遇到了难以突破的瓶颈和技术挑战,需要进行技术尝试和突破。

本文会从底层原理、优化思路、实际场景的优化策略、核心技术实现、优化成果等方面进行讲述,期望可以为大家带来一定的启发和帮助,也欢迎多多交流与指正,共建美好的 Flutter 技术社区。

渲染机制

原生 vs Flutter

Flutter 本身是基于原生系统之上的,所以渲染机制和 Native 是非常接近的,引用 Google Flutter 团队 Xiao Yu分享[1],如下图所示:

淘特 Flutter 流式场景的深度优化_第2张图片

渲染流程

如图左中,Flutter 从接收到 VSync 信号之后整体经历 8 个阶段,其中 Compositing 阶段后会将数据提交给GPU。

淘特 Flutter 流式场景的深度优化_第3张图片

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经过处理后进行显示。

核心渲染阶段

淘特 Flutter 流式场景的深度优化_第4张图片

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 中。

性能优化思路

了解底层渲染机制和核心渲染阶段,可以将优化分为三层:

淘特 Flutter 流式场景的深度优化_第5张图片

这里不具体展开讲每一层的优化细节,本文主要从实际的场景来讲述。

流式场景

流式组件原理

在原生开发下,通常使用 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 ,结构如下:

淘特 Flutter 流式场景的深度优化_第6张图片

我们首先看它的 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 _childElements = 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

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 做如下分类:

淘特 Flutter 流式场景的深度优化_第7张图片

下面我们来看用户向上滑动查看更多商品卡片并触发加载下一页数据进行展示时,整体的渲染流程和机制:

淘特 Flutter 流式场景的深度优化_第8张图片

  • 向上滑动时,顶部 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) 的部分实现局部刷新,如下图:

淘特 Flutter 流式场景的深度优化_第9张图片[]()

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 的分类增加一个缓存态:

淘特 Flutter 流式场景的深度优化_第10张图片[]()

如下图:

淘特 Flutter 流式场景的深度优化_第11张图片[]()

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。

淘特 Flutter 流式场景的深度优化_第12张图片[]()

核心代码

@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 对应组件类型的链表中。

淘特 Flutter 流式场景的深度优化_第13张图片[]()

注:不 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 来进行处理。

淘特 Flutter 流式场景的深度优化_第14张图片[]()

核心代码

@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 流式场景的深度优化_第15张图片

优化成果

基于在之前的一系列深度优化以及切换 Flutter Engine 为UC Hummer 之上,单独控制流式场景的优化变量,使用PerfDog 获取流畅度数据,进行了流畅度测试对比:

淘特 Flutter 流式场景的深度优化_第16张图片

淘特 Flutter 流式场景的深度优化_第17张图片

可以看到整体性能数据都有优化提升,结合替换 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 篇移动技术实践&干货给你思考!

你可能感兴趣的