Follow me on my blog shymean.com

Recently, some businesses need to use Flutter to develop apps. They plan to reuse some of their existing Web applications, so they need to study Flutter’s Hybird application development. This article mainly summarizes the tutorials and problems encountered in using Webview in Flutter, and finally gives a simple encapsulation of JSBridge in Flutter.

The complete code for this article is on Github. reference

  • Flutter uses JsBridge to handle Webview and H5 communication
  • Easy guide to Flutter WebView and JS interaction

Using webview_flutter

Webview_flutter is officially maintained as a plugin, so it is fairly reliable to run the sample code directly

IOS Blank screen when opening web pages, you need to configure this parameter in iOS /Runner/ info.plist

<key>io.flutter.embedded_views_preview</key>
<true/>
Copy the code

Android also need to configure the network access, in the file/Android/app/SRC/main/AndroidManifest. Added to XML

<uses-permission android:name="android.permission.INTERNET"/>
<application>.</application>
Copy the code

Set the UA

Pass a custom UA string into the WebView construction parameter userAgent, so that the current operating platform can be determined based on ua in the web page

const ua = navigator.userAgent

let pageType
//
if (/xxx-app/i.test(ua)) {
  pageType = 'app'
}else {
  // Other platforms
  // ...
}
Copy the code

Set the Header

Please note that this is the header for the first URL requested, not the header for each browser request, such as cookies, which needs to be set manually through 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);
});
Copy the code

Blocking network requests

NavigationDelegate can implement interception operations on network requests, such as window.location, iframe.src, etc., so it can realize communication between JavaScript and Native through customized schema.

navigationDelegate: (NavigationRequest request) {
  print(request.url);
  // Can implement schema-related functions
  if (request.url.startsWith('xxx-app')) {
    // Todo parses path and query to implement the corresponding API
    return NavigationDecision.prevent;
  }
  print('allowing navigation to $request');
  return NavigationDecision.navigate;
},
Copy the code

In JS, communication can be achieved by creating network requests that can be intercepted. JavascriptChannels encapsulated by Webview_FLUTTER will be introduced below, so it is only necessary to understand

requestBtn.onclick = (a)= > {
  let iframe = document.createElement('iframe')
  iframe.style.display = 'none'
  iframe.src = 'xxx-app://toast'
  document.body.appendChild(iframe)
  // This approach does not intercept network requests sent by Ajax
}
Copy the code

Intercept return operation

By default, in Webview, a back button or right swipe (on iOS) returns the previous native page instead of the previous Webview page. If you want to block this action, you can wrap the WillPopScope component around the Webview component

WillPopScope(
  onWillPop: () async {
    var canBack = await_webViewController? .canGoBack();if (canBack) {
      // Return to the previous page of the WebView while the page still has history
      await _webViewController.goBack();
    } else {
      // Return to the previous page of the native page
      Navigator.of(context).pop();
    }
    return false;
  },
  child: WebView(...),
)
Copy the code

Webview_flutter does not support alert

By referring to issues, you can use flutter_webview_plugin or custom alert

interaction

Native invoke JavaScript

Call the methods in the Webview through the evaluateJavascript method of the webviewController

controller.data.evaluateJavascript('console.log("123")')
Copy the code

This method returns the Future

, which is the result of the corresponding JS code execution.

Wait until the client is ready

Because the _controller.future of webview_flutter is executed after the page has been loaded, the synchronization code in the page has already been executed.

In other words, any code executed using evaluateJavascript occurs after the window.onload event.

In some cases, however, JavaScript needs to wait for the interface to be initialized before invoking the interface in a web page. This can be done with evaluateJavascript and dispatchEvent.

// Notify the web page that the webView has loaded
void triggerAppReady(controller) {
  var code = 'window.dispatchEvent(new Event("appReady"))';
  controller.evaluateJavascript(code);
}

_controller.future.then((controller)) {
  triggerAppReady(controller);
});
Copy the code

Then listen for the appReady method in the web page

window.addEventListener('appReady', () = > {// Initialize the web application logic
  init()
})
Copy the code

JavaScript call Native

The javascriptChannels constructor parameter is passed when initializing the Webview component to register the API provided to the browser

WebView( 
    javascriptChannels: <JavascriptChannel>[
        _toasterJavascriptChannel(context),
    ].toSet())
Copy the code

The individual API definitions are similar to

JavascriptChannel _toasterJavascriptChannel(BuildContext context) {
    return JavascriptChannel(
        name: 'Toaster',
        onMessageReceived: (JavascriptMessage message) {
          Scaffold.of(context).showSnackBar(
            SnackBar(content: Text(message.message)),
          );
        });
}
Copy the code

A global variable, Toaster, is injected into the browser and called in JavaScript

btn1.onclick = function () {
    Toaster.postMessage('hello native') // Get the 'hello native' argument with message.message
};
Copy the code

Encapsulation JSBridge

You can see some problems from the previous interaction

  • injavascriptChannelsParameter, you need to pass in more than oneJavascriptChannelObject, each object will add a global variable to the Webview’s JS environment,
  • For every API in JS, you need to passmethondName.postMessageIs not convenient for unified management and maintenance

Based on these issues, we can further encapsulate, a better approach is to mount all apis into a global object, such as the JSSDK in the wechat browser

wx.onMenuShareTimeline({
  title: ' '.// Share the title
  link: ' '.// Share link, the link domain name or path must be the same as the current page corresponding public number JS security domain name
  imgUrl: ' '.// Share ICONS
  success: function () {
  // The callback function executed after the user clicks share}},Copy the code

If we call the structure of Native methods uniformly by convention, we can achieve a method that registers only one global object to encapsulate all apis.

Convention request type

JavaScript

The unified call structure is {method: API method name, params: call parameter, callBcak: callback function}.

function _callMethod(config) {
  // Global object injected by JavaScriptChannel
  window.AppSDK.postMessage(JSON.stringify(config))
}

function toast(data){
  _callMethod({
    method: 'toast'.params: data,
  })
}

// Call the toast method
toast({message:'hello from js'})
Copy the code

Because of the limited data formats supported by postMessage, we uniformly serialized the parameters into JSON strings and deserialized the strings into Dart entities when we received the message.

Since the return function cannot be serialized, we can do this in a tricky way:

  • In the callpostMessageBefore, construct a global callback function and pass the name of the callback function through the argumentcallbackAll together to Flutter
  • When the Flutter has completed its corresponding logic, the Flutter is based on the parameterscallbackName, the use ofevaluateJavascript("window.$callbackName()")Method, you can call the callback function that implements the registration

The following refines _callMethod and adds logic to register global callback functions

let callbackId = 1

function _callMethod(config) {
  const callbackName = `__native_callback_${callbackId++}`
  // Register the global callback function
  if (typeof config.callback === 'function') {
    const callback = config.callback.bind(config)
    window[callbackName] = function(args) {
      callback(args)
      delete window[callbackName]
    }
  }
  config.callback = callbackName
  // Global object injected by JavaScriptChannel
  window.AppSDK.postMessage(JSON.stringify(config))
}
After the API call is completed, the logic of the global callback function is determined and executed
Copy the code

Dart

The window.AppSDK called above is registered through JavascriptChannel

JavascriptChannel _appSDKJavascriptChannel(BuildContext context) {
  return JavascriptChannel(
    name: 'AppSDK',
    onMessageReceived: (JavascriptMessage message) {
      // Convert the JSON string to a Map
      Map<String.dynamic> config = jsonDecode(message.message);
    });
}
Copy the code

To add type constraints, we first convert the config Map into an entity object


// The convention is a uniform template for JavaScript method calls
class JSModel {
  String method; / / the method name
  Map params; / / parameters
  String callback; // The callback function name

  JSModel(this.method, this.params, this.callback);

  // Implement the jsonEncode method to call the entity class toJSON method
  Map toJson() {
    Map map = new Map(a); map["method"] = this.method;
    map["params"] = this.params;
    map["callback"] = this.callback;
    return map;
  }

  // Convert the JSON string passed from JS to MAP and initialize the Model instance
  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}"; }}// The JSON string can then be converted into an instance class by jsonDecode
var model = JsBridge.fromMap(jsonDecode(jsonStr));
Copy the code

Encapsulate apis and callbacks

By convention, jsbridgemodel.method is used to determine the method to be executed, and we encapsulate this part of logic in a new class


class JsSDK {
  static WebViewController controller;

  // Format parameters
  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'; // The interface returns a value that is passed transparently to the JS registered callback function
  }

  // Call to H5 exposed interface
  static void executeMethod(BuildContext context, WebViewController controller, String message) {
    // Construct the JSModel object from the JSON string,
    // Then execute the model corresponding method
    // Determine if there is a callback argument, and if so, call the global function through evaluateJavascript}}Copy the code

Here is the implementation of the entire executeMethod method

static String toast(context, JsBridge jsBridge) {
  String msg = jsBridge.params['message']????' ';
  Scaffold.of(context).showSnackBar(
    SnackBar(content: Text(msg)),
  );
  return 'success'; // The interface returns a value that is passed transparently to the JS registered callback function
}

static void executeMethod(BuildContext context, WebViewController controller, String message) {
  var jsBridge = JsSDK.parseJson(message);

  // All apis are mapped through handlers, and the key values correspond to methodNames passed in by the front end
  var handlers = {
    // test toast
    'toast': () {
      returnJsSDK.toast(context, jsBridge); }};// Run method to implement the corresponding method
  var method = jsBridge.method;
  dynamic result; // Get the interface return value
  if (handlers.containsKey(method)) {
    try {
      result = handlers[method]();
    } catch (e) {
      print(e); }}else {
    print($method = $method;);
  }

  // Handle JS registered callback functions uniformly
  if(jsBridge.callback ! =null) {
    var callback = jsBridge.callback;
    // Pass the return value as an argument to the callback function
    varresultStr = jsonEncode(result? .toString() ??' ');
    controller.evaluateJavascript("$callback($resultStr);"); }}Copy the code

At this point, we’ve wrapped up a series of native apis for JavaScript calls.

Provide hook functions to Dart

In most business scenarios, JavaScript basically calls natively provided interfaces to fulfill requirements; But in certain scenarios, JavaScript also needs to provide interfaces or hooks to be called natively.

A familiar scenario is: click to buy in the web page appears SKU popup window, at this time click back, want to close the SKU popup window rather than go back to the previous page.

So we also need to consider a scenario in which JS provides hooks to native objects, similar to the SDK wrapper above, which can unify all hooks into a global object

window.callJS = {}


Copy the code

Then register a goBack method when opening the SKU popover,

let canGoBack = true
toggleBack.onclick = (a)= >{
  // If 0 is returned, nothing is returned
  return 0
}
Copy the code

By convention, in dart’s return judgment, window.calljs. goBack is called and the return value is used to determine whether the return to the previous page needs to be cancelled

onWillPop: () async {
  try {
    String value = await controller.evaluateJavascript('window.callJS.goBack()');
    // Note that the result of execution is converted to a string, such as the JS Boolean True is also converted to the string '1'.
    bool canBack = value == '1';
    return canBack;
  } catch (e) {
    return true; }}Copy the code

This doesn’t look very elegant because we’re manipulating global variables in JS. In the above example, if the SKU popover is turned off, we also need to deal with removing the global method calljs.goback, which would invalidate the return key. Let me check to see if there is a more reasonable way to do this and then update

summary

This article mainly summarizes some basic usage of webview_flutter, understands the mutual call of Flutter and JavaScript, and finally studies how to encapsulate a simple JSBridge. In actual business, requirements such as version compatibility and data burying point need to be considered. In the following business development, we will gradually try to improve these functions one by one.