在webview_flutter中封裝JSBridge

橙紅年代發表於2020-06-22

本文同步在個人部落格shymean.com上,歡迎關注

最近的業務需要使用Flutter開發App應用了,其中打算將部分已有的Web應用進行復用,因此需要研究一下Flutter的Hybird應用開發。本文主要整理在Flutter中使用Webview的教程和遇見的一些問題,最後給出了關於Flutter中對JSBridge的簡單封裝。

本文完整程式碼均放在github上面。參考

使用webview_flutter

webview_flutter是官方維護的一個外掛,因此還是比較可靠的,直接執行示例程式碼

iOS開啟網頁載入白屏,需要在ios/Runner/Info.plist中配置

<key>io.flutter.embedded_views_preview</key>
<true/>
複製程式碼

Android也需要配置網路許可權,在檔案/android/app/src/main/AndroidManifest.xml中加入

<uses-permission android:name="android.permission.INTERNET"/>
<application>...</application>
複製程式碼

設定UA

WebView構造引數userAgent中傳入自定義的ua字串即可,這樣在網頁中就可以根據UA判斷當前執行平臺

const ua = navigator.userAgent

let pageType
//
if (/xxx-app/i.test(ua)) {
  pageType = 'app'
}else {
  // 其他平臺
  // ...
}
複製程式碼

設定Header

需要注意的是這裡設定的是請求首次URL時對應的header,並不是設定瀏覽器每次請求的header,如Cookie等資訊,還是需要通過evaluateJavascript手動進行設定

_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.locationiframe.src等,因此可以實現通過自定義schema實現JavaScript與Native互相通訊,

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,因此這裡僅做了解即可

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(...),
)
複製程式碼

webview_flutter不支援alert

參考issue,可以使用flutter_webview_plugin或者自定義alert

互動

Native呼叫JavaScript

通過webviewController 的evaluateJavascript方法呼叫Webview中的方法

controller.data.evaluateJavascript('console.log("123")')
複製程式碼

該方法返回的是Future<String>,其結果為對應JS程式碼執行的返回結果。

等待客戶端準備完畢

由於webview_flutter_controller.future是在網頁都載入完畢之後才執行的,此時網頁中的同步程式碼都已執行完畢。

換句話說,使用evaluateJavascript執行的程式碼均發生在window.onload事件之後,參考issue,

但是在某些場景下,JavaScript需要等待介面初始化完畢之後,才能在網頁中呼叫對應介面,這個需求可以通過evaluateJavascriptdispatchEvent來實現。

// 通知網頁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()
})
複製程式碼

JavaScript呼叫Native

在初始化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'引數
};
複製程式碼

封裝JSBridge

從前面的互動可以看出一些問題

  • javascriptChannels引數中,需要傳入多個JavascriptChannel物件,每個物件都會想Webview的JS環境中新增一個全域性變數,
  • 在JS中對於每個API,都需要通過methondName.postMessage的方法呼叫,不方便統一管理及維護

基於這些問題,我們可以進一步封裝,一種更好的方式是將所有API都掛載到一個全域性物件中,如微信瀏覽器中的JSSDK

wx.onMenuShareTimeline({
  title: '', // 分享標題
  link: '', // 分享連結,該連結域名或路徑必須與當前頁面對應的公眾號JS安全域名一致
  imgUrl: '', // 分享圖示
  success: function () {
  // 使用者點選了分享後執行的回撥函式
  }
},
複製程式碼

如果按照約定統一呼叫Native方法的結構,我們就可以實現只註冊一個全域性物件來封裝所有API的方法。

約定請求型別

JavaScript

我們統一呼叫結構為{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一起傳遞給Flutter
  • 當Flutter執行完對應邏輯時,根據引數的callbackName,使用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呼叫後,會判斷並執行該全域性回撥函式的邏輯
複製程式碼

Dart

上面呼叫的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));
複製程式碼

封裝API和回撥

根據約定,需要通過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的一系列封裝。

向Dart提供鉤子函式

在大部分業務場景下,基本上都是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。在實際業務中,還需要考慮版本相容、資料埋點等需求,在接下來的業務開發中,會逐步嘗試將這些功能一一完善。

相關文章