如何寫出一手好的小程式之多端架構篇

villainhr發表於2019-03-02

作為微信小程式底層 API 維護者之一,經歷了風風雨雨、各種各樣的吐槽。為了讓大家能更好的寫一手小程式,特地梳理一篇文章介紹。如果有什麼吐槽的地方,歡迎去 developers.weixin.qq.com/ 開發者社群吐槽。

PS: 老闆要找人,對自己有實力的前端er,可以直接發簡歷到我的郵箱: villainthr@gmail.com

簡述小程式的通訊體系

為了大家能更好的開發出一些高質量、高效能的小程式,這裡帶大家理解一下小程式在不同端上架構體系的區分,更好的讓大家理解小程式一些特有的程式碼寫作方式。

整個小程式開發生態主要可以分為兩部分:

  • 桌面 nwjs 的微信開發者工具(PC 端)
  • 移動 APP 的正式執行環境

一開始的考慮是使用雙執行緒模型來解決安全和可控性問題。不過,隨著開發的複雜度提升,原有的雙執行緒通訊耗時對於一些高效能的小程式來說,變得有些不可接受。也就是每次更新 UI 都是通過 webview 來手動呼叫 API 實現更新。原始的基礎架構,可以參考官方圖:

官方架構

不過上面那張圖其實有點誤導行為,因為,webview 渲染執行在手機端上其實是核心來操作的,webview 只是核心暴露的一下 DOM/BOM 介面而已。所以,這裡就有一個效能突破點就是,JSCore 能否通過 Native 層直接拿到核心的相關介面?答案是可以的,所以上面那種圖其實可以簡單的再進行一下相關劃分,新的如圖所示:

new_structure

簡單來說就是,核心改改,然後將規範的 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 內容。

appservice_webview

content_webview 對外其實沒必要暴露出來,因為裡面執行的小程式底層的基礎庫和 開發者實際寫的程式碼關係不大。大家理解的話,可以就把顯示的 WXML 假想為 content_webview。

content_webview

當你在實際預覽頁面執行邏輯時,都是通過 content_webview 把對應觸發的信令事件傳遞給 service_webview。因為是雙執行緒通訊,這裡只要涉及到 DOM 事件處理或者其他資料通訊的都是非同步的,這點在寫程式碼的時候,其實非常重要。

如果在開發時,需要什麼困難,歡迎聯絡:開發者專區 | 微信開放社群

IOS/Android 協議分析

前面簡單瞭解了開發者工具上,小程式模擬的架構。而實際執行到手機上,裡面的架構設計可能又會有所不同。主要的原因有:

  • IOS 和 Android 對於 webview 的渲染邏輯不同
  • 手機上效能瓶頸,JS 原始不適合高效能運算
  • video 等特殊元素上不能被其他 div 覆蓋

一開始做小程式的雙執行緒架構和開發者工具比較類似,content_webview 控制頁面渲染,appservice 在手機上使用 JSCore 來進行執行。它的預設架構圖其實就是這個:

JSCore_content_webview

但是,隨著使用者量的滿滿增多,對小程式的期望也就越高:

  • 小程式的效能是被狗吃了麼?
  • 小程式開啟速度能快一點麼?
  • 小程式的包大小為什麼這麼小?

這些,我們都知道,所以都在慢慢一點一點的優化。考慮到原生 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 之間的型別轉換。

大體內容可以參考這張架構圖:

JSCore

當然,除了正常的執行邏輯的上述是三個架構體外,還有提供介面協議的類架構。

  • 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。(速度賊快)

由於工作太忙,社群很少會上,這裡推薦大家,關注我的微信公眾號 《前端小吉米》,公眾號一般會及時更新

如何寫出一手好的小程式之多端架構篇

參考:

教程 | 《小程式開發指南》

相關文章