原生长列表内嵌 Flutter 卡片性能调研
共 5156字,需浏览 11分钟
·
2021-03-28 23:29
作者:易旭昕
原文链接:https://zhuanlan.zhihu.com/p/354631257
本文由作者授权发布。
写作费时,敬请点赞,关注,收藏三连。
这篇文章主要是对在原生长列表中嵌入多个 Flutter 卡片,每个卡片都对应一个独立的 FlutterView/Engine 这种使用场景进行调研,分析该场景下的性能和内存使用等指标。通过调研,我们希望了解这种使用场景下 Flutter 的性能表现如何,在实际的业务中是否可行。
主要调研的指标包括三方面:
原生长列表的滚动流畅度,是否存在一些 Flutter 相关的调用会长时间阻塞主线程,也就是 Flutter.platform 线程,导致掉帧; Flutter 卡片的空白延迟帧数,我们知道 Flutter 的布局是在 Flutter.ui 线程,光栅化是在 Flutter.raster 线程,它们跟原生 UI 的绘制是异步的,如果在 FlutterView 可见之后才触发卡片的布局和光栅化,卡片必然存在一定时间的空白,我们希望知道这个空白持续的帧数和对视觉的影响; 内存占用,Flutter 本身会带来一定的内存增量,那多个 FlutterView/Engine 同时共存并显示是不是会进一步增大内存的压力,图片纹理缓存管理在该场景下表现如何,是否还有进一步优化的空间;
心急的同学可以直接跳到最后结论的部分。
Flutter Card Demo 说明
为了进行调研,我们编写了一个 Android Demo,Demo 在 Android Native 端使用了 androidx 提供的 RecyclerView 实现长列表。RecyclerView 会自动创建多个卡片并循环使用,在 Demo 中,每个卡片都是一个 FlutterCard 对象,其中包含一个独立 FlutterView 和 FlutterEngine,卡片的内容由 Flutter 呈现。
在上图 "#5 at 11" 的文本中,5 代表这个卡片的 ID,对应创建的 FlutterView/FlutterEngine 的序号,11 代表这个卡片在 RecyclerView 显示的位置,从这段文本我们可以很清楚地看到创建的 FlutterCard 卡片对象是不断被 RecyclerView 循环使用的; 长列表包含了 200 张卡片,在实际的运行中 RecyclerView 创建了约 9 个 FlutterCard 对象,也就是 9 对 FlutterView/FlutterEngine(实际个数跟 RecyclerView 的高度和卡片的高度有关); 为了模拟真实的场景,我们会在 RecyclerView 重用 FlutterCard 对象时,会重新随机产生一个新的卡片高度,并通过 MessageChannel 通知 FlutterEngine 更新内容,触发该卡片的 Widget 树的更新和重布局,每个卡片显示一张图片和两段文本; FlutterView 使用 TextureView 作为输出的 Surface,当 FlutterView 被 RecyclerView 回收时,TextureView 会触发 Surface Destroy,当 FlutterView 被 RecyclerView 重用并重新参与绘制时,TextureView 会触发 Surface Available(Create);
性能表现分析
测试手机使用了 Google Pixel,在现在来说算是性能比较差了,可以更好地反映实际的状况。
滚动流畅度
FlutterCard
可能是因为压缩的原因,视频显示不如实际表现流畅
除了初始滚动时,可能因为集中创建和初始化 FlutterEngine 导致主线略微阻塞,会有轻微掉帧的现象外,整个滚动过程都非常流畅。在惯性滚动中,卡片会不断地被回收和重用,所以 Surface 的 Destroy 和 Create 会频繁地被触发,在应用主线程,也就是 Flutter.platform 线程触发 Surface Destroy 和 Create,主线程需要阻塞等待 Flutter 完成清理或者初始化的操作,如果它造成明显阻塞就很容易导致掉帧。
在 Android 平台上,PlatformViewAndroid::NotifyDestroyed 主要工作:
通知 Flutter.ui 线程停止 Animator; 通知 Flutter.raster 线程的光栅化器释放资源,如 RasterCache,GrResourceCache,LayerTree,GrContext 等; 通知 http://Flutter.io 线程释放已经处于等待释放状态的 GPU 资源; 通知 Flutter.raster 线程释放 Window Surface;
PlatformViewAndroid::NotifyCreated 主要工作:
通知 Flutter.raster 线程设置 Window Surface; 通知 Flutter.raster 线程创建 GrContext; 通知 http://Flutter.io 线程设置纹理上传使用的 GrContext; 通知 Flutter.ui 线程启动 Animator,开始调度渲染 ScheduleFrame; 通知 Flutter.raster 设置光栅化器;
通过分析发现,在对比开启和关闭我们的引擎优化的情况下:
Surface Destroy 过程耗时 1 ~ 2ms,开启和关闭引擎优化无明显影响; Surface Create 过程没有开启引擎优化耗时 4 ~ 7ms,开启引擎优化后降到了 2 ~ 4ms,引擎优化降低了 3ms 左右的耗时;
可以看到,在开启引擎优化后,Surface Destroy 和 Create 的耗时都很少,绝大部分情况下都不会导致掉帧。
卡片空白帧数
在 Demo 的场景中,RecyclerView 在惯性滚动时,将新的卡片从不可见区域移进可见区域,触发了 TextureView 的绘制,而 TextureView 的 Surface Available(Create)是在它第一次被绘制的时候触发。
RecyclerView 会提前一些将卡片加入 View 树参与布局
按照原生的逻辑,Flutter 需要在 Surface Create 时才触发 ScheduleFrame。如果当前帧是第 N 帧,在第 N 帧的 Draw 的过程中触发了 TextureView 的 Surface Available(Create),同时触发了 Flutter 的 ScheduleFrame,Flutter 要等到 N + 1 帧的 VSync 回调时才触发 BeginFrame 开始绘制,如果 Flutter 首帧的布局 + 光栅化耗时少于一个 VSync 周期,那 Flutter 的首帧可以在 Native UI 第 N + 2 帧输出。
也就是说即使卡片的 Widget 树很简单,或者设备的性能非常高,Flutter 卡片最少也有两帧的空白时间,实际空白持续的帧数跟设备的性能,Widget 树的复杂程度都有关系。从 Demo 在 Pixel 上运行的情况来看,因为卡片比较简单,大部分情况下都是两帧空白。
如果仅仅只是两帧的空白,考虑到卡片本身只是一部分可见,设置卡片的 Flutter Widget 背景色跟原生 View 保持一致,或者干脆 Flutter Widget 不绘制背景,完全透明(需要使用 TextureView),这样一般情况下也不太容易察觉。
另外,因为 Flutter 的图片是异步加载和解码,所以图片如果太大,图片的绘制相比其它 Widget 可能会有更明显的延迟。
相关的 Android 渲染流水线帧调度的分析,可以参考我的文章TextureView 的血与泪
内存占用分析
为了排除图片解码缓存内存管理的干扰,我们专门测试了无图和有图两种情况,并且增加了开启引擎优化和关闭引擎优化的对比。我们加入了只有一个 FlutterView/Engine 的无图简单 Demo 作为对比参考(使用 SurfaceView,大小只有窗口的一半),另外也加入了一个纯原生无图的长列表 Demo 作为对比参考(卡片内容不完全一致,仅供参考)。
内存占用通过 meminfo 查看,主要看 PSS,PSS 虽然不能完全代表真实的物理内存占用,不过用于对比增量还是有一定参考价值的。实际操作中会滚动到底部之后再滚动回头部,长列表设置显示 200 张卡片,在这个过程中 RecyclerView 一共创建了 9 个 FlutterCard 对象,也就是 9 对 FlutterView/Engine 循环使用。
我们首先对比单引擎的简单 Demo 和完全原生的应用,主要增加的部分在:
.so mmap:额外的 so 库; EGL mtrack:额外的 Surface buffer,考虑到 Demo 的 FlutterView 只有一半窗口大小,如果是整个窗口大小,应该增加 24m 左右(Android 的 Surface 是 triple buffer); Unknown:主要是 Flutter Engine 分配的内存,包括 Skia 的内存分配,Dart VM 的内存分配;
所以一个单引擎全屏简单的 Flutter App 对比纯原生也会带来 40 ~ 50m 左右的额外开销。
再对比多引擎同时运行多个 Flutter App 的情况:
Native Heap 小幅增加,猜测主要是额外线程的堆栈; EGL mtrack 因为多引擎 Demo 使用的是 TextureView,TextureView 分配的 buffer 在 meminfo 中存在重复计数的问题,改成 SurfaceView 之后两者应该是差不多的,括号里面的 46 是改成使用 SurfaceView 时的占用,实际上这一项的增量只取决于当前可见的 FlutterView 的总面积,在我们的卡片场景,全部可见的卡片总面积也只是略大于当前窗口的面积,在 1080p 的手机上,20 ~ 30m 的增量是一个典型值; Unknown 增加的比较多,猜测主要来源至多个 Flutter App 运行在多个 Dart Isolate,Dart VM 分配的内存;
从上面的对比,如果在可见的 FlutterView 面积一样的情况下,并且开启引擎优化,9 个引擎运行 9 个比较简单的 Flutter App 对比只有一个引擎运行一个 Flutter App 大约增加了 40 ~ 50m 左右的额外开销。如果没有开启引擎优化,我们会看到大量额外的线程和 GL 上下文会导致 Native Heap 和 GL mtrack 大幅增加,总共增加了 68m。
开启有图之后,我们可以看到 Gfx Dev 大幅增加 348m,主要来自于图片解码后上传的纹理。Unknown 部分也有一定幅度增加,猜测主要来自于图片原始数据的内存缓存。这里面最主要的问题是 Engine 在循环使用的过程中,会一直累积图片纹理缓存不会主动释放,并且每个 Engine 独立管理纹理缓存,缺少全局管控。
结论
惯性滚动十分流畅,Surface Destroy 和 Create 在开启引擎优化后基本不会导致掉帧; 原生的逻辑导致最少两帧的卡片空白,实际的空白帧数取决于设备的性能和 Widget 树的复杂程度,测试 Demo 在 Pixel 上大部分情况都是两帧; 内存占用的问题比较明显,虽然我们的引擎优化已经大幅减少了额外的内存占用,但是每个独立的 Flutter App 运行在独立的 Dart Isolate 仍然有一定的内存增量(简单的卡片大概 4m 左右),我们仍然需要限制一定数量的引擎分配,不过最严重的还是图片的纹理内存占用,这是我们需要进一步优化的;