天天看點

JSBridge原理與實作分析JSBridge原理與實作分析

JSBridge原理與實作分析

前言:本文講述JSBridge相關原理以及實作方面的知識,主内容整理自網絡。

Hybrid App簡介

hybrid app是一種将native app和web app結合起來的一項app開發政策。

衆所周知,native app開發周期長,稽核嚴格,同時更新較為繁瑣,但使用者體驗非常好;而web app開發周期比較短,版本更新疊代都非常快速,但是使用者體驗較差。它們各自擁有不同的适用場景,那麼能否根據具體的業務場景,在app開發的過程中将二者結合起來,發揮各自的長處呢?有!那就是hybrid混合app。

一般hybrid app的體驗效果介于上述兩類app之間,其原理是将android和iOS app中都有的WebView(Android為WebView,iOS為UIWebView)嵌入到app頁面中并像浏覽器一樣加載網頁。如果将業務頁面寫成網頁形式,通過WebView加載到app中,就可以将web app和native app混合起來,成為hybrid app。

但是僅僅嵌入WebView并加載特定的頁面并不能完全滿足所有的業務場景,有些業務場景需要使用原生能力,比如擷取定位、調用原生彈窗等。有時app也需要通知業務頁面作出指定的行為,比如重定向、修改隔離配置等。這時候就需要js和native之間可以通信(互相調用),即業務頁面的js腳本可以調用原生代碼去執行相關操作,原生代碼可以調用js代碼去操作業務邏輯。

下面将分析js與native幾種通信政策的實作原理以及相容等問題。

Js與Native通信

JavaScript 調用 Native

JavaScript 調用 Native 的方式,主要有兩種:注入 API 和 攔截 URL SCHEME。

注入API

注入 API 方式的主要原理是,通過 WebView 提供的接口,向 JavaScript 的 Context(window)中注入對象或者方法,讓 JavaScript 調用時,直接執行相應的 Native 代碼邏輯,達到 JavaScript 調用 Native 的目的。

iOS UIWebView實作:

JSContext *context = [uiWebView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];

context[@"postBridgeMessage"] = ^(NSArray<NSArray *> *calls) {
    // Native 邏輯
};
           

前端調用方式:

iOS WKWebView實作:

@interface WKWebVIewVC ()<WKScriptMessageHandler>

@implementation WKWebVIewVC

- (void)viewDidLoad {
    [super viewDidLoad];

    WKWebViewConfiguration* configuration = [[WKWebViewConfiguration alloc] init];
    configuration.userContentController = [[WKUserContentController alloc] init];
    WKUserContentController *userCC = configuration.userContentController;
    // 注入對象,前端調用其方法時,Native 可以捕獲到
    [userCC addScriptMessageHandler:self name:@"nativeBridge"];

    WKWebView wkWebView = [[WKWebView alloc] initWithFrame:self.view.frame configuration:configuration];

    // TODO 顯示 WebView
}

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
    if ([message.name isEqualToString:@"nativeBridge"]) {
        NSLog(@"前端傳遞的資料 %@: ",message.body);
        // Native 邏輯
    }
}
           

前端調用方式:

Android addJavascriptInterface 實作:

public class JavaScriptInterfaceDemoActivity extends Activity {
	private WebView Wv;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        Wv = (WebView)findViewById(R.id.webView);     
        final JavaScriptInterface myJavaScriptInterface = new JavaScriptInterface(this);    	 

        Wv.getSettings().setJavaScriptEnabled(true);
        Wv.addJavascriptInterface(myJavaScriptInterface, "nativeBridge");

        // TODO 顯示 WebView

    }

    public class JavaScriptInterface {
         Context mContext;

         JavaScriptInterface(Context c) {
             mContext = c;
         }

         @JavascriptInterface
         public void postMessage(String webMessage){	    	
             // Native 邏輯
         }
     }
}
           

前端調用方式:

Android 攔截Prompt 實作:

該實作并不屬于注入API類型,它的原理是通過js調用prompt(或alert\confirm)方法觸發WebView相關事件,并将調用的接口和參數資訊存入事件對象,android端通過重寫事件響應函數,擷取到相關的接口和參數資訊,并執行調用操作。

@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
    return super.onJsPrompt(view, url, message, defaultValue, result);
}
           

前端調用方式:

注意:在 4.2 之前,Android 注入 JavaScript 對象的接口是 addJavascriptInterface,但是這個接口有漏洞,可以被不法分子利用,危害使用者的安全,是以在 4.2 中引入新的接口 @JavascriptInterface(上面代碼中使用的)來替代這個接口,解決安全問題。是以 Android 注入js對象的方式是有相容性問題的,Android 4.2以前多采用的是攔截 Prompt 實作的。

攔截 URL SCHEME

URL SCHEME:URL SCHEME是一種類似于url的連結,是為了友善app直接互相調用設計的,形式和普通的 url 近似,主要差別是 protocol 和 host 一般是自定義的,例如: qunarhy://hy/url?url=http://ymfe.tech,protocol 是 qunarhy,host 則是 hy。

攔截 URL SCHEME 的主要流程是:Web 端通過某種方式(例如 iframe.src)發送 URL Scheme 請求,之後 Native 攔截到請求并根據 URL SCHEME(包括所帶的參數)進行相關操作。

前端發送 URL Scheme:

//建立隐藏iframe過程
var messagingIframe = document.createElement('iframe');
messagingIframe.style.display = 'none';
document.documentElement.appendChild(messagingIframe);
 
//觸發scheme
messagingIframe.src = uri;
           

Android shouldOverrideUrlLoading 攔截 Scheme:

public boolean shouldOverrideUrlLoading(WebView view, String url){
    //如果傳回false,則WebView處理連結url,如果傳回true,代表WebView根據程式來執行url
    return true;
}
           

iOS中,UIWebView有個特性:在UIWebView内發起的所有網絡請求,都可以通過delegate函數在Native層得到通知。這樣,我們可以在webview中捕獲url scheme的觸發(原理是利用 shouldStartLoadWithRequest)。

iOS shouldStartLoadWithRequest 攔截 Scheme:

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {
    NSURL *url = [request URL];
 
    NSString *requestString = [[request URL] absoluteString];
    //擷取利潤url scheme後自行進行處理
}
           

在實際使用過程中,這種方式有一定的 缺陷:

  • 使用 iframe.src 發送 URL SCHEME 會有 url 長度的隐患。
  • 建立請求,需要一定的耗時,比注入 API 的方式調用同樣的功能,耗時會較長。

但是之前為什麼很多方案使用這種方式呢?因為它 支援 iOS6。而現在的大環境下,iOS6 占比很小,基本上可以忽略,是以并不推薦為了 iOS6 使用這種并不優雅 的方式。

注意:

  • 有些方案為了規避 url 長度隐患的缺陷,在 iOS 上采用了使用 Ajax 發送同域請求的方式,并将參數放到 head 或 body 裡。這樣,雖然規避了 url 長度的隐患,但是 WKWebView 并不支援這樣的方式。
  • 為什麼選擇 iframe.src 不選擇 locaiton.href ?因為如果通過 location.href 連續調用 Native,很容易丢失一些調用。

Native 調用 JavaScript

相比于 JavaScript 調用 Native, Native 調用 JavaScript 較為簡單,畢竟不管是 iOS 的 UIWebView 還是 WKWebView,還是 Android 的 WebView 元件,都以子元件的形式存在于 View/Activity 中,直接調用相應的 API 即可。

Native 調用 JavaScript,其實就是執行拼接 JavaScript 字元串,從外部調用 JavaScript 中的方法,是以 JavaScript 的方法必須在全局的 window 上。(閉包裡的方法,JavaScript 自己都調用不了,更不用想讓 Native 去調用了)

iOS UIWebView 實作:

iOS WKWebView 實作:

Android Kitkat(4.4)之前loadUrl 實作:

Android Kitkat(4.4)之後 evaluateJavascript 實作:

webView.evaluateJavascript(javaScriptString, new ValueCallback<String>() {
    @Override
    public void onReceiveValue(String value) {

    }
});
           

注意:使用 loadUrl 的方式,并不能擷取 JavaScript 執行後的結果。

JSBridge原理

JavaScript 是運作在一個單獨的 JS Context 中(例如,WebView 的 Webkit 引擎、JSCore)。由于這些Context 與原生運作環境的天然隔離,我們可以将這種情況與 RPC(Remote Procedure Call,遠端過程調用)通信進行類比,将 Native 與 JavaScript 的每次互相調用看做一次 RPC 調用。如此一來我們可以按照通常的 RPC 方式來進行設計和實作。

在 JSBridge 的設計中,可以把前端看做 RPC 的用戶端,把 Native 端看做 RPC 的伺服器端,進而 JSBridge 要實作的主要邏輯就出現了:通信調用(Native 與 JS 通信) 和 句柄解析調用。(如果你是個前端,而且并不熟悉 RPC 的話,你也可以把這個流程類比成 JSONP 的流程)

Native 和 Javascript 通信的原理是 JSBridge 實作的核心,實作方式可以各種各樣,但是萬變不離其宗。這裡,筆者推薦的實作方式如下:

  • JavaScript 調用 Native 推薦使用 注入 API 的方式(iOS6 忽略,Android 4.2以下使用 WebViewClient 的 onJsPrompt 方式)。
  • Native 調用 JavaScript 則直接執行拼接好的 JavaScript 代碼即可。
  • 也可以将URL SCHEME的Restful形式的接口參數格式同注入API方式相結合,做到接口調用方式上的相容(隻改動底層實作)

JSBridge設計與實作

使用url scheme形式來設計和實作 JSBridge,現有的可參考架構和文章都非常多,若想詳細了解建議閱讀《JSBridge深度剖析》,此處不作贅述。

設計與實作

上文中已介紹了native與js互動的方式,但是都是較為底層的實作,實際使用時需要在 javascript 端和 native 端封裝出對應的子產品,來統一管理它們之間的互相調用。

javascript端的子產品需要實作兩個功能:

  • 調用 Native(給 Native 發消息)
  • 被 Native 調用(接收 Native 消息)

根據這兩個功能點,可以初步設計出JSBridge js子產品的初步結構:

window.JSBridge = {
    // 調用 Native
    invoke: function(msg) {
        // 判斷環境,擷取不同的 nativeBridge
        nativeBridge.postMessage(msg);
    },
    receiveMessage: function(msg) {
        // 處理 msg
    }
};
           

在上面的文章中,提到過 RPC 中有一個非常重要的環節是 句柄解析調用 ,這點在 JSBridge 中展現為 句柄與功能對應關系。同時,我們将句柄抽象為橋名(BridgeName),最終演化為 一個 BridgeName 對應一個 Native 功能或者一類 Native 消息。 基于此點,JSBridge 的實作可以優化為如下:

window.JSBridge = {
    // 調用 Native
    invoke: function(bridgeName, data) {
        // 判斷環境,擷取不同的 nativeBridge
        nativeBridge.postMessage({
            bridgeName: bridgeName,
            data: data || {}
        });
    },
    receiveMessage: function(msg) {
        var bridgeName = msg.bridgeName,
            data = msg.data || {};
        // 具體邏輯
    }
};
           

JSBridge 大概的雛形出現了。現在終于可以着手解決這個問題了:消息都是單向的,那麼調用 Native 功能時 Callback 怎麼實作的?

對于 JSBridge 的 Callback ,其實就是 RPC 架構的回調機制。當然也可以用更簡單的 JSONP 機制解釋: 當發送 JSONP 請求時,url 參數裡會有 callback 參數,其值是目前頁面唯一 的,而同時以此參數值為 key 将回調函數存到 window 上,随後伺服器傳回 script 中,也會以此參數值作為句柄,調用相應的回調函數。

由此可見,callback 參數這個唯一辨別是這個回調邏輯的關鍵。這樣,我們可以參照這個邏輯來實作 JSBridge:用一個自增的唯一 id,來辨別并存儲回調函數,并把此 id 以參數形式傳遞給 Native,而 Native 也以此 id 作為回溯的辨別,來實作 Callback 回調邏輯。

(function () {
    let id = 0,
        callbacks = {};

    window.JSBridge = {
        // 調用 Native
        invoke: function(bridgeName, callback, data) {
            // 判斷環境,擷取不同的 nativeBridge
            let thisId = id++; // 擷取唯一 id
            callbacks[thisId] = callback; // 存儲 Callback
            nativeBridge.postMessage({
                bridgeName: bridgeName,
                data: data || {},
                callbackId: thisId // 傳到 Native 端
            });
        },
        receiveMessage: function(msg) {
            let bridgeName = msg.bridgeName,
                data = msg.data || {},
                callbackId = msg.callbackId; // Native 将 callbackId 原封不動傳回
            // 具體邏輯
            // bridgeName 和 callbackId 不會同時存在
            if (callbackId) {
                if (callbacks[callbackId]) { // 找到相應句柄
                    callbacks[callbackId](msg.data); // 執行調用
                }
            } else if (bridgeName) {

            }
        }
    };
})();
           

最後用同樣的方式加上 Native 調用的回調邏輯,同時對代碼進行一些優化,就大概實作了一個功能比較完整的 JSBridge。其代碼如下:

(function () {
    var id = 0,
        callbacks = {},
        registerFuncs = {};

    window.JSBridge = {
        // 調用 Native
        invoke: function(bridgeName, callback, data) {
            // 判斷環境,擷取不同的 nativeBridge
            var thisId = id ++; // 擷取唯一 id
            callbacks[thisId] = callback; // 存儲 Callback
            nativeBridge.postMessage({
                bridgeName: bridgeName,
                data: data || {},
                callbackId: thisId // 傳到 Native 端
            });
        },
        receiveMessage: function(msg) {
            var bridgeName = msg.bridgeName,
                data = msg.data || {},
                callbackId = msg.callbackId, // Native 将 callbackId 原封不動傳回
                responstId = msg.responstId;
            // 具體邏輯
            // bridgeName 和 callbackId 不會同時存在
            if (callbackId) {
                if (callbacks[callbackId]) { // 找到相應句柄
                    callbacks[callbackId](msg.data); // 執行調用
                }
            } else if (bridgeName) {
                if (registerFuncs[bridgeName]) { // 通過 bridgeName 找到句柄
                    var ret = {},
                        flag = false;
                    registerFuncs[bridgeName].forEach(function(callback) => {
                        callback(data, function(r) {
                            flag = true;
                            ret = Object.assign(ret, r);
                        });
                    });
                    if (flag) {
                        nativeBridge.postMessage({ // 回調 Native
                            responstId: responstId,
                            ret: ret
                        });
                    }
                }
            }
        },
        register: function(bridgeName, callback) {
            if (!registerFuncs[bridgeName])  {
                registerFuncs[bridgeName] = [];
            }
            registerFuncs[bridgeName].push(callback); // 存儲回調
        }
    };
})();
           

當然,這段代碼片段隻是一個示例,主要用于剖析 JSBridge 的原理和流程,裡面存在諸多省略和不完善的代碼邏輯,讀者們可以自行完善。

這一節主要講的是,JavaScript 端的 JSBridge 的實作,對于 Native 端涉及的并不多。在 Native 端配合實作 JSBridge 的 JavaScript 調用 Native 邏輯也很簡單,主要的代碼邏輯是:接收到 JavaScript 消息 => 解析參數,拿到 bridgeName、data 和 callbackId => 根據 bridgeName 找到功能方法,以 data 為參數執行 => 執行傳回值和 callbackId 一起回傳前端。 Native 調用 JavaScript 也同樣簡單,直接自動生成一個唯一的 ResponseId,并存儲句柄,然後和 data 一起發送給前端即可。

JSBridge 引用

對于 JSBridge 的引用,常用有兩種方式,各有利弊。

由 Native 端進行注入:

注入方式和 Native 調用 JavaScript 類似,直接執行橋的全部代碼。

  • 優點:橋的版本很容易與 Native 保持一緻,Native 端不用對不同版本的 JSBridge 進行相容;
  • 缺點:注入時機不确定,需要實作注入失敗後重試的機制,保證注入的成功率,同時 JavaScript 端在調用接口時,需要優先判斷 JSBridge 是否已經注入成功。

由 JavaScript 端引用:

直接與 JavaScript 一起執行。

  • 優點:JavaScript 端可以确定 JSBridge 的存在,直接調用即可;
  • 缺點:如果橋的實作方式有更改,JSBridge 需要相容多版本的 Native Bridge 或者 Native Bridge 相容多版本的 JSBridge。

靈犀LXJSBridge.js實作

實作原理基于上文,具體實作時,LXJSBridge.js在接口層面設計了兩類js調用native方式:一類是方法的直接調用,分為同步調用和異步調用;一類是注冊監聽事件的方式,但是這兩類調用方式其本質是一樣,js調用andriod native利用的是prompt方法,js調用ios native利用的postMessage方法。具體協定參見下圖,需要web端和native側約定好native能力的service以及action名稱。

JSBridge原理與實作分析JSBridge原理與實作分析

針對異步調用的結果傳回以及native事件通知,LXJSBridge.js在接口層面也是設計了兩類native調用js接口:異步調用傳回結果使用callbackFromNative方法;事件觸發使用fireFromNative方法。

JSBridge原理與實作分析JSBridge原理與實作分析

為了友善web端使用,我們仿照微信jssdk,在LXJSBridge之上又封裝了一層LXJSSDK的接口層,web隻需要使用LXJSSDK裡面的接口,就可以通路到native中的本地能力。具體參見代碼分析。

參考文章

  • 《移動混合開發中的 JSBridge》
  • 《JSBridge深度剖析》
  • 《Hybrid應用開發入門》

繼續閱讀