1. 硬件绘图原理
显示器成相的原理就是在不同的物理像素点上显示不同的颜色,最终构成完整的图像。
为了更新显示画面,显示器是以固定的频率刷新(从GPU取数据),比如有一部手机屏幕的刷新频率是 60Hz。当一帧图像绘制完毕后准备绘制下一帧时,显示器会发出一个垂直同步信号(如VSync), 60Hz的屏幕就会一秒内发出 60次这样的信号。而这个信号主要是用于同步CPU、GPU和显示器的。一般地来说,计算机系统中,CPU、GPU和显示器以一种特定的方式协作:CPU将计算好的显示内容提交给 GPU,GPU渲染后放入帧缓冲区,然后视频控制器按照同步信号从帧缓冲区取帧数据传递给显示器显示。
CPU和GPU的任务是各有偏重的,CPU主要用于基本数学和逻辑计算,而GPU主要执行和图形处理相关的复杂的数学,如矩阵变化和几何计算,GPU
的主要作用就是确定最终输送给显示器的各个像素点的色值。
#
2. Flutter UI的原理
提供了一套Dart API,在底层通过OpenGL这种跨平台的绘制库(内部会调用操作系统API)实现了一套代码跨多端。由于Dart API也是调用操作系统API,所以它的性能接近原生。
虽然Dart是先调用了OpenGL,OpenGL才会调用操作系统API,但是这仍然是原生渲染,因为OpenGL只是操作系统API的一个封装库,它并不像WebView渲染那样需要JavaScript运行环境和CSS渲染器,所以不会有性能损失。
Flutter中,一切都是Widget,当UI要发生变化时,我们不去直接修改DOM,而是通过更新状态,让Flutter UI系统来根据新的状态来重新构建UI。
3. widget与element
- Widget实际上就是
Element
的配置数据,Widget树实际上是一个配置树,而真正的UI渲染树是由Element
构成;不过,由于Element
是通过Widget生成的,所以它们之间有对应关系,在大多数场景,我们可以宽泛地认为Widget树就是指UI控件树或UI渲染树。 - 一个Widget对象可以对应多个
Element
对象。这很好理解,根据同一份配置(Widget),可以创建多个实例(Element)。
Element的生命周期如下:
- Framework 调用
Widget.createElement
创建一个Element实例,记为element
- Framework 调用
element.mount(parentElement,newSlot)
,mount方法中首先调用element
所对应Widget的createRenderObject
方法创建与element
相关联的RenderObject对象,然后调用element.attachRenderObject
方法将element.renderObject
添加到渲染树中插槽指定的位置(这一步不是必须的,一般发生在Element树结构发生变化时才需要重新attach)。插入到渲染树后的element
就处于“active”状态,处于“active”状态后就可以显示在屏幕上了(可以隐藏)。 - 当有父Widget的配置数据改变时,同时其
State.build
返回的Widget结构与之前不同,此时就需要重新构建对应的Element树。为了进行Element复用,在Element重新构建前会先尝试是否可以复用旧树上相同位置的element,element节点在更新前都会调用其对应Widget的canUpdate
方法,如果返回true
,则复用旧Element,旧的Element会使用新Widget配置数据更新,反之则会创建一个新的Element。Widget.canUpdate
主要是判断newWidget
与oldWidget
的runtimeType
和key
是否同时相等,如果同时相等就返回true
,否则就会返回false
。根据这个原理,当我们需要强制更新一个Widget时,可以通过指定不同的Key来避免复用。 - 当有祖先Element决定要移除
element
时(如Widget树结构发生了变化,导致element
对应的Widget被移除),这时该祖先Element就会调用deactivateChild
方法来移除它,移除后element.renderObject
也会被从渲染树中移除,然后Framework会调用element.deactivate
方法,这时element
状态变为“inactive”状态。 - “inactive”态的element将不会再显示到屏幕。为了避免在一次动画执行过程中反复创建、移除某个特定element,“inactive”态的element在当前动画最后一帧结束前都会保留,如果在动画执行结束后它还未能重新变成“active”状态,Framework就会调用其
unmount
方法将其彻底移除,这时element的状态为defunct
,它将永远不会再被插入到树中。 - 如果
element
要重新插入到Element树的其它位置,如element
或element
的祖先拥有一个GlobalKey(用于全局复用元素),那么Framework会先将element从现有位置移除,然后再调用其activate
方法,并将其renderObject
重新attach到渲染树。
Flutter正是通过Element这个纽带将Widget和RenderObject关联起来。
Element是Flutter UI框架内部连接widget和RenderObject
的纽带,大多数时候开发者只需要关注widget层即可,但是widget层有时候并不能完全屏蔽Element
细节,所以Framework在StatelessWidget
和StatefulWidget
中通过build
方法参数又将Element
对象也传递给了开发者,这样一来,开发者便可以在需要时直接操作Element
对象。
4. BuildContext
BuildContext
就是widget对应的Element
,所以我们可以通过context
在StatelessWidget
和StatefulWidget
的build
方法中直接访问Element
对象。我们获取主题数据的代码Theme.of(context)
内部正是调用了Element的dependOnInheritedWidgetOfExactType()
方法。
5. RenderObject
每个Element
都对应一个RenderObject
,我们可以通过Element.renderObject
来获取。并且我们也说过RenderObject
的主要职责是Layout和绘制,所有的RenderObject
会组成一棵渲染树Render Tree。
RenderObject
就是渲染树中的一个对象,它拥有一个parent
和一个parentData
插槽(slot),所谓插槽,就是指预留的一个接口或位置,这个接口和位置是由其它对象来接入或占据的,这个接口或位置在软件中通常用预留变量来表示,而parentData
正是一个预留变量,它正是由parent
来赋值的,parent
通常会通过子RenderObject
的parentData
存储一些和子元素相关的数据,如在Stack布局中,RenderStack
就会将子元素的偏移数据存储在子元素的parentData
中(具体可以查看Positioned
实现)。
RenderObject
类本身实现了一套基础的layout和绘制协议,但是并没有定义子节点模型(如一个节点可以有几个子节点,没有子节点?一个?两个?或者更多?)。 它也没有定义坐标系统(如子节点定位是在笛卡尔坐标中还是极坐标?)和具体的布局协议。
Flutter提供了一个RenderBox
类,它继承自``RenderObject,布局坐标系统采用笛卡尔坐标系,这和Android和iOS原生坐标系是一致的,都是屏幕的top、left是原点,然后分宽高两个轴,大多数情况下,我们直接使用
RenderBox`就可以了。
RenderBox的原理
1.布局过程–layout()
在RenderBox中size属性用来保存控件的宽和高。RenderBox的layout是通过在组件树中从上往下传递BoxConstraints对象的实现的。BoxConstraints对象可以限制子节点的最大和最小宽高,子节点必须遵守父节点给定的限制条件。
在布局阶段,父节点会调用子节点layout()方法。该方法有2个入参,第一个为constraints(父节点对子节点大小的限制,该值由父节点的布局逻辑确定),第二个参数为parentUsesSize,用于确定relayoutBoundary,该参数表示子节点布局变化是否影响父节点。如果为true,当子节点布局发生变化时,父节点都会标记为重新布局,若为false,则子节点发生变化后不会影响父节点。
当一个Element标记为 dirty 时便会重新build,这时RenderObject
便会重新布局,我们是通过调用 markNeedsBuild()
来标记Element
为dirty的。在RenderObject
中有一个类似的markNeedsLayout()
方法,它会将RenderObject
的布局状态标记为 dirty,这样在下一个frame中便会重新layout。当一个控件的大小被改变时可能会影响到它的 parent,因此 parent 也需要被重新布局。
RenderBox 实际的测量和布局逻辑是在performResize()
和 performLayout()
两个方法中,RenderBox子类需要实现这两个方法来定制自身的布局逻辑。只有 sizedByParent
为 true
时,performResize()
才会被调用,而 performLayout()
是每次布局都会被调用的。在 performLayout()
方法中除了完成自身布局,也必须完成子节点的布局,这是因为只有父子节点全部完成后布局流程才算真正完成。
布局过程:
调用栈将会变成:layout() > performResize()/performLayout() > child.layout() > … ,如此递归完成整个UI的布局。
RenderBox
子类要定制布局算法,通过重写performResize()和
performLayout()两个方法来实现,他们会在
layout()`中被调用。
#
ParentData,当layout结束后,每个节点的位置(相对于父节点的偏移)就已经确定了,renderObject就可以通过位置信息进行最终的绘制。节点的位置信息保存时通过父节点的parentData的进行保存的。
2.绘制过程–paint()
void defaultPaint(PaintingContext context, Offset offset) {
ChildType child = firstChild;
while (child != null) {
final ParentDataType childParentData = child.parentData;
//绘制子节点,
context.paintChild(child, childParentData.offset + offset);
child = childParentData.nextSibling;
}
}
直接遍历其子节点,然后调用paintChild()
来绘制子节点,同时将子节点ParentData
中在layout阶段保存的offset加上自身偏移作为第二个参数传递给paintChild()
。而如果子节点还有子节点时,paintChild()
方法还会调用子节点的paint()
方法,如此递归完成整个节点树的绘制。
绘制过程:
最终调用栈为: paint() > paintChild() > paint() … 。
isRepaintBoundary为true时,表示该RenderObject是独立于其父元素,可进行独立绘制,独立绘制是通过在不同的layer(层)上绘制的。正确使用isRepaintBoundary
属性可以提高绘制效率,避免不必要的重绘。具体原理是:和触发重新build和layout类似,RenderObject
也提供了一个markNeedsPaint()
方法。
使用实例:
CustomPaint(
size: Size(300, 300), //指定画布大小
painter: MyPainter(),
child: RepaintBoundary(//通过指定这个,就声明了isRepaintBoundary为true,独立绘制
child: Container(...),
),
),
3.命中测试
什么是命中测试?
一个对象是否可以响应事件,取决于其对命中测试的返回,当发生用户事件时,会从根节点(RenderView
)开始进行命中测试。
hitTest
方法用来判断该RenderObject
是否在被点击的范围内,同时负责将被点击的 RenderBox
添加到 HitTestResult
列表中,参数 position
为事件触发的坐标(如果有的话),返回 true 则表示有RenderBox
通过了命中测试,需要响应事件,反之则认为当前RenderBox
没有命中。
5.语义化即Semantics
提供给读屏软件的接口,也是实现辅助功能的基础,通过语义化借口可以让机器理解页面上的内容,提供给有视力障碍的用户来理解内容。
总结:
如果要从头到尾实现一个RenderObject
是比较麻烦的,我们必须去实现layout、绘制和命中测试逻辑,但是值得庆幸的是,大多数时候我们可以直接在Widget层通过组合或者CustomPaint
完成自定义UI。
6. 运行机制——从启动到显示
启动:启动 app,app是一个widget,WidgetsFlutterBinding是绑定widget框架和Flutter engine的桥梁。
void runApp(Widget app) {
WidgetsFlutterBinding.ensureInitialized()//初始化一个widgetBinding的全局单例
..attachRootWidget(app)//将根widget添加到RenderView上。代码中的有renderView和renderViewElement两个变量,renderView是一个RenderObject,它是渲染树的根,而renderViewElement是renderView对应的Element对象,可见该方法主要完成了根widget到根 RenderObject再到根Element的整个关联过程。
..scheduleWarmUpFrame();//渲染
}
渲染:
scheduleWarmUpFrame()实现是在SchedulerBinding中,会被调用后立刻进行一次绘制,而不是等待“vsync”信号,在此次绘制结束前,该方法会锁定事件分发,本次绘制结束前flutter不会响应各种事件,可以保证在绘制过程中,不会再触发新的重绘。
- Frame: 一次绘制过程,我们称其为一帧。Flutter engine受显示器垂直同步信号"VSync"的驱使不断的触发绘制。我们之前说的Flutter可以实现60fps(Frame Per-Second),就是指一秒钟可以触发60次重绘,FPS值越大,界面就越流畅。
- FrameCallback:
SchedulerBinding
类中有三个FrameCallback回调队列, 在一次绘制过程中,这三个回调队列会放在不同时机被执行:transientCallbacks
:用于存放一些临时回调,一般存放动画回调。可以通过SchedulerBinding.instance.scheduleFrameCallback
添加回调。persistentCallbacks
:用于存放一些持久的回调,不能在此类回调中再请求新的绘制帧,持久回调一经注册则不能移除。SchedulerBinding.instance.addPersitentFrameCallback()
,这个回调中处理了布局与绘制工作。postFrameCallbacks
:在Frame结束时只会被调用一次,调用后会被系统移除,可由SchedulerBinding.instance.addPostFrameCallback()
注册,注意,不要在此类回调中再触发新的Frame,这可以会导致循环刷新。
绘制:渲染和绘制逻辑在RenderBinding中。
7. 图片加载原理与缓存
Image组件,Flutter框架对加载过的图片是有缓存的(内存),默认最大缓存数量是1000,最大缓存空间为100M。
ImageProvider的功能:加载图片数据并进行缓存、解码。绘制部分逻辑主要是由RawImage
来完成。 而Image
正是连接起ImageProvider
和RawImage
的桥梁。
它的主要职责有两个:
- 提供图片数据源
- 缓存图片
图片的编解码逻辑不是在Dart 代码部分实现,而是在flutter engine中实现的。
ImageConfiguration
包含图片和设备的相关信息,如图片的大小、所在的AssetBundle
(只有打到安装包的图片存在)以及当前的设备平台、devicePixelRatio(设备像素比等)。
PaintingBinding.instance.imageCache
是 ImageCache
的一个实例,它是PaintingBinding
的一个属性,而Flutter框架中的PaintingBinding.instance
是一个单例,imageCache
事实上也是一个单例,也就是说图片缓存是全局的,统一由PaintingBinding.instance.imageCache
来管理。
图片缓存是在内存中,并没有进行本地文件持久化存储,这也是为什么网络图片在应用重启后需要重新联网下载的原因。
在应用生命周期内,如果缓存没有超过上限,相同的图片只会被下载一次。
8.渲染过程
整个 Flutter 界面渲染是从GPU
开始,「垂直串行vsync
」进行依次渲染,直至将「图层树Layer Tree
」展示出来。
那么,途径的几个阶段到底是怎么样呢?
-
Animate
:标记改变控件状态的动画(Tick animations to change widget state) -
Build
: 当组件状态发生变化时,重构控件还记得之前通过setState()改变控件状态吗
(Rebuild widgets to account for state changes) -
Layout
:更新要渲染控件的尺寸和位置(Update size and position of render objects) -
Paint
:记录要展示的混合视图层(Record display lists for composited layers)链接:https://www.jianshu.com/p/b5da0273be72
#
疑问点:
1.RenderObject
2,assert(parent == this.parent); 什么意思?会产生什么影响?断言,非生产模式不满足条件会报错,AssertionError。
3.mixin?