深入浅出JSBridge

2,547 阅读10分钟

混合开发

随着 Web 技术、移动端应用的高速发展、业务快速迭代,混合开发(Hybrid)已经成为一种主流且常见的技术。一套好的 Hybrid 的技术方案,不仅能让 APP 拥有极致的性能和体验,同时也能拥有 Web 灵活的开发模式、跨平台 以及 快速更新部署的能力。

混合开发优劣势

  • 优势:开发速度快、维护更新快、业务迭代周期短
  • 劣势:性能问题、兼容性问题
  • Android 5.0+,IOS 9.0+ 劣势不再明显

常见混合开发方案

多 View 混合型

多 View 混合型是基于 Webview 的方案,整个 APP 大部分是原生代码实现的,少部分界面在 Webveiw 中展现,通过 JSBridge 调用原生能力,市面上大部分 APP 中都有使用。后面我们也主要介绍这种形式混合开发相关技术点。

Web 主体型

Web 主体型同样也是基于 Webview 的方案,不同于多 View 混合型的地方在于 其整个 APP 界面都是由 Web 技术实现的,原生 APP 端仅提供一个 Webview。

WEEX\React Native

严格来说 WEEX | React Native 这类框架并不属于混合开发,在这里仅仅简单介绍。Native UI型指 在开发时使用Web端语言,渲染时调用原生能力进行界面绘制,常见的框架WEEX\React Native

对比

核心技术JSBridge

上面我们介绍了混合开发的几种常见方案,能让 Web 具有 APP 原生能力离不开 JSBridge。

那什么是 JSBridge 呢?

  • JSBridge 是 Native 端和 Web 端双向通信的一种机制,可以理解为一种协议。
  • Native 代码和 Web 端 JS 代码 都通过 Javascript 引擎、Webview 作为中间媒介进行交互。
  • 双方进行协议约定后就可以开始愉快通信了。

JSBridge原理

从上图我们可以了解到 Native 和 Web 如何通过 JSBridge 进行交互的。

  • 图中虚线部分为 JSBridge 桥接部分。
  • Native 端通过调用 Web 端注册的 JavaScript API 传递消息。
  • Web 端通过调用 Native 端注册的 Native API 传递消息。
  • 相互之间可以传递信息,即可实现复杂的交互场景(同步、异步)。
  • 传递参数统一使用 JSON 字符串,方便各端解析。

通信方式

下图中覆盖 Web 端和 Native 端大部分的通信方法和手段。

下面从 注册 和 调用 的各端具体代码实现上来深入了解一下。

Web 端注册 API, Native端调用

  1. Web 端注册 API
window.foo = function foo() {
	console.log('I am Web')
}
  1. IOS 端调用
// WKWebview 内使用 evaluateJavaScript 方法
[wkWebView evaluateJavaScript:@"window.foo();" completionHandler:^(id result, NSError * _Nullable error) {
    // 接受返回的参数,result中
}];
// UIWebview 内使用 stringByEvaluatingJavaScriptFromString 方法
NSString *result = [uiWebView stringByEvaluatingJavaScriptFromString:@"window.foo();"];

详情参考: WKWebView-evaluateJavaScript | UIWebView-stringByEvaluatingJavaScriptFromString

  1. Android 端调用
// 因为该方法在 Android 4.4 版本才可使用,所以使用时需进行版本判断
if (Build.VERSION.SDK_INT < 18) {
    // 该方法无法直接获取返回值
    mWebView.loadUrl("javascript:foo()");
} else {
    mWebView.evaluateJavascript("javascript:foo()", new ValueCallback<String>() {
        @Override
        public void onReceiveValue(String value) {
            //此处为 js 返回的结果
        }
    });
}

Native 端调用方式对比

Native 端注册 API, Web 端调用

Webview 注入

  1. Android 端实现

    • 第一步:定义 交互方法
    public class NativeAPI extends Object {
        @JavascriptInterface
        public void hello(String msg) {
            System.out.println("收到 JS 调用")
        }
    }
    
    • 第二步:将方法绑定到 Webview
      public class MainActivity extends AppActivity {
          WebView webview;
          @Override
          protected void onCreate(Bundle savedInstanceState) {
              // ...
              // 通过该方法将 Java 对象映射到 JS 对象
              webview.addJavascriptInterface(new NativeAPI(), "test");
              // 加载网页
              webview.loadUrl("https://xxxxx");
          }
      }
      
    • 第三步:Web 端调用
    window.test.hello('I am web.')
    

    注意:公开给 JS 调用的方法 必须加上 @JavascriptInterface 注解,Android 4.2以下版本没有 JavascriptInterface 注解,通过 addJavascriptInterface 方法公开的 Java 对象存在 注入风险.

  2. IOS 端实现

  • 第一步:初始化 webview 配置,注册监听 hello 方法 oc WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init]; // ... [config.useContentController addScriptMessageHandler:self name:@"hello"];
  • 第二步:监听并处理 Web 端 JS 调用
        - (void)userContentController: (WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
                if ([message.name isEqualToString:@"hello"]) {
                    [self hello:message.body]
                }
          }
        - (void)hello:(id)arguments {
            // do something ...
        }
    
  • 第三步:Web 端调用
window.webkit.messageHandlers.hello.postMessage('I am web.')

注意:addscriptmessagehandler 是 WKWebView 上的方法,UIWebView 没有类似方法。

URL 拦截

在使用 URL拦截 方式进行双向通信时,首先我们要 定义通信协议,当 web 端发起一个请求时,这个请求的格式要区别于 http 请求,避免 Native 拦截正常请求。例如我们仿照 http 协议 约定 jsbridge://methodName?args,协议头 为 jsbridge,下一部分为 方法名,问号后面部分为参数,参数的格式为 JSON 字符串。

  1. Android 端实现
    • 第一步:重写 webveiwclient 内拦截 URL 的方法
    webView.setWebViewClient(new WebViewClient() {
        @Override
        public boolean shouldOverrideUrlLoading(WebView view, String url) {
            Uri uri = Uri.parse(url);                                 
            // if (这里判断URL是否是我们定义的协议格式) {
                  // do something
                  // 阻止跳转
                  // return true;
            // }
            return super.shouldOverrideUrlLoading(view, url);
        }
    }
    
  • 第二步:在 Web 端通过发起 请求调用
    iframe.src = 'jsbridge://methodName?args'
    location.href = jsbridge://methodName?args'
    
  1. IOS 端实现
    • 第一步:在 WKwebveiw 中拦截 URL
    - (void)webView:(WKWebView *)webView 
       decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction 
       decisionHandler: (void (^)(WKNavigationActionPolicy))decisionHandler
       {
           NSURL *URL = navigationAction.request.URL;
           // if (这里判断URL是否是我们定义的协议格式) {
               //
               // do something
               //
               // decisionHandler(WKNavigationActionPolicyCancel); 
               // 以阻止 `URL` 的加载或者跳转
               // decisionHandler(WKNavigationActionPolicyCancel);
               // return;
           // }
           decisionHandler(WKNavigationActionPolicyAllow);
       }
    
  • 第二步:在 Web 端通过发起 请求调用
    iframe.src = 'jsbridge://methodName?args'
    location.href = jsbridge://methodName?args'
    

弹窗拦截

字面理解 通过 web 端弹窗 传递消息,同样要先定义 通信协议,我们可以和上面 URL 那种方式一致,也可以直接传一个 JSON 字符串,类似:

{
    "methodName": "xxxx",
    "args": "xxx"
}
  1. Android 端实现
    • 第一步:Webview 内 弹窗拦截
    webSettings.setJavaScriptCanOpenWindowsAutomatically(true);
    mWebView.setWebChromeClient(new WebChromeClient() {
       @Override
       public boolean onJsPrompt(
       	WebView view, 
       	String url, 
       	String message, 
       	String defaultValue, 
       	JsPromptResult result
       	) {
           // if (这里判断message是否是我们定义的协议格式) {
           // do something
           // 阻止弹窗
           // return true;
         	// }
           return super.onJsPrompt(view, url, message, defaultValue, result);
       }
       // 拦截JS的警告框
       @Override
       public boolean onJsAlert(...) {
           return super.onJsAlert(view, url, message, result);
       }
       // 拦截JS的确认框
       @Override
       public boolean onJsConfirm(...) {
           return super.onJsConfirm(view, url, message, result);
       }
    });
    
  • 第二步:在 Web 端调用
    alert('jsbridge:hello?msg=I am Web')
    prompt('jsbridge:hello?msg=I am Web')
    confirm('jsbridge:hello?msg=I am Web')
    
  1. IOS 端实现
    • 第一步:在 WKwebveiw 中拦截弹窗
       - (void)webView:(WKWebView *)webView 
           // 参数1:弹窗消息
       	runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt 
           // 参数2:要在文本输入字段中显示的初始文本。
       	defaultText:(NSString *)defaultText 
       	// 参数3:有关其JavaScript进程发起此调用的框架的信息。
       	initiatedByFrame:(WKFrameInfo *)frame 
       	// 参数4:取消文本输入面板后调用的完成处理程序
       	completionHandler:(void (^)(NSString * _Nullable))completionHandler{
           NSError *err = nil;
           NSData *message = [prompt dataUsingEncoding:NSUTF8StringEncoding];
           // if (这里判断message是否是我们定义的协议格式) {
           // do something
           // 阻止弹窗
           // completionHandler(returnValue);
         	// }
       }
    
  • 第二步:在 Web 端调用
    alert('jsbridge:hello?msg=I am Web')
    prompt('jsbridge:hello?msg=I am Web')
    confirm('jsbridge:hello?msg=I am Web')
    

Native 端注册方式对比

异步实现

Web 与 Native 交互的场景中经常会碰到需要处理一些耗时操作,同时也存在 通过 Webview 互相调用时无法直接拿到方法返回值的情况,在这种时候 我们通过异步的方式实现双方的通信。

实现的流程 如下图所示: 简单解释一下图中各个步骤:

  • 第一步:在 Web 端调用 Native 端提供的 异步方法
  • 第二步:在 Web 端发起调用前注册一个回调方法(callback method)到 window 对象,用于接收 异步方法的返回值。
  • 第三步:发起调用将调用的方法名 和 回调方法名 传给 Native 端。
  • 第四步:Native 端在新的线程内处理 耗时操作。
  • 第五步:Native 端处理完成后,将处理结果通过调用 window 对象上的回调方法传回 Web 端。
  • 第六步:Web 端接收到返回值后 处理...

上面介绍了 Native 端异步方法的处理过程,反之 Web 端提供的异步方法 处理流程一样。

终极方案

上面介绍了那么多种通信方式,我如何选择使用呢? 当然 适合 才是最重要的。

APP 兼容性Web 端调用方式Native 端调用方式
IOS 8 +
Android 4.2 +
IOS 内使用:window.webkit.messageHandlers.[方法名].postMessage('传参')
Android 内使用:window[方法名]
IOS: WKWebView-evaluateJavaScript
Android: Webview-evaluateJavascript
全兼容使用 prompt 弹窗Android 端拦截弹窗,IOS 端在 UIWebView 上改造拦截弹窗
  • 为什么不使用 URL 拦截方案?
    通过 web 端发起的 URL 请求,通过改变iframe src属性的这种方式并不能保证shouldOverrideUrlLoading每次都会被调用。
  • 为什么使用 prompt 弹窗,不使用 alert 或 comfirm?
    因为只有 prompt 可以返回任意类型的值,而 alert 对话框没有返回值,confirm 对话框只能返回两种状态(确定 / 取消)两个值

开源实现源码简析

在之前对 JSBridge 相关实现调研中发现了 DSBridge 库,该库在三端(Web、IOS、Android)都有实现,并且丰富了很多功能,因此对其 源码进行了研究学习,验证了前文介绍的一些方法,同时也收获了一些实践经验。

copy 原文的介绍: DSBridge 三端易用的现代跨平台 Javascript bridge, 通过它,你可以在Javascript和原生之间同步或异步的调用彼此的函数。

在阅读学习代码后,整理了一下思路,将源码分为 初始化流程Web 端调用逻辑Native 端注册逻辑Native 端调用逻辑 这四个部分。由于本人对 Object-c 不是很了解,这里就不献丑了,原理和 Android 的实现一致,核心方法的位置会标注出来,感兴趣的同学可以自行研究。

Native 初始化流程

如上图所示,DSBridge 在初始化原生 Webview 时:

  1. 在 Android 做了兼容处理,> android 4.4 的系统直接 使用 webview 注入的方式注册 API,< android 4.4 的系统通过在 window.userAgent 对象上增加标志位(表示该环境下 通过弹窗拦截方式)。
  2. 在 IOS 内直接在 window 对象上增加 _dswk 标志位(表示 IOS 下通过弹窗拦截方式调用)。

源码位置:

  1. Android初始化机制
  2. IOS初始化机制

Web 初始化过程

Web 端初始化,其实就是提前定义一些后续需要用到的公共变量、通用方法等,直接上代码:


!function () {
    if (window._dsf) return;
    var _close=window.close;
    var ob = {
        // 保存 JS API 同步方法
        _dsf: {
            _obs: {}
        },
        // 保存 JS API 异步方法
        _dsaf: {
            _obs: {}
        },
        // 回调函数自增 ID
        dscb: 0,
        // 页面关闭后清除绑定关系
        close: function () {
            // do something
        },
        // 处理 Native 发来的消息(本质上用于 Native 调用 JS API)
        _handleMessageFromNative: function (info) {
            // do something
        }
    }
    for (var attr in ob) {
        window[attr] = ob[attr]
    }
    bridge.register("_hasJavascriptMethod", function (method, tag) {
        // do something
    })
}();

这里可以注意一下 _handleMessageFromNative 这个方法,这个方法主要用于 处理 Native 对 JS API 的调用,后面在 Native 端调用逻辑中会再看到,与之呼应。

感兴趣的可以看一下源码:源码位置

Web 端核心代码

其实 Web 端核心代码 主要是由 调用 Native 方法注册 JS 方法 两部分组成。

var bridge = {
    // 调用 Native API
    call: function (method, args, cb) {
        // 将回调函数注册到全局
        if (typeof cb == 'function') {
            var cbName = 'dscb' + window.dscb++;
            window[cbName] = cb;
        }
        // android 4.4+
        if(window._dsbridge){
           ret=  _dsbridge.call(method, arg)
        }
        // android 4.4以下 和 IOS内
        else if(
            window._dswk ||
            navigator.userAgent.indexOf("_dsbridge")!=-1
            ){
           ret = prompt("_dsbridge=" + method, arg);
        }
    },
    // 注册 JS 方法
    register: function (name, fun, asyn) {
        var q = asyn ? window._dsaf : window._dsf
        // ....
        if (typeof fun == "object") {
            q._obs[name] = fun;
        } else {
            q[name] = fun
        }
    },
    // 注册异步方法
    registerAsyn: function (name, fun) {
        this.register(name, fun, true);
    }
}

这段源码中,注意下 window._dswk || navigator.userAgent.indexOf("_dsbridge")!=-1 这个地方的判断逻辑,针对不同的宿主环境进行不同的方法调用,正好与 前面 Native 初始化 小结对应上。

Android 端注册 API 逻辑

使用 DSBridge SDK 时,会先将 Native 端提供的 API 通过 addJavascriptObject 存储记录到 javaScriptNamespaceInterfaces 内。

public class JsApi{
    // 注册同步方法
    @JavascriptInterface
    public String testSyn(Object msg)  {
        return msg + "[syn call]";
    }

    // 注册异步方法
    @JavascriptInterface
    public void testAsyn(Object msg, CompletionHandler handler) {
        handler.complete(msg+" [ asyn call]");
    }
}

// ....
DWebView dwebView= (DWebView) findViewById(R.id.dwebview);
dwebView.addJavascriptObject(new JsApi(), null);

然后,在初始化 Android 端 SDK 时,会根据不同 android 版本进行注册。DWebView.java# init 方法

if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN) {
    // 这里的 super 指的是 webview,当 android 大于 4.1 版本时,通过 webview 自带的 addJavascriptInterface 方法注入方法到 web 端 windows 对象。
    // 这里的 innerJavascriptInterface 对象实例包含了一个 call 方法 供 web 端调用
	super.addJavascriptInterface(innerJavascriptInterface, BRIDGE_NAME);
} else {
	// 在低版本的 android 上在 web UA 上设置 dsbridge 标志
	settings.setUserAgentString(settings.getUserAgentString() + " _dsbridge");
}

最后,Web 端调用时,其实仅调用 call 方法进行交互,call 方法的核心逻辑在下面有讲解。

Android 端调用API核心代码

从 DSBridge 使用文档中,可以看出注册 API 时,通过定义 class,使用 @JavascriptInterface 注解标注 提供给 Web 的 API 方法,但是在 Web 端调用时 却是通过 call 方法 传递需要调用的 API 方法名 统一调用,这里该库通过在 call 方法内通过反射的机制在运行时调用之前定义好的 class 内的方法,这样做大大提高了 灵活性和可配置性,同时降低模块间的耦合。 下面我们来看一下具体的实现:DWebView.java#L80 call 方法

@JavascriptInterface
public String call(String methodName, String argStr) {

    // 第一步:通过反射方式获取 JAVA方法(Native API)
    Class<?> cls = jsb.getClass();
    boolean asyn = false;
    try {
        // 通过反射获取方法,第二个参数存在CompletionHandler类时表示为异步
        method = cls.getMethod(methodName,
                new Class[]{Object.class, CompletionHandler.class});
        asyn = true;
    } catch (Exception e) {
        try {
            method = cls.getMethod(methodName, new Class[]{Object.class});
        } catch (Exception ex) {

        }
    }
    // 省略 注解校验
    
    // 处理异步方法
    if (asyn) {
        // 记录回调函数名
        final String cb = callback;
        // 第二步:调用Native API
        method.invoke(jsb, arg, new CompletionHandler() {
            // 异步方法执行完成,存在返回值
            @Override
            public void complete(Object retValue) {
                complete(retValue, true);
            }
            // 异步方法执行完成,无返回值
            @Override
            public void complete() {
                complete(null, true);
            }
            // 进度回调
            @Override
            public void setProgressData(Object value) {
                complete(value, false);
            }
            // 异步方法执行成功的回调
            private void complete(
                // 返回值
                Object retValue, 
                boolean complete) {

                // 异步方法是否需要返回值
                if (cb != null) {
                    // ...
                    evaluateJavascript(script);
                }
            }
        });
    } else {
        // 第二步:同步方法执行后直接返回
        retData = method.invoke(jsb, arg);
        ret.put("code", 0);
        ret.put("data", retData);
        return ret.toString();
    }
    return ret.toString();
}

参考