JavaScript呼叫App原生程式碼(iOS、Android)通用解決方案
實際場景
場景:現在有一個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
實際場景
場景:現在有一個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