原生渲染、自渲染,都是跨端开发领域耳熟能详的词。
但是:
- 原生渲染和自渲染的准确概念是什么?
- 自渲染为什么有时候比原生组件还快?
- 为什么 flutter 自己画得很快,但一旦混入原生 View,就会出现各种复杂问题?
- 原生渲染是不是天然意味着跨平台 UI 不一致?
- 有没有一种方案,既渲染的快,又多平台UI一致,且没有混合原生 View 渲染的各种兼容性问题?
理解这些问题,需要先把渲染系统搞明白。
渲染系统简述
典型原生渲染
以原生渲染为列,看看一套渲染系统包括什么,渲染流程是什么。
- 初始化渲染资源:操作系统给App初始化一套渲染资源,包括窗体、渲染线程/进程、GPU上下文、字体加载、事件系统等。
- 组件系统初始化:操作系统会提供一套的原生的 UI Framework 和组件系统。有的操作系统会提供多套,比如Android有View体系和Compose UI库,iOS有UIView体系和SwiftUI。
- 排版布局:操作系统会提供多种布局,比如线性布局、约束布局、绝对定位等。
- 渲染线程/进程:负责把开发者写的组件代码和使用的布局,转换为GPU可识别的指令。比如Android上skia运行在这个环节来操作GPU。
- 合成与上屏:操作系统会合成应用的绘制内容,最终显示在屏幕上。
flutter的自渲染
那么flutter的自渲染是什么?
- 在应用完成第一步初始化渲染资源后,
- flutter使用了OS提供的一类特殊组件,可以直接操作GPU。Android上是surfaceView/textureVIew,iOS是MTKView/CAMetalLayer,鸿蒙是XComponent。
- 在创建好可操作GPU的组件后,flutter自行又初始化了一套渲染资源,仍然要做一遍原生已经做过的事情,包括窗体、渲染线程、GPU上下文、字体加载、事件系统等。
- flutter提供了自己的一套widget组件系统。
- flutter提供了自己的排版布局系统。
- flutter的渲染线程把开发者写的组件代码和使用的布局,转换为GPU可识别的指令。
- 回到原生层进行合成和上屏。
也就是flutter在原生渲染系统之上,又开辟了一套自己的渲染系统,但最终还要和原生合成。
在web上,flutter采取了一致的思路,在浏览器已经为这个网页初始化好资源后,flutter利用了canvas组件,通过编译为wasm的渲染引擎和Dart代码在上面自绘UI,然后整体合成上屏。
自渲染除了初始化渲染系统慢外,还有一个很大的问题是和原生组件的融合。后面会详细展开。
flutter在不同平台的组件,都是一套dart编写的组件,所以不同平台的组件渲染一致性很高。
再看看 react native
react native 使用的是原生渲染,但在排版布局这个环节,改成了用 yoga 这个c库来做 flex排版。这种方式可以让多平台使用一致的flex布局。
但 react native 使用了大量的各平台原生组件,分别由java/kotlin、objective-c/Swift等不同语言编写,这些组件差异,导致 react native 在不同平台的细节无法像flutter那样对齐。
react native 的渲染性能,在跨平台框架中属于偏低的,虽然在 Fabric + JSI 架构下消除了序列化,但它仍然在原生渲染系统之上,又建立了自己的js层虚拟DOM,js代码中发送指令到原生层,在原生层动态创建OS的原生组件。
这是 react native 的渲染流程:
- 初始化渲染资源:与原生一致
- 组件初始化:创建js虚拟dom,在js层 diff UI变更,发送指令给原生层,由原生层创建OS的原生组件
- 排版布局:在C层统一flex布局
- 渲染线程/进程:与原生一致
- 合成与上屏:与原生一致
再看看 Compose UI
它家的做法又有点不同。
- Compose UI 在Android平台是原生渲染,但没有使用 Android View体系组件和排版布局系统,而是在Android canvas上重新开发了一套自己的组件和排版布局系统。 虽然听起来也有点像canvas自绘,但它不是基于surface/texture直接操作GPU的。它的绘制指令仍然要走到原生的渲染线程中统一处理。不存在跨渲染管线融合的问题。 这种做法的好处是可以和 Android View 体系完美融合。
- 不过 compose-multiplatform 的iOS版,又和flutter一样,是完全的自渲染。也就是它在不同平台,采取了完全不同的渲染技术路线。这种工程选择有点怪异,其实是KMP语言本身的特性决定的。
最后看看 uni-app x 蒸汽模式
uni-app x 蒸汽模式也基于原生渲染管线,没有自渲染。
在组件系统方面,它和 Compose UI 的Android平台类似,开发了一套自己的组件系统,可以和系统原生组件生态完美融合。但uni在Android、iOS和鸿蒙,是统一的策略,不存在一会儿原生渲染、一会儿自渲染。
uni-app x 蒸汽模式的组件系统采用c++和uts语言开发,不同平台的组件代码一致,良好的保证了跨平台对齐。 并且渲染速度更快。详见benchmark
对比标准的原生渲染,uni-app x 蒸汽模式的变化如下:
- 初始化渲染资源:与原生一致
- 组件初始化:无虚拟DOM,通过预编译字节码/机器码,直接在原生层创建由C++编写的组件。
- 排版布局:在C层统一flex布局
- 渲染线程/进程:与原生一致
- 合成与上屏:与原生一致
了解了渲染系统的基本原理和各家的做法,我们来看看原生渲染和自渲染的性能差异。
自渲染问题1:初始化性能
前文的渲染系统简述中,已经看出自渲染多做的事情。
初始化渲染资源是一套较重的流程,在原生已经初始化好渲染资源后,自渲染需要再次创建一套自己的渲染系统。但最终也需要由OS统一合成。
可以展开讲下一套自渲染系统要做的事情,在一个应用启动后,原生渲染系统已经准备好,此时发现界面中有flutter的view,
- 创建surfaceView/textureVIew/MTKView/CAMetalLayer/XComponent等重型组件。
- flutter要初始化图形后端如Impeller/skia,创建自己的渲染线程、GPU上下文、图层合成管理。有些情况下还要额外创建Platform线程。
- flutter要创建自己的屏幕事件监听系统,由于platformView的存在,flutter的屏幕事件监听系统还需要和原生屏幕事件监听系统互相竞赛。
- flutter要再自行开辟资源加载系统字体库,需要自建常用字光栅化缓存体系。
- 此外,flutter使用了大量的着色器,在低版本Android上,无法启用impeller时,会造成大量耗时用于着色器的运行时编译。
这就是自渲染会比原生渲染在初始化时慢、占用内存和显存更多的原因。
自渲染问题2:原生混合渲染
在自渲染系统创建好后,渲染内置组件(如view、text)的速度,取决于渲染系统的代码实现性能。 此时原生渲染和自渲染,谁快谁慢是不一定的,取决于谁的代码写的更好。
不得不说,flutter的渲染内置组件代码写的非常优秀,尤其是在Android和鸿蒙上,部分组件的渲染速度比原生的同功能组件要快。
但注意这里有2个前提:
- 自渲染系统已经初始化完毕
- 渲染的是自渲染系统的内置组件
一旦要绘制平台原生组件(flutter中叫platform View),就不是这回事了。
很多系统组件、三方sdk,用的是原生渲染,这是无法改变的。
- 系统组件里有iOS的液态玻璃,鸿蒙的沉浸光感,还有弹出的软键盘也是原生渲染。
- 三方SDK中,比如信息流广告等都是原生的。如果这些组件是flutter的dart语言实现的,那就可以和 flutter UI 很好的融合,但现实中这很难做到。
- 还有一些组件不是原生渲染,但也不是flutter渲染,而是有自己的独立渲染,比如webview、video、map地图、camera、直播,这些也融不到flutter的渲染管线中,统一被纳入到platform view中。
当一个界面同时要渲染多套不同管线的内容时,合成问题非常多。
单一渲染体系主要是性能问题;多套渲染体系混合以后,问题就变成了 Composition、Synchronization、Input、Transform 和 Semantics 等系统级问题。
flutter在原生混合渲染上也在持续优化,推出各种不同思路的方案,但官方文档只能列出不同方案的优劣权衡,没有完美方案。本质是多套渲染管线难以调和的问题。
1. 层叠合成
假设有这样的页面:
Flutter Widget
↓
半透明弹层
↓
Native WebView
如果所有内容属于同一个渲染体系,渲染系统知道每一层的位置、透明度和绘制顺序,可以统一处理。
但如果上面是 Flutter 自己绘制的内容,下面是原生view,那么问题就复杂了。
尤其是:
- opacity
- blur
- backdrop blur
- mask
- clipping
- transform
- shadow
- material effect
这些效果都依赖正确的前后层关系。
如果来回穿插多层,为了正确合成,要处理很多事情,性能问题就会更加严重。
正常情况下,一个渲染系统内部不同组件层级和透明度,是在渲染系统的渲染线程里统一计算的,动态变更也没问题。
flutter的渲染系统,内部计算自己的widget的层级和半透明关系没问题,但和原生view混合在一起计算层级和半透明,真是难为它了,只能上各种黑科技。
为了处理不同渲染系统的合成,flutter要做很多事情。 所以如果一个界面没有platform view,性能有时能超过原生渲染。 但一旦有带有层级的platform view,性能就不行了。
iOS的液态玻璃效果,被开发者吐槽精准打击 flutter 和 compose-multiplatform iOS版。
这个透明效果与众不同,动态材质、手势变化、光照形变都非常复杂,Apple并未公开其算法。 原生开发可以直接用iOS提供的API,但自渲染很难精准模拟,也达不到系统的省电效果。
更麻烦的是合成,如果液态玻璃下面,一会展现自渲染的组件、一会展现原生或其他渲染方式的platform view,实现起来各种兼容性问题,高性能更不可能。
那开发者不在界面上使用液态玻璃效果是否就能规避此问题?iOS系统有很多隐密的坑,输入框长按的上下文菜单也是液态玻璃的,这个菜单在透显自渲染和自渲染框架下的原生视图都有问题。
注:uni-app x 蒸汽模式,提供了内置的液态玻璃组件,使用系统原生能力,就不会有完全自渲染带来的问题。可以在iOS 26+设备上体验hello uni-app x。
2. 滚动同步
flutter的滚动,是自己模拟实现的,并非系统原生滚动。
在flutter滚动容器中,如果插入一个原生view,比如信息流广告或视频:
Flutter List
├── Flutter Cell
├── Flutter Cell
├── Native Advertisement
├── Flutter Cell
└── Flutter Cell
Flutter滚动容器的滚动位置由 Flutter 管理。
但是中间的原生view,是另一条渲染管线上的,它的排版布局不是flutter的。
前端同学,可以理解下flutter在web上的做法,在canvas上创建了一套自己的渲染管线,滚动也是自己模拟的,此时需要在flutter的列表中插入信息流广告,但这个广告是一个div组件, 如何把这些div,按正确的位置,显示在canvas里的某处,并且随着canvas内部的模拟滚动而移动div的位置?
如果 Flutter 和 原生内容不能在正确的时机提交到同一帧,用户就可能看到位置不同步、抖动或者短暂错位。
保持同步。这不仅是简单地修改一个坐标。还涉及:
- Scroll position
- frame timing
- input
- composition
- clipping
- visibility
- nested scrolling
为了维持滚动同步,flutter需要多做不少事情来处理各种情况,这造成了性能下降。
并且在高渲染压力下,仍然可能会错帧。
这里并没有否认flutter的努力,它在高版本利用OS的新API做了一定优化,但只是缓解,本质不变。
3. 动画联动
自渲染和原生渲染,走在不同的渲染管线上。 如果需要一个flutter widget 需要和一个 platform view 做联动动画,甚至多个不同组件需要联动动画,难以流畅衔接。
- 如果不同渲染系统的组件是嵌套关系,它们联动时遇到的问题类似滚动同步,都需要解决在动画过程中保持同一帧提交的问题,否则就会错位。
- 如果不同渲染系统的组件是衔接关系,比如让一个原生view做动画,把一个widget顶飞,2个组件可能叠在一起或者中间有缝隙。
软键盘动画是很多开发者未预见的坑。因为它不是flutter的 platform view,但软键盘也是属于原生渲染管线的。
软键盘弹起时有一套原生动画,界面上的widget如想跟着动,在不同平台有各自的坑。
基于手势的动画也是难点,手势事件同时传递给自渲染和原生渲染,并且让各自的区域都跟随手势做动画并衔接,性能不佳且复杂动画很容易错帧。
4. 事件冲突
原生和自渲染不仅有两套渲染体系,还存在两套事件体系。
Flutter 有自己的:
- Hit Test(根据手势坐标判断点到了那个组件)
- Gesture Arena
- Pointer Event
- Gesture Recognizer
Native UI 也有自己的:
- Hit Test(根据手势坐标判断点到了那个组件)
- Touch Event
- Gesture Recognizer
- Gesture Conflict Resolution
flutter和原生,都要各自独立的处理手势事件,点击、缩放、双击、平移、长按等。hit test、冒泡、协商各自独立。
手指在屏幕上滑过多个区域,有的是自渲染、有的是原生渲染,或者多指触摸有的在自渲染区域、有的在原生渲染区域,都会引发事件冲突。
还有手势协商,本来系统原生是有手势协商概念的,可以每个组件吃掉一部分手势余量。 但2套渲染管线,难以高性能协商,需要跨语言、甚至可能跨线程协商,麻烦很多,而且协商的不够快就可能造成渲染异常。
很常见的是嵌套滚动, 比如滚动容器中内嵌了webview组件。在原生渲染中,webview滚动到头后,可以触发原生的父滚动容器的继续滚动。它们直接还可以协商谁吃掉多少滚动余量。
但让flutter的滚动容器和系统webview滚动协商,就难以流畅运行了。
5. Transform形变
如果 Flutter 对一个区域进行了形变处理:
- Scale
- Rotate
- Translate
- Clip
- Perspective
然后这个区域里面又有原生view,就需要把 Flutter 的 transform 正确传递给原生的合成系统。
否则就可能出现flutter渲染的内容正确形变了,而原生view没变或错位。
反之也一样。
除了platform view,也有一个开发者难以预料的原生view会造成问题,就是输入框中长按然后左右移动时放大镜。
这个放大镜需要对下面的内容实时放大和位移,并以显示当前光标的具体位置。 这本来就很麻烦了,更麻烦是一旦此时实时在一套渲染中又做了形变操作,另一套渲染系统并不知道,就会错位。
6. 综合冲突
上面5个混合渲染问题,可能在一个界面中组合出现,那就会造成更多兼容性问题和更差的性能。
UI一致性
有一个广泛流传的讹传。自渲染,是不是不同平台的UI一致性是严格一样的?原生渲染,是不是就无法保证不同平台的一致性?
首先,自渲染也有不一致
真正开发过渲染引擎的人才知道,自渲染在不同平台要做很多原生适配工作。
- 滚动的阻尼算法、边缘的回弹效果,iOS、Android、鸿蒙,都是不一样的
为了和原生应用贴近,flutter等自渲染框架需要查看开源系统的相关算法,或者推测闭源系统的相关算法,在不同平台进行模拟。
一旦OS系统升级,修改了相关算法,flutter就得尽快更新,开发者也得尽快更新内嵌的flutter sdk。
Android有很多ROM厂商,如果有ROM厂商自己调整了滚动和回弹算法,flutter是难以适配这些个性化ROM的。
在一个App里,如果部分界面使用flutter渲染,这个问题会尤其严重,使用者会感觉到这个界面的滚动和回弹有点怪。
- 输入法适配
flutter的输入框,并不是系统的输入框控件,那个闪烁的光标是flutter引擎自己绘制的,为了让光标的大小、颜色、闪烁频率和系统保持一致,flutter都需要做平台适配。
输入框点击后,又会有更多问题
- 如何弹出原生渲染的软键盘?
- 软键盘上输入的字或快捷短语如何上到flutter渲染的界面上?
- 原生渲染的长按上下文菜单如何处理?
- 长按的放大镜如何正确显示放大内容?
- 自动填充、语法校验这些原生已经存在的功能如何重新实现?
这都是flutter需要在每个平台都自行写代码处理的。这些代码,也都在消耗系统资源。
flutter和系统预置输入法的适配一般没有问题。 而三方输入法如果实现的很规范,也不会有问题,但仍然可能出现三方输入法在系统输入框下正常,但在flutter的输入框中异常的情况。 这很大程度上取决于三方输入法在发版时是否同时测试了flutter输入框的兼容性。
- GPU适配
现代 Flutter 已经通过 Impeller 等方案显著改善了渲染后端的一致性和着色器问题。
但 Android 设备仍然存在大量 GPU 和驱动差异。
尤其是老设备、不同 GPU 厂商以及不同 Android 版本之间,图形能力并不完全一致。
因此,自渲染引擎依然需要进行大量兼容性测试。
也存在在某些边缘设备上GPU适配有问题的可能。
其次,原生渲染也并非效果不一致。
原生渲染效果在各平台不一致,这是react native的一些内置组件的特点,并不是所有原生渲染系统的共性。
造成这种差异的核心,不是原生渲染系统的问题,而是原生组件的实现方式问题。
flutter的widget,是一套dart代码编写的。而react native的不同组件,几乎都是各平台原生语言写的。
在 uni-app x 蒸汽模式中,通过相同的组件代码,实现了原生渲染下的UI一致性。这些组件使用跨平台的c++和uts语言编写。(并不要求开发者使用uts,开发者可使用js/ts)
这种方式,即保障了跨平台的严格一致性,又大幅提升了渲染性能。
同时 uni-app x 中使用系统原生组件也是无缝的,比如 uni-app x 内置的 glass-effect-view组件,就是iOS原生的液态玻璃组件。
总结
几种不同的架构总结到下表中:
| 架构 | 渲染管线 | 组件和排版 | 典型代表 |
|---|---|---|---|
| 经典原生 | OS | OS native | UIKit / Android View |
| 原生渲染的框架UI | OS | 框架 | Compose UI Android版 / uni-app x 蒸汽模式 |
| 自渲染 | 框架 | 框架 | Flutter 和 compose-multiplatform iOS版 |
我们钦佩flutter团队的工程能力和代码水平,尤其是在GPU着色器上的极致运用。
但如果这个世界出现了一个跨平台引擎:
- 渲染速度更快
- UI一致性很好
- 没有自渲染的初始化和混合渲染顽疾
- 除了iOS和Android,还能跨好更多平台,比如web、小程序、鸿蒙
那对于开发者,是一个更完美的方案。
uni-app x 蒸汽模式,正是基于这种思路设计的,并且成功实现了上述目标。
在之前公布的benchmark中显示, uni-app x 蒸汽模式在渲染大量view和text时,性能是原生同类组件的2~3倍。 在长列表、canvas、rich-text等各项测试中也展现出强于原生或其他跨平台相同功能组件的性能表现。
详见:
一些人可能难以相信 uni-app x 蒸汽模式的渲染性能这么快,但解释 uni-app x 渲染更快的原因,需要更多单独长文,本文主要讲述的是原生渲染和自渲染的原理和优劣。
如果开发者对为什么渲染快的原因感兴趣,可以阅读 uni-app x 蒸汽模式的文档