VillainHR

Android 和 Webview 如何相互 sayHello(一)

webview PWA 2018-08-21

本系列文章一共有两篇:主要来讲解 webview 和客户端的交互。 本篇为第一篇:Android 和 webview 的交互 后续一篇是:IOS 和 webview 的交互 如需获得最新的内容,可以关注微信公众号:前端小吉米

在移动时代 Web 的开发方式逐渐从 PC 适配时代转向 Hybird 的 Webview。以前,我们只需要了解一下 PC Chrome 提供的几个操作行为,比如 DOM、BOM、页面 window.location 跳转等等。你的一切行为都是直接和 浏览器打交道,只要规规矩矩的按照 W3C/MDN 上面的文档开发即可。比如,我需要你实现一个截屏的需求,后面一查文档,发现 API 不支持,没法做,直接打回~

后面,你开始做 Hybird APP,产品又提了这个截屏的需求,你查了一下文档,发现 API 还是不支持,但是,客户端同学就在边上,你一拍大腿说,老铁,给我一个截屏的 jsbridge 没问题吧?

对于 PC Web 和 Hybird App 来说,给 HTML5 开发者最直观的感受就是,以前 PC 上一些底层基础功能,你可以直接在 App 里面,配合客户端直接使用。除了这一点还有一些其它的区别点,比如:

  • 使用 window.location,并不能一定能实现跳转
  • unload 事件并不一定会触发
  • 302/301 重定向问题会让客户端同学崩溃
  • https 证书问题 log,只能从客户端同学那取
  • 客户端可以直接拿到你的 cookie
  • UA 的定制需要客户端来手动设置
  • ServiceWorker 开不开还得问客户端
  • 不一而足…

这里,将从一个 Web 开发者的角度触发,仔细探寻一下 Webview 开发下,Web 开发者将遇见哪些问题,了解和 客户端 交互的底层原理。本系列文章将分别介绍一下在 Android 和 IOS 系统下,开发 Hybird APP 大致流程和其中的需要注意、优化的地方。

本文主要介绍的是 Android 下 Webview 的开发。

tl;dr

本文主要从 H5 开发者的角度来简单讲解一下在 Hybird 开发过程中遇到的相关问题和对应的解决方案。

  • android 两种调用 H5 的方式
  • javascript 调用 android 方式的对比
  • jsbridge.js 文件的起源
  • android 如何 inject JS 文件
  • 客户端对于 webview 的性能优化

Anriod 开发 Webview 基础

Webview 在 Android 里面其实就是一个组件而已,它可以像其他的 Android 组件一样在 screen 中定位布局。对比于 HTML5 开发来说,可以类比为一个 Div,也就是说,webview 可以重叠 webview,同一个 screen 可以展示多个 webview 内容。

<RelativeLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:showIn="@layout/activity_main">
    <WebView
        android:id="@+id/webview"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
</RelativeLayout>

上面就是一个简单的 webview-activity 定义。顺便提一下:activity 是 Android 开发的一个非常重要的概念,相当于 Router 中的一个子页面。所以说,你新打开的 webview 的样式和布局,都需要通过客户端发版本才能更新的。比如,微信的 webview-acitivit 和 手Q 的 webview-activity 是两个完全不一样的 activity.

手Q 微信

在定制特有的 acitvity 之后,对于一个可用的 webivew,还需要对 webview 做相关的配置。整个流程图为:

image.png-55.5kB

参考实际代码为:

// activity 的 onCreate 事件中
WebView webView = (WebView) findViewById(R.id.webview);
webView.setWebViewClient(defaultViewClient);
webView.setWebChromeClient(mChromeClient);

// 设置 webSettings
WebSettings webSettings = webView.getSettings();
webSettings.setJavaScriptEnabled(true);
webSettings.setAllowContentAccess(true);
webSettings.setDatabaseEnabled(true);
webSettings.setDomStorageEnabled(true);


// 初始化完毕之后,就可以直接调用 loadUrl,来加载页面
webView.loadUrl(url);

这里简单解释一下上述代码。webview 本身只是用来作为打开 H5 页面的容器,其本身并不能很好的处理页面之间跳转或者加载事件等行为。而 setWebViewClient 和 setWebChromeClient 主要是用来作为补充使用。具体解释可以参考:

  • webview: 仅仅用来渲染和解析页面
  • webviewClient: 解决页面跳转问题,重定向、异步请求发送,https 证书问题。
  • webChromeClient: 处理页面 console.xx、alert、prompt 的信息、定制化设置页面的标题、页面加载进度等。

更多的内容,大家可以直接参考 Android 官方文档的 public method 查阅即可。如果对 react 开发有了解的同学,应该能很容易理解上面 public method 的大致含义。当设置对应的 webview 配置之后,打开一个页面就非常简单了,就两行代码:

WebView myWebView = (WebView) findViewById(R.id.webview);
myWebView.loadUrl("https://www.example.com");

findViewById 和前端的 document.getElementById 很类似,直接找到对应的 webview 节点,然后利用 loadUrl API 直接打开指定的地址。后面,我们就主要来介绍一下,android 是如何和 js 进行通信的。

android 如何和 js 相互通信

首先,我们提出这个问题的时候,可以想一想为什么?为什么 android 和 js 之间一定要进行通信呢?

回想一下平常的 hybird 的开发,我们通常在前端调用客户端接口来获取相关内容:

  • 获取用户地理位置
  • 获取用户选择照片的内容(通常返回的是 base64)
  • 拿到靠谱的 visibilityChange 事件
  • 调用客户端的消息发送接口 加快请求速度,比如腾讯内部的 Webso

所以,两者之间的通信,不仅必须,而且很重要。下面我们来简单介绍一下 通信

所谓的通信,其实更确切的来说就是传递消息。不过,这两者之间并不是简单的建立起一个通道,就可以直接进行通信。他们之间的通信方向和方式还是有些区别的。

  • android => js: 是通过 javascript:window.jsbridge_visibilityChange(xxx) 直接调用 window 里面绑定的执行函数,如果要传参的话,是直接转换成字符串 inline 到函数里面去。
  • js => android: 简单来说,就是让 android 监听相关的事件。这些事件对应着 JS API 里面的某些方法,比如 console、alert、prompt 等。

android 调用 js

我们深入到 API 层面来看一下,他们之间是如何相互进行调用的:

  • android => js: 方法只有两个非常简单
    • 使用 loadUrl("javascript:window.jsbridge_visibilityChange ")
    • API > 19。
mWebView.evaluateJavascript("(function() { return 'this'; })();", new ValueCallback<String>() {
    @Override
    public void onReceiveValue(String s) {
        // 上述定义函数执行完成时,return 的内容
        Log.d("LogName", s); // Prints: "this"
    }
});
  • js => android
    • 调用 android 设置的 JavascriptInterface (4.2 以上才能使用)
    • 通过 WebViewClient.shouldOverrideUrlLoading() 事件拦截对应的调用
    • WebChromeClient.onConsoleMessage() 监听 js 的 console 触发内容。
    • WebChromeClient.onJsPrompt 监听 js 的 prompt 触发内容。

js => android 的方法比较多,其中比较常用的有:WebChromeClient.onJsPrompt、WebViewClient.shouldOverrideUrlLoading、JavascriptInterface。

这里,我们着重来讲解一下 js 调用 android 的简单过程。

js 直接调用 android

这里,我们分方法来介绍一下上面对应的调用方式。首先是 addJavaScriptInterface。

addJavaScriptInterface

通过 addJavaScriptInterface 方法,可以直接在 window 上注入一个对象,上面挂载这 JavaScriptInterface 里面定义的所有方法和内容。

我们直接看一个 addJavascriptInterface 内容。

# 定义一个 interface 对象
public class WebAppInterface {
    Context mContext;

    /** Instantiate the interface and set the context */
    WebAppInterface(Context c) {
        mContext = c;
    }

    // 可以直接调用 Android 上面的
    @JavascriptInterface
    public void showToast(String toast) {
        Toast.makeText(mContext, toast, Toast.LENGTH_SHORT).show();
    }
}

# 在 webview 实例里面添加该 interface
mWebView.addJavascriptInterface(new JavaScriptInterface(), "jsinterface");

然后,我们可以直接在 js 代码里面调用对象上挂载的 API。

var String = window.jsinterface.showToast("update");

不过,该方法在 4.2 版本之前存在严重的安全漏洞–利用 Java 反射机制,直接能直接执行敏感的 android 权限。详细可以参考 addJavascriptInterface 远程指定代码漏洞。所以,你需要在指定的方法上面加上 @JavascriptInterface 装饰符。

对于这种方式,客户端同学是非常认可而且推崇的。因为不需要和其它复杂的方法耦合在一起,使用起来干净整洁。不过,有个问题是,4.2 一下的版本不能使用。

对于比使用其它的,比如通过 shouldOverrideUrlLoading 来处理的方法,这种方法实现的效率更高,更有效率。但是,一旦考虑的低版本,就不得不对于同一份 jsbridge 实现两次,所以这对于客户端就像是 Achilles’ Heel。

onJsPrompt

使用 onJsprompt 的逻辑很简单,通过直接监听 WebChromeClient.onJsPrompt 事件,设置好对应协议的内容即可。jsPrompt 在 Web 中对应的行为是弹出一个框,里面有用户的输入框和确定、取消按钮。

image.png-17.1kB

具体代码如下:

 mWebView.setWebChromeClient(new WebChromeClient() {
    /**
     * msg: 是通过 prompt(msg) 里面的内容
     * 
     */
    @Override
    public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
  
        Uri uri = Uri.parse(message);
        
        boolean handle = message.startsWith("jsbridge:");

        if(handle){
            result.confirm("trigger"); // 有客户端直接返回结果,不会吊起 input 
            return true;
        }

        return super.onJsPrompt(view, url, message, defaultValue, result);
    }
});

然后,我们只要在 webview 里面直接使用 prompt 调用即可。

function jsbridgePrompt(url){
    if(url) {
        nativeReady = false;
        var jsoncommand = JSON.stringify(url);
        var _temp = prompt(jsoncommand,'');
        return true;
    } else {
        // there is invalid jsbridge url
        return false;
    }
}

效率低下的 shouldOverrideUrlLoading

上面两种方法调用非常简单,不需要再对应方法里面额外耦合一些其它处理逻辑。另外,还有一种调用方式,是直接用来监听页面的请求来做相应处理的 – WebViewClient.shouldOverrideUrlLoading。

这种方式在 Android 里面用起来比较复杂,不仅需要处理对应的 302/301 跳转,还需要做相关 webview 的权限处理。虽然,调用处理是在主线程中完成的,但是里面代码复杂度和实现效率比起来是无法和上面两种方法相比的。

这里对 shouldOverrideUrlLoading 方法进行简单的介绍一下。shouldOverrideUrlLoading 一般只对于 a 标签的跳转和 HTML 的请求有相关的响应。但是,有个问题,我们怎样去构造这样的请求?

对于 a 标签来说,如果没有用户的手动行为,你是无法触发 onclick 事件的。所以,这里可以考虑使用构造 iframe 请求来实现类 shouldOverrideUrlLoading 的请求。这里提供一个最简版本:

const fakeInvokeSchema = (url, errHandler) => {
  let iframe = document.createElement('iframe');

  let onload = function () {
    // 如果 shouldOverrideUrlLoading 没有很好的捕获并且取消 iframe 请求,则会直接执行 iframe 的 onload 事件
    if (typeof errHandler === 'function') {
      errHandler("trigger faile", url);
    }


  };
  iframe.src = url;
  iframe.onload = onload;
  (document.body || document.documentElement).appendChild(iframe);

  // 触发完成后移除,减少页面的渲染。
  setTimeout(function () {
    iframe && iframe.parentNode && iframe.parentNode.removeChild(iframe);
  }, 0);
}

正常思维逻辑按照上面这样处理就没啥问题了,但是实际往往会给你一巴掌。具体细节可以参考如下:

  • 如果是 IOS 平台:
    • 需要先进行 onload 和 src 的绑定,然后再将 iframe append 到 body 里面,否则会造成连续 api 的调用,会间隔执行成功。
  • 如果是 Android:
    • 则需要先 append 到页面,然后再绑定 onload 和 src。否则会造成 onload 失败 和额外触发一次 about:blank 的 onload 事件。about:black 的现象可以参考 juejin.com 打开 github 登录。

listener 监听回调之谜

通过前文的 android js 的相互调用,我们大致能了解两者之前互相调用的基础。但在真正实践当中,jsbridge 的相互调用其实可以归纳为两种类型:

  • java call js:
    • with callback
      • once
      • permanent: 比如,用来获取用户状态的变换信息。相当于就是 listener。
    • without callback
  • js call java:
    • with callback
      • once
      • permanent: 比如,用来获取页面 visibility 的变更状态。
    • without callback

这里,我们一步一步的来解决(我们只了解 H5 相关的内容),首先简单了解一下 once callback 如何解决。

once callback: 该类的 callback 非常好解决,可以通过在自定义的 jsbridge 文件里面通过 _CLIENT_CALLBACK + globalId++ 的方式生成唯一的 callback 方法。对于此类 callback 有时候为了节省内存在执行完毕后,还需要删除该 callback

// jsCode call jsbridge
jsbridge.getDeviceId(callback)

// jsbridge.js register once callback
let onceCallback = "__ONCE_CALLBACK__" + globalId++;
window[onceCallback] = function(data){
    callback(data);
    delete(window[onceCallback]);
}

with callback: 这类带 callback 或者说是带 listener 的方式,比较难处理,因为客户端需要一直保留当前的 Listener ,如果 webview 通过 removeListener 移除还需要做相应的操作。另外,客户端还需要判断当前的 listener 是否和对应 register 的 webview 一致,不一致还需要销毁当前注册的 listener. 不过,具体思路也很简单,直接通过 jsbridge 将在 window 里面注册的函数传递给客户端。

// jsCode call jsbridge
jsbridge.qbVisibilityChange((vis)=>{
    // xxx
})


# 底层的解析代码为:
const jsbridge.qbVisibilityChange = function(callback){

        let CALLBACK_Listner = function(param){
            callback(param);
        }
        window["CALLBACK_Listner"] = CALLBACK_Listner;
        prompt(`jsbridge://qq.com/visibility#__callback__=CALLBACK_Listner`);
}

jsbridge.js 文件的起源

上面这些调用代码,其实都是和业务代码无关的。你可以仔细预想一下,如果 H5 需要适配多个 app 的 jsbridge,那么你需要写一个 switch/case 的语句。

switch(){
    case xx:
        load('bridgeA.js')
    case xx:
        load('bridgeB.js');
    case xx:
        load('bridgeC.js');
    break;
}

而且如果他们对应的 API 接口名不一致的话,你还需要再包一层进行优化。这也就会导致,你可能会想自己写一个 jsbridge,将所有不一致的 API 接口名,放到一个函数里面进行处理。

// WrapBridge.js
jsbridge.visibilityChange = function(cb){
    if(UA.isQQ){
        jsbridge.qqVisibilityChange(cb)
    }else if(UA.isWeChat){
        jsbridge.wxVisibilityChange(cb)
    }
    ...
}

所以,有时候你调用一个 jsbridge 的时候,其实并不知道该方法下面包了多少层。但是,有时候有些 app 为了解决该 jsbridge.js 侵入业务层业务引入的步骤,选择使用由客户端直接侵入加载。

下面我们来简单介绍一下,客户端如何做到直接侵入 webview 加载 jsbridge.js 文件的。

android 侵入 webview 加载 bridge.js

这里我们了解到如果 java 调用 js 是需要额外引入定制化的 invokeSchame://xxx ,方便提供给 web 进行调用。对于这类定制化需求,需要额外引入 jsbridge.js。这里一般提供两种方式来引入 jsbridge.js。一是通过官方文档的形式,告诉 H5 开发者,在开发之前需要额外引入指定文件。而是直接利用 webview 注入的方式,将指定的 js 文件打进去。

  • 知会 H5 开发额外引入文件:这通常是搭配 hybird 开发使用,一来共同方便,二来也方便 debugger
  • 直接客户端引入:对于平台级的应用,常常会用到这种办法,减少 H5 不必要的沟通和复杂度。

这里,简单介绍一下,客户端如何引入 JS 文件,并保证其能够生效。一般情况下,客户端注入的时机应该是在 DomContentLoaded 事件之后,保证不会阻塞相关的内容和事件。反映到 webviewClient 里面的事件也就是:

  • onPageStarted
  • onPageFinished

最保险的方式,是直接在 onPageFinished 事件里面注入 JS 文件. 下面是一个伪代码,直接在全局里面执行一个函数。

webView.setWebViewClient(new WebViewClient(){
    @Override
    public void onPageFinished(WebView view, String url) {
        super.onPageFinished(view, url);
        webView.loadUrl(
            "javascript:(function() { " +
                "var element = document.getElementById('hplogo');"
                + "element.parentNode.removeChild(element);" +
            "})()");
    }
});

如果仔细思考一下就会发现,当前的 webview 是否值得注入,即,判断 webview 的有效性,通常我们认为如下 webview 是有效的:

  • 重定向完毕后,最新打开稳定的 webview
  • 已经打开的 webview ,并且没有被注入过

一般处理做法是直接在 webview 的 onPageFinished 事件里面直接注入 jsbridge 文件。当然,如果你的文件简单可以直接根据上面,写在 inline 里面,但是一般情况下,一般会抽离成一个单独的 js 文件。这里,可以直接将 jsbridge 文件转换成 base64 编码,然后利用 window.atob 直接解析即可。这其实和解析图片有些类似。这样的好处是可以直接外带文件,但坏处是增加 js 的解析时间。具体如下代码:

        @Override
       public void onPageFinished(WebView view, String url) {
          super.onPageFinished(view, url);

          injectScriptFile(view, "js/script.js"); // 注入外链代码

          // test if the script was loaded
          view.loadUrl("javascript:setTimeout(test(), 500)");
       }

       private void injectScriptFile(WebView view, String scriptFile) {
          InputStream input;
          try {
            // 一般会直接在初始化时,将该 js 文件预读为 base64
             input = getAssets().open(scriptFile);
             byte[] buffer = new byte[input.available()];
             input.read(buffer);
             input.close();
             String encoded = Base64.encodeToString(buffer, Base64.NO_WRAP);

             view.loadUrl("javascript:(function() {" +
                          "var parent = document.getElementsByTagName('head').item(0);" +
                          "var script = document.createElement('script');" +
                          "script.type = 'text/javascript';" +
             // 将 base64 转成 string 代码
                          "script.innerHTML = window.atob('" + encoded + "');" +
                          "parent.appendChild(script)" +
                          "})()");
          } catch (IOException e) {
             // TODO Auto-generated catch block
             e.printStackTrace();
          }
       }

具体参考:standalone js

前面我也告诫过大家:

教科书式的解决办法,啥也解决不了

客户端一般选择侵入的时机通常会选在 onPageFinished 中,这已经是最简单的了。但是,由于重定向的问题,又让实现方法变得不那么优雅。

webview 重定向解决办法

现在最关键的是如何判断当前打开的 webview 是有效果的?

打开一个网页有两个办法:

  • webivew 自身控制:点击 a 标签直接跳转、通过 window.location 直接修改
  • 调用WebView的loadUrl()方法

和 URL 打开相关的三个事件有:

  • shouldOverrideUrlLoading(): 拦截页面的 GET/POST 请求,注意 HTML 其实就是一个简易 GET 请求 同样也会拦截。
  • onPageStarted():页面开始加载时,会直接触发
  • onPageFinished(): 页面加载完成时会触发。当请求重定向地址,并且成功返回结果时,也会触发该事件
  • onProgressChanged: 主要是用来计算页面加载的进度,会在 onPageStarted 和 onPageFinished 之间触发多次,通常是 20-50-80-100 这样的次数。另外,在重定向加载时,也会多次触发该函数。

所以,为了得到页面真正加载完毕的 flag,我们需要仔细了解一下在 301/302 时,上述对应事件触发的流程。这里就对应了两种不同的打开方式,以及是否存在重定向的 2x2 的选择。

  • 200 正常一次性直接返回

    • loadUrl 打开
      • onPageStarted()-> onPageFinished()
        • 注意,这里并不会触发 shouldOverrideUrlLoading 事件,这个很重要
    • a 标签,window.location 打开
      • shouldOverrideUrlLoading() => onPageStarted() => onPageFinished()
  • 301/302 重定向返回

    • loadUrl 打开
      • repeat( onPageStarted()->shouldOverrideUrlLoading()->onPageFinished() ) * 重定向次数N => onPageStarted()->onPageFinished()
    • a 标签,window.location 打开
      • shouldOverrideUrlLoading => repeat( onPageStarted()->shouldOverrideUrlLoading()->onPageFinished() ) * 重定向次数N => onPageStarted()->onPageFinished()

简单归纳一下,在 webview 中新打开页面,一定会触发 shouldOverrideUrlLoading。在 native 里面打开 url,则只会走正常逻辑 (pageStart => onPageFinished ),除非重定向。

也就是凡是在 onPageStarted 和 onPageFinished 之间,触发了 shouldOverrideUrlLoading 都是重定向,这个就是关键点。那么我们可以设置一个 flag 标志位,记录此时文档是否真正的加载完成。基本步骤为:

  • onPageStarted 里面,设置一个全局变量 this.loaded = true
  • 在 shouldOverrideUrlLoading,将 this.loaded = false
  • 在 onPageFinished 判断 this.loaded === true, 是代表当前 webview 已经加载完毕。不是,则代表重定向

webview 的性能优化

众所周知,webview 的渲染性能在 Android 机上算是差强人意。但是,其本身的性能永远是无法和客户端相提并论的。当然,为了让 webview 优化性能更进一步提升,平常做的方案有:

  • 离线包:通过客户端预先下载 web 的离线包资源,极大的减少 webview 的加载时延。
  • RN/Flutter: 通过 JsBundle 的形式将客户端组件的 API 进行封装,将使用代码解析为 DSL 树,由 JsBundle 解析渲染。由于参照对象完全是客户端,所以,如果要将代码完全设计为 H5 代码来说是非常困难的,特别是实现像 CSS 一样的布局语法。
    • 他还有一个致命的劣势,即,如果存在客户端组件的更新,必须每次更新底层的解析版本,然后发布到 Store 里面并更新。这对于紧急 Bug 和新功能的提审来说影响非常大。

本文后续涉及的内容,只针对于偏向前端的 H5 资源加载优化和渲染优化。

离线包优化

对于 H5 资源加载优化,离线包可以说是碾压一切,不过弊端和 RN 差不多。同样也需要客户端的联动,如果发生 bug 只能按照版本的更替进行发布。仅仅考虑到更新和版本问题来说,离线包确实很渣。However,你仔细想一想,离线包机制有 RN 复杂?它会涉及 UI 么?实现难度大么?

这个问题,我想应该不需要做太多解释。首先,离线包仅仅是一个资源管理的逻辑 package,出了问题顶多就是走线上的资源而已。对于这一点来说,离线包机制更胜于 RN、性能更优于 H5。

ServiceWorker webview 内优化

ServiceWorker 其实不仅仅只局限于 H5,对所有用到 网页开发 来说都意义重大。究其缘由主要是他的性能优势,以及可编程性开发。对标于 Android 的四大组件的 Service 来说,ServiceWorker 本身的想象力就可以理解为一个驻留 Web 程序以及网络中间层的代理控制。

但,弊端也不是没有,主要在于它自身业务逻辑是独立于当前对应的 Web 项目,需要在项目里面额外引入一个 sw.js。对于某些新手同学来说,上手难度还是有一点,不过影响不大。但对比于离线包机制来说,处理缓存差一点,其他的应该算是碾压。

欢迎关注 前端小吉米

原文链接: https://www.villianhr.com/2018/08/21/Android 和 Webview 如何相互 sayHello(一)

更新时间: 2018-08-21