本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
番外篇
在学习完前面几篇关于ArkUI的文章后,我们来学习一篇番外篇,主要讲述鸿蒙发展的历程以及相关跨平台技术的发展,了解ArkUI背后的设计思路以及与跨平台技术的关系
这些年,写过Compose,也了解过Flutte、ReactNative ,但是我在ArkUI中,背后看到了跨平台技术的另一个方向,希望本期文章能够给你不一样的视觉
ArkUI与跨平台UI技术的发展
可能你已经看过很多跨平台的文章了,或者已经接触了很多跨平台的框架,比如ReactNative,Flutter等,但其实跨平台UI永远离不开三个核心,这里我把其称为UI的语言+ UI表示 + 渲染引擎。
UI语言:用来生成UI表示的载体,比如我们通过Java/Kotlin 可以描述View的行为,也可以通过Dart描述Widget的行为,也可以通过 ,JS语言通过ReactNative 描述生成的控件。
UI表示:即描述一个或者多个渲染引擎的抽象。比如表述一个图形的长宽、颜色、父子包含关系等
渲染引擎:渲染引擎广义上来说,具备与底层图形交互能力的且具备图形指令生成的,我们都可以称为渲染引擎。比如OpenGLES。
这里值得注意的是,UI表示与渲染引擎是可以独立的。比如Android开发中,我们即使不使用View,也依旧能够通过OpenGL的能力进行图形渲染,画出自己想要的东西。
而UI的语言,它也可以说是独立的,比如我们可以通过写JS的方式来写Android,也可以通过JS的方式来写“Flutter”,做到这些只是需要我们进一步的修改罢了。
为什么选择ArkTS
在ArkUI的选择的语言最终被主推为ArkTS,这里我们也可以简单认为,抛去华丽的外壳之后,就是“JS”。这里很多人会有一个误区,就是JS那么慢,它能支撑起来APP的运行吗?
一个语言慢不慢,其实并不取决于语言本身,而是取决于它的行为,正常情况下的JS代码,一般是以下图方式运行的。
我们可以看到,很多关键的步骤,比如生成机器码等,都是运行时才会触发的,比如传统JS在网页运行,你是需要花费一定的时间去解析JS文件然后再去真正运行的,这也是为什么大家会有一个固有印象JS很慢的一个原因。而它也有一个好处就是,既然解析代码发生在运行时,我们只需要替换代码文件,就能做到运行时的动态化,这种动态化能力满足了很多明确的需求,比如网页需要经常动态刷新。
但是对于APP来说,运行时的开销往往是不可接受的,比如加载的等候时间就会损失很多用户,这是因为移动端的性质决定的,正常网速好的情况下,用户可能没有感知到网页加载与APP原生的区别,但是如果在一些复杂的环境,比如地铁站这种弱网条件下还去刷新网站,那么这里的体验是非常差的(毕竟很少有人会在地铁站用电脑等pc设备刷网页,但是你一点会带有手机)。同时APP虽然也有动态化的需求,但是对于厂商来说,动态化能力往往也是不可预测的双刃剑,谁也不想一个学习APP第二天就变成了“菠菜”软件。因此对于手机厂商来说,动态化的支持并不是必要的。因此AOT通常代表"Ahead of Time",提前编译好机器码,就是为了解决这种运行时的JIT消耗而产生(AOT也能实现动态化,只不过不是从代码编译角度后生成的角度出发)。
那么JS能不能做到"Ahead of Time"呢?当然是可以的,比如ReactNative的Hermes 引擎就支持直接解析编译好的字节码。字节码是实现Ahead of Time的有利条件,因此,华为创建了一种叫做ABC字节码,通过abc字节码,就能够同时支持高性能JIT与AOT。同样,ArkUI选择的ArkTS,也是往这方面去靠拢,打破固有的js很慢的印象。
ArkTS跟普通我们写的Java/Kotlin 没有太多差别(只要你是机器码,运行速度都基本一样,只是生成的指令有少部分区别罢了,你甚至可以直接在鸿蒙写汇编)。它也是提前生成好了适配鸿蒙系统的机器码,在安装的时候就可以直接安装从而避免JS在运行时解析的损耗,满足一个APP语言的设计需求。ArkUI中的Ark Runtime,也满足了一个基本JS引擎解析的能力,从而为后面构建生态打好了基础。
ArkUI的不得不做出跨平台选择
跨平台技术发展至今,按照渲染引擎 与 UI表示的不同组合,主要分为三个部分,
-
以网页为核心的webkit
-
以系统Native渲染引擎为主,具备以JS编写的UI表示代表ReactNative
-
以自绘制引擎(Flutter Engine)为主,以Dart编写的UI表示的Flutter
以上三种选择,ArkUI最终是在旧的道路上选择,还是另谋出路呢?
JS引擎与Webkit渲染
网页的发展史比移动端APP要长,同时也更加成熟,因为手机系统本身就默认携带JS解析引擎的浏览器或者JS引擎解析能力的基础控件,WebView。JS引擎系统内置有很多,比如V8,JavaScriptCore等。移动端手机本身就可以携带浏览器以及JS解析引擎,基于以上特点,开发者其实可以很快把前端的HTML文件进行复用,达到在Android/iOS身上,因此这是一种天然的跨平台方式。JS侧与Native侧同样可以采用JS Bridge的方式,进行交互。比如在Android,其实本质上就是C++与JNI交互。C++的引擎可以直接执行javascript的解析,得到结果,同样可以通过JNI调用起Java环境的方法。
但是缺点也很明显,该有的损耗还是在,比如WebView的初始化,以及网页的加载,下载js文件网页文件等,因此这个方向的优化大部分在WebView的预热,或者拦截URL解析,采取本地资源替换远端下载的资源(俗称“离线包”)等用于解决加载问题,同时渲染侧还是依照HTML的方式进行渲染。大致的步骤分为以下几大步,构建 DOM 树,解析 CSS 文件,构建渲染树,webkit绘制 UI。
JS引擎与Native渲染
这类的方式代表是ReactNative,因为在前端在发展时长或者成熟度上,其实成熟度已经很高了,同时大部分公司也会配备前端开发人员,React就是Web开发中很成熟的框架之一。React中编写的代码,其实就可以作为UI的表示了,它具备完整的树形结构(比如组件之间的依赖关系,父子组件的关系,对于一个组件的表述比如大小,长宽,颜色),因此如果我们能够把这部分已有的UI表示(见开头定义)利用起来,那么将减少不少成本。因此ReactNative诞生了,它充分利用JS引擎的性质,即生成UI表示的能力,进行了进一步抽象。在渲染的时候,通过把一系列的UI表示转化为Native View的表示,利用了Native的渲染能力进行复用。
比如你写一个ReactNative的Text,ReactNative 可以简单理解为帮你把这些TS/JS代码通过之前定义好的映射关系变成了原生的写法
import React, {useState} from 'react';
import {Text, StyleSheet} from 'react-native';
const TextInANest = () => {
const [titleText, setTitleText] = useState("Bird's Nest");
const bodyText = 'This is not really a bird nest.';
const onPressTitle = () => {
setTitleText("Bird's Nest [pressed]");
};
return (
<Text style={styles.baseText}>
<Text style={styles.titleText} onPress={onPressTitle}>
{titleText}
{'\n'}
{'\n'}
</Text>
<Text numberOfLines={5}>{bodyText}</Text>
</Text>
);
};
const styles = StyleSheet.create({
baseText: {
fontFamily: 'Cochin',
},
titleText: {
fontSize: 20,
fontWeight: 'bold',
},
});
export default TextInANest;
ReactNative的Text其实就转化为了原生的Text,比如Android平台的ReactTextView。只是这些针对React的JS UI表示会被框架内部聚合,处理成一个个针对原生View属性的设置
public class ReactTextView extends AppCompatTextView implements ReactCompoundView {
private static final ViewGroup.LayoutParams EMPTY_LAYOUT_PARAMS =
new ViewGroup.LayoutParams(0, 0);
// https://github.com/aosp-mirror/platform_frameworks_base/blob/master/core/java/android/widget/TextView.java#L854
private static final int DEFAULT_GRAVITY = Gravity.TOP | Gravity.START;
private boolean mContainsImages;
private int mNumberOfLines;
private TextUtils.TruncateAt mEllipsizeLocation;
private boolean mAdjustsFontSizeToFit;
private float mFontSize;
private float mMinimumFontSize;
private float mLetterSpacing;
private int mLinkifyMaskType;
private boolean mNotifyOnInlineViewLayout;
private boolean mTextIsSelectable;
private boolean mShouldAdjustSpannableFontSize;
private ReactViewBackgroundManager mReactBackgroundManager;
private Spannable mSpanned;
....
换算成
ReactTextView.setText
ReactTextView.setColor
ReactTextView.setFontSize
举一个很简单的例子就知道了,它其实就是自定义View,你可以在XML文件中写很多自定义的属性,然后View构建时解析这些xml属性并赋值的行为与ReactNative做的事情本质都是一样的,就是针对UI的表示进行替换。
通过直接对接到手机系统本身提供的View,你可以不用关心渲染引擎的处理,因为不同系统会根据自己提供的UI抽象进行绘制,这一步跟原生开发是一样的。ReactNative的跨平台就体现在它提前定义好了一系列的自定义原生控件,然后只需要更具UI表示不同改变自定义原生控件的属性即可。
这种思想也是跨平台方案中最主要也是运用最广的思想,你可以自定义很多自己的原生控件,你也可以使用其他的JS引擎,只要能够产出UI表示就能够适配控件。甚至你还可以抛弃JS,定义自己的语言,比如你自己写一个文件:
<pika image width:100 height:100>
我们在原生中需要读取这个文件,遇到pika开头的下一个关键字,比如image,你就new 一个Image控件出来一个意思,只需要处理好映射关系即可。当然,JS引擎提供了一种额外的能力,就是JS与Native的通信,其实本质就是C++与上层语言的通信,比如C++与Java。因为大部分JS引擎是C++/C写的,引擎本身写了关于JS的解析逻辑之外,也可以通过一些接口,比如JNI调用起Java提供的方法。
因此你也可以用C/C++去编写自己所谓的“引擎”,让其能力更加充分。
<pika image width:100 height:100 click:java function:x:f>
比如解析到以java function开头的,我就利用JNI的能力去java中找x类,f方法 ,这些都是可以做到的,只是实现起来要复杂,解析的协议要合适,同时现有的JS引擎与JS语言更成熟没必要自己造轮子。很多大厂探索的跨平台方法,其实思想都是大差不差,本质上还是UI表示与适配Native View 从而复用Native渲染引擎,比如可以适配鸿蒙的Taro,Compose Multiplatform 。至于UI表示的生成,其实可以有很多,并不只局限在JS,你可以通过适配Compose runtime与鸿蒙组件,用Compose写鸿蒙,你可以用Yoga,用js写Flutter,只要你能处理好UI表示的适配(虽然处理可能会很烦碎),这点是希望大家知道的。
Dart VM + Flutter引擎
现代Flutter,其实包括Dart编写Widget 与Flutter引擎,这里的Flutter引擎包含了dart相关的,比如framework.dart ,object.dart 等相关的核心渲染实现,还有C++编写的Flutter Engine等。后面我都以“Flutter引擎”作为这些的表述的集合,并不是只包含Flutter Engine。一般Flutter引擎指的是(由C++编写的Flutter Engine),但是这里笔者有意把render相关的放在了Flutter引擎这个大类中,读者们可以注意一下。
Flutter 借助平台相关的渲染View,比如Android 中的SurfaceView,比如FlutterSurfaceView,或者TextureView这些能够提供渲染环境的View,比如提供OpenGLES环境,能够把生成的绘制指令直接在这些View上执行,从而达到自渲染的目的。这些绘制指令生成依赖于由Dart编写的Widget通过层层转化,比如三棵树后,通过抽象的Layer生成这些绘制指令,它并不与原生的普通View交互,比如Flutter的文本可以直接生成满足OpenGLES的文本绘制一系列执行,从而画出想要的东西。
OpenGLES我们也是可以直接使用的,比如我们定义好SurfaceView,就可以在里面进行编译(OpenGL Shading Language) 全称OpenGL着色语言,编写顶点着色器与片元着色器,就能够开心的用点、线、三角形 去画出我们想要的UI(手动狗头)。虽然写着色器语言绘制出图形是比较复杂的,但是我们依旧是可以在不使用普通View的基础上去画出我们想要的东西的,比如我们直接相机给到的流绘制场景,不使用原生组件情况下*
顶点着色器
layout(location = 0) in vec4 a_Position;
layout(location = 1) in vec2 a_CameraTexCoord;
// The virtual scene texture coordinate is unused in the background shader, but
// is defined in the BackgroundRenderer Mesh.
layout(location = 2) in vec2 a_VirtualSceneTexCoord;
out vec2 v_CameraTexCoord;
void main() {
gl_Position = a_Position;
v_CameraTexCoord = a_CameraTexCoord;
}
片元着色器
#extension GL_OES_EGL_image_external_essl3 : require
precision mediump float;
uniform samplerExternalOES u_CameraColorTexture;
in vec2 v_CameraTexCoord;
layout(location = 0) out vec4 o_FragColor;
void main() { o_FragColor = texture(u_CameraColorTexture, v_CameraTexCoord); }
外部调用忽略,大家可以找到很多这种case
回到正题,Flutter其实在UI表示上与渲染引擎上都采取了不一样的方式,通过Dart生成的UI表示能够天然的对接Flutter渲染引擎。
虽然如此,Dart可以说并不是一个成本最低的UI表示,同时Flutter Engine 本身其实也依赖了很多由Flutter本身的产物,这些产物都是由framework.dart 或者object.dart 产生的,比如UI的刷新逻辑。Flutter中,Dart的UI表示与渲染引擎其实结构上不存在耦合,但是产物上存在耦合,怎么理解呢?你必须要用Dart去写UI表示,比如Widget的产物LayerTree才能够被FlutterEngine 所识别产生一系列的Scene,感兴趣可以看 scene_builder。
作为Flutter的一部分,FlutterEngine的设计非常优秀,也经过了这么多年的发展,于是开发者们也有想过把FlutterEngine作为单独一部分,把UI表示层替换成非Dart编写,是不是也可以呢?
因此,一个奇思妙想出现了,如果我们能够复用Flutter Engine(渲染引擎部分),同时把Dart替换掉,比如用其他语言也能够完美适配Flutter Engine,岂不是妙哉?比如用TS写Flutter,用Kotlin写Flutter?可不可行呢?
没错,可行,它就是ArkUI的技术出发点!
JS引擎 + Flutter 引擎 + 多引擎
笔者阅读了OpenHarmony中对于ArkUI大量的Commit,得出ArkUI的架构布局历程,属于笔者自己的观点,当然华为官方的本身对ArkUI的设计其实还未公开,大家可以顺着笔者的思路去看待ArkUI的设计历程~
ArkUI 无论是发布的时机,还是华为对其自身的定位,都是一步棋局。我们开头有讲到,ArkUI不得不做出跨平台选择,这是必须的选择。在鸿蒙系统发布早期,并没有一个可以良好循环的生态,因此想要发展自己的系统,那么必须要在已经的系统上一步步做出变化才行,因此必须要能够把ArkUI在Android上面跑通。
而ArkUI作为鸿蒙的UI框架,底层上必须要能够做到同时对接Android系统,同时也要能够对接以后自定义系统的做出准备,因此架构上的选择是一个大难题。我们上面学习了三种跨平台的方式,都是可以参考的范例。
Flutter作为一个良好的跨平台框架,同时Flutter引擎本身就能够对接到Android系统本身,那么如果ArkUI能够快速迁移到FlutterEngine上,那么适配Android UI系统这个问题就迎刃而解了。
但是迁移到FlutterEngine上,有几个难点
-
FlutterEngine大量依赖了Flutter framework中的概念,比如framework.dart,还有UI渲染的适配,而这些是用dart语言编写的。直接采取Dart编写UI表示,无论是开发者生态还是后续定制化,都比不上TS/JS,因此,需要把dart这部分迁移出来,用C++重写
-
架构上要支持多引擎,这对架构设计上更加苛刻
-
定义好TS/JS 编写的组件,提供以C++为基础的组件封装,对标dart编写的基础控件
-
适配好所有Flutter相关的线程模型,满足Engine需求
这就是ArkUI的选择,通过ArkTS编写的控件,会经过ArkUI的封装,直接把TS产物对接到引擎上,比如鸿蒙4.0就可以通过默认的Flutter Engine直接适配Android相关的UI展示,在这个过程中,Flutter UI渲染相关的一些好的流程,ArkUI也进行了保留,比如element 更新。同时也把Flutter的三颗树适配为C++的三棵树,ArkUI更新请看这篇
RefPtr<Element> Element::UpdateChildWithSlot(
const RefPtr<Element>& child, const RefPtr<Component>& newComponent, int32_t slot, int32_t renderSlot)
{
// Considering 4 cases:
// 1. child == null && newComponent == null --> do nothing
如果Element 为 null 并且 Component为null,则什么也不做
if (!child && !newComponent) {
return nullptr;
}
// 2. child == null && newComponent != null --> create new child configured with newComponent
新增:child == null && newComponent != null:通过Component建立对应的element
if (!child) {
auto newChild = InflateComponent(newComponent, slot, renderSlot);
ElementRegister::GetInstance()->AddElement(newChild);
return newChild;
}
// 3. child != null && newComponent == null --> remove old child
删除:child != null && newComponent == null:移除elemnt
if (!newComponent) {
ElementRegister::GetInstance()->RemoveItemSilently(child->GetElementId());
DeactivateChild(child);
return nullptr;
}
// 4. child != null && newComponent != null --> update old child with new configuration if possible(determined by
// [Element::CanUpdate]), or remove the old child and create new one configured with newComponent.
更新:child != null && newComponent != null
auto context = context_.Upgrade();
不支持更新,那么删除旧的element,添加新的element
if (!child->CanUpdate(newComponent)) {
// Can not update
auto needRebuildFocusElement = AceType::DynamicCast<Element>(GetFocusScope());
if (context && needRebuildFocusElement) {
context->AddNeedRebuildFocusElement(needRebuildFocusElement);
}
ElementRegister::GetInstance()->RemoveItemSilently(child->GetElementId());
DeactivateChild(child);
auto newChild = InflateComponent(newComponent, slot, renderSlot);
ElementRegister::GetInstance()->AddElement(newChild);
return newChild;
}
.....
能够更新
auto newChild = DoUpdateChildWithNewComponent(child, newComponent, slot, renderSlot);
if (newChild != nullptr) {
newChild->SetElementId(newComponent->GetElementId());
ElementRegister::GetInstance()->AddElement(newChild);
}
return newChild;
.....
return newChild;
}
ArkUI保留了Flutter的设计精华,也做出了很多自己的创新,比如TS控件到C++控件的转化,以及简化了Flutter的页面更新流程,同时在这个基础上也抽象出自己的渲染引擎,以Rosen开头,这个渲染引擎后续就可以替换Flutter引擎,完成更加复杂且更加贴合鸿蒙系统的UI工作。
JS引擎 + Flutter引擎+多引擎的方式,它是ArkUI历史发展的产物,它既保留了Flutter中实践的优点,完成了Flutter引擎与TS的适配,能够在Android系统跑通整个体系,同时最重要的是它为后续切换自己的引擎提供了可能。
总结
通过本章,我们通过学习当前移动端跨平台方案的实现,到ArkUI的技术选择,我们清晰的了解到了ArkUI背后的设计理念以及设计初衷,ArkUI的适配FlutterEngine的方式,也为我们后续尝试拿掉Dart 自定义其他语言适配到Flutter Engine做出了范例,有一定的参考价值。