本系列文章一共有两篇:主要来说解 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
这里,将从一个 Web 开发者的角度触发,仔细探寻一下 Webview 开发下,Web 开发者将碰见哪些问题,了解和 客户端 交互的底层原理。本系列文章将分别介绍一下在 Android 和 IOS 系统下,开发 Hybird APP 大体流程和其中的须要注意、优化的地方。react
本文主要介绍的是 Android 下 Webview 的开发。
本文主要从 H5 开发者的角度来简单讲解一下在 Hybird 开发过程当中遇到的相关问题和对应的解决方案。android
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
在定制特有的 acitvity 以后,对于一个可用的 webivew,还须要对 webview 作相关的配置。整个流程图为:web
参考实际代码为:编程
// 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
更多的内容,你们能够直接参考 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 之间必定要进行通讯呢?
回想一下日常的 hybird 的开发,咱们一般在前端调用客户端接口来获取相关内容:
因此,二者之间的通讯,不只必须,并且很重要。下面咱们来简单介绍一下 通讯。
所谓的通讯,其实更确切的来讲就是传递消息。不过,这二者之间并非简单的创建起一个通道,就能够直接进行通讯。他们之间的通讯方向和方式仍是有些区别的。
javascript:window.jsbridge_visibilityChange(xxx)
直接调用 window 里面绑定的执行函数,若是要传参的话,是直接转换成字符串 inline 到函数里面去。咱们深刻到 API 层面来看一下,他们之间是如何相互进行调用的:
android => js: 方法只有两个很是简单
mWebView.evaluateJavascript("(function() { return 'this'; })();", new ValueCallback<String>() { @Override public void onReceiveValue(String s) { // 上述定义函数执行完成时,return 的内容 Log.d("LogName", s); // Prints: "this" } });
js => android
js => android 的方法比较多,其中比较经常使用的有:WebChromeClient.onJsPrompt、WebViewClient.shouldOverrideUrlLoading、JavascriptInterface。
这里,咱们着重来说解一下 js 调用 android 的简单过程。
这里,咱们分方法来介绍一下上面对应的调用方式。首先是 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 的逻辑很简单,经过直接监听 WebChromeClient.onJsPrompt 事件,设置好对应协议的内容便可。jsPrompt 在 Web 中对应的行为是弹出一个框,里面有用户的输入框和肯定、取消按钮。
具体代码以下:
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; } }
上面两种方法调用很是简单,不须要再对应方法里面额外耦合一些其它处理逻辑。另外,还有一种调用方式,是直接用来监听页面的请求来作相应处理的 -- 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 平台:
若是是 Android:
经过前文的 android js 的相互调用,咱们大体能了解二者以前互相调用的基础。但在真正实践当中,jsbridge 的相互调用其实能够概括为两种类型:
java call js:
with callback
js call java:
with callback
* once * permanent: 好比,用来获取页面 visibility 的变动状态。
这里,咱们一步一步的来解决(咱们只了解 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`); }
上面这些调用代码,其实都是和业务代码无关的。你能够仔细预想一下,若是 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 文件的。
这里咱们了解到若是 java 调用 js 是须要额外引入定制化的 invokeSchame://xxx ,方便提供给 web 进行调用。对于这类定制化需求,须要额外引入 jsbridge.js。这里通常提供两种方式来引入 jsbridge.js。一是经过官方文档的形式,告诉 H5 开发者,在开发以前须要额外引入指定文件。而是直接利用 webview 注入的方式,将指定的 js 文件打进去。
这里,简单介绍一下,客户端如何引入 JS 文件,并保证其可以生效。通常状况下,客户端注入的时机应该是在 DomContentLoaded 事件以后,保证不会阻塞相关的内容和事件。反映到 webviewClient 里面的事件也就是:
最保险的方式,是直接在 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 的 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 是有效果的?
打开一个网页有两个办法:
和 URL 打开相关的三个事件有:
因此,为了获得页面真正加载完毕的 flag,咱们须要仔细了解一下在 301/302 时,上述对应事件触发的流程。这里就对应了两种不一样的打开方式,以及是否存在重定向的 2x2 的选择。
200 正常一次性直接返回
loadUrl 打开
onPageStarted()-> onPageFinished()
a 标签,window.location 打开
301/302 重定向返回
loadUrl 打开
a 标签,window.location 打开
简单概括一下,在 webview 中新打开页面,必定会触发 shouldOverrideUrlLoading。在 native 里面打开 url,则只会走正常逻辑 (pageStart => onPageFinished ),除非重定向。
也就是凡是在 onPageStarted 和 onPageFinished 之间,触发了 shouldOverrideUrlLoading 都是重定向,这个就是关键点。那么咱们能够设置一个 flag 标志位,记录此时文档是否真正的加载完成。基本步骤为:
众所周知,webview 的渲染性能在 Android 机上算是差强人意。可是,其自己的性能永远是没法和客户端相提并论的。固然,为了让 webview 优化性能更进一步提高,日常作的方案有:
RN/Flutter: 经过 JsBundle 的形式将客户端组件的 API 进行封装,将使用代码解析为 DSL 树,由 JsBundle 解析渲染。因为参照对象彻底是客户端,因此,若是要将代码彻底设计为 H5 代码来讲是很是困难的,特别是实现像 CSS 同样的布局语法。
本文后续涉及的内容,只针对于偏向前端的 H5 资源加载优化和渲染优化。
对于 H5 资源加载优化,离线包能够说是碾压一切,不过弊端和 RN 差很少。一样也须要客户端的联动,若是发生 bug 只能按照版本的更替进行发布。仅仅考虑到更新和版本问题来讲,离线包确实很渣。However,你仔细想想,离线包机制有 RN 复杂?它会涉及 UI 么?实现难度大么?
这个问题,我想应该不须要作太多解释。首先,离线包仅仅是一个资源管理的逻辑 package,出了问题顶多就是走线上的资源而已。对于这一点来讲,离线包机制更胜于 RN、性能更优于 H5。
ServiceWorker 其实不只仅只局限于 H5,对全部用到 网页开发 来讲都意义重大。究其原因主要是他的性能优点,以及可编程性开发。对标于 Android 的四大组件的 Service 来讲,ServiceWorker 自己的想象力就能够理解为一个驻留 Web 程序以及网络中间层的代理控制。
但,弊端也不是没有,主要在于它自身业务逻辑是独立于当前对应的 Web 项目,须要在项目里面额外引入一个 sw.js。对于某些新手同窗来讲,上手难度仍是有一点,不过影响不大。但对比于离线包机制来讲,处理缓存差一点,其余的应该算是碾压。
欢迎关注 前端小吉米