记录一次在移动端运行JS脚本的试验

1,850 阅读7分钟

前言

最近接触了一些需求,需要在移动端运行比较繁琐的数据层逻辑,而且还需要兼顾良好的更新时效性。所以摆在笔者面前的就主要有两条路:

  1. 采用移动端的Hotfix技术。
  2. 采用脚本运行的方式。

移动端的Hotfix,Android有Classloader那一套,iOS有jsPatch。先不讨论上架审核的问题,基于原生的方法多多少少会有一些兼容性问题,且实现成本不够轻量。ps:现在iOS新的项目会使用SwiftjsPatch在它面前就是一块铁板;而Android部分,团队有使用Tinker,目前还是挺好用的。

所以笔者最终选取了第二种为研究对象,鉴于近几年大前端的趋势,混端开发普遍。基于这种背景,技术栈生态应该比较丰富。而脚本的选型,这里笔者选用的是JS。主要的原因在于,背靠整个大前端生态,比较好乘凉

故本文主要是记录笔者搭建一个简单的脚本工程,以及在移动端的选型和实现一个实验demo。文中涉及的内容,如果读者有不同的观点或者建议,欢迎留言一起讨论。

移动端的JS引擎

先来说说,在移动端的运行脚本,我们需要一个JS引擎,其实就是一个JS环境啦。

iOS上的JS引擎

iOS7之后,原生是提供了api,封装了其在WebKit中使用的JavaScriptCore。我们可以通过调用原生api使用该部分的能力。

JavaScriptCore | Apple Developer Documentation

Android上的JS引擎

Google并没有在Android中开放像iOS的JavaScriptCore类似的能力。有一种做法是借助WebView#evaluateJavascript来执行JS脚本。但是这个笔者实际测试中,利用一个嵌套好几层遍历,每层次数大概是50的脚本,运行速度非常得慢(ps:单纯只是想简单粗暴的提高时间复杂度测试一下性能)。

后面找到了bellard/quickjs这个轻量级的JS引擎。由于这是一个纯c的库,所以这里找了一个基于它封装的Java库:cashapp/zipline at 0.9.2。该库是cashapp出品的,由于它后续又使用Kotlin重构了一次,这里为了降低成本还是使用它的Java库,这个库的封装也简单一些

这样做的好处是,可以在Android中统一一个JS引擎。Weex、ReactNative也是这种的做法

两篇关于QuickJs的文章

QuickJS 引擎一年见闻录

如何评价 Fabrice Bellard 发布 QuickJS JS 引擎?

脚本工程化

大概流程

image.png

运行JS脚本,不可避免的是运行一些原生的方法,譬如查询原生的数据库、利用原生的能力进行网络请求等。所以这里粗略会分为三部分:通用逻辑、Bridge、原生。

  • 通用逻辑:就是通用的js逻辑。
  • Bridge:主要会做两件事,负责通过调用原生注入的对象,调用原生方法;负责抹平平台的特性所产生的差异。ps:这里的特性存在于平台自身和项目本身的设计上。
  • 原生:负责提供原生方法以供JS调用。

其实这里的设计,像极了JsBridge。也可以说它其实就是一种JsBridge

工程化

简单的运行一个JS脚本,只需要简单的一个JS文件即可。但随着逻辑越发复杂,工程化的重要性就体现出来了。参考上述的流程图,最终只需要输出一个JS脚本提供给原生的JS引擎eval。那么工程化所带来的问题是将多个JS文件打包成一个(最好是一个功能一份脚本产物),最好兼顾压缩等功能。所以这里就引入了Webpack,做工程的构建。

npm 安装 webpack

JS引擎还需要考虑的问题是支持的es版本,这个和前端适配浏览器有相似的地方。实际开发中,为了避免差异,这里引入了Babel。又由于希望有更好的关于强类型和面向对象设计的lint,这里使用了Typescript。ps:这里使用了Babel编译Typescript

[译]TypeScript 和 Babel 7

为什么 Babel 要支持编译 TypeScript

从0开始的TypeScriptの五:webpack打包typescript

所以最终的package.jsonbabel.config.js

"devDependencies": {
    "@babel/cli": "^7.17.6",
    "@babel/core": "^7.17.9",
    "@babel/plugin-proposal-class-properties": "^7.16.7",
    "@babel/preset-env": "^7.16.11",
    "@babel/preset-typescript": "^7.16.7",
    "babel-loader": "^8.2.5",
    "typescript": "^4.6.3",
    "webpack": "^5.72.0",
    "webpack-cli": "^4.9.2"
  }
const presets = [
    [
        "@babel/env"
    ],
    "@babel/preset-typescript"
]
const plugins = [
    '@babel/plugin-proposal-class-properties'
]

module.exports = { presets, plugins }

ps:因为只是一个demo,并没有做很好的配置,所以仅供参考

脚本调试

设计中最大的问题是调试。笔者希望达到的效果是在pc中可以直接调试,不需要涉及移动端。所以笔者想借助浏览器的控制台进行调试。当然这样做的弊端是无法很好的抹平环境上的差异。但鉴于两端对于ECMA标准的支持都比较高,这里由采用了Babel进行的编译,这部分误差就先忽略了。

在上面流程图基础上,又新增了调试层。 image.png

这里借鉴了模块化的思想,将JS脚本独立于任何一端,调试的时候可以注入到浏览器上面运行。所以这里笔者用Vue写了一个简单的页面作为在PC上的调试层。

// HelloWorld.vue
<template>
  <div class="hello">
    <button @click="evalJs">eval js</button>
  </div>
</template>

<script>
export default {
  name: "HelloWorld",
  props: {
    msg: String,
  },
  methods: {
    evalJs: async function () {
      let res = await fetch("./dist/test.js");
      let js = await res.text();
      eval(js);
      console.log('eval js complete')
    },
  },
};
</script>

这里为了模拟移动端的注入,会先将脚本打包好,使用eval在浏览器注入。

ps:这里补充一下,在iOS上使用JavaScriptCore是可以通过Safari调试的。只是最好您的JSContext是全局的,不然就很难捕捉到。

在Vue中使用Sqlite

由于在移动端运行,会比较依赖原生的数据库。这里为了保证调试模拟的真实性,考虑在浏览器连接一个Sqlite数据库。所以使用了sql-js/sql.js

有关在Vue中引入可以参考这个项目:ICJIA/spac-statute-explorer

模拟原生对象注入

这个就比较简单了,只需要在浏览器声明和移动端相同的原生对象以及实现他们的方法即可。

// main.js
loadDB({
  dbPath: './db/test.db'
}).then((db) => {
  global['database'] = db
  console.log('load db complete')
})

global['deviceInfo'] = loadDeviceInfo()
console.log('load deviceInfo complete')

new Vue({
  render: h => h(App),
}).$mount('#app')

实验性Demo

笔者准备了一个Demo。里面包含了脚本、调试层以及Android、iOS部分。内容是通过连接一个Sqlite数据库查询一条记录。

库中有一个User表,其中有两条记录:

idusernameaccount
1Jerry13800138000
2Tom13800138001

脚本层

function requireModule(moduleName: string): any {
    const object = global[moduleName]
    if (object == undefined) {
        throw EvalError(`${moduleName} is not exit`)
    } else {
        return object
    }
}

// Bridge层
class DBMoudle {
    fetchOne(sql: string): string {
        const deviceInfo = requireModule('deviceInfo')
        const platform = deviceInfo.platform()
        if (platform == 'Android') {
            return this.fetchOneFromAndroid(sql)
        } else if (platform == 'iOS') {
            return this.fetchOneFromiOS(sql)
        }
        throw EvalError('unknown platform')
    }

    private fetchOneFromAndroid(sql: string): string {
        const database = requireModule('database')
        return database.fetchOne(sql)
    }

    private fetchOneFromiOS(sql: string): string {
        const database = requireModule('database')
        return database.fetchOneWithSql(sql)
    }
}

global['test'] = {
    // 通用逻辑层
    testFetchOne: function testFetchOne() {
        const db = new DBMoudle()
        const json = db.fetchOne("select * from User where username='Tom'")
        return json
    }
}

代码注释中可以看出通用逻辑层和Bridge层。这里由于数据库的设计是统一的所以可以两端共用一条SQL,但在实际项目中可能存在两端的数据库设计差异,这就是上面提到“项目本身的设计所产生的差异”。这也是Bridge层需要抹平的问题。

运行效果

有关原生的代码,这里就只列举部分代码了

Android

// MainActivity.kt
private val quickJs: QuickJs by lazy {
    val quickJs = QuickJs.create()
    val testJs = assetsToStr()
    quickJs.set("deviceInfo", DeviceInfoInterface::class.java, DeviceInfo())
    quickJs.set("database", DatabaseInterface::class.java, Database(this.applicationContext))
    quickJs.evaluate(testJs)
    quickJs
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    findViewById<Button>(R.id.btn).setOnClickListener {
        Thread {
            copyDatabase()
            val test = quickJs.get("test", TestInterface::class.java)
            val userJson = test.testFetchOne()
            Log.d(this.javaClass.simpleName, userJson)
        }.start()
    }
}

输出结果:

2022-05-01 16:46:27.528 5366-6200/me.xcyoung.js.engine D/MainActivity: {"id":2,"account":"13800138001","username":"Tom"}

iOS

private let jsContext: JSContext = {
  let jsVirtualMachine = JSVirtualMachine.init()
  let ctx = JSContext.init(virtualMachine: jsVirtualMachine)!
  
  let jsPath = Bundle.init(for: Database.classForCoder()).path(forResource: "test", ofType: "js")!
  let js = try! String.init(contentsOfFile: jsPath)
  
  ctx.setObject(DeviceInfo.init(), forKeyedSubscript: "deviceInfo" as NSCopying & NSObjectProtocol)
  ctx.setObject(Database.init(), forKeyedSubscript: "database" as NSCopying & NSObjectProtocol)
  
  ctx.evaluateScript(js)
  return ctx
}()

override func viewDidLoad() {
  super.viewDidLoad()
  
  let btn: UIButton = {
      let btn = UIButton.init(type: .system)
      btn.backgroundColor = UIColor.blue
      btn.frame = CGRect.init(x: self.view.center.x - 50, y: self.view.center.y - 25, width: 100, height: 50)
      return btn
  }()
  
  self.view.addSubview(btn)
  btn.addTarget(self, action: #selector(onClick), for: .touchUpInside)
}

@objc private func onClick() {
  let test = jsContext.objectForKeyedSubscript("test")!
  let testFetchOne = test.objectForKeyedSubscript("testFetchOne")!
  let result = testFetchOne.call(withArguments: [])!.toString()!
  
  debugPrint("ViewController", result)
}

输出结果:

"ViewController" "{\"account\":\"13800138001\",\"id\":2,\"username\":\"Tom\"}"

Vue调试层

image.png

总结

总结一下Demo的问题:

  1. 模块间还不能做到很好的整合,这里可以考虑sub-module的形式集成module-script,这样可以考虑集成到各平台的构建流程上。
  2. 细心的同学可能注意到,上面注入了一个deviceInfo来判断平台,这是由于目前还没有比较好的办法区分平台。不过这里可以考虑采用条件编译的方式来构建脚本产物
  3. 脚本调试的成本,可能会随着复杂度提高。

最后再贴一下Demo地址:xcyoung/mobile-js-engine-exsample (github.com)