众所周知,鸿蒙next的应用运行在ark runtime下,而ark runtime是驱动着鸿蒙应用的基石。ark runtime支持动态类型和静态类型在内的多种编程语言(目前支持js, ts, arkTs等,与传统js runtime不同的是,ark compiler工具链在编译ts源码时,不会像传统js引擎,先将ts转为js代码,再交给js runtime执行,而是在编译ts源码时,会分析推导类型信息,在运行前即可预生成内联缓存从而加速字节码执行)。详细的代码实现以及官方文档大家可以去这里看:arkcompiler仓库。
整体上看,ark runtime仍然是符合Ecmascript规范的js虚拟机实现。既然是js虚拟机实现,那大家一定会想到这样执行远程动态代码下发/热修等场景在鸿蒙上岂不是可以遍地开花,分分钟绕过App Gallary的代码签名/审核随意更新客户端代码!
Eval / new Function
提起使用js动态执行代码,大家第一个想起的一定是eval。
Eval作为js标准规范的方法,用于执行一段javascript表达式、声明、脚本并返回代码段执行结果。同时使用Function prototype也能达到相同的效果。
果然没有那么简单,还没有编译Dev Eco Studio的静态检查便提示无法在arkts中使用标准库。
这可难不倒大家,众所周知(大声宣扬),arkts的严格的静态检查仅适用于ets文件,只要把代码写在ts文件中,就能愉快的使用ts/js的大部分特性了(甚至any,随心所欲的向任意对象中加入/删除字段、方法等)。
说干就干,代码是不报错了,程序也跑起来了,但是在调用时app果断崩溃,调用eval / new Function
分别提示:
Error message:not support eval().
Error message:Not support eval. Forbidden using new Function()/Function().
export class InjectUtil {
static inject(obj: any) {
eval("1+1")
InjectUtil.evalByFunc()
obj.Nav = null
}
static evalByFunc() {
var fn = Function as any
return fn('console.log(eval!!!!!!!!!!!!!!!!!!!!!!!!!!!!)')();
}
}
看来arkcompiler在运行时也屏蔽掉了js可以随意动态执行代码的特性。看来直接使用ts的api是无法达到动态执行代码的目的。
查阅官方文档,已经对ark runtime的这一表现作出了解释:
ArkCompiler前端编译工具链将ArkTS/TS/JS程序预先静态编译为方舟字节码,并且还提供了多重混淆能力的增强,有效地提升了开发者代码资产的安全强度。另外出于安全的考虑,ArkCompiler不支持sloppy模式的JS代码,也不支持eval等运行动态字符串的功能。
在查阅了鸿蒙官方文档后,发现其并未完全堵死ark runtime动态代码的执行能力,在官方支持上仍然提供了使用ndk来开发动态js代码执行的能力。
JSVM-API
JSVM-API
是鸿蒙中提供的基于标准JS引擎提供的一套稳定ABI,为开发者提供了一套较为完整的JS引擎能力,包括创建和销毁引擎,执行JS代码,JS/C++交互等关键能力。相当于v8 api的精简版。
这里的js engine在官方文档上并未说明是否是ark runtime,但基于目前ark runtime的开源代码,这里js vm的能力应该还是由ark runtime提供(欢迎勘误)。
受限于JSVM-API
提供的能力,我们无法获取到主线程所在的jsvm env(也有可能主线程根本不是jsvm),只能通过jsvm创建一个全新的隔离环境,在这个js环境中,我们可以动态的执行js代码。
使用jsvm执行js脚本的主要流程:
核心代码
// jsvm提供的接口头文件
#include "ark_runtime/jsvm.h"
JSVM_VM* vm;
JSVM_ENV* env;
// 创建jsvm虚拟机 通过napi调用
static void createArcJsContext() {
JSVM_Status status;
// 初始化 可以设置vm启动参数 argv argc
JSVM_InitOptions init_options;
memset(&init_options, 0, sizeof(init_options));
OH_JSVM_Init(&init_options);
// 虚拟机实例
// 可以设置gc相关参数、snapshot bin等
JSVM_CreateVMOptions options;
memset(&options, 0, sizeof(options));
status = OH_JSVM_CreateVM(&options, vm);
// vm scope。在jsvm中scope 即作用域,用于资源管理,在scope打开期间资源可用,scope关闭后自愿释放(RAII)
// 类似v8中各种scope的设计,在后续使用中,各种scope可以以raii风格封装方便使用
JSVM_VMScope vm_scope;
status = OH_JSVM_OpenVMScope(js_vm, &vm_scope);
// js env
js_env = new JSVM_Env;
status = OH_JSVM_CreateEnv(...);
// env scope
status = OH_JSVM_OpenEnvScope(...);
}
static napi_value EvalJS(napi_env env, napi_callback_info info) {
...省略...
// 待运行js代码
std::string scriptStr = napiValueToString(env, args[1]);
// scope 用于资源管理
OH_JSVM_OpenHandleScope(...);
// 转为jsvm字符串
OH_JSVM_CreateStringUtf8(...);
// 编译js脚本
OH_JSVM_CompileScript(...);
// 运行脚本
OH_JSVM_RunScript(...);
// 获取执行结果(省略...)
...省略...
}
// 释放先前创建的各种scope 关闭vm napi调用
static void releaseResources() {
OH_JSVM_CloseEnvScope(...);
OH_JSVM_DestroyEnv(...);
OH_JSVM_CloseVMScope(...);
OH_JSVM_DestroyVM(...);
}
// index.d.ts 导出napi方法定义
export const createJsCore: (fun: Function) => number;
export const releaseJsCore: (a: number) => void;
export const evaluateJs: (a: number, str: string) => string;
// 创建首个运行环境,并绑定TS回调
const coreId = testNapi.createJsCore(MyCallback);
let sourcecodestr = `
{
let a = "hello World";
consoleinfo(a);
const mPromise = createPromise();
mPromise.then((result) => {
assertEqual(result, 0);
onJSResultCallback(result, "abc", "v");
});
a;
};`;
// 在首个运行环境中执行JS代码
console.log("TEST evalUateJS : " + testNapi.evalUateJS(coreId, sourcecodestr));
// 释放运行环境
testNapi.releaseJsCore(coreId);
实测js代码片段可以成功运行编译执行,从应用的主线程调用napi创建jsvm,并执行指定的一段js代码,在jsvm中创建的环境也能调用创建vm时传入的回调函数将结果返回主线程/主vm。
可以正常实现动态代码和app主线程代码的双向调用:
主isolate(主线程) -> napi -> 用户isolate(jsvm)
用户isolate(jsvm) -> napi -> 主isolate(主线程)
优点
- js执行环境与主线程环境隔离,通过napi进行中转通讯,在jsvm环境中执行代码不会影响到主线程代码执行,相对安全。
缺点
- 需要手动管理jsvm、相关vm env等,使用较为繁琐,应用前需要对js vm api进行封装。
- 动态执行的js代码不能使用import等,需为完整逻辑代码。
- 不能调用主vm中中相关对象/api。
- 未经编译arc compiler编译优化,可能会有性能损失。
- 需要写大量的binding相关的胶水代码,繁杂的工作量较大。
NAPI
node的napi将v8底层数据结构全部黑盒化,抽象为统一的接口,使得开发者无需再使用v8相关api操作,与v8解耦,使开发者不会遇到由于v8引擎变更或node版本升级而重新开发的风险。
在鸿蒙中也实现了napi相关规范与接口定义,使开发者可以以napi规范来开发鸿蒙应用的native扩展。
napi_run_script
napi_run_script是napi标准规范中执行js代码的函数,依赖于napi,该方法的调用比较简单,直接上运行代码及调试结果
在鸿蒙中napi_run_script调用可以成功完成,也返回了napi_ok成功状态的返回码,但是实际上代码并没有执行,脚本运行返回值也被置为null。
查阅官方文档,并没有对napi_run_script这个接口做出说明,猜测ark runtime屏蔽了或未实现这个接口。
napi_run_script_path
针对这个接口,鸿蒙官方有给相关说明:napi_run_script_path官方文档。文档中提到可以使用该接口执行abc格式的文件(arkts编译后生成的字节码产物),但没有给出操作流程。摸索了下,鸿蒙sdk中的es2abc工具可以实现这个目标:
/Applications/DevEco-Studio.app/Contents/sdk/HarmonyOS-NEXT-DB3/openharmony/ets/build-tools/ets-loader/bin/ark/build-mac/bin/es2abc
用法(注意,使用macos的同学需要将es2abc这个文件拷贝出.app, 否则会因为权限不足的原因拷贝失败):
./es2abc test.js
将生成的test.abc文件拷贝至entry的rawfile中,调用即可执行对应的代码。
napi_run_script_path(env, "/entry/resources/rawfile/test.abc", &result);
附上测试脚本test.ets:
console.log('hello world from dynamic code')
console.log(globalThis)
globalThis.Cornerstone.hello()
可以发现动态下发的代码可以正常调用到我们在程序运行时向globalThis中注入的Cornerstone对象,说明动态执行的代码与app运行的主线程代码处于同一vm环境。
优点
使用napi执行动态代码,无需手动管理vm虚拟机的生命周期,同时可以复用并调用到主vm环境中已存在的对象,更加灵活友好。
可享受到.abc带来的性能优化。
但同时下发的abc文件也与jsvm的限制一致,无法使用import,需将动态代码中的相关依赖提前注入到vm环境/携带完整逻辑等。
缺点
因为执行环境与app主vm一致,恶意代码可随意破环主vm中相关内存数据。
总结
出于安全考虑鸿蒙(ark runtime)在ts层面对动态执行代码作出了诸多限制,但是我们仍能通过napi / jsvm的方式达到动态执行代码的目的,尤其是napi,可在主vm环境中执行动态代码,并且能享受到方舟编译器所带来的性能优化。