作者:京东科技 杜强强

前言

在Roma跨端方案中,JS虚拟机是框架的核心,负责执行动态化的JS代码。在Android平台采用了基于V8的J2V8,iOS平台则使用了系统自带的JSCore,而在HarmonyOS中,由于业界无类似的框架,我们需要自行实现以确保核心基础能力的完整。 鸿蒙虚拟机的开发经历了从最初 ArkTs2V8JSVM + Roma新架构方案。在此过程中,我们实现了完整的鸿蒙版的“J2V8”和 基于系统JSVM的JS虚拟机框架,解决了JS引擎库移植、多语言通信能力、多类型数据结构转换等众多挑战。本文将从实现的各个阶段过程出发,探讨在实践中遇到的问题及解决方案。

一、鸿蒙版 “J2V8”虚拟机实现 - ArkTs2V8

ArkTs2V8框架依赖 V8引擎, 鸿蒙前期交叉编译资料少,V8官方也未有HarmonyOS端编译方式。因此在这过程中, 我们采取初期使用QuickJS引擎(C语言开发,代码少,移植方便), 后期自编译V8完成后替换QuickJS, 保证快速验证跨端前期技术调研方案以及其他依赖项基础能力的开展。 自编译V8 通过学习交叉编译相关技术,摸索式逐步解决编译期间这种报错,完成V8虚拟机移植。

ArkTs2V8 架构借鉴了Android J2V8( 动态化-J2V8文章中讲述了具体原理及实践)的实现原理。 J2V8为针对V8的 Java实现,采用最直接的方式在Java中访问V8原始值,因此具备较高的性能。 在HarmonyOS中,采用V8作为JS引擎, JSI作为通信层完成设计。

鸿蒙跨端实践-JS虚拟机架构实现_V8

1、引入JSI

考虑到跨端框架的未来发展,虽然通过C++ 能够直接与V8交互,但这种方式不利于虚拟机代码的共享和扩展。因此Roma框架引入JSI,以增强代码的可扩展性,促进更有效的代码共享,并实现更灵活的虚拟机集成。

JSI(JavaScript Interface),轻量级,通用且同步的JavaScript接口, 通过JSI,JS代码可以直接与C++原生代码通信。

有了JSI层对虚拟机的封装,Roma框架开发者无需在关心虚拟机底层能力, 同时也可以自由切换引擎,比如使用V8,QuickJS、JSVM等, 规范了数据格式,统一为JSIValue。

2、API与框架设计原理

接口设计采用和J2V8 类似的设计,支持多虚拟机实例方式。

鸿蒙跨端实践-JS虚拟机架构实现_数据_02

实现原理:

1、本地接口: 使用 napi 使用创建桥梁, 完成本地代码调用Quick引擎函数。

2、C++数据绑定:在C++层面 ,定义虚拟机交互操作的相关函数,完成V8引擎相关API 来执行JS代码、 处理JS对象和执行虚拟机相关的操作。

3、JSIRuntime: 在C++层面引入JSI概念,通过完成JSIRuntime - QuickJSRuntime & V8Runtime, 完成虚拟机层通信能力。

4、虚拟机对象的定义及封装:根据JS数据类型,定义ArkTS数据结构,包括基本数据类型、JSObject、JSArray、JSFunction。ArkTS侧 类型对象持有C++ JSIValue 对象指针,当执行具体能力时,通过napi 传递指针,完成具体功能的调用。 简单来说,相当于ArkTS JS对象代理C++ 虚拟机数据对象。

5、内存管理: ArkTs2V8负责管理ArkTS与JSValue 之间的内存交互。其中C++侧完成JSValue对象的创建、引用持有与销毁。 ArkTS数据对象中定义对象释放函数, 数据使用完后,由ArkTS调用释放内存。

ArkTs2V8架构设计支持虚拟机多实例, 单个虚拟机的创建过程时由 ArkTs通过JSEngine发起创建JSRuntime虚拟机实例创建,经过napi,在C++环境创建JSRuntime引擎实例及引用, 并完成环境Context及global的初始化, 同时创建ArkTs JSRuntime对象,代理C++虚拟机对象JSRuntime(QuickJSRuntime or V8Runtime) 并绑定指针引用。

初始化过程:

鸿蒙跨端实践-JS虚拟机架构实现_V8_03

V8Runtime实现

鸿蒙跨端实践-JS虚拟机架构实现_字符串_04

3、JS、JSI、JSRuntime 关系

鸿蒙跨端实践-JS虚拟机架构实现_数据_05

JSRuntime (QuickJSRuntime or V8Runtime) 是 JS运行时环境。一个 JSRuntime 通常包括一个或多个引擎,JSI 可以看作是连接 JS 代码和 JSRuntime 的桥梁。通过 JSI,开发者可以更直接地与 JSRuntime 交互,实现原生功能的调用和管理。

4、部分过程剖析

ArkTs2V8实现的过程中,最基础的两个功能原理:JSObject对象的创建与获取、原生方法的注入, 这两个能力的实现可以扩展到其他大多数API功能实现上。

1、JSObject对象及获取对象数据过程。

通过JSRuntime 发起接口的调用,通过napi,根据对象类型在C++侧创建对象的JSValue对象及象指针引用, 并将引用指针绑定至ArkTS对象,完成对象的创建。

鸿蒙跨端实践-JS虚拟机架构实现_字符串_06

2、 JS虚拟机注入原生方法

ArkTS方法到JS虚拟机中,主要实现原理:

将ArkTs的方法 和 目标注册对象指针 生成MethodDescriptor方法描述对象, 通过functionID将对象存储在当前JSContext环境中。 通过napi 发起在C++侧代理函数HostFunction的创建,并绑定ArkTs的方法的引用。 进入到JSI内部,创建方法代理HostFunctionProxy 对象,绑定代理方法HostFunction及守护函数Finalizer, v8::External 将HostFunctionProxy与 JS环境对象(V8对象) 关联起来,生成V8 Function , 此时V8函数会与HostFunctionProxy生命周期绑定。 简单来说相当于ArkTS callback,传递至C++,C++创建JSI Callback并绑定ArkTS callback, JSI Callback 设置到HostFunctionProxy中,HostFunctionProxy 通过 v8::External与 JS环境绑定。

当JS触发该该函数时,通过v8::External绑定HostFunctionProxy这层关系,HostFunctionProxy中JSI Callback会收到JS环境的响应消息,在通过绑定的ArkTs的方法 通过napi接口返回至ArkTS中,最终ArkTS收到方法响应。

这种代理函数的实现, 初次学习可能比较复杂,但整个过程实际是多个对象间引用的持久化和不同数据对象的交换, 大致过程图如下:

鸿蒙跨端实践-JS虚拟机架构实现_数据_07

4、问题及挑战

1、 数据对象的内存管理

手动内存管理。 ArkTs2V8 负责管理ArkTS与V8之间的内存交互中,ArkTs发起对象的创建和销毁。 整个内存的管理是基于手动管理,需使用方用完后及时关闭,避免内存泄露。 这种设计模式下,使用者操作不当极为容易造成内存泄露,并且使用也较为不便。

针对这问题,在后续的迭代设计中,将内存管理升级为自动内存管理的方式。 JS为单线程执行,单方法片段或一些逻辑中,如果有了调用开始时机结束调用时机, 通过开始时记录当前时刻后开始创建的对象,在调用结束时刻对记录的对象进行统一的内存释放,类似于标记垃圾回收,完成内存的统一管理。借助Roma框架中对虚拟机层的封装,做到了内存自动管理。

2、 跨语言性能问题

基于ArkTs2V8 的API实现,在原生、JS环境中,无法直接使用对方的数据类型,二者之间数据类型需要转换。 JS到原生的过程中,ArkTs2V8中目前提供的API仅可以获取当前层级的JS对象数据,子对象数据需要通过递归遍历从JS环境中一一获取。因此解析的过程中需要频繁的通过C++读取V8,当数据量较大时通常比较耗时。拿常用的网络模块来说,接口下发的业务接口数据至少都在几K甚至几十K,转为JS对象在中端性能手机上 会有几十ms的耗时,这对单线程模式的JS环境来说影响时巨大的。

ArkTS 、C++ 跨语言通信性能我们可以采取类似于Roma Android的通信次数压缩策略,或者使用JSON序列化来减少跨语言交互的性能损耗, 但无论用哪种都仅是从行为上规避跨语言的性能,而无法彻底解决。

3、 线程管理问题

ArkTS基于TS语言,由于语言特性,ArkTS线程隔离,那么对于ArkTs2V8这种接口设计并不友好。 JS线程需要在ArkTS开启独立worker JS线程,收发JS消息,线程间的隔离,涉及再次序列化数据影响性能。

基于问题2、3 以及对框架未来的思考,Roma鸿蒙端决定采用新的方案: 框架C++化,框架逻辑实现全部放在native侧, 虚拟机实现全部切C++,C++侧完成线程管理,ArkTS不在承担线程和逻辑任务。 这种既提升了解决了问题,提升框架性能,也为今后框架移植其他平台打好基础

二、基于JSVM虚拟机实现 (Roma新架构)

1、鸿蒙JSVM

在V8移植上,从短期看虽然我们初步掌握了V8交叉编译移植技术,但从稳定性、兼容性、维护成本、包大小等维度看, 采用系统内置虚拟机有巨大的长期收益。 年初Roma框架与华为专家多次沟通交流,最终HarmonyOS将V8内置到了操作系统, Q2我们实现了第三个JSRuntime - JSMVRuntime, 至此鸿蒙动态化架构修改趋于稳定。

JSVMRuntime:

鸿蒙跨端实践-JS虚拟机架构实现_数据_08

2、新架构思路 - Roma架构C++化

新架构的设计思路SDK核心逻辑整体C++侧实现, 这样在底层引擎与核心流程之间可以直接c++通信,线程间上与其他端保持相同 - 三线程模型JS线程 +UI线程 + 耗时计算线程。通过C++ PThread完成线程管理, 从而避免跨语言、ArkTS线程隔离带来的多种性能损耗。 在数据结构设计上, JS数据采用JSI::Value, 与其他线程数据相互交互时, 统一使用folly完成。 另外将虚拟机层下沉,对外提供JSExecutor, 功能开发时框架开发者无需关心虚拟机层的实现。

虚拟机方法与对象的注入上, 通过HostObject代理对象能力的双边映射,原生模块直接与JS 同步或异步交互, 从而缩短了流程链路。

鸿蒙跨端实践-JS虚拟机架构实现_字符串_09

框架大致原理:

鸿蒙跨端实践-JS虚拟机架构实现_V8_10

3、过程遇到的

1、JSVM字符串引用问题

JSVMRuntime实现期间,字符串无法创建对象引用。 JSI的设计中将字符串作为 pointer 自定义指针类,通过指针地址访问, 与其相同的还有对象,方法。 在许多语言中字符串都作为一种特殊的类型(非基本数据类型), 例如在C++中,字符是一种基本数据类型,但是字符串不是,字符串由字符组成, V8引擎亦如此。 V8中通常使用v8::String来创建JS字符串, 我们可以对齐进行持久化引用。

而JSVM中 OH_JSVM_CreateReference 无法针对字符串类型创建引用, 字符串的持久化需从JSVM_Value从copy出来通过智能指针或者new内存的方式进行存储,这种copy持久化的方式会造成字符串内存两份(JSVM一份,自己存一份), 实际开发中大量的字符串类型转换,这样会造成内存占比过高。

为此, 经过与华为专家多次交流沟通,最终将字符串归为引用类型,可通过OH_JSVM_CreateReference持久化引用,修改后的方式如下:

鸿蒙跨端实践-JS虚拟机架构实现_字符串_11

2、HostObject代理对象实现

HostObject 是JS对象,提供与原生直接通信的方式。 相当于 native 在 JS的代理对象,双向映射,原生模块直接与JS 同步或异步交互, 在一些功能实现上可以缩短流程链路, 在JS中可直接调用C++的对象。 在动态化中, 模块的实现采用的就是HostObject能力, 框架层实现模块代理对象及桥通信层面的双向通信过程。 比如登录模块,在ArkTS侧封装模块的API,通过C侧的HostObject映射,可以在JS中直接调用登录模块的登录,退出登录等能力。 HostObject的实现,虽然在框架层面相比于乔通道的方式更加复杂,但对于复杂逻辑流程和交互链路, 基础开发可以更注重于功能逻辑。

HostObject 实现过程较为复杂, 但我们可以将过程拆分,通过对象管理 + 代理函数的方式将过程简化。 首先对象的管理直接JSRuntime中持久化即可,Roma中采用智能指针,那么就剩下代理函数,前面我们讲了JS中注入方法里面包括了代理函数的实现原理,采用类似的思路来完成HostObject。

HarmonyOS提供的JSVM API最初仅支持代理函数的创建, 而我们需要是创建代理对象,对象中可以有任意方法,仅通过代理函数方式无法满足任意方法的需求,为此通过在JS中注入代理对象脚本实现,通过Proxy代理的方式,将get、set等代理对象的方法通过代理函数的方式返回,这种情况下,我们的函数数量就被简化成了get、set及一些固定的方法。 通过这些方法做代理转接,调用到C++对象方法,借助JSI::Value的包装,将具体结果返回。

JS代理脚本部分代码:

大致实现过程:

鸿蒙跨端实践-JS虚拟机架构实现_V8_12

示例 - 基于HostObject Console能力实现

鸿蒙跨端实践-JS虚拟机架构实现_V8_13

三、总结

0到1实现鸿蒙版“j2v8”、“JSRuntime” 让我们更加了解引擎实现中的各种细节和一些难点问题的解决。 一些方案的实现,也可以延展到其他(非虚拟机)场景。 Roma 框架C++, 让Roma框架走向技术深水区, 为今后capi、未来技术做好了基础,旨在带来更优的性能和更好的用户体验。