實際場景
場景:現在有一個H5活動頁面,上面有一個登陸按鈕,要求點擊登陸按鈕以后,喚出App內部的登錄界面,當登錄成功以后將用戶的手機號返回給H5頁面,顯示出來。
這個場景應該算是比較完整的一次H5中的JavaScript與App原生代碼進行交互了,這個過程,我們制定的方案滿足以下幾點:
- 滿足基本的交互流程的功能
- Android與iOS都能適用
- H5的前端開發者,在書寫JavaScript的業務代碼的時候不需要為了遷就移動端語言的特性而寫特殊的磨合代碼
- 方便調試
交互流程
當H5頁面上的JavaScript代碼要調用原生的頁面或者組件的時候,調用最好是雙向的,一來一回,這樣比較容易滿足一些比較復雜的業務場景,就像上面的場景一樣,有調用,有回調告知H5調用的結果。前端開發寫的JavaScript代碼基本上都是異步風格的,就拿上面的場景,如果登錄是H5前端的,那么這個流程就會是:
function loginClick() { loginComponent.login(function (error,result) { //處理登錄完成以后的邏輯 }); } var loginComponent = { callBack:null, "login":function (callBack) { this.show(); this.callBack = callBack; }, show:function (loginComponent) { //登錄組件顯示的邏輯 }, confirm:function (userName,password) { ajax.post('https://xxxx.com/login',function (error,result) { if(this.callBack !== null){ this.callBack(error,result); } }); } }
如果要改成調用原生登錄,那么這個流程就應該是這樣:
確定了流程,接下來就可以詳細設計和實現
原生與JavaScript的橋梁
為了實現上述流程,並且能讓H5的前端開發盡可能少的語法損失,我們需要構建一個JavaScript與原生App進行交互的橋梁,這個橋梁來處理與App的協議交互,兼容iOS與Android的交互實現。
Android與iOS都支持在打開H5頁面的時候,向H5頁面的window對象上注入一個JavaScript可以訪問到的對象,Android端使用的是
webView.addJavascriptInterface(myJavaScriptInterface, “bridge”);
iOS則可以使用JavaScriptCore來完成:
#import <Foundation/Foundation.h> #import <JavaScriptCore/JavaScriptCore.h> @protocol PICBridgeExport <JSExport> @end @interface PICBridge : NSObject<PICBridgeExport> @end self.jsContext = [self.webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"]; self.bridge =[[PICBridge alloc] init];
這里面Android的myJavaScriptInterface與PICBridge都是作為與JavaScript進行通信的橋梁。
我們使用設計這個橋梁的時候,需要使用一個具體的語法約定和數據約定,比方說,當前端開發調用App登錄的時候,他一定是希望就像調用其他JavaScript的組件一樣,而登錄的結果通過傳入callBack的函數來完成,對於callBack函數,我們希望借助NodeJS的規范:
function(error,res) { //回調函數第一個參數是錯誤,第二個參數是結果 }
以上我們可以看到,bridge必須有能力將前端開發寫的JavaScript回調函數傳入到App內部,然后App處理完邏輯以后通過回調函數來告知前端處理,並且這個需要通過約定好的數據格式來傳遞入參和返回值。
為了完成雙向通信,我們就需要在JavaScript設置一個bridge,原生再注入一個bridge,這兩個bridge按照一定的數據約定來進行雙向通信和分發邏輯。
原生端注入到JS當中的“橋”(iOS端)
通過使用JavaScriptCore這個庫,我們能很容易的將JavaScript傳入的回調函數在objective-c或者是swift端持有,並回去回調這個回調函數。
#import <Foundation/Foundation.h> #import <JavaScriptCore/JavaScriptCore.h> @protocol PICBridgeExport <JSExport> JSExportAs(callRouter, -(void)callRouter:(JSValue *)requestObject callBack:(JSValue *)callBack); @end @interface PICBridge : NSObject<PICBridgeExport> -(void)addActionHandler:(NSString *)actionHandlerName forCallBack:(void(^)(NSDictionary * params,void(^errorCallBack)(NSError * error),void(^successCallBack)(NSDictionary * responseDict)))callBack; @end
需要說明的是,JavaScript沒有函數參數標簽的概念,JSExportAs是用來將objective-c的方法映射為JavaScript的函數。
-(void)callRouter:(JSValue )requestObject callBack:(JSValue )callBack);
這個方法是暴露給JavaScript端調用的。
第一個參數requestObject是一個JavaScript對象,傳入到objective-c中以后就可以轉換為key-value結構的字典,那么這個字典的數據約定是:
{ 'Method':'Login', 'Data':null }
其中Method是App內部對外提供的API,而這個Data則是該API需要的入參。
第二個參數是一個callBack函數,該類型的JSValue可以調用callWithArguments:方法來invoke這個回調函數。
前面已經說明,回調函數的第一個參數是error,第二個參數是一個結果,而回調的結果我們也進行一下約定,那就是:
{ 'result':{} }
這樣的好處是,業務邏輯可以講返回的結果放入result中,跟result同級別的我們還可以加入統一的簽名認證的東西,在此暫時不延伸。
原生端的bridge的來實現一下callRouter:
-(void)callRouter:(JSValue *)requestObject callBack:(JSValue *)callBack{ NSDictionary * dict = [requestObject toDictionary]; NSString * methodName = [dict objectForKey:@"Method"]; if (methodName != nil && methodName.length>0) { NSDictionary * params = [dict objectForKey:@"Data"]; __weak PICBridge * weakSelf = self; //因為JavaScript是單線程的,需要盡快完成調用邏輯,耗時操作需要異步提交到主線程中執行 dispatch_async(dispatch_get_main_queue(), ^{ [weakSelf callAction:methodName params:params success:^(NSDictionary *responseDict) { if (responseDict != nil) { NSString * result = [weakSelf responseStringWith:responseDict]; if (result) { [callBack callWithArguments:@[@"null",result]]; } else{ [callBack callWithArguments:@[@"null",@"null"]]; } } else{ [callBack callWithArguments:@[@"null",@"null"]]; } } failure:^(NSError *error) { if (error) { [callBack callWithArguments:@[[error description],@"null"]]; } else{ [callBack callWithArguments:@[@"App Inner Error",@"null"]]; } }]; }); } else{ [callBack callWithArguments:@[@NO,[PICError ErrorWithCode:PICUnkonwError].description]]; } return; } //將返回的結果字典轉換為字符串通過回調函數傳回給JavaScript -(NSString *)responseStringWith:(NSDictionary *)responseDict{ if (responseDict) { NSDictionary * dict = @{@"result":responseDict}; NSData * data = [NSJSONSerialization dataWithJSONObject:dict options:NSJSONWritingPrettyPrinted error:nil]; NSString * result = [[NSString alloc]initWithData:data encoding:NSUTF8StringEncoding]; return result; } else{ return nil; } }
callAction函數實際上就是分發業務邏輯用的
-(void)callAction:(NSString *)actionName params:(NSDictionary *)params success:(void(^)(NSDictionary * responseDict))success failure:(void(^)(NSError * error))failure{ void(^callBack)(NSDictionary * params,void(^errorCallBack)(NSError * error),void(^successCallBack)(NSDictionary * responseDict)) = [self.handlers objectForKey:actionName]; if (callBack != nil) { callBack(params,failure,success); } }
這個callBack Block是在self.handlers的字典中存儲,比較復雜,block第一個參數是傳入的入參,后面兩個參數是成功以后的回調和失敗以后的回調,以便業務邏輯完成后進行回調給JavaScript。
同時會有注冊業務邏輯的方法:
-(void)addActionHandler:(NSString *)actionHandlerName forCallBack:(void(^)(NSDictionary * params,void(^errorCallBack)(NSError * error),void(^successCallBack)(NSDictionary * responseDict)))callBack{ if (actionHandlerName.length>0 && callBack != nil) { [self.handlers setObject:callBack forKey:actionHandlerName]; } }
至此,原生端路由實現完畢。
JavaScript端路由
(function(win) { var ua = navigator.userAgent; function getQueryString(name) { var reg = new RegExp('(^|&)' + name + '=([^&]*)(&|$)', 'i'); var r = window.location.search.substr(1).match(reg); if (r !== null) return unescape(r[2]); return null; } function isAndroid() { return ua.indexOf('Android') > 0; } function isIOS() { return /(iPhone|iPad|iPod)/i.test(ua); } var mobile = { /** *通過bridge調用app端的方法 * @param method * @param params * @param callback */ callAppRouter: function(method, params, callback) { var req = { 'Method': method, 'Data': params }; if (isIOS()) { win.bridge.callRouter(req, function(err, result) { var resultObj = null; var errorMsg = null; if (typeof(result) !== 'undefined' && result !== 'null' && result !== null) { resultObj = JSON.parse(result); if (resultObj) { resultObj = resultObj['result']; } } if (err !== 'null' && typeof(err) !== 'undefined' && err !== null) { errorMsg = err; } callback(err, resultObj); }); } else if (isAndroid()) { //生成回調函數方法名稱 var cbName = 'CB_' + Date.now() + '_' + Math.ceil(Math.random() * 10); //掛載一個臨時函數到window變量上,方便app回調 win[cbName] = function(err, result) { var resultObj; if (typeof(result) !== 'undefined' && result !== null) { resultObj = JSON.parse(result)['result']; } callback(err, resultObj); //回調成功之后刪除掛載到window上的臨時函數 delete win[cbName]; }; win.bridge.callRouter(JSON.stringify(req), cbName); } }, login: function() { // body... this.callAppRouter('Login', null, function(errMsg, res) { // body... if (errMsg !== null && errMsg !== 'undefined' && errMsg !== 'null') { } else { var name = res['phone']; if (name !== 'undefined' && name !== 'null') { var button = document.getElementById('loginButton'); button.innerHTML = name; } } }); } }; //將mobile對象掛載到window全局 win.webBridge = mobile; })(window);
在window上掛在一個叫webBridge的對象,其他業務JavaScript可以通過webBridge.login來進行調用原生端開放的API。
callAppRouter方法的實現我們來分析一下:
如果判斷是iOS設備,則使用iOS注冊的bridge對象進行調用callRouter方法:
if (isIOS()) { win.bridge.callRouter(req, function(err, result) { var resultObj = null; var errorMsg = null; if (typeof(result) !== 'undefined' && result !== 'null' && result !== null) { resultObj = JSON.parse(result); if (resultObj) { resultObj = resultObj['result']; } } if (err !== 'null' && typeof(err) !== 'undefined' && err !== null) { errorMsg = err; } callback(err, resultObj); }); }
req是標准的包含Method和Data的對象,緊接着傳入回調函數,回調函數有err與result,里面做好各種類型檢查。
着重說一下Android端的實現,因為Android端的JavaScript方法注冊,參數類型只能字符串,java語言本身沒有匿名函數的概念,所以只能給Java端傳入回調函數的名字,而回調函數的實現則在JavaScript端持有。
else if (isAndroid()) { //生成回調函數方法名稱 var cbName = 'CB_' + Date.now() + '_' + Math.ceil(Math.random() * 10); //掛載一個臨時函數到window變量上,方便app回調 win[cbName] = function(err, result) { var resultObj; if (typeof(result) !== 'undefined' && result !== null) { resultObj = JSON.parse(result)['result']; } callback(err, resultObj); //回調成功之后刪除掛載到window上的臨時函數 delete win[cbName]; }; win.bridge.callRouter(JSON.stringify(req), cbName); }
本質上就是將其他業務JavaScript代碼傳入的callBack函數通過隨機生成函數名,掛在到window變量上,回調以后將其刪除:delete win[cbName]。
當調用Java端的bridge.callRouter(JSON.stringify(req), cbName),Java端拿到cbName,在完成業務邏輯后,按照標准數據格式,在JavaScript執行的上下文中,回調這個名字的方法。
至此,前端的webBridge完成。
最后附上Demo地址:
https://github.com/Neojoke/Picidae.git