深入解析ACE UI框架,带你看懂UI渲染流程 精华
UI 框架简介以及业界发展趋势
UI,即用户界面,主要包含视觉(比如图像、文字、动画等可视化内容)以及交互(比如按钮点击、列表滑动、图片缩放等用户操作)。UI框架,则是为开发UI而提供的基础设施,比如视图布局,UI组件,事件响应机制等。
从操作系统平台支持方式来看,UI框架一般可分为原生UI框架和跨平台UI框架两种。
1.原生UI框架。 一般是指操作系统自带的UI框架,典型的例子包括iOS的UI Kit,Android的View框架等。这些UI框架和操作系统深度绑定,一般只能运行在相应的操作系统上。功能,性能,开发调测等方面和相应的操作系统结合较好。
2.跨平台UI框架。 一般是指可以在不同的平台(OS)上运行的独立的UI框架。典型例子包括HTML5以及基于HTML5延伸出来的前端框架React Native, 以及Google 的Flutter等。跨平台UI框架的目标是代码只需一次编写,经过少量修改甚至不修改,可以部署到不同的操作系统平台上。当然,实现跨平台也是有代价的,由于不同平台存在差异性(比如UI的呈现方式差异,API差异等等),导致UI框架本身的架构实现,以及和不同平台的融合都有不小的挑战。
从编程方式上来看,UI框架一般可分为命令式UI框架和声明式UI框架两种:
1.命令式UI框架,过程导向 ——告诉“机器”具体步骤,命令“机器”按照指定步骤去做。比如Android原生UI框架(View框架)或iOS的UIKit,提供了一系列的API让开发者直接操控UI组件-比如定位到某个指定UI组件,进行属性变更等。这种方式的优点是开发者可以控制具体的实现路径,经验丰富的开发者能够写出较为高效的实现。不过这种情况下,开发者需了解大量的API细节并指定好具体的执行路径,开发门槛较高。具体的实现效果上,也高度依赖开发者本身的开发技能。另外,由于和具体实现绑定较紧,在跨设备情况下,灵活性和扩展性相对有限。
2.声明式UI框架,结果导向—— 告诉“机器”你需要什么,“机器”负责怎么去做。比如Web前端框架Vue,或iOS的SwiftUI等,框架会根据声明式语法的描述,渲染出相应的UI,同时结合相应编程模型,框架会根据数据的变化来自动更新相应的UI。
这种方式的优点是开发者只需描述好结果,相应的实现和优化由框架来处理。另外,由于结果描述和具体实现分离,实现方式相对灵活同时容易扩展。不过这种情况下,对框架的要求较高,需要框架有完备的直观的描述能力并能够针对相应的描述信息实现高效的处理。
UI框架是应用开发的核心组成部分。纵观业界UI框架,其主要发展趋势表现为:
从命令式UI往声明式UI发展
比如iOS中的UIKit到SwiftUI, Android中的View到Jetpack Compose。这样可以实现更加直观便捷的UI开发。
UI框架和语言运行时深度融合
SwiftUI,Jetpack Compose, Flutter都利用了各自的语言特性——比如在UI描述方面,SwiftUI中的Swift语言,Jetpack Compose中的Kotlin语言都精简了UI描述语法;在性能方面, Swift通过引入轻量化结构体等语言特性更好的实现内存快速分配和释放,Flutter中Dart语言则在运行时专门针对小对象内存管理做相应优化等。
跨平台(OS)能力
跨平台(OS)能力可以让一套代码复用到不同的OS上,主要是为了提升开发效率,降低开发成本。不过这里面也有一系列的挑战,比如运行在不同平台上的性能问题,能力和渲染效果的一致性问题等。业界在这方面也是不断的演进,主要有几种方式:
1.JS/Web方案。 比如HTML5利用JS/Web的标准化生态,通过相应的Web引擎实现跨平台目标;
2.JS+Native混合方式。 比如React Native、Weex等,结合JS桥接到原生UI组件的方式实现了一套应用代码能够运行到不同OS上;
3.平台无关的UI自绘制能力+新的语言。 比如Flutter,整个UI基于底层画布由框架层来绘制,同时结合Dart语言实现完整的UI框架。Flutter从设计之初就是将跨平台能力作为重要的竞争力去吸引更多的开发者;
另外,有趣的是,部分原生开发框架也开始往跨平台演进。比如,Android原生的开发框架Jetpack Compose也开始将跨OS支持作为其中的目标,计划将Compose拓展到桌面平台,比如Windows,MacOS等。
然而,随着智能设备的普及,多设备场景下,设备的形态差异(屏幕大小、分辨率,形状, 交互模式等),设备的能力差异(从百K级内存到G级内存设备等)以及应用需要在不同设备间协同,这些都对UI框架以及应用开发带来了新的挑战。
为什么要重新设计一个ACE UI框架
ACE全称是Ability Cross-platform Environment (元能力跨平台执行环境)。是华为设计的应用在HarmonyOS上的UI框架。ACE UI框架结合HarmonyOS的基础运行单元Ability,语言和运行时,以及各种平台(OS)能力API等共同构成HarmonyOS应用开发的基础,实现了跨设备分布式调度,以及原子化服务免安装等能力。
ACE提供两种开发语言以供不同开发者进行选择,分别为Java语言和JavaScript语言,其中Java仅支持在内存较大的设备上使用如大屏、手机、平板等设备使用,而JavaScript支持在百K级到G级设备上使用。
目前主流的UI框架都有各自的不足。另外,在多设备场景下,由于不同的设备形态以及设备能力的巨大差异,目前业界还没有任何一个UI框架能够较好的解决相应的问题。
而ACE UI框架的整体设计思路是:
1. 建立分层机制,引入高效的UI基础后端,并能够与OS平台解耦,形成一致化的UI体验
2. 通过多前端的方式扩展应用生态,并结合声明式UI,在开发效率上持续演进
3. 框架层统一结合语言以及运行时,分布式,组件化设计等,围绕跨设备,进一步提升体验
ACE将应用的UI界面进行解析,通过创建后端具体UI组件、进行布局计算、资源加载等处理后生成具体绘制指令,并将绘制命令发送给渲染引擎,渲染引擎将绘制指令转换为具体屏幕像素,最终通过显示设备将应用程序转换为可见的界面效果展示给用户。
ACE UI框架的整体架构如下图所示,主要由前端框架层、桥接层、引擎层和平台抽象层四大部分组成,下面我们一一介绍。
::: hljs-center
:::
1 前端框架层
该层主要包括相应的开发范式(比如主流的类Web开发范式),组件/API,以及编程模型MVVM(Model-View-ViewModel)。其中Model是数据模型层,代表了从数据源读取到的数据;View是视图UI层,通过一定的形式把系统中的数据向用户呈现出来;ViewModel: 视图模型层,是数据和视图之间的桥梁。它双向绑定了视图和数据,使得数据的变更能够及时在视图上呈现,用户在视图上的修改也能够及时传递给后台数据,从而实现数据驱动的UI自动变更。
开发范式可以扩展,来支持生态发展。不同的开发范式可以统一适配到底层的引擎层
2 桥接层
该层主要是作为一个中间层,实现前端开发范式到底层引擎(包括UI后端,语言&运行时)的对接。
3 引擎层
该层主要包含两部分:UI后端引擎和语言执行引擎。
1.由C++构建的UI后端引擎,包括UI组件、布局视图、动画事件、自绘制渲染管线和渲染引擎 。
在渲染方面,我们尽可能把这部分组件设计得小而灵活。这样的设计,为不同前端框架提供灵活的UI能力,这部分通过C++组件组合而成。通过底层组件的按需组合,布局计算和渲染并行化,并结合上层开发范式实现了视图变化最小化的局部更新机制,从而实现高效的UI渲染。
除此之外,引擎层还提供了组件的渲染管线、动画、主题、事件处理等基础能力。目前复用了Flutter引擎提供基础的图形渲染能力、字体管理、文字排版等能力,底层使用Skia或其他图形库实现,并通过OpenGL实现GPU硬件渲染加速。
在多设备UI适配方面,通过多种原子化布局能力(自动折行、隐藏、等比缩放等),多态UI控件(描述统一,表现形式多样),以及统一交互框架(不同的交互方式归一到统一的事件处理)来满足不同设备的形态差异化需求。
另外,引擎层也包含了能力扩展基础设施,来实现自定义组件以及系统API的能力扩展
2.语言&运行时执行引擎。 可根据需要切换到不同的运行时执行引擎,满足不同设备的能力差异化需求。
4 平台抽象层
该层主要是通过平台抽象,将平台依赖聚焦到底层画布,通用线程以及事件机制等少数必要的接口上,为跨平台打造了相应的基础设施,并能够实现一致化UI渲染体验。
相应的,配套的开发者工具(HUAWEI DevEco Studio)结合ACE UI的跨平台渲染基础设施,以及自适应渲染,可做到和设备比较一致的渲染体验以及多设备上的UI实时预览。
另外,ACE UI框架还设计了可伸缩的架构,即前端框架、语言运行时、UI后端等都做了解耦,可以有不同的实现。这样就具备可部署到百K级内存的轻量级设备的能力,如下所示:
::: hljs-center
:::
在ACE UI的轻量化实现中,通过前端框架核心下沉C++ 化,减小JS部分的内存占用,使用C++ 进行更为严格的内存分配与管理,并且采用更为轻量的JS引擎,UI部分采用轻量的UIKit并结合轻量图形引擎,达到内存非常轻量占用的目标。接口能力保证是全量能力的子集,这样可以保证轻量化设备上可执行的应用,可以在更高等级的设备上执行,而无需重新开发。这也就是采用ACE JS开发范式的优势所在,采用统一的开发范式进行应用开发后,开发者无需关心具体运行时的前端框架、JS引擎与后端UI组件,根据运行平台不同,采用最佳的模块,保障了应用在不同平台都可具有最佳的运行性能。不过由于轻量级设备上的资源限制, 所支持的API 能力相对有限,但公共部分的API是完全共通的。
综上所述,ACE UI框架具备如下特点:
- 支持主流的语言生态 – JavaScript;
- 支持类Web开发范式, MVVM机制。并在架构上可支持多前端开发范式,进一步简化开发;
- 通过统一的UI后端,实现高性能以及跨平台一致化的渲染体验;
- 通过多态UI、原子化布局、统一交互,以及可伸缩的运行时设计,进一步降低不同设备形态下的UI开发门槛,并能够通过统一的开发范式,实现一套代码跨设备部署(覆盖百K级到G级内存设备)。
ACE UI框架渲染流程解析
接下来我们通过一个手机侧ACE JS应用渲染流程的完整流程来介绍ACE UI框架的具体渲染技术。
1 线程模型
ACE JS应用启动时会创建一系列线程,形成独立的线程模型,以实现高性能的渲染流程。
每个ACE JS应用的进程,包含唯一一个Platform线程和若干后台线程组成的异步任务线程池:
- Platform线程: 当前平台的主线程,也就是应用的主线程,主要负责平台层的交互、应用生命周期以及窗口环境的创建
- 后台线程池: 一系列后台任务,用于一些低优先级的可并行异步任务,如网络请求、Asset资源加载等。除此之外,每个实例还包括一系列专有线程
- JS线程: JS前端框架的执行线程,应用的JS逻辑以及应用UI界面的解析构建都在该线程执行
- UI线程: 引擎的核心线程,组件树的构建以及整个渲染管线的核心逻辑都在该线程:包括渲染树的构建、布局、绘制以及动画调度
- GPU线程: 现代的渲染引擎,为了充分发挥硬件性能,都支持GPU硬件加速,在该线程上,会通过系统的窗口句柄,创建GPU加速的OpenGL环境,负责将整个渲染树的内容光栅化,直接将每一帧的内容渲染合成到该窗口的Surface上并送显
- IO线程: 主要为了异步的文件IO读写,同时该线程会创建一个离屏的GL环境,这个环境和 GPU线程的GL环境是同一个共享组,可以共享资源,图片资源解码的内容可直接在该线程上传生成GPU纹理,实现更高效的图片渲染
每个线程的作用,在后续的渲染流程中还会进一步提到。
2 前端脚本解析
ACE UI框架支持不同的开发范式,可以对接到不同的前端框架上。
以类Web开发范式为例,开发者开发的应用,通过开发工具链的编译,会生成引擎可执行的Bundle文件。应用启动时,会将Bundle文件在JS线程上进行加载,并且将该内容作为输入,供JS引擎进行解析执行,最终生成前端组件的结构化描述,并建立数据绑定关系。例如包含若干简单文本的应用会生成类似下图的树形结构,每个组件节点会包含该节点的属性及样式信息。
::: hljs-center
:::
3 渲染管线构建
::: hljs-center
:::
如上图,前端框架的解析后,根据具体的组件规范定义向前端框架对接层请求创建ACE渲染引擎提供的组件。
前端框架对接层 通过ACE引擎层提供的Component组件实现前端组件定义的能力。Component是一个由C++ 实现的UI组件的声明式描述,描述了UI组件的属性及样式,用于生成组件的实体元素。每一个前端组件会对接到一个Composed Component,表示一个组合型的UI组件,通过不同的子Component组合,构造出前端对应的Composed组件。每个Composed组件是前后端对接的一个基础的更新单位。
以上面的前端组件树为例,每个节点会使用一组Composed组件进行组合描述,对应关系如下图,该对应关系只是一个示例,实际场景的对应关系可能会更复杂。
::: hljs-center
:::
有了每个前端节点对应的Component,就形成了一个完成Page的描述结构,通知渲染管线挂载新的页面。
在Page挂载之前,渲染管线已经提前创建了几个关键的核心结构,Element树和Render树:
Element树, Element是Component的实例,表示一个具体的组件节点,它形成的Element树负责维持界面在整个运行时的树形结构,方便计算局部更新算法。另外对于一些复杂的组件,在该数据结构上会实现一些对子组件逻辑上的管理。
Render树, 对于每个可显示的Element都会为其创建对应的RenderNode,它负责一个节点的显示信息,它形成的Render树维护着整个界面的渲染需要用到的信息,包括它的位置、大小、绘制命令等,界面后续的布局、绘制都是在Render树上进行的。
当应用启动时,最初形成的Element树只有几个基础的几节点,一般包括root、overlay、stage,分别作用如下:
RootElement: Element树的根节点,仅仅负责全局背景色的绘制
OverlayElement: 一个全局的悬浮层容器,用于弹窗等全局绘制场景的管理
StageElement: 一个Stack容器,作为全局的“舞台”,每个加载完成的页面都要挂载到这个“舞台”下,它管理应用的多个页面之间的转场动效等。
在Element树创建的过程中,也会同步的把Render树也创建起来,初始状态如下图:
当前端框架对接层通知渲染管线准备好了页面,在下一个帧同步信号(VSync)到来时,就会在渲染管线上进行页面的挂载,具体流程就是通过Component来实例化生成Element的过程,创建成功的Element同步创建对应的RenderNode:
如上图所示,目标要将整个Page的Component描述挂载到StageElement上,如果当前Stage下还未有任何Element节点,就会递归逐个节点生成Component对应的Element节点。对于组合类型的ComposedElement,则同时会把Element的引用记录到一个Composed Map中,方便后续更新时快速查找。对于可见类型的容器节点或渲染节点,则会创建对应的RenderNode,并挂在Render树上。
当生成了当前页面的Element树和Render树,页面渲染构建的完整过程就结束了。
4 布局绘制机制
接下来就进入了布局和绘制的阶段,布局和绘制都是在Render树上进行的。每个RenderNode都会实现自己的布局算法和绘制方法。
布局
布局的过程就是通过各类布局的算法计算出每个RenderNode在相对空间上的真实大小和位置。
如下图所示,当某个节点的内容发生变化时,就会标记自己为needLayout,并一直向上标记到布局边界(ReLayout Boundary),布局边界是重新布局的一个范围标记,一般情况下,如果一个节点的布局参数信息(LayoutParam)是强约束的,例如它布局期望的最大尺寸和最小尺寸是相同的,那么它就可以作为一个布局边界。布局是个深度优先遍历的过程。从布局边界开始,父节点自顶向下将LayoutParam传给子节点,子节点自底向上据此计算得到尺寸大小和位置。
对于每个节点来说,布局分为三个步骤:
① 当前节点递归调用子节点的layout方法,并传递布局的参数信息(LayoutParam),包含了布局期望的最大尺寸和最小尺寸等;
② 子节点根据布局参数信息,使用自己定义的布局算法来计算自己的尺寸大小;
③ 当前节点获取子节点布局后的大小,再根据自己的布局算法来计算每个子节点的位置信息,并将相对位置设置给子节点保存。
根据上述的流程,一次布局遍历完成后,每个节点的大小和位置就都计算出来了,可以进行下一步的绘制。
绘制
同布局一样,绘制也是一个深度遍历的过程,遍历调用每个RenderNode的Paint方法,此时的绘制只是根据布局算出来的大小和位置,在当前绘制的上下文记录每个节点的绘制命令。
为什么是记录命令,而不是直接绘制渲染呢?在现代的渲染引擎中,为了充分使用GPU硬件加速的能力,一般都会使用DisplayList的机制,绘制过程中仅仅将绘制的命令记录下来,在GPU渲染的时候统一转成OpenGL的指令执行,能最大限度的提高图形的处理效率。所以在上面提到的绘制上下文中,会提供一个可以记录绘制命令的画布(Canvas)。每一个独立的绘制上下文可以看作是一个图层。
为了提高性能,这里引入了图层(Layer)的概念。通常绘制会将渲染内容分为多个层进行加速。对于会频繁变化的内容,将其单独创建一个图层,那么这个独立图层的频繁刷新就不必导致其他内容重新绘制,从而达到提升性能并减少功耗的效果,同时还可以支持GPU缓存等优化。每个RenderNode都可以决定自己是否需要单独分层。
如下图所示,绘制流程会从需要绘制的节点中,挑选最近的且需要分层的节点开始,自顶向下的执行每个节点的Paint方法。
对每个节点,绘制分为四个步骤:
① 如果当前节点需要分层,那么需要创建一个新的绘制上下文,并提供可以记录绘制命令的画布;
② 在当前的画布上记录背景的绘制命令;
③ 递归调用子节点的绘制方法,记录子节点的绘制命令;
④ 在当前的画布上记录前景的绘制命令。
一次完整的绘制流程结束后,我们会得到一棵完整的Layer树,Layer树上包含了这一帧完整的绘制信息:包括每一层的位置、transform信息、Clip信息、以及每个元素的绘制命令。下一步就要经过光栅化和合成的过程,将这一帧的内容显示到界面。
5 光栅化合成机制
在上面的绘制流程结束后,会通知GPU线程开始进行合成的流程。
如上图所示,UI线程(UI Thread)在渲染管线中的输出是LayerTree,它相当于一个生产者,将生产的LayerTree添加到渲染队列中。GPU线程(GPU Thread)的合成器(Compositor)相当于消费者,每个新的渲染周期中,合成器会从渲染队列中获取一个LayerTree进行合成消费。
对于需要缓存的Layer,还要执行光栅化生成GPU纹理,所谓光栅化就是将Layer里面记录的命令进行回放,生成每个实体的像素的过程。像素是存储在纹理的图形内存中。
合成器会从系统的窗口中获取当前的Surface,将每个Layer生成的纹理进行合成,最终合成到当前Surface的图形内存(Graphic Buffer)中。这块内存中存储的就是当前帧的渲染结果内容。最终还需要将渲染结果提交到系统合成器中合成显示。系统的合成过程如下图所示:
当GPU线程的合成器完成一帧的合成后,会进行一次SwapBuffer的操作,将生成的Graphic Buffer提交到与系统合成器建立的帧缓冲队列(Buffer Queue)中。系统合成器会从各个生产端获取最新的内容进行最终的合成,以上图为例,系统合成器会将当前应用的内容和系统其它的显示内容,例如System UI的状态栏、导航栏,进行一次合成,最终写入到屏幕对应的帧缓冲区(Frame Buffer)中。液晶屏的驱动就会从缓冲区读取内容进行屏幕的刷新,最终将内容显示到屏幕上。
6 局部更新机制
经过上面1~5的流程,完成了首次完整的渲染的流程,在后续的运行中,例如用户输入、动画、数据改变都有可能造成页面的刷新,如果只是部分元素发生了变化,并不需要全局的刷新,只需要启动局部更新即可。那么局部更新是怎么做到的?下面我们介绍一下局部 更新的流程。
以上图为例,JS在代码中更新了数据,通过数据绑定模块会自动触发前端组件属性的更新,然后通过JS引擎异步发起更新属性的请求。前端组件会根据变更的属性,构建一组新的Composed的补丁(Patch),作为渲染管线更新的输入。
如上图所示,在下一个VSync到来时,渲染管线会在UI线程开始更新的流程。通过Composed补丁的Id,在ComposedMap中查询到对应的ComposedElement在Element树上的位置。通过补丁对Element树进行更新。以ComposedElement为起始,逐层进行对比,如果节点类型一致则直接更新对应属性和对应的RenderNode,如果不一致则重新创建新的Element和RenderNode。并将相关的RenderNode标记为needLayout和needRender。
如上图所示,根据标记需要重新布局和重新渲染的RenderNode,从最近的布局边界和绘制图层进行布局和绘制的流程,生成新的Layer树,只需要重新生成变更RenderNode对应的Layer即可。
如上图所示,接下来,根据刷新后的Layer树作为输入,在GPU线程进行光栅化和合成。对于已经缓存的Layer则不需要重新光栅化,合成器只需要将已缓存的Layer和未缓存或更新的Layer重新合成即可。最终经过系统合成器的合成,就会将新一帧的内容显示。
以上就是一个ACE JS应用的渲染及更新的流程。最后,通过两张流程图回顾一下整体的流程:
了解完ACE JS应用的渲染及更新的流程,如果大家想了解更多关于HarmonyOS UI框架如何解决设备形态差异带来的开发挑战和应用示例,可参考我们之前推出的内容:
ACE UI框架目前的成熟度以及演进
截至目前,ACE UI框架已商用落地了华为运动手表,华为智能手表,华为智慧屏,华为手机,华为平板等一系列产品。使用场景包括日历、出行、健身、实用工具等各类应用,手机-设备碰一碰全品类的应用,以及今年六月份发布的HarmonyOS中各类的服务卡片-图库、相机等。另外,在开发调测方面,开发者工具(HUAWEI DevEco Studio)中也集成了ACE UI框架,支持在PC端(MacOS,Windows)上的开发调测,实时预览(包括实时多设备预览,组件级预览,双向预览等),实现了在PC上和设备上一致的渲染体验。
未来,面向开发者的极简开发,面向消费者的流畅酷炫的体验,以及能够高效在不同设备不同平台上部署,ACE UI框架会继续沿着精简开发和高性能两个方面演进,结合语言更进一步简化开发范式,结合运行时在跨语言交互,类型优化等方面进一步增强性能体验,结合分布式能力将编程模型从MVVM演进到分布式MVVM(Distributed Model-View-ViewModel)等。采用类自然语言的声明式UI描述进行界面搭建,编程语言也进一步开放,未来考虑向TS进行演进,从动效、布局和性能方面进一步提升用户使用体验。
当然,应用生态还会涉及更多的方面,比如三方插件的繁荣,跨OS平台的扩展,更具创新的分布式体验等等。ACE UI框架还很年轻,期待和众多开发者一起,重点围绕着多设备组成的超级终端的新兴场景,不断打磨完善,共同构建领先的应用体验和生态!
目前HarmonyOS的线上开发体验已上架,欢迎大家在线体验。
作者:yuzhiqiang、sunfei、wanglei,华为软件架构工程师
讲解的确实明了
学习ACE UI的好帖
感觉在看一本大作的某一章节,太详细了!