如何写出一手好的小程序之多端架构篇
本文大致需要 14m+ 的阅读时间。
简述小程序的通信体系
为了大家能更好的开发出一些高质量、高性能的小程序,这里带大家理解一下小程序在不同端上架构体系的区分,更好的让大家理解小程序一些特有的代码写作方式。
整个小程序开发生态主要可以分为两部分:
桌面 nwjs 的微信开发者工具(PC 端)
移动 APP 的正式运行环境
一开始的考虑是使用双线程模型来解决安全和可控性问题。不过,随着开发的复杂度提升,原有的双线程通信耗时对于一些高性能的小程序来说,变得有些不可接受。也就是每次更新 UI 都是通过 webview 来手动调用 API 实现更新。原始的基础架构,可以参考官方图:
[图片]
不过上面那张图其实有点误导行为,因为,webview 渲染执行在手机端上其实是内核来操作的,webview 只是内核暴露的一下 DOM/BOM 接口而已。所以,这里就有一个性能突破点就是,JSCore 能否通过 Native 层直接拿到内核的相关接口?答案是可以的,所以上面那种图其实可以简单的再进行一下相关划分,新的如图所示:
[图片]
简单来说就是,内核改改,然后将规范的 webview 接口,选择性的抽一份给 JsCore 调用。但是,有个限制是 Android 端比较自由,通过 V8 提供 plugin 机制可以这么做,而 IOS 上,苹果爸爸是不允许的,除非你用的是 IOS 原生组件,这样的话就会扯到同层渲染这个逻辑。其实他们的底层内容都是一致的。
后面为了大家能更好理解在小程序具体开发过程中,手机端调试和在开发者工具调试的大致区分,下面我们来分析一下两者各自的执行逻辑。
tl;dr
开发者工具 通信体系 (只能采用双向通信) 即,所有指令都是通过 appservice <=> nwjs 中间层 <=> webview
Native 端运行的通信体系:
小程序基础通信:双向通信-- ( core <=> webview <=> intermedia <=> appservice )
高阶组件通信:单向通信体系 ( appservice <= android/Swift => core)
JSCore 具体执行 appservice 的逻辑内容
开发者工具的通信模式
一开始考虑到安全可控的原因使用的是双线程模型,简单来说你的所有 JS 执行都是在 JSCore 中完成的,无论是绑定的事件、属性、DOM操作等,都是。
开发者工具,主要是运行在 PC 端,它内部是使用 nwjs 来做,不过为了更好的理解,这里,直接按照 nwjs 的大致技术来讲。开发者工具使用的架构是 基于 nwjs 来管理一个 webviewPool,通过 webviewPool 中,实现 appservice_webview 和 content_webview。
所以在小程序上的一些性能难点,开发者工具上并不会构成很大的问题。比如说,不会有 canvas 元素上不能放置 div,video 元素不能设置自定义控件等。整个架构如图:
[图片]
当你打开开发者工具时,你第一眼看见的其实是 appservice_webview 中的 [代码]Console[代码] 内容。
[图片]
content_webview 对外其实没必要暴露出来,因为里面执行的小程序底层的基础库和 开发者实际写的代码关系不大。大家理解的话,可以就把显示的 WXML 假想为 content_webview。
[图片]
当你在实际预览页面执行逻辑时,都是通过 content_webview 把对应触发的信令事件传递给 service_webview。因为是双线程通信,这里只要涉及到 DOM 事件处理或者其他数据通信的都是异步的,这点在写代码的时候,其实非常重要。
如果在开发时,需要什么困难,欢迎联系:开发者专区 | 微信开放社区
IOS/Android 协议分析
前面简单了解了开发者工具上,小程序模拟的架构。而实际运行到手机上,里面的架构设计可能又会有所不同。主要的原因有:
IOS 和 Android 对于 webview 的渲染逻辑不同
手机上性能瓶颈,JS 原始不适合高性能计算
video 等特殊元素上不能被其他 div 覆盖
…
一开始做小程序的双线程架构和开发者工具比较类似,content_webview 控制页面渲染,appservice 在手机上使用 JSCore 来进行执行。它的默认架构图其实就是这个:
[图片]
但是,随着用户量的满满增多,对小程序的期望也就越高:
小程序的性能是被狗吃了么?
小程序打开速度能快一点么?
小程序的包大小为什么这么小?
…
这些,我们都知道,所以都在慢慢一点一点的优化。考虑到原生 webview 的渲染性能很差,组内大神 rex 提出了使用同层渲染来解决性能问题。这个办法,不仅搞定了 video 上不能覆盖其他元素,也提高了一下组件渲染的性能。
开发者在手机上具体开发时,对于某些 高阶组件,像 video、canvas 之类的,需要注意它们的通信架构和上面的双线程通信来说,有了一些本质上的区别。为了性能,这里底层使用的是原生组件来进行渲染。这里的通信成本其实就回归到 native 和 appservice 的通信。
为了大家更好的理解 appservice 和 native 的关系,这里顺便简单介绍一下 JSCore 的相关执行方法。
JSCore 深入浅出
在 IOS 和 Android 上,都提供了 JSCore 这项工程技术,目的是为了独立运行 JS 代码,而且还提供了 JSCore 和 Native 通信的接口。这就意味着,通过 Native 调起一个 JSCore,可以很好的实现 Native 逻辑代码的日常变更,而不需要过分的依靠发版本来解决对应的问题,其实如果不是特别严谨,也可以直接说是一种 "热更新" 机制。
在 Android 和 IOS 平台都提供了各自运行的 JSCore,在国内大环境下运行的工程库为:
Anroid: 国内平台较为分裂,不过由于其使用的都是 Google 的 Android 平台,所以,大部分都是基于 chromium 内核基础上,加上中间层来实现的。在腾讯内部通常使用的是 V8 JSCore。
IOS: 在 IOS 平台上,由于是一整个生态闭源,在使用时,只能是基于系统内嵌的 webkit 引擎来执行,提供 webkit-JavaScriptCore 来完成。
这里我们主要以具有官方文档的 webkit-JavaScriptCore 来进行讲解。
JSCore 核心基础
普遍意义上的 JSCore 执行架构可以分为三部分 JSVirtualMachine、JSContext、JSValue。由这三者构成了 JSCore 的执行内容。具体解释参考如下:
JSVirtualMachine: 它通过实例化一个 VM 环境来执行 js 代码,如果你有多个 js 需要执行,就需要实例化多个 VM。并且需要注意这几个 VM 之间是不能相互交互的,因为容易出现 GC 问题。
JSContext: jsContext 是 js代码执行的上下文对象,相当于一个 webview 中的 window 对象。在同一个 VM 中,你可以传递不同的 Context。
JSValue: 和 WASM 类似,JsValue 主要就是为了解决 JS 数据类型和 swift 数据类型之间的相互映射。也就是说任何挂载在 jsContext 的内容都是 JSValue 类型,swift 在内部自动实现了和 JS 之间的类型转换。
大体内容可以参考这张架构图:
[图片]
当然,除了正常的执行逻辑的上述是三个架构体外,还有提供接口协议的类架构。
JSExport: 它 是 JSCore 里面,用来暴露 native 接口的一个 protocol。简单来说,它会直接将 native 的相关属性和方法,直接转换成 prototype object 上的方法和属性。
简单执行 JS 脚本
使用 JSCore 可以在一个上下文环境中执行 JS 代码。首先你需要导入 JSCore:
[代码]import JavaScriptCore //记得导入JavaScriptCore
[代码]
然后利用 Context 挂载的 evaluateScript 方法,像 new Function(xxx) 一样传递字符串进行执行。
[代码]let contet:JSContext = JSContext() // 实例化 JSContext
context.evaluateScript("function combine(firstName, lastName) { return firstName + lastName; }")
let name = context.evaluateScript("combine('villain', 'hr')")
print(name) //villainhr
// 在 swift 中获取 JS 中定义的方法
let combine = context.objectForKeyedSubscript("combine")
// 传入参数调用:
// 因为 function 传入参数实际上就是一个 arguemnts[fake Array],在 swift 中就需要写成 Array 的形式
let name2 = combine.callWithArguments(["jimmy","tian"]).toString()
print(name2) // jimmytian
[代码]
如果你想执行一个本地打进去 JS 文件的话,则需要在 swift 里面解析出 JS 文件的路径,并转换为 String 对象。这里可以直接使用 swift 提供的系统接口,Bundle 和 String 对象来对文件进行转换。
[代码]lazy var context: JSContext? = {
let context = JSContext()
// 1
guard let
commonJSPath = Bundle.main.path(forResource: "common", ofType: "js") else { // 利用 Bundle 加载本地 js 文件内容
print("Unable to read resource files.")
return nil
}
// 2
do {
let common = try String(contentsOfFile: commonJSPath, encoding: String.Encoding.utf8) // 读取文件
_ = context?.evaluateScript(common) // 使用 evaluate 直接执行 JS 文件
} catch (let error) {
print("Error while processing script file: \(error)")
}
return context
}()
[代码]
JSExport 接口的暴露
JSExport 是 JSCore 里面,用来暴露 native 接口的一个 protocol,能够使 JS 代码直接调用 native 的接口。简单来说,它会直接将 native 的相关属性和方法,直接转换成 prototype object 上的方法和属性。
那在 JS 代码中,如何执行 Swift 的代码呢?最简单的方式是直接使用 JSExport 的方式来实现 class 的传递。通过 JSExport 生成的 class,实际上就是在 JSContext 里面传递一个全局变量(变量名和 swift 定义的一致)。这个全局变量其实就是一个原型 prototype。而 swift 其实就是通过 context?.setObject(xxx) API ,来给 JSContext 导入一个全局的 Object 接口对象。
那应该如何使用该 JSExport 协议呢?
首先定义需要 export 的 protocol,比如,这里我们直接定义一个分享协议接口:
[代码]@objc protocol WXShareProtocol: JSExport {
// js调用App的微信分享功能 演示字典参数的使用
func wxShare(callback:(share)->Void)
// setShareInfo
func wxSetShareMsg(dict: [String: AnyObject])
// 调用系统的 alert 内容
func showAlert(title: String,msg:String)
}
[代码]
在 protocol 中定义的都是 public 方法,需要暴露给 JS 代码直接使用的,没有在 protocol 里面声明的都算是 私有 属性。接着我们定义一下具体 WXShareInface 的实现:
[代码]@objc class WXShareInterface: NSObject, WXShareProtocol {
weak var controller: UIViewController?
weak var jsContext: JSContext?
var shareObj:[String:AnyObject]
func wxShare(_ succ:()->{}) {
// 调起微信分享逻辑
//...
// 成功分享回调
succ()
}
func setShareMsg(dict:[String:AnyObject]){
self.shareObj = ["name":dict.name,"msg":dict.msg]
// ...
}
func showAlert(title: String, message: String) {
let alert = AlertController(title: title, message: message, preferredStyle: .Alert)
// 设置 alert 类型
alert.addAction(AlertAction(title: "确定", style: .Default, handler: nil))
// 弹出消息
self.controller?.presentViewController(alert, animated: true, completion: nil)
}
// 当用户内容改变时,触发 JS 中的 userInfoChange 方法。
// 该方法是,swift 中私有的,不会保留给 JSExport
func userChange(userInfo:[String:AnyObject]) {
let jsHandlerFunc = self.jsContext?.objectForKeyedSubscript("\(userInfoChange)")
let dict = ["name": userInfo.name, "age": userInfo.age]
jsHandlerFunc?.callWithArguments([dict])
}
}
[代码]
类是已经定义好了,但是我们需要将当前的类和 JSContext 进行绑定。具体步骤是将当前的 Class 转换为 Object 类型注入到 JSContext 中。
[代码]lazy var context: JSContext? = {
let context = JSContext()
let shareModel = WXShareInterface()
do {
// 注入 WXShare Class 对象,之后在 JSContext 就可以直接通过 window.WXShare 调用 swift 里面的对象
context?.setObject(shareModel, forKeyedSubscript: "WXShare" as (NSCopying & NSObjectProtocol)!)
} catch (let error) {
print("Error while processing script file: \(error)")
}
return context
}()
[代码]
这样就完成了将 swift 类注入到 JSContext 的步骤,余下的只是调用问题。这里主要考虑到你 JS 执行的位置。比如,你可以直接通过 JSCore 执行 JS,或者直接将 JSContext 和 webview 的 Context 绑定在一起。
直接本地执行 JS 的话,我们需要先加载本地的 js 文件,然后执行。现在本地有一个 share.js 文件:
[代码]// share.js 文件
WXShare.setShareMsg({
name:"villainhr",
msg:"Learn how to interact with JS in swift"
});
WXShare.wxShare(()=>{
console.log("the sharing action has done");
})
[代码]
然后,我们需要像之前一样加载它并执行:
[代码]// swift native 代码
// swift 代码
func init(){
guard
let shareJSPath = Bundle.main.path(forResource:"common",ofType:"js") else{
return
}
do{
// 加载当前 shareJS 并使用 JSCore 解析执行
let shareJS = try String(contentsOfFile: shareJSPath, encoding: String.Encoding.utf8)
self.context?.evaluateScript(shareJS)
} catch(let error){
print(error)
}
}
[代码]
如果你想直接将当前的 WXShareInterface 绑定到 Webview Context 中的话,前面实例的 Context 就需要直接修改为 webview 的 Context。对于 UIWebview 可以直接获得当前 webview 的Context,但是 WKWebview 已经没有了直接获取 context 的接口,wkwebview 更推崇使用前文的 scriptMessageHandler 来做 jsbridge。当然,获取 wkwebview 中的 context 也不是没有办法,可以通过 KVO 的 trick 方式来拿到。
[代码]// 在 webview 加载完成时,注入相关的接口
func webViewDidFinishLoad(webView: UIWebView) {
// 加载当前 View 中的 JSContext
self.jsContext = webView.valueForKeyPath("documentView.webView.mainFrame.javaScriptContext") as! JSContext
let model = WXShareInterface()
model.controller = self
model.jsContext = self.jsContext
// 将 webview 的 jsContext 和 Interface 绑定
self.jsContext.setObject(model, forKeyedSubscript: "WXShare")
// 打开远程 URL 网页
// guard let url = URL(string: "https://www.villainhr.com") else {
// return
//}
// 如果没有加载远程 URL,可以直接加载
// let request = URLRequest(url: url)
// webView.load(request)
// 在 jsContext 中直接以 html 的形式解析 js 代码
// let url = NSBundle.mainBundle().URLForResource("demo", withExtension: "html")
// self.jsContext.evaluateScript(try? String(contentsOfURL: url!, encoding: NSUTF8StringEncoding))
// 监听当前 jsContext 的异常
self.jsContext.exceptionHandler = { (context, exception) in
print("exception:", exception)
}
}
[代码]
然后,我们可以直接通过上面的 share.js 调用 native 的接口。
原生组件的通信
JSCore 实际上就是在 native 的一个线程中执行,它里面没有 DOM、BOM 等接口,它的执行和 nodeJS 的环境比较类似。简单来说,它就是 ECMAJavaScript 的解析器,不涉及任何环境。
在 JSCore 中,和原生组件的通信其实也就是 native 中两个线程之间的通信。对于一些高性能组件来说,这个通信时延已经减少很多了。
那两个之间通信,是传递什么呢?
就是 事件,DOM 操作等。在同层渲染中,这些信息其实都是内核在管理。所以,这里的通信架构其实就变为:
[图片]
Native Layer 在 Native 中,可以通过一些手段能够在内核中设置 proxy,能很好的捕获用户在 UI 界面上触发的事件,这里由于涉及太深的原生知识,我就不过多介绍了。简单来说就是,用户的一些 touch 事件,可以直接通过 内核暴露的接口,在 Native Layer 中触发对应的事件。这里,我们可以大致理解内核和 Native Layer 之间的关系,但是实际渲染的 webview 和内核有是什么关系呢?
在实际渲染的 webview 中,里面的内容其实是小程序的基础库 JS 和 HTML/CSS 文件。内核通过执行这些文件,会在内部自己维护一个渲染树,这个渲染树,其实和 webview 中 HTML 内容一一对应。上面也说过,Native Layer 也可以和内核进行交互,但这里就会存在一个 线程不安全的现象,有两个线程同时操作一个内核,很可能会造成泄露。所以,这里 Native Layer 也有一些限制,即,它不能直接操作页面的渲染树,只能在已有的渲染树上去做节点类型的替换。
最后总结
这篇文章的主要目的,是让大家更加了解一下小程序架构模式在开发者工具和手机端上的不同,更好的开发出一些高性能、优质的小程序应用。这也是小程序中心一直在做的事情。最后,总结一下前面将的几个重要的点:
开发者工具只有双线程架构,通过 appservice_webview 和 content_webview 的通信,实现小程序手机端的模拟。
手机端上,会根据组件性能要求的不能对应优化使用不同的通信架构。
正常 div 渲染,使用 JSCore 和 webview 的双线程通信
video/map/canvas 等高阶组件,通常是利用内核的接口,实现同层渲染。通信模式就直接简化为 内核 <=> Native <=> appservice。(速度贼快)
参考:
教程 | 《小程序开发指南》