jeremygo

jeremygo

我是把下一颗珍珠串在绳子上的人

JSBridge Principle

Introduction#

In today's era of mobile popularity, technology selection is primarily hybrid development, which combines the advantages of both native and Web H5 technologies:

  • Native technology refers to iOS (Objective C, Swift) and Android (Java): lower development efficiency, dependent on user updates for releases, but with higher performance and broader functionality coverage.
  • Web technology refers to JavaScript: higher development efficiency, flexible release updates, but with lower performance and limited functionality features.

In this model, H5 often needs to use native functionalities, such as opening the camera, viewing the local photo album, image uploading, page sharing, etc. Native also needs to push update statuses to the Web side.
JavaScript runs in a separate JS Context (Webview container, JSCore, etc.), isolated from the native runtime environment, requiring a mechanism for bidirectional communication between Native and Web, which is JSBridge:

A mechanism for bidirectional communication between Native and Web, using the JavaScript engine or Webview container as a medium to communicate through a defined protocol.

Through JSBridge, the Web side can call Native's Java interface, and the Native side can also call the Web's JS interface, achieving bidirectional calls.

Communication Principles#

JS Calls Native#

JavaScript calls Native mainly in three ways: Injecting API, Intercepting URL SCHEME, and Overriding global JS methods like prompt.

Injecting API#

The main principle is: through the interface provided by Webview, inject objects or methods into the JS runtime environment (window), allowing JS to directly execute the corresponding Native code logic when called.

iOS UIWebView implementation (mainly JavaScriptCore)

JSContext *context = [uiWebView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];

context[@"postBridgeMessage"] = ^(NSArray<NSArray *> *calls) {
    // Native logic
};

Frontend call

window.postBridgeMessage(message)

iOS WKWebView implementation (mainly WKScriptMessageHandler)

@interface WKWebVIewVC ()<WKScriptMessageHandler>

@implementation WKWebVIewVC

- (void)viewDidLoad {
    [super viewDidLoad];

    WKWebViewConfiguration* configuration = [[WKWebViewConfiguration alloc] init];
    configuration.userContentController = [[WKUserContentController alloc] init];
    WKUserContentController *userCC = configuration.userContentController;
    // Inject object, Native can capture when the frontend calls its method
    [userCC addScriptMessageHandler:self name:@"nativeBridge"];

    WKWebView wkWebView = [[WKWebView alloc] initWithFrame:self.view.frame configuration:configuration];
}

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
    if ([message.name isEqualToString:@"nativeBridge"]) {
        NSLog(@"Data passed from frontend %@: ",message.body);
        // Native logic
    }
}

Frontend call

window.webkit.messageHandlers.nativeBridge.postMessage(message);

Android implementation

/**
 Before 4.2, the interface for injecting JavaScript objects in Android was addJavascriptInterface,
 but this interface had vulnerabilities that could be exploited by malicious actors, endangering user security.
 Therefore, a new interface @JavascriptInterface was introduced in 4.2 to replace this interface and solve security issues.
 Thus, the method of injecting objects in Android has compatibility issues.
*/  
public class JavaScriptInterfaceDemoActivity extends Activity {
private WebView Wv;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        Wv = (WebView)findViewById(R.id.webView);     
        final JavaScriptInterface myJavaScriptInterface = new JavaScriptInterface(this);    	 

        Wv.getSettings().setJavaScriptEnabled(true);
        Wv.addJavascriptInterface(myJavaScriptInterface, "nativeBridge");
    }

    public class JavaScriptInterface {
         Context mContext;

         JavaScriptInterface(Context c) {
             mContext = c;
         }

         public void postMessage(String webMessage){	    	
             // Native logic
         }
     }
}

Frontend call

window.nativeBridge.postMessage(message);
Intercepting URL Scheme#

URL Scheme is a link similar to a URL, designed for convenient direct calls between apps, formatted as:
<protocol>://<host>/<path>?<query>#fragment
A custom JSBridge communication URL Scheme: jsbridge://showToast?text=hello

The main principle is: After Native loads the Webview, all requests sent by the Web will pass through the Webview component, so Native can override the corresponding method to intercept requests that match the custom URL Scheme format for parsing and calling the native method.

There are several ways for the Web to send URL requests:

  • a tag (disadvantage: requires user action)
  • location.href (disadvantage: may cause page navigation loss)
  • ajax request (Android does not have a corresponding interception method)
  • Using iframe src

Among these methods, iframe src has been a commonly used solution since early on, with good compatibility:

  • Android provides the shouldOverrideUrlLoading method for interception
  • UIWebView uses shouldStartLoadWithRequest, while WKWebView uses decidePolicyForNavigationAction

However, there are certain drawbacks:

  • Based on URL, there is a length limitation
  • Creating requests takes some time, making it slower than calling the same functionality through injected API
Overriding global JS methods like prompt#

Webview has a method setWebChromeClient that can set a WebChromeClient object, which has three methods onJsAlert, onJsConfirm, onJsPrompt. When JS calls window.alert, window.confirm, or window.prompt, the corresponding methods will also be triggered, allowing for some processing using this mechanism.
Since intercepting these methods can impact performance, it is necessary to choose methods that are used less frequently. In Android, compared to the other methods, the prompt method is rarely used, making it the best option to occupy.

Native Calls JS#

Native calling JS is relatively simple; JavaScript, being an interpreted language, has the significant feature of being able to execute a piece of JS code anytime and anywhere through the interpreter. Thus, you can pass a concatenated JavaScript code string to the JS parser for execution, where the JS parser is the Webview.

  • iOS UIWebview uses stringByEvaluatingJavaScriptFromString
NSString *jsStr = @"JS code to execute";
[webView stringByEvaluatingJavaScriptFromString:jsStr];
  • iOS WKWebView uses evaluateJavaScript
[webView evaluateJavaScript:@"JS code to execute" completionHandler:^(id _Nullable response, NSError * _Nullable error) {
  
}];
  • Android before 4.4 could only use loadUrl to implement and could not execute callbacks, and calling would refresh the WebView
String jsCode = String.format("window.showWebDialog('%s')", text);
webView.loadUrl("javascript: " + jsCode);
  • Android after 4.4 provides evaluateJavascript to execute JS code and obtain return values for callbacks
String jsCode = String.format("window.showWebDialog('%s')", text);
webView.evaluateJavascript(jsCode, new ValueCallback<String>() {
  @Override
  public void onReceiveValue(String value) {

  }
});

Calls with Callbacks#

The methods of bidirectional communication between Native and Web mentioned above, when viewed from one end, still represent a unidirectional communication process: the Web side calls the Native method, and Native directly performs the relevant operation but cannot return the result to Web.
So how can we achieve returning results after the operation on the other end?

Based on the previous unidirectional communication, when one side calls, it adds a callbackId in the parameters to mark the corresponding callback. After the other side receives the call request and performs the actual operation, it calls back with the result and callbackId, allowing the original side to match the corresponding callback and pass the result for execution. In fact, both sides go through a call once.

image

Specific example: Clicking a button on the Web side retrieves the value from the Native input box and displays it in a Web popup.

// Web side code
<body>
  <div>
    <button id="showBtn">Get Native input and display in Web popup</button>
  </div>
</body>
<script>
  let id = 1;
  // Save callback based on id
  const callbackMap = {};
  // Use JSSDK to encapsulate the event for calling and communicating with Native, avoiding excessive pollution of the global environment
  window.JSSDK = {
    // Get the value of the Native input box, with a callback
    getNativeEditTextValue(callback) {
      const callbackId = id++;
      callbackMap[callbackId] = callback;
      // Call JSB method and pass in callbackId
      window.NativeBridge.getNativeEditTextValue(callbackId);
    },
    // Receive callbackId sent from Native
    receiveMessage(callbackId, value) {
      if (callbackMap[callbackId]) {
        // Match callback based on ID and execute
        callbackMap[callbackId](value);
      }
    }
  };

	const showBtn = document.querySelector('#showBtn');
  // Bind button event
  showBtn.addEventListener('click', e => {
    // Call through JSSDK, passing in the callback function
    window.JSSDK.getNativeEditTextValue(value => window.alert('Native input value: ' + value));
  });
</script>
// Android side code
webView.addJavascriptInterface(new NativeBridge(this), "NativeBridge");

class NativeBridge {
  private Context ctx;
  NativeBridge(Context ctx) {
    this.ctx = ctx;
  }

  // Get Native input value
  @JavascriptInterface
  public void getNativeEditTextValue(int callbackId) {
    MainActivity mainActivity = (MainActivity)ctx;
    // Get the value of the Native input box
    String value = mainActivity.editText.getText().toString();
    // JS code to be injected for execution in Web
    String jsCode = String.format("window.JSSDK.receiveMessage(%s, '%s')", callbackId, value);
    // Execute in the UI thread
    mainActivity.runOnUiThread(new Runnable() {
      @Override
      public void run() {
        mainActivity.webView.evaluateJavascript(jsCode, null);
      }
    });
  }
}

This achieves a Web -> Native call with a callback, and similarly, Native -> Web follows the same logic (with the concept of bridgeName relative to callbackId).

Here is a complete call sequence diagram, showing that the entire process is very similar to the JSONP mechanism, requiring both sides to adhere to a set of calling mechanisms:

image

Reference Methods#

The actual JSBridge reference methods have the following two types, each with its pros and cons:

MethodAdvantagesDisadvantages
Native side injectionJSBridge version is easy to align with Native, no compatibility concernsInjection timing is uncertain, requiring a failure retry mechanism
Also, the JS side must check if it has been injected when calling
JS side referenceTiming is controllable, JS can call directly and convenientlyNeeds to consider compatibility between multiple versions of JSBridge and Native

References#

Principles of JSBridge
In-depth Understanding of JSBridge: From Principles to Usage
JSBridge Principles and Practices

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.