本文同步在我的博客shymean.com上,歡迎關注javascript
最近的業務須要使用Flutter開發App應用了,其中打算將部分已有的Web應用進行復用,所以須要研究一下Flutter的Hybird應用開發。本文主要整理在Flutter中使用Webview的教程和碰見的一些問題,最後給出了關於Flutter中對JSBridge的簡單封裝。html
本文完整代碼均放在github上面。參考前端
webview_flutter是官方維護的一個插件,所以仍是比較可靠的,直接運行示例代碼java
iOS打開網頁加載白屏,須要在ios/Runner/Info.plist
中配置android
<key>io.flutter.embedded_views_preview</key>
<true/>
複製代碼
Android也須要配置網絡權限,在文件/android/app/src/main/AndroidManifest.xml
中加入ios
<uses-permission android:name="android.permission.INTERNET"/>
<application>...</application>
複製代碼
在WebView
構造參數userAgent
中傳入自定義的ua字符串便可,這樣在網頁中就能夠根據UA判斷當前運行平臺git
const ua = navigator.userAgent
let pageType
//
if (/xxx-app/i.test(ua)) {
pageType = 'app'
}else {
// 其餘平臺
// ...
}
複製代碼
須要注意的是這裏設置的是請求首次URL時對應的header,並非設置瀏覽器每次請求的header,如Cookie等信息,仍是須要經過evaluateJavascript
手動進行設置github
_controller.future.then((controller) {
_webViewController = controller;
String tokenName = 'token';
String tokenValue = 'TkzMDQ5MTA5fQ.eyybmJ1c2ViJAifQ.hcHiVAocMBw4pg';
Map<String, String> header = {'Cookie': '$tokenName=$tokenValue', 'x-test':'123213'};
_webViewController.loadUrl('http://127.0.0.1:9999/2.html', headers: header);
});
複製代碼
經過navigationDelegate
能夠實現關於網絡請求的攔截操做如window.location
、iframe.src
等,所以能夠實現經過自定義schema實現JavaScript與Native互相通訊,web
navigationDelegate: (NavigationRequest request) {
print(request.url);
// 能夠實現schema相關功能
if (request.url.startsWith('xxx-app')) {
// todo 解析path和query,實現對應API
return NavigationDecision.prevent;
}
print('allowing navigation to $request');
return NavigationDecision.navigate;
},
複製代碼
在JS中,則能夠經過創建可以被攔截的網絡請求來實現通訊,下面咱們會介紹webview_flutter封裝的javascriptChannels
,所以這裏僅作了解便可json
requestBtn.onclick = () => {
let iframe = document.createElement('iframe')
iframe.style.display = 'none'
iframe.src = 'xxx-app://toast'
document.body.appendChild(iframe)
// 這種方式沒法攔截到Ajax發送的網絡請求
}
複製代碼
默認地,在Webview中,經過返回按鈕或者右滑返回(iOS下),會返回上一個原生頁面而不是上一個webview頁面,若是但願攔截該操做,能夠在Webview組件外包裹一層WillPopScope組件
WillPopScope(
onWillPop: () async {
var canBack = await _webViewController?.canGoBack();
if (canBack) {
// 當網頁還有歷史記錄時,返回webview上一頁
await _webViewController.goBack();
} else {
// 返回原生頁面上一頁
Navigator.of(context).pop();
}
return false;
},
child: WebView(...),
)
複製代碼
參考issue,可使用flutter_webview_plugin或者自定義alert
經過webviewController 的evaluateJavascript
方法調用Webview中的方法
controller.data.evaluateJavascript('console.log("123")')
複製代碼
該方法返回的是Future<String>
,其結果爲對應JS代碼執行的返回結果。
因爲webview_flutter
的_controller.future
是在網頁都加載完畢以後才執行的,此時網頁中的同步代碼都已執行完畢。
換句話說,使用evaluateJavascript
執行的代碼均發生在window.onload
事件以後,參考issue,
可是在某些場景下,JavaScript須要等待接口初始化完畢以後,才能在網頁中調用對應接口,這個需求能夠經過evaluateJavascript
和dispatchEvent
來實現。
// 通知網頁webview加載完畢
void triggerAppReady(controller) {
var code = 'window.dispatchEvent(new Event("appReady"))';
controller.evaluateJavascript(code);
}
_controller.future.then((controller)) {
triggerAppReady(controller);
});
複製代碼
而後在網頁中監聽appReady
方法
window.addEventListener('appReady', ()=>{
// 初始化網頁應用邏輯
init()
})
複製代碼
在初始化Webview
組件的時候傳入javascriptChannels
構造參數註冊提供給瀏覽器的API
WebView(
javascriptChannels: <JavascriptChannel>[
_toasterJavascriptChannel(context),
].toSet())
複製代碼
單個API定義相似於
JavascriptChannel _toasterJavascriptChannel(BuildContext context) {
return JavascriptChannel(
name: 'Toaster',
onMessageReceived: (JavascriptMessage message) {
Scaffold.of(context).showSnackBar(
SnackBar(content: Text(message.message)),
);
});
}
複製代碼
會向瀏覽器注入一個全局變量Toaster
,而後就在JavaScript中調用了
btn1.onclick = function () {
Toaster.postMessage('hello native') // 經過message.message獲取到'hello native'參數
};
複製代碼
從前面的交互能夠看出一些問題
javascriptChannels
參數中,須要傳入多個JavascriptChannel
對象,每一個對象都會想Webview的JS環境中添加一個全局變量,methondName.postMessage
的方法調用,不方便統一管理及維護基於這些問題,咱們能夠進一步封裝,一種更好的方式是將全部API都掛載到一個全局對象中,如微信瀏覽器中的JSSDK
wx.onMenuShareTimeline({
title: '', // 分享標題
link: '', // 分享連接,該連接域名或路徑必須與當前頁面對應的公衆號JS安全域名一致
imgUrl: '', // 分享圖標
success: function () {
// 用戶點擊了分享後執行的回調函數
}
},
複製代碼
若是按照約定統一調用Native方法的結構,咱們就能夠實現只註冊一個全局對象來封裝全部API的方法。
咱們統一調用結構爲{method: api方法名, params: 調用參數, callbcak: 回調函數}
這種形式,
function _callMethod(config) {
// 經過JavaScriptChannel注入的全局對象
window.AppSDK.postMessage(JSON.stringify(config))
}
function toast(data){
_callMethod({
method: 'toast',
params: data,
})
}
// 調用toast方法
toast({message:'hello from js'})
複製代碼
因爲postMessage
支持的數據格式有限,咱們統一將參數序列化爲JSON字符串,在接收消息時將字符串反序列化爲Dart實體。
因爲回到函數沒法被序列化,咱們能夠經過一種取巧的方法實現:
postMessage
前,構造一個全局的回調函數,並將該回調函數的名字經過參數callback
一塊兒傳遞給FluttercallbackName
,使用evaluateJavascript("window.$callbackName()")
方法,就能夠調用實現註冊的回調函數了下面對_callMethod
進行完善,並增長了註冊全局回調函數的邏輯
let callbackId = 1
function _callMethod(config) {
const callbackName = `__native_callback_${callbackId++}`
// 註冊全局回調函數
if (typeof config.callback === 'function') {
const callback = config.callback.bind(config)
window[callbackName] = function(args) {
callback(args)
delete window[callbackName]
}
}
config.callback = callbackName
// 經過JavaScriptChannel注入的全局對象
window.AppSDK.postMessage(JSON.stringify(config))
}
// 咱們在客戶端實現:完成api調用後,會判斷並執行該全局回調函數的邏輯
複製代碼
上面調用的window.AppSDK
是經過JavascriptChannel
註冊的
JavascriptChannel _appSDKJavascriptChannel(BuildContext context) {
return JavascriptChannel(
name: 'AppSDK',
onMessageReceived: (JavascriptMessage message) {
// 將JSON字符串轉成Map
Map<String, dynamic> config = jsonDecode(message.message);
});
}
複製代碼
爲了增長類型約束,咱們先將config這個Map轉成一個實體對象
// 約定JavaScript調用方法時的統一模板
class JSModel {
String method; // 方法名
Map params; // 參數
String callback; // 回調函數名
JSModel(this.method, this.params, this.callback);
// 實現jsonEncode方法中會調用實體類的toJSON方法
Map toJson() {
Map map = new Map();
map["method"] = this.method;
map["params"] = this.params;
map["callback"] = this.callback;
return map;
}
// 將JS傳過來的JSON字符串轉換成MAP,而後初始化Model實例
static JSModel fromMap(Map<String, dynamic> map) {
JSModel model =
new JSModel(map['method'], map['params'], map['callback']);
return model;
}
@override
String toString() {
return "JSModel: {method: $method, params: $params, callback: $callback}";
}
}
// 而後就能夠經過jsonDecode將JSON字符串轉爲實例類了
var model = JsBridge.fromMap(jsonDecode(jsonStr));
複製代碼
根據約定,須要經過jsBridgeModel.method
來判斷須要執行的方法,咱們將這部分的邏輯封裝在一個新的類中
class JsSDK {
static WebViewController controller;
// 格式化參數
static JSModel parseJson(String jsonStr) {
try {
return JSModel.fromMap(jsonDecode(jsonStr));
} catch (e) {
print(e);
return null;
}
}
static String toast(context, JSModel jsBridge) {
String msg = jsBridge.params['message'] ?? '';
Scaffold.of(context).showSnackBar(
SnackBar(content: Text(msg)),
);
return 'success'; // 接口返回值,會透傳給JS註冊的回調函數
}
// 向H5暴露接口調用
static void executeMethod(BuildContext context, WebViewController controller, String message) {
// 根據JSON字符串構造JSModel對象,
// 而後執行model對應方法
// 判斷是否有callback參數,若是有,則經過evaluateJavascript調用全局函數
}
}
複製代碼
下面是整個executeMethod
方法的實現
static String toast(context, JsBridge jsBridge) {
String msg = jsBridge.params['message'] ?? '';
Scaffold.of(context).showSnackBar(
SnackBar(content: Text(msg)),
);
return 'success'; // 接口返回值,會透傳給JS註冊的回調函數
}
static void executeMethod(BuildContext context, WebViewController controller, String message) {
var jsBridge = JsSDK.parseJson(message);
// 全部的API均經過handlers進行映射,鍵值對應前端傳入的methodName
var handlers = {
// test toast
'toast': () {
return JsSDK.toast(context, jsBridge);
}
};
// 運行method對應方法實現
var method = jsBridge.method;
dynamic result; // 獲取接口返回值
if (handlers.containsKey(method)) {
try {
result = handlers[method]();
} catch (e) {
print(e);
}
} else {
print('無$method對應接口實現');
}
// 統一處理JS註冊的回調函數
if (jsBridge.callback != null) {
var callback = jsBridge.callback;
// 將返回值做爲參數傳遞給回調函數
var resultStr = jsonEncode(result?.toString() ?? '');
controller.evaluateJavascript("$callback($resultStr);");
}
}
複製代碼
至此,咱們就完成了JavaScript調用原生API的一系列封裝。
在大部分業務場景下,基本上都是JavaScript調用原生提供的接口完成需求;但在一些特定的場景下,也須要JavaScript提供一些接口或鉤子由原生調用。
一個比較熟悉的場景是:網頁中的點擊購買出現SKU彈窗,此時點擊返回時,更但願關閉SKU彈窗而不是返回上一頁。
所以咱們還須要考慮JS向原生提供鉤子的場景,與上面的sdk
封裝相似,能夠將全部的鉤子統一放在一個全局對象上
window.callJS = {}
複製代碼
而後在打開sku彈窗時註冊一個goBack
方法,
let canGoBack = true
toggleBack.onclick = ()=>{
// 返回0則不返回
return 0
}
複製代碼
根據約定,在dart的返回判斷中,會調用window.callJS.goBack
並根據返回值判斷是否須要取消返回上一頁的操做
onWillPop: () async {
try {
String value = await controller.evaluateJavascript('window.callJS.goBack()');
// 注意執行返回結果會轉換成字符串,好比JS的布爾值True也會轉換成字符串'1'
bool canBack = value == '1';
return canBack;
} catch (e) {
return true;
}
}
複製代碼
這種作法看起來不是很優雅,由於咱們要在JS中操做全局變量,在上面的例子中,若是關閉了SKU彈窗,咱們還須要處理移除全局方法callJS.goBack
,不然會致使返回鍵失效。待我查查看有沒有其餘更合理的作法,而後再更新~
本文主要整理了webview_flutter
的一些基本用法,瞭解了Flutter與JavaScript的相互調用,最後研究瞭如何封裝一個簡易的JSBridge。在實際業務中,還須要考慮版本兼容、數據埋點等需求,在接下來的業務開發中,會逐步嘗試將這些功能一一完善。