Flutter 脏区管理(译文)

221 阅读23分钟

译文地址 用来收藏记录

1、摘要

本文档描述了与UI API设计和实现相关的脏区域管理问题,以及它与Flutter应用程序的关系。 作者: jimgraham@ (flar)

2、目标

本文件应该作为脏区管理概念和此类机制的一般性的历史介绍,和Flutter如何管理这些问题,从而可以对哪些进行更改以提高其效率。

3、背景

脏区域管理一直是几十年来UI框架的主要内容。但是最先进的技术已经发生了很大的变化,从早期在MHz时钟芯片上进行CPU渲染到现代在几乎任何设备上进行GHz GPU渲染。

基本概念是,当屏幕上只有少量内容发生变化时,减少更新屏幕所花费的时间和资源。最初,需求是至关重要的,因为所花费的时间对操作员来说是可见的,影响了他们的生产力,并衡量了他们完成工作的速度。最近的转变是衡量节省的工作量,即当用户在UI程序中工作时,机器可以在后台做多少其他事情,或者动画是否可以保持一致的帧速率(>60+fps),以及使用电池供电的设备使用了多少电能。

现代硬件能够维护复杂的3D场景,即使在手机上也能以30-60fps的速度进行更新,主要用于游戏,因此对这种机制的需求并不明确。不过,区别在于,游戏往往是根据硬件的功能编写的,这些功能具有相当定制的引擎,这些引擎由这些功能驱动,并且由那些擅长这些引擎约束的开发人员驱动。游戏往往也是用户当时在设备上做的唯一事情,因此为后台任务预留计算时间可以放在次要位置。游戏的内容往往比用户思考时在合成屏幕上闪烁的简单文本光标更积极。

另一方面,UI框架旨在尽可能方便地向用户呈现信息,以便开发人员可以花更多的时间在内容创意上,而不是在学习硬件的功能和受这些因素的驱动上。虽然用户界面的内容往往比游戏的内容要求低得多,但现代图形用户界面正在引入一些概念,将3D、抗锯齿和像素效果带到最简单的应用程序中,而概念上的代价是设置标志或用样板容器包装小部件。大多数情况下,像素效果的后一个功能往往会使渲染小部件层次的方式复杂化,并且与简单的文本、按钮、颜色、渐变和半透明相比,渲染时间也要长得多。UI应用程序的内容目标与其说是视觉上的娱乐,不如说是使用最小的处理和最小的电量来传达一些小概念,如“此内容仍在加载”或“一旦你决定下一步要说什么,这里就是你正在输入的内容”。

即使现代硬件可以掩盖某些渲染操作来在保持30或60fps帧速率平稳运行方面,为GPU馈电所花费的时间也会占用设备在后台执行的其他任务,并可能导致移动设备使用更多电量来完成闪烁的文本光标或闪烁按钮等简单任务。

术语汇编
**脏区域 **
应用程序的一部分,其中一个框架中的小部件和组件与之前显示的框架不同。 Render Root(渲染根)
应用程序的一个区域,通常与单个小部件关联,该区域阻挡了其背后的所有内容,因此可以通过在渲染根处开始重新绘制来修复该区域前面的任何更改以及该区域内的所有更改。
Mahogany Staircase(桃花心木楼梯)
该术语指的是Flutter框架设计,其中使用代表性物体的树来构建以新方式描述原始物体的平行树。有几个这样的树转换发生在Flutter从一个小部件Widget树细化为一个可渲染层对象树的过程中。

4、概述

Flutter有许多机制,旨在根据应用程序指定的小部件层次结构的变化来减少计算新框架的工作量。但渲染系统尚未使用这些机制来最小化在每个帧上重新绘制的屏幕区域。

非目标
本文档主要是要考虑的问题和潜在/通用解决方案想法的列表,但没有提出在Flutter中使用的具体实现。

5、详细设计/讨论

5.1、历史

用于管理脏区域的典型技术分为两个步骤

  • 累积和处理

  • 重新喷漆

    Dirty region accumulation
    污染区域的来源(历史上)包括: 项目状态的更改:颜色、形状 层次结构的更改:添加或删除小部件,滚动 外部因素:应用程序生命周期、重叠窗口、GPU资源 动画(主要是上述内容之一的常见来源)

    其中一些来源往往不会发生在嵌入式设备上(通常只有一个应用程序在运行,因此不会出现“损坏修复”),现代桌面系统通常会为每个窗口使用backbuffer,因此即使重叠的窗口也可以完全由系统桌面合成机制处理。剩下的唯一外部因素往往是生命周期问题,如映射到屏幕、最小化、最大化和退出。

    大多数编程源可以通过在各种小部件(或RenderObject)setter中包含代码来管理,这样,当影响其视觉表示的属性发生更改时,它会通知框架需要重新绘制。如果正在更改的属性只影响其屏幕表示的一小部分,某些系统允许渲染对象选择性地指定需要重新绘制的矩形。


跟踪脏区域的各种簿记策略包括:

  • 所有更改的一个大边界框

  • 合并更改的N个边界框列表

  • 像素级区域对象

    大多数系统从“一个大的边界框”开始,因为它可以处理99%的应用程序的需求,这些应用程序可能一次只处理一个在屏幕上改变的小部件(闪烁的光标或按钮/复选框由于用户输入而改变状态)。边界框和区域对象列表有助于处理电子表格或仪表板等情况,其中多个孤立的矩形区域可以在每个帧上更新,但不能更新所有内容。

    如果系统具有经过良好优化的区域实现,并且能够渲染剪裁到该区域,则历史上会选择最后一个解决方案。但是GPU不倾向于提供剪切到复杂矩形列表的能力,因此该选项不再被选中。另一个需要考虑的问题是,最小化像素操作只是这里的目标之一,但最小化生成渲染操作的开销也很重要。

    如果累积了一个由N个矩形组成的短列表,那么列表的大小需要在处理与重新渲染整个场景的成本之间保持平衡。此决策考虑了许多因素,包括仅对与矩形相交的可渲染对象列表执行渲染操作的容易程度。如果保留数据以允许有效地向下钻取到受影响的可渲染对象,并且如果矩形列表主要涉及不相交的可渲染对象,则列表可以更大。如果渲染给定矩形的响应归结为设置全局剪辑矩形,然后对整个场景执行渲染操作,那么对于列表中的每个矩形重复此解决方案通常不会比仅使用“一个大边界框”方法好多少。如果在整个场景中导航和调度操作的成本很低,并且不渲染每个组件边界框之外的像素节省了大量成本,那么这可能会获胜,但使用现代GPU,填充率方面的节省相当小,因此处理每个边界框的任何开销都会放大。

    最后,许多系统使用“N个脏矩形列表”的方法,但使用启发式方法来降低单个边界框的复杂性。一些系统甚至可以跟踪剪辑内容相对于场景大小的总比例,并可能提前停止累积过程,转而重新绘制整个场景。启发式算法还可能考虑是否存在分离良好的渲染根(我将很快解释这一概念)、GPU与CPU相比的速度,以及渲染树的深度与宽度。

    Handling a dirty region

    如上所述,重新绘制脏区域的最简单方法是将全局剪辑设置为脏区域的范围,然后重新绘制整个场景,并让GPU或渲染代码省略任何无关紧要的操作。如果渲染操作的剔除是有效的,那么这可能非常有效,但有一些技术可以帮助做到更好。

    一种更好的“划算”技术是寻找充满不透明像素的大区域。通常,这些容器具有不透明的背景色,但系统中出现的任何矩形都可能提供一些不错的优化。(特别是,当颤振开始渲染时,不再跟踪具有背景色的容器和填充颜色的随机矩形之间的区别。)这些区域可以表示修复脏区域的原点,因为当脏区域完全位于其不透明边界内时,不需要处理它们后面的任何对象。可以将这些区域称为渲染根,因为它们可以在累积脏矩形的同时被标注和标记。如果需要重新绘制对象,则会记录其边界,并将其与其父对象为覆盖脏区域的渲染根注册的不透明矩形进行比较,然后将该父对象与矩形一起记录。修复区域时,只需要访问该渲染根的子级及其前面的任何同级。

    在处理渲染根时,另一个有助于跟踪的数据点是具有互不重叠子级的容器。如果这样的容器是渲染根,则在重新绘制将其列为根的区域时,只需要访问其子对象的子集。在某些情况下,父级可能对这些子级的布局了解得足够多,甚至可以立即知道哪些子级位于脏区域,而无需全部处理(考虑具有固定单元格大小的电子表格-可以通过对损坏矩形的LTRB属性进行一对简单的分割来快速计算受影响的子级)。

5.2、最近的注意事项

“回到过去”,UI呈现往往是非抗锯齿、不透明、平坦(2D)且没有像素效果。脏区域管理技术诞生于那一天,充满不透明背景色(可能重复)的大矩形非常丰富,很容易找到(基本上,除了偶尔出现的“无背景”容器外,小部件的父控件)。

但是,今天的GUI有许多属性,使累积损坏矩形的过程和处理它的过程都变得复杂。

Opacity
在使脏区域管理复杂化方面,最简单的改进是包含非不透明颜色。虽然这些颜色确实会影响场景的渲染,但它们基本上取消了渲染操作被视为渲染根的资格。这与没有背景颜色集的容器或经典脏区域管理中的“仅输入”容器没有太大区别。

Transforms
当GUI具有2D或3D仿射变换时,要处理的第一个坐标问题是,需要将更改的小部件的边界转换为用于跟踪脏区域的公共空间,通常是屏幕或像素空间。边界的变换不是火箭科学,渲染对象的屏幕边界通常会被跟踪,因此这不是一个巨大的复杂问题。

其次,我们必须处理想要成为渲染根的矩形:“嘿,我是一个矩形!我有一个不透明的背景!我是渲染根!”好了,不再是了。如果使用除平移或缩放以外的任何仿射操作进行变换,则无法很好地与通常为脏区域跟踪的屏幕空间矩形对齐。仍然可以计算边界的不透明和屏幕对齐的子区域,但这有多频繁?如果从Z=0平面进行变换,则作为渲染根的可能性可能要小得多,并且分析该电势变得更加复杂。

Antialiasing
计算小部件的不透明边界很简单——这只是它的实际边界。通过选择适当的舍入操作,您甚至可以知道经过转换的小部件的精确边界。反走样只会使这些计算稍微复杂一些,因为您希望包括受渲染影响的任何像素,通常只需要在边界的左上角执行floor操作,在右下角执行ceil操作。

计算潜在渲染根的不透明区域时也会用到它,但在这里,您按相反的顺序进行取整-ceil为左上角坐标,floor为右下角坐标。

Pixel effects 在本节中,我只考虑应用于该效果后代的像素效果所引起的问题。特别是,Flutter包括一种将像素效果应用于小部件后面区域的机制,但我将在后面的Flutter特定部分中讨论这一点。

许多像素效果对边界没有影响,但可以更改像素的不透明度。更有趣的影响(从脏区域管理的角度来看)是改变边界的影响。这并不是一个巨大的复杂问题——只需要求效果根据其内容的原始边界或其内容中“脏区域”的边界推荐新边界,这就成为了您跟踪的脏区域。这意味着正在更改的小部件的父级不仅需要能够检查它们是否提供脏区域的渲染根背景,还需要能够推荐不同的脏区域(换句话说,将脏区域传播到关联渲染根的调用序列需要包括一种方法,以便在搜索过程中更新边界)。

这些影响与上述抗锯齿部分中提到的考虑因素具有类似的影响,但分析更为复杂。如果效果产生的结果比其内容的边界更大(如在模糊效果中)或移动(如变换效果),则在传播到管理所有脏区域的公共空间时,需要修改其内容的脏区域。导致部分内容变得不太不透明的效果(例如模糊效果)还需要修改视为渲染根的良好不透明背景的区域。但通常,为了简单起见,这种效果只记录了这样一个事实,即它可以降低其内容的不透明度,然后为了查找渲染根而忽略其边界。计算坐标变换模糊区域的精确不透明区域通常不值得避免使用更古老的小部件作为渲染根。

6、Flutter特定问题

BackdropFilter
这里的主要复杂之处在于,BackdropFilter是一种向后看的效果,这意味着它改变了背后事物的外观。大多数脏区域管理集中于树中的每个节点约束或跟踪其子节点,以便通过典型的树遍历技术向前跟踪各个节点渲染的结果和交互。

但是BackdropFilter通过更改其背后对象的渲染,在几个方面违反了这种前向作用方法。

首先,默认情况下,BackdropFilter应用于整个屏幕/应用程序。虽然可以插入剪辑小部件来限制其呈现,但一般来说,它的输出与它的子项(可以说是它的“内容”)无关,而是与对其继承祖先的开放式影响相关联。

这主要意味着它可以覆盖所有内容,因此在默认情况下,它的边界是“屏幕”。但是,即使程序员安装了一个clip小部件来限制它的输出,它的界限“比我通过检查我的孩子可以知道的要大,但我不知道有多大,因为一些家长可能会限制我”。只需将脏矩形更改为“屏幕”,然后将其传递给其父对象,即可处理子对象的脏区域。如果其父对象之一是片段,则该父对象可以将脏区域限制在片段的边界内。当你从树上下来时,剪辑也可以累积起来,并呈现给孩子们,孩子们正在确定他们的变化对整个脏区域的影响-他们会在录制之前先通过剪辑传递自己的脏区域。

好的,所以BackdropFilter的输出端影响实际上只是我们在其他渲染树操作中看到的一个特例,但有一种情况是,输入也是令人惊讶的非树型的,在单遍树遍历中很难自然处理。

由于BackdropFilter将其后面的层作为输入,并且这些层不受BackdropFilter周围任何剪辑容器的限制,因此在某些情况下,不与剪辑容器相交的脏区域仍然可以修改BackdropFilter渲染的方式。通常,如果有两个相邻的ClipRect容器不重叠,并且其中一个容器内记录了一个脏区域,则脏区域将被剪裁到父片段容器,然后与另一个片段容器的边界进行比较,并发现不相交。但是,如果第二个剪辑容器的子容器之一恰好是BackdropFilter,则它可能会从另一个剪辑容器(脏的那一个)中读取像素,对其执行模糊,并且这些像素现在将影响该BackdropFilter的剪裁边界中显示的内容,即使BackdropFilter及其树的一部分是稳定的。

所有这些考虑的最终结果是,当BackdropFilter位于场景中时,我们需要特别注意,即使包含它们的树看起来没有与脏区域相交,也要重新绘制它们。

但是,等等!还有更多!

考虑一个没有剪辑的BackdropFilter的简单例子。当背景中的任何对象发生变化时,我们需要重新应用模糊。通常,我们只会重新绘制更改的对象,但由于需要重新应用模糊,我们还必须重新绘制其下位于模糊半径内的所有内容。在这种情况下,我们在累积已更改对象的脏区域时执行的优化由于其上方存在BackdropFilter而无效。

因此,通常,我们需要跟踪BackdropFilter应用其过滤器的屏幕的任何区域,并在重新绘制该区域的任何部分时重新绘制整个“模糊区域”。

作为例外。如果修改了BackdropFilter的子级,并且其(模糊的)脏区域在到达BackdropFilter父级之前由渲染根封装,则该优化可以保持有效。此外,BackdropFilter的任何兄弟在其上方绘制时,都可以将重绘区域捕获为渲染根,而无需调用“重绘模糊中的所有内容”区域。这种优化不仅需要我们跟踪被BackdropFilter模糊的屏幕区域,还需要跟踪树中位于BackdropFilter下方的部分。

Layers
Flutter框架将用渲染术语描述小部件树的所有输出组合成一系列层对象,然后传递给渲染引擎,以处理和表示为对各种平台渲染API(如OpenGL、Metal或Vulkan)的调用。在大多数平台上,它使用Skia渲染库作为中介来处理各种图形API的细节。

通常,这些层代表了小部件树的各个部分的渲染中的一个根本性突破,在这些地方,不透明度、变换、剪辑等应用于许多小部件。将推送这些特定操作的层,然后向其中添加多个子层。这些层是分层的,并形成一棵树,但它不是小部件的1:1转换。小部件可以组合在一个层中呈现其输出,或者单个小部件可以生成多个层(不太常见)。一些常见的渲染层包括:api

这些层可能会使用一些附加信息(例如对当前变换的修改)修改图形上下文,然后传递给其子层以进行更多渲染。但其中许多人要求他们的孩子分别渲染到屏幕外的纹理,然后合成到现有场景中,并进行一些可能的修改。例如,OpacityLayer将其子对象渲染为纹理,然后使用drawTexture调用上的附加不透明度调制属性将该纹理绘制到场景中。如果剪辑类型请求硬边,ClipLayer可能只在渲染上下文上安装剪辑,但如果剪辑类型需要软边/抗锯齿边或不是简单的矩形,它可能会为其子对象使用中间的临时纹理。

BackdropFilterLayer
BackdropFilterLayer是一种特殊情况,它不仅使用单独的纹理来渲染其子对象,而且还要求图形机器在渲染子对象之前使用现有场景的过滤副本填充该纹理。

但是,关于管理脏区域,这些层只代表可能需要对场景其他部分的输出区域进行特殊大小写的任何渲染技术。更重要的是,如果层的属性没有更改,层有时会尝试在场景之间重用这些层,因此可以使用在多个场景中重用这些层来衡量输出的稳定性或肮脏程度。

Pictures
场景的许多常规渲染(填充的背景形状、文本、复选框、图标、线条)被合并到一个记录的基本渲染操作列表中,称为图片。图片的优点是,它可以在少量对象中紧密地表示大量渲染调用。图片的缺点之一是,它可以表示大量小部件的渲染调用,包括从帧到帧稳定的小部件组合,以及一些在每帧上设置动画的小部件。

这些图片对象的另一个复杂性是,它们当前在框架级别和引擎级别都是不透明的对象。框架级别的Dart对象在本机引擎级别保存对Skia SkPicture对象的引用。这些对象的Skia API和Dart API都没有提供任何方法来确定它们内部的渲染操作类型。Skia API确实提供了一些最小的信息,例如操作的数量,以便引擎在决定缓存输出时可以松散地分析图片的复杂性。

关于脏区域管理和稳定性,提供的唯一机制是,如果框架没有重建图片,那么引擎级别会注意到它的持久性,因为每个对象都与一个唯一的ID(可以说是指纹)相关联,以便进行缓存,但前提是框架没有重新计算渲染命令。不幸的是,这是检测其稳定性的唯一机会。如果对图片对象有贡献的单个小部件更改了其渲染命令,则将为新帧重建图片,并且其标识将不匹配。此外,在某些情况下,这些图片对象与任何已更改的小部件没有直接关联,而是与其他已更改的小部件相邻,因此必须重复它们的绘制,只会生成一个新的图片对象,该对象包含与前一个对象相同的渲染。已使用比较两个图片对象的序列化形式等技术来发现此类情况的存在,但尚未对其原因进行更深入的分析。

为了帮助改善这个问题,该框架确实提供了一个RepaitBoundary小部件,该小部件将隔离其子代与他们的祖先或兄弟姐妹共享图片对象,如果应用程序明智地使用了这样的对象,那么设置小部件动画将不会破坏它们周围树中静态小部件的记录输出。不过,这种机制需要应用程序开发人员手动干预,而他们使用这些小部件需要对颤振框架的各个层有一定的了解,这可能会成为新开发人员甚至经验丰富的开发人员的障碍。

另一个需要考虑的基于图片的问题是,如果表示渲染根的实际渲染隐藏在这些不透明对象中,则无法计算渲染根。

Mahogany Staircase
颤振中有多个基于树的级别,每个级别在跟踪状态变化时具有相似但不完全相同的规则。这些级别包括:

  • 小部件树,由应用程序开发人员维护
  • 元素树,在控件树发生变化时从其重建
  • RenderObject树,由元素树管理
  • 层树,由RenderObject树构建

EngineLayer树与图层对象的比例大多为1:1,但它们在图层树中确实有代表性的“句柄”对象。Dart句柄类称为“EngineLayer”,本机C++类称为Flatter::Layer。

需要注意的是,应用程序可以绕过这些层中的每一层来实现自定义结果。稳定性的最终确定必须在最低水平,以考虑外部来源直接操纵最终发动机的可能性。有了适当的文档,创建自己的渲染对象的开发人员可以像颤振框架中的代码一样,保留关于稳定性的良好信息,并且只要稳定性是引擎层的可选优化,他们的不合规性就不会产生错误的结果-它可能不会像可能的那样有效。

但是,检测树稳定性的大部分工作可以在我们有更多信息的更高层次上完成,事实上,它已经这样做了,以便减少每个阶梯步骤中的分配和计算。每当一个级别决定在下一棵树中保留相同的对象以表示前一棵树中的状态时,将保留有关树稳定性的重要信息,以供下一个级别使用。如果重用可以一直到EngineLayer树,那么底层就有最好的机会最小化重绘。一旦我们检测发动机级别以开始减少重新喷漆区域,每个级别实现这些目标的程度将变得更加明显。