JSBridge的思考

欣東?發表於2019-01-28

前言

最近在做一個web與原生互動的需求,需求背景是這樣子的,提供一個SDK裡面包含一個webview用於載入業務h5,原生這邊賦予webview選擇相片、相機、刷臉、關閉原生介面的能力。雖然這個功能邏輯都是“熟悉的配方”,但還是有不少坑。

webview執行JS阻塞

專案一開始使用的橋接框架是以前專案用的橋接框架,但這個專案裡面有一功能點跟舊專案不一樣,舊專案只涉及到單圖片的選擇和上傳而新專案需要支援多圖片選擇和上傳,因為以前單圖片選擇上傳整個過程響應較快,所以沒關注執行JS時卡住了主執行緒,但這次專案是多圖片選擇上傳而且h5多了ocr識別,導致整個處理相對耗時,原生這邊執行JS一個回撥將多張圖片資料回傳給h5處理,例項程式碼如下

[UIWebView stringByEvaluatingJavaScriptFromString:jsstring]
複製程式碼

這個方法是一個同步方法,他會阻塞到JS方法執行結束才會返回,這時整個UI就會卡住。一開始的解決方案是通過原生這邊非同步派發佇列解決同步的問題,但這又是一個坑,會致webview出現偶現的crash,這個稍後再詳講。原生這邊不通,那就從JavaScript這一邊著手,熟悉JavaScript的同學都知道,setTimeout方法能夠實現非同步,如果程式碼中設定了一個 setTimeout,那麼瀏覽器便會在合適的時間,將程式碼插入任務佇列,如果這個時間設為 0,就代表立即插入佇列,但不是立即執行,仍然要等待前面程式碼執行完畢,所以 setTimeout 並不能保證執行的時間,是否及時執行取決於 JavaScript 執行緒是擁擠還是空閒,但它能夠解決我們執行JS程式碼導致的同步問題,在我們原生呼叫JS回撥之前用setTimeout做一層包裝,相當於呼叫setTimeout方法,一呼叫就即刻返回,不阻塞執行緒,例項程式碼如下:

function asyncallback(callback,params) {if(typeof callback == 'function'){setTimeout(function () {callback(params);},0);}}
複製程式碼

Why no WebViewJavascriptBridge

當給出第一版SDK給h5同事聯調的時候,h5同事反饋了幾個意見:
1、橋接依賴於協議定製和iframe,資料傳輸透明,存在安全隱患;
2、呼叫方式過於硬編碼,呼叫時需要匹對填入方法名和引數,希望我這邊設計出類似微信web api;
3、webview出現偶現的crash;
4、希望支援名稱空間;
有人會問為什麼不用業界更加成熟橋接框架WebViewJavascriptBridge,我們通過讀原始碼可知WebViewJavascriptBridge底層還是依賴於協議定製和iframe,並不支援名稱空間,而且crash還是會出現(網友反饋)。 綜合上次的意見,我們需要重新設計我們的橋接框架,原框架的兩端互動依賴iframe發請求、攔截請求來進行互動,iOS還有另外一個方案來實現兩端互動:JavaScriptCore,想深入瞭解JavaScriptCore可以看這篇文章,而且通過JavaScriptCore設計的js api的程式碼風格可以做到微信web api的效果。JavaScriptCore框架是一個蘋果在iOS7引入的框架,該框架讓 Objective-C 和 JavaScript 程式碼直接的互動變得更加的簡單方便,而JavaScriptCore是蘋果Safari瀏覽器的JavaScript引擎。通過JavaScriptCore,我們可以以寫原生程式碼的方式寫JavaScript,最終JavaScriptCore都會將我們的原生程式碼順滑、安全轉化為JavaScript層的實現。我們以這個JavaScriptCore框架為基礎設計我們的橋接元件XDMicroJSBridge。

XDMicroJSBridge簡概

關鍵類

JSContext: JSContext是JavaScript的執行環境;
JSValue: JSValue代表一個JavaScript實體,一個JSValue可以表示很多JavaScript原始型別例如boolean、 integers、doubles甚至包括物件和函式;

實現原理

先在原生註冊對應的暴露給h5使用js API函式名,通過[JSContext currentArguments]捕獲方法的引數,引數的型別是JSValue,JSValue提供一系列方法將值轉換成合適的Objective-C值或物件,方便這邊原生處理,通過block包裝原生呼叫方法(相機、相簿等),將block注入JSContext當中,名稱空間的實現是往JSContext注入一個空實現的類,需要賦予名稱空間的方法則將對應包裝的block注入到這個空實現的類中。想了解具體實現點選github.com/caixindong/…。例項程式碼如下:

- (void)registerAction:(NSString *)action handler:(XDMCJSBHandle)handler {
    if (action && handler) {
        __weak typeof(self) weakSelf = self;
        _context[_nameSpace][action] = ^{
            NSLog(@"action is %@",action);
            __strong typeof(weakSelf) strongSelf = weakSelf;
            strongSelf.webThread = [NSThread currentThread];
            NSLog(@"webThread is %@",[NSThread currentThread]);
            NSArray *args = [JSContext currentArguments];
            JSValue *last = (JSValue *)[args lastObject];
            XDMCJSBCallback ncallback = nil;
            NSMutableArray *trueArgs = [NSMutableArray arrayWithArray:args];
            if ([last isObject] && [[last toDictionary] isEqualToDictionary:@{}]) {
                [trueArgs removeLastObject];
                ncallback = ^(NSDictionary *params){
                    [strongSelf performSelector:@selector(_callJSMethodWithArgs:) onThread:strongSelf.webThread withObject:@[last, params] waitUntilDone:NO];
                };
            }
            NSMutableArray *trueOCArgs = [NSMutableArray array];
            for (JSValue *value in trueArgs) {
                if ([value isObject]) {
                    [trueOCArgs addObject:[value toDictionary]];
                } else if ([value isString]) {
                    [trueOCArgs addObject:[value toString]];
                } else if ([value isNull]) {
                    [trueOCArgs addObject:[NSNull null]];
                } else if ([value isBoolean]) {
                    [trueOCArgs addObject:[NSNumber numberWithBool:[value toBool]]];
                }
            }
            handler([trueOCArgs copy], ncallback);
        };
    }
}
複製程式碼

實現難點

JSValue提供了JavaScript原始型別boolean、integers、doubles、物件轉化方法,但沒有提供函式的轉化方法,因為JS函式引數一般都會包含回撥,回撥是function物件,所以這一塊轉化是很有必要的,由程式碼可見我這邊是通過一個oc的block儲存了函式回撥的資訊。

webthread crash

對於crash問題,經過我多次除錯發現,在web與原生互動多次後再觸發下一次互動會發現野指標crash,頻次不定,crash棧定位到webview的webthread。兩種實現方案都會出現這個問題。總所周知,JavaScript是以單執行緒的方式執行的,所以webview底層會維護一個執行緒用於處理JavaScript的互動,網上很多例子和教程在webview執行js程式碼的時候都會派發到主執行緒,可是webthread有時候並不在主執行緒,這是有隱患的,如果是頻次低的互動可能不會觸發這個bug,當頻次高時,就例如我這個專案,h5內有很多表單需要上傳選擇圖片這種跨端操作,就可能會觸發webthread crash。網上資料和官方文件並沒有對這個crash做具體的解釋,我猜測可能是底層執行緒通訊派發出現問題,所以正確的做法應該是webview內JavaScript的執行和回撥應始終在一個執行緒,以防止執行緒切換導致偶現crash。那怎麼獲取webthread,獲取webthread的時機應該是JavaScript的執行環境初始化完成之後,所以可以在包裝原生呼叫方法的block捕獲這個webthread,因為h5觸發原生封裝的js api後會跑進封裝原生方法block,這時候上下文已經初始化完成,而且也是在webview維護的webthread內。例項程式碼如下:

- (void)registerAction:(NSString *)action handler:(XDMCJSBHandle)handler {
    if (action && handler) {
        __weak typeof(self) weakSelf = self;
        _context[_nameSpace][action] = ^{
            NSLog(@"action is %@",action);
            __strong typeof(weakSelf) strongSelf = weakSelf;
            strongSelf.webThread = [NSThread currentThread];
            NSLog(@"webThread is %@",[NSThread currentThread]);
}
複製程式碼

然後在這個執行緒執行js相關邏輯程式碼,這樣修改後,crash沒再出現,例項程式碼如下:

[self performSelector:@selector(_callJSMethodWithArgs:) onThread:strongSelf.webThread withObject:@[callback, params] waitUntilDone:NO];
複製程式碼

最終框架實現效果

相比其他橋接框架,XDMicroJSBridge更加輕量(程式碼量不到100行),支援名稱空間,原生專注原生程式碼,web專注JavaScript,維護一致的web thread。

初始化Bridge

#import "XDMicroJSBridge.h"
@property (nonatomic, strong) UIWebView *webview;
@property (nonatomic, strong) XDMicroJSBridge *bridge;
@property (nonatomic, copy) XDMCJSBCallback callback;
self.bridge = [XDMicroJSBridge bridgeForWebView:_webview];
複製程式碼

註冊JS方法

__weak typeof(self) weakself = self;
[_bridge registerAction:@"camerapicker" handler:^(NSArray *params, XDMCJSBCallback callback) {
        dispatch_async(dispatch_get_main_queue(), ^{
            //if your javaScript method has callback, you should register this call like this.
            if (callback) {
                weakself.callback = callback;
            }
            UIImagePickerController *cameraVC = [[UIImagePickerController alloc] init];
            cameraVC.delegate = weakself;
            cameraVC.sourceType = UIImagePickerControllerSourceTypeCamera;
            [weakself presentViewController:cameraVC animated:YES completion:nil];
        });
    }];
複製程式碼

h5呼叫原生註冊的JS方法

<script>
    function clickcamera() {
        XDMCBridge.camerapicker(function (response) {
            var photos = response['photos'];
            var insert = document.getElementById('insert');
            for(var i = 0; i < photos.length; i++) {
                var img = new Image(100,100);
                img.src = photos[i];
                insert.appendChild(img);
            }
        });
    }
</script>
複製程式碼

想了解更多iOS終端相關知識可以前往終端雜談

相關文章