Android 和 Webview 如何相互 sayHello(一)

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

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

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

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

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

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

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

tl;dr

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

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

Anriod 开发 Webview 基础

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

<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.github

手Q微信

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

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 主要是用来做为补充使用。具体解释能够参考:json

  • webview: 仅仅用来渲染和解析页面

更多的内容,你们能够直接参考 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。对于某些新手同窗来讲,上手难度仍是有一点,不过影响不大。但对比于离线包机制来讲,处理缓存差一点,其余的应该算是碾压。

    • 其余客户端优化,能够参考这篇文章:

    腾讯祭出大招VasSonic,让你的H5页面首屏秒开

    欢迎关注 前端小吉米
    相关文章
    相关标签/搜索