查看原文
其他

Flutter 高延迟渲染流水线调度

易旭昕 U4内核技术 2022-12-14

我们在 Flutter 渲染性能问题分析一文中分析了 Flutter 渲染性能,特别是惯性滚动性能的问题和原因。而通过高延迟渲染流水线调度,我们重构了 Flutter 渲染流水线的调度逻辑,通过更深的流水线深度来增加输出的吞吐量,使得输出更平稳连续。这也是目前若干优化尝试中效果最好的一种:


  1. 在手机性能差,滚动速度快,帧率低的情况下有非常明显的性能提升,同时卡顿率也有明显改善(原本帧率在 35~45 这个范围,大约提升 10 帧约 20~30% 左右);

  2. 手机性能较好,或者滚动速度较慢,原本帧率还可以的情况下,也有一定幅度的性能提升(原本帧率在 45~50 这个范围,大约提升 4~5 帧约 10% 左右,原本帧率超过 50 的,大约提升 1~2 帧约 2~5% 左右);


在这篇文章中,我会对高延迟渲染流水线调度机制进行解析,和说明为什么它会起到性能优化的效果。


Flutter 渲染流水线


已经有很多文章描述 Flutter 渲染流水线的各个环节,读者也可以参考我的文章 Flutter 渲染流水线浅析,在这里我再简单描述一下:


  1. Flutter Engine 在 UI 线程接收到 vsync 信号后,会调用 Framework 层的 BeginFrame 和 DrawFrame,这里我们统称为 Frame,它包括 Animate,Build,Layout,Paint 等四个主要步骤,因为在 Layout 步骤可能会调用子树的 Build,所以 Build 和 Layout 其实并没有严格分界,这个环节我们统称为 Build & Layout

  2. Paint 之后,Framework 就会向 Raster 线程发送绘制请求,Raster 线程会马上调用 Rasterizer::Draw 完成这一帧的绘制,这里我们统称为 Draw


的来说就是 Flutter 收到 vsync 信号后就会请求渲染新的一帧,帧的渲染是由 UI 线程的 Frame 和 Raster 线程的 Draw 组成的,它们是连续的,如果要保证不掉帧 Frame + Draw 的耗时就需要小于一个 vsync 周期。


Flutter 惯性滚动中的掉帧


上面一节我们讨论了 UI::Frame + Raster::Draw 的耗时需要小于一个 vsync 周期才能保证不掉帧,对于屏幕刷新率为 60hz 的手机,一个 vsync 周期就是 16.67 毫秒,如果加上接收到 vsync 信号的延迟,TextureView 绘制机制的副作用等等其它因素,UI::Frame + Raster::Draw 实际上最多只有 15 毫秒左右的宽裕。


Flutter 的掉帧基本上都是下面几个原因引起:


  1. UI::Frame 耗时超过一个 vsync 周期,或者本身已经很接近一个 vsync 周期,加上 Raster::Draw 后超过一个 vsync 周期;

  2. UI 线程被其它非 UI 任务阻塞,导致开始处理 vsync 回调调用 UI::Frame 时已经延迟了一段时间,这个延迟加上 UI::Frame 的耗时超过一个 vsync 周期或者已经很接近一个 vsync 周期;

  3. 光栅化的耗时比较高,UI::Frame 虽然耗时不高,但是加上 Raster::Draw 后还是超过一个 vsync 周期;


而 Flutter 在惯性滚动过程中比较容易掉帧的原因包括:


  1. 新挂载列表单元的 Build & Layout 耗时过长导致 UI::Frame 的耗时过长;

  2. UI 线程被其它非 UI 任务阻塞导致 UI::Frame 的调用被延迟,这些非 UI 任务包括应用处理外部数据和事件的回调,GC,图片加载成功后生成 Codec 等等;

  3. 部分页面的光栅化耗时比较长,叠加 UI::Frame 的耗时超过一个 vsync 周期;


而其中新挂载列表单元的 Build & Layout 耗时过长是大部分页面惯性滚动掉帧的最主要原因,并且经常导致了长时间的卡顿(两个 vsync 周期以上的卡顿)。



在 Flutter 渲染流水线中,UI::Frame 和 Raster::Draw 是可以隔帧并发的,也就是说第 N + 1 帧的 UI::Frame 可以跟上一帧第 N 帧的 Raster::Draw 可以同时运行,这样可以有效地规避连续掉帧的现象。不过在 Flutter 渲染性能问题分析一文我们也分析过,Flutter 在 UI 线程的阻塞通常并不是持续不断的阻塞,新挂载列表单元的 Build & Layout 通常是每隔几帧触发一次,这导致了实际的掉帧大部分是每隔几帧就卡顿一次的情况,卡顿的频率和时长通常跟滚动速度有直接的关系。


高延迟渲染流水线调度


在讨论高延迟渲染流水线调度之前,我们先讨论一下输出延迟的问题。假设硬件的后处理延迟非常低可以忽略不计,如果使用 SurfaceView,Flutter 理论上的最低输出延迟是两个 vsync 周期,一个周期内完成一帧的渲染(UI::Frame + Raster::Draw),另外一个周期操作系统完成窗口合成和屏幕刷新,如果是使用 TextureView,那么最低输出延迟就是三个 vsync 周期。而所谓高延迟调度,就是我们人为地让最低输出延迟增加一个 vsync 周期,这意味着在使用 SurfaceView 的时候,最低输出延迟是三个 vsync 周期,而使用 TextureView 则是四个 vsync 周期,高延迟调度的目的就是通过更深的流水线深度来增加输出的吞吐量,使得输出更平稳和连续。接下来的内容会解析我们是怎么做的,为什么它能够减少掉帧,和对其理论副作用的讨论。


高延迟调度的逻辑很简单,在该调度模式下,当 Raster 线程在接收到 UI 线程请求 Draw 时,它会先判断当前的时间点是不是已经超过或者很接近一个 vsync 周期。如果已经超过一个 vsync 周期,则马上调用 Draw,如果距离一个 vsync 周期还有比较充裕的时间,则延迟到下一个 vsync 周期再调用 Draw。这样渲染一帧的 UI::Frame + Raster::Draw 最少也要跨越两个 vsync 周期。



上图比较清楚地展现了高延迟的调度逻辑,在第一个 vsync 周期,UI::Frame 的耗时比较低,我们将 Raster::Draw 延迟到下一个 vsync 周期再执行,而第二个 vsync 周期的 UI::Frame 超过了一个 vsync 周期,Raster::Draw 则即刻执行,在这个过程中输出始终是连续没有中断的。



上图显示在高延迟调度模式下避免了掉帧的其中一种场景。上图的 UI::Frame N + 1 耗时较长,加上 Raster::Draw N + 1 已经超过了一个 vsync 周期,图上方的默认调度模式下输出间隔超过了一个 vsync 周期,导致了掉帧,不过 Raster::Draw N + 1 和 UI::Frame N + 2 的并行执行避免了连续掉帧。而该场景在图下方的高延迟调度下,Raster::Draw 始终是连续的,中间没有中断,从而避免了掉帧。而该场景只是高延迟调度生效场景的其中一种,配合其它优化如 vsync 调度优化,高延迟调度能够覆盖的场景实际更多。


在高延迟调度下,UI::Frame + Raster::Draw 理论上有两个 vsync 周期的宽裕时间,这意味着,如果 UI::Frame 和 Raster::Draw 始终分别小于一个 vsync 周期,则可以一直保持不掉帧。并且即使 UI::Frame 超过一个 vsync 周期,也有较大概率不掉帧,比如 Raster::Draw 只需要 0.5 个 vsync 周期的话,那么我们可以留给 UI::Frame 的宽裕是 1.5 个 vsync 周期。虽然如果连续多次 UI::Frame 都超过一个 vsync 周期,高延迟调度也无法避免最终出现掉帧,不过前面我们也说过,在惯性滚动中新挂载列表单元的 Build & Layout 通常情况下并不是连续发生的,这也意味着出现连续多次 UI::Frame 都耗时较高的情况实际上很少。


总的来说高延迟调度极大增加了 UI::Frame 的宽裕度,也部分增加了 Raster::Draw 的宽裕度。即契合 Flutter 在惯性滚动过程中新挂载列表单元的 Build & Layout 间隔触发导致 UI::Frame 耗时较高会间隔发生的情况,也契合了 Flutter 的光栅化机制导致部分页面光栅化耗时较高的情况,达到了非常好的优化效果。并且优化的场景覆盖非常广,对各种不同类型页面,不同性能的设备,不同的滚动速度都有优化效果。


最后我们再来讨论了一下高延迟调度理论上的副作用。高延迟(High Latency)顾名思义会增加渲染的最低输出延迟,增加了一个 vsync 周期。所以一开始我们也会担心这个增加的延迟会不会影响用户视觉上的观感,特别是页面拖动时跟手的程度。不过实际上我们发现这点增加对用户的观感几乎完全没有影响。我们用一个默认调度模式的基准包和一个 Hard Code 了高延迟调度模式的优化包做对比,让几位同学进行体验,包括负责测试性能的同学,都基本上分辨不出哪个延迟更高,感觉不到跟手程度有存在差异。并且因为高延迟调度下输出中断的概率更低,整体流畅度体验也更好,毕竟默认调度模式的基准包并不能一直维持最低输出延迟,它实际上的输出延迟上下波动更频繁,导致观感更差。


除此以外,我们也对此做了进一步的优化。渲染流水线默认情况下并不会主动进入高延迟调度模式,而是处于默认调度模式,只有我们判断某一帧的 UI::Frame 耗时过长,当前已经处于丢帧的状态时,才会被动地进入高延迟调度模式,并一直维持到动画结束,当渲染流水线停止渲染一段时间后自动退出。被动进入和主动退出的逻辑也避免了动画第一帧就主动延迟带来的主动掉帧,并且如果页面和设备的性能足够好,整个动画过程能够一直不丢帧,那渲染流水线也会一直保持默认调度模式下的最低输出延迟不变。


结尾

UC 内核团队,专注渲染引擎 & 虚拟机技术十数年。作为阿里巴巴集团经济体共建 Flutter 的重要参与方,积极拥抱社区,力求给业务带来最大化的价值提升。Hummer 是我们深度定制优化的 Flutter 引擎,融合了团队在 Web 引擎上的多年技术沉淀。欢迎从事相关技术研究或基于 Flutter 构建应用的同学提出宝贵的意见或建议。



U4内核致力于打造性能最好、最安全的web平台,让web无所不能;

Hummer引擎旨在全面对标native性能,提升native业务开发效率及体验。


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存