WebView核心设计
# WebView封装实践
# 目录介绍
# 01.整体概述
# 1.1 项目背景
# 1.2 遇到问题
- 重定向导致的问题
- 加载进度条,重定向(多次调用onPageStarted和onPageFinished)会导致多次加载情况,如何避免多次重定向只会加载一次进度条。
- WebView加载异常难管理
# 1.3 基础概念
- 触发加载网页的行为
- 主要有两种方式:(A)点击页面,触发标签;(B)调用WebView的loadUrl()方法。这两种方法都会发出一条地址,区别就在于这条地址是目的地址还是重定向地址。
- 加载h5页面大概流程【请求数据+渲染数据】
- 1.dns域名解析(将域名解析成ip地址,比较耗时);2.TCP的三次握手;3.建立TCP连接后发起HTTP请求;4.服务器响应HTTP请求
- 5.浏览器解析html代码;6.同时请求html代码中的资源(如js、css、图片等),注意html中资源是串行;7.最后浏览器对页面进行渲染并呈现给用户
# 1.4 设计目标
- 支持处理js的交互逻辑,方便快捷,并且无耦合,操作十分简单,优雅解决重定向回退,白屏等问题;
- 暴露进度条加载进度,结束,以及异常状态(分多种状态:无网络,404,onReceivedError,sslError异常等)listener给开发者;
- 支持视频播放,可以切换成全频播放视频,可旋转屏幕,暴露视频操作监听listener给开发者;
- 集成了腾讯x5的WebView,最新版本,功能强大;支持打开文件的操作,比如打开相册,然后选中图片上传,兼容版本(5.0);
- 支持加载word,xls,ppt,pdf,txt等文件文档,使用方法十分简单;
- 支持设置仿微信加载H5页面进度条,完全无耦合,操作简单,极大提高用户体验;
- 支持用户按照规范自定义WebViewClient和WebChromeClient,不影响js通信;
- 汇集绝大多数问题,以及解决方案,是学习和深入理解webView的一个比较全面的案例;
- 除了webView自带缓存外,还添加了资源拦截缓存,交给OkHttp去做,支持设置超时,设置缓存空间大小;
- 统一处理web页面打电话,发短信,定位,邮件,开启支付宝,微信等scheme拦截处理;
- 充分运用了面向对象的设计思想,将视频全屏播放,scheme拦截,web进度条,拦截缓存抽成独立的部分,你也可以拿来即用,完全解耦;
# 1.5 收益分析
- 提高webView开发效率,大概要节约你百分之六十的时间成本,一键初始化操作;
# 02.WebView痛点
# 2.1 为何WebView难搞
- 繁杂的WebView配置
- WebView在初始化的时候就提供了默认配置WebSettings,但是很多默认配置是不能够满足业务需求的,还需要进行二次配置
- 除此之外,使用方还需要根据业务需求实现WebViewClient和WebChromeClient,这两个类所需要覆写的方法更多,用来实现标题定制、加载进度条控制、jsbridge交互、url拦截、错误处理(包括http、资源、网络)等很多与业务相关的功能。
- 复杂的前端环境
- html、css、js相应的升级与更新。高版本的语法无法在低版本的内核上识别和渲染,业务上需要使用到新的特性时,开发不得不面对后向兼容的问题。
- 需要一定的Web知识
- 使用WebView.loadUrl()来加载一个网页而不了解底层到底发生了什么,那么url发生错误、url中的某些内容加载不出来、url里的内容点击无效、支付宝支付浮层弹不起来、与前端无法沟通等等问题就会接踵而至。要开发好一个功能完整的WebView,需要对Web知识(html、js、css)有一定了解,知道loadUrl,WebView在后台请求这个url以后,服务器做了哪些响应,又下发了哪些资源,这些资源的作用是怎么样的。
# 2.2 loadUrl过程复杂
- WebView.loadUrl(url)加载网页做了什么?
- 加载网页前,重置WebView状态以及与业务绑定的变量状态。WebView状态包括重定向状态(mTouchByUser)、前端控制的回退栈(mBackStep)等,业务状态包括进度条、当前页的分享内容、分享按钮的显示隐藏等。
- 加载网页前,根据不同的域拼接本地客户端的参数,包括基本的机型信息、版本信息、登录信息以及埋点使用的Refer信息等,有时候涉及交易、财产等还需要做额外的配置。
- 开始执行页面加载操作时,会回调WebViewClient.onPageStarted(webView,url,favicon)。在此方法中,可以重置重定向保护的变量(mRedirectProtected),当然也可以在页面加载前重置,由于历史遗留代码问题,此处尚未省去优化。
- 加载页面的过程中回调哪些方法?
- WebChromeClient.onReceivedTitle(webView, title),用来设置标题。需要注意的是,在部分Android系统版本中可能会回调多次这个方法,而且有时候回调的title是一个url,客户端可以针对这种情况进行特殊处理,避免在标题栏显示不必要的链接。
- WebChromeClient.onProgressChanged(webView, progress),根据这个回调,可以控制进度条的进度(包括显示与隐藏)。一般情况下,想要达到100%的进度需要的时间较长(特别是首次加载),用户长时间等待进度条不消失必定会感到焦虑,影响体验。其实当progress达到80的时候,加载出来的页面已经基本可用了。事实上,国内厂商大部分都会提前隐藏进度条,让用户以为网页加载很快。
- WebViewClient.shouldInterceptRequest(webView, request),无论是普通的页面请求(使用GET/POST),还是页面中的异步请求,或者页面中的资源请求,都会回调这个方法,给开发一次拦截请求的机会。在这个方法中,我们可以进行静态资源的拦截并使用缓存数据代替,也可以拦截页面,使用自己的网络框架来请求数据。包括后面介绍的WebView免流方案,也和此方法有关。
- WebViewClient.shouldOverrideUrlLoading(webView, request),如果遇到了重定向,或者点击了页面中的a标签实现页面跳转,那么会回调这个方法。可以说这个是WebView里面最重要的回调之一,后面WebView与Native页面交互一节将会详细介绍这个方法。
- WebViewClient.onReceivedError(webView,handler,error),加载页面的过程中发生了错误,会回调这个方法。主要是http错误以及ssl错误。在这两个回调中,我们可以进行异常上报,监控异常页面、过期页面,及时反馈给运营或前端修改。在处理ssl错误时,遇到不信任的证书可以进行特殊处理,例如对域名进行判断,针对自己公司的域名“放行”,防止进入丑陋的错误证书页面。也可以与Chrome一样,弹出ssl证书疑问弹窗,给用户选择的余地。
- 加载页面结束回调哪些方法
- 会回调WebViewClient.onPageFinished(webView,url)。这时候可以根据回退栈的情况判断是否显示关闭WebView按钮。通过mActivityWeb.canGoBackOrForward(-1)判断是否可以回退。
# 2.4 异常Error场景多
- 对于WebView加载一个网页过程中所产生的错误回调,在WebViewClient中,大致有三种:
- onReceivedHttpError,任何HTTP请求产生的错误都会回调这个方法,包括主页面的html文档请求,iframe、图片等资源请求。
- onReceivedSslError,任何HTTPS请求,遇到SSL错误时都会回调这个方法。
- onReceivedError,只有在主页面加载出现错误时,才会回调这个方法。
- 加载页面还可能有异常,比如找不到资源,404,在WebChromeClient中,大概有两种:
- onReceivedTitle,监听标题可能出现 404,网页无法打开,等异常
- 这些异常中处理什么逻辑
- onReceivedHttpError,在这个回调中,由于混杂了很多请求,不适合用来展示加载错误的页面,而适合做监控报警。当某个URL,或者某个资源收到大量报警时,说明页面或资源可能存在问题,这时候可以让相关运营及时响应修改。
- onReceivedSslError,比较正确的做法是让用户选择是否信任这个网站,这时候可以弹出信任选择框供用户选择(大部分正规浏览器是这么做的)。也可以让一些特定的网站,不管其证书是否存在问题,都让用户信任它。
- onReceivedError,这正是展示加载错误页面最合适的方法。如果不管三七二十一直接展示错误页面的话,那很有可能会误判,给用户造成经常加载页面失败的错觉。
# 2.6 H5为何加载速度慢
- webView是怎么加载网页的呢?
- webView初始化->DOM下载→DOM解析→CSS请求+下载→CSS解析→渲染→绘制→合成
- 渲染速度慢
- 前端H5页面渲染的速度取决于 两个方面:
- Js 解析效率。Js 本身的解析过程复杂、解析速度不快 & 前端页面涉及较多 JS 代码文件,所以叠加起来会导致 Js 解析效率非常低
- 手机硬件设备的性能。由于Android机型碎片化,这导致手机硬件设备的性能不可控,而大多数的Android手机硬件设备无法达到很好很好的硬件性能
- 总结:上述两个原因 导致 H5页面的渲染速度慢。
- 前端H5页面渲染的速度取决于 两个方面:
- 页面资源加载缓慢
- H5 页面从服务器获得,并存储在 Android手机内存里:
- H5页面一般会比较多
- 每加载一个 H5页面,都会产生较多网络请求:
- HTML 主 URL 自身的请求;
- HTML外部引用的JS、CSS、字体文件,图片也是一个独立的 HTTP 请求
- 每一个请求都串行的,这么多请求串起来,这导致 H5页面资源加载缓慢
- H5 页面从服务器获得,并存储在 Android手机内存里:
- 总结:H5页面加载速度慢的原因:渲染速度慢 & 页面资源加载缓慢 导致。
# 03.技术点分析
# 3.1 js交互介绍说明
- Java调用js方法有两种:
- 第一种:WebView.loadUrl("javascript:" + javascript);
- 第二种:WebView.evaluateJavascript(javascript, callback);
- Java调用js方法方案对比
- 一般最常使用的就是第一种方法,但是第一种方法获取返回的值比较麻烦,而第二种方法由于是在 4.4 版本引入的,所以局限性比较大。注意问题,记得添加ws.setJavaScriptEnabled(true)代码
- js调用Java的方法有四种:
- 第一种:JavascriptInterface
- 第二种:WebViewClient.shouldOverrideUrlLoading()
- 第三种:WebChromeClient回调接口
- js调用Java的方案对比
- 第一种:这种方式的好处在于使用简单明了,本地和 JS 的约定也很简单,就是对象名称和方法名称约定好即可,缺点就是要提到的漏洞问题。在Js代码中就能直接通过“JSObject”的对象直接调用了该Native的类的方法。
- 第二种:这个方法可以拦截 WebView 中加载 url 的过程,得到对应的 url,我们就可以通过这个方法,与网页约定好一个协议,如果匹配,执行相应操作。
- 第三种:onJsAlert,onJsConfirm,onJsPrompt,三个弹窗都是可以处理js交互的。拦截这些方法,得到他们的内容,进行解析,比如如果是 JS 的协议,则说明为内部协议,进行下一步解析然后进行相关的操作即可。
- 需要注意的是 prompt 里面的内容是通过 message 传递过来的,并不是第二个参数的 url,返回值是通过 JsPromptResult 对象传递。
- 为什么要拦截 onJsPrompt 方法,而不是拦截其他的两个方法,这个从某种意义上来说都是可行的,但是如果需要返回值给 web 端的话就不行了。
- 因为 onJsAlert 是不能返回值的,而 onJsConfirm 只能够返回确定或者取消两个值,只有 onJsPrompt 方法是可以返回字符串类型的值,操作最全面方便。
# 3.2 Android调用Js
- 第一种方式:native 调用 js 的方法,注意的是名字一定要对应上,要不然是调用不成功的,而且还有一点是 JS 的调用一定要在 onPageFinished 函数回调之后才能调用,要不然也是会失败的。
//java //调用无参方法 mWebView.loadUrl("javascript:callByAndroid()"); //调用有参方法 mWebView.loadUrl("javascript:showData(" + result + ")"); //javascript,下面是对应的js代码 <script type="text/javascript"> function showData(result){ alert("result"=result); return "success"; } function callByAndroid(){ console.log("callByAndroid") showElement("Js:无参方法callByAndroid被调用"); } </script>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 - 第二种方式:使用evaluateJavascript,Android4.4新增加了一个新方法,调用Web还有回调。
- 这个方法比 loadUrl 方法更加方便简洁,效率更高,因为 loadUrl 的执行会造成页面刷新一次,这个方法不会,因为这个方法是在 4.4 版本才引入的,所以使用的时候需要添加版本的判断。思考为何效率更高?高在哪里?
if (Build.VERSION.SDK_INT < 18) { mWebView.loadUrl(jsStr); } else { mWebView.evaluateJavascript(jsStr, new ValueCallback<String>() { @Override public void onReceiveValue(String value) { //此处为 js 返回的结果 } }); }1
2
3
4
5
6
7
8
9
10
# 3.3 Js调用Android
- 第一种方式:通过 addJavascriptInterface 方法进行添加对象映射
- 这种是使用最多的方式了,首先第一步我们需要设置一个属性:setJavaScriptEnabled(true)
- 这个函数会有一个警告,因为在特定的版本之下会有非常危险的漏洞,设置完这个属性之后,Native需要定义一个类:
- 在 API17 版本之后,需要在被调用的地方加上 @addJavascriptInterface 约束注解,因为不加上注解的方法是没有办法被调用的
public class JSObject { private Context mContext; public JSObject(Context context) { mContext = context; } @JavascriptInterface public String showToast(String text) { Toast.show(mContext, text, Toast.LENGTH_SHORT).show(); return "success"; } @JavascriptInterface public void imageClick(String src) { Log.e("imageClick", "----点击了图片"); } //网页使用的js,方法无参数 @JavascriptInterface public void startFunction() { Log.e("startFunction", "----无参"); } } //特定版本下会存在漏洞,第一个是对象,第二个是名称 mWebView.addJavascriptInterface(new JSObject(this), "javascriptInterface"); //下面是js代码 function showToast(){ var result = myObj.showToast("我是来自web的Toast"); } function showToast(){ myObj.imageClick("图片"); } function showToast(){ myObj.startFunction(); }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38 - 第二种方式:shouldOverrideUrlLoading
- 这种方式其实实现也很简单,使用的频次也很高,上面介绍到了 WebViewClient 。其中有个回调接口 shouldOverrideUrlLoading ,就是利用这个拦截 url,然后解析这个 url 的协议。如果发现是我们预先约定好的协议就开始解析参数,执行相应的逻辑。
public boolean shouldOverrideUrlLoading(WebView view, String url) { //假定传入进来的 url = "ycjs://openActivity?arg1=111&arg2=222",代表需要打开本地页面,并且带入相应的参数 //如果 scheme 为 ycjs,代表为预先约定的 ycjs 协议 if (Uri.parse(url).getScheme().equals("ycjs")) { //代表应用内部处理完成 return true; } return super.shouldOverrideUrlLoading(view, url); } //JS 代码调用 function openActivity(){ document.location = "js://openActivity?arg1=111&arg2=222"; }1
2
3
4
5
6
7
8
9
10
11
12
13
14- 存在问题:这个代码执行之后,就会触发本地的 shouldOverrideUrlLoading 方法,然后进行参数解析,调用指定方法。
//java mWebView.loadUrl("javascript:returnResult(" + result + ")"); //javascript function returnResult(result){ alert("result is" + result); }1
2
3
4
5
6
7 - 第三种方式:利用 WebChromeClient 回调接口的三个方法拦截消息
- 这个方法的原理和第二种方式原理一样,都是拦截相关接口,只是拦截的接口不一样:
@Override public boolean onJsAlert(WebView view, String url, String message, JsResult result) { return super.onJsAlert(view, url, message, result); } @Override public boolean onJsConfirm(WebView view, String url, String message, JsResult result) { return super.onJsConfirm(view, url, message, result); } @Override public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) { //假定传入进来的 message = "js://openActivity?arg1=111&arg2=222",代表需要打开本地页面,并且带入相应的参数 Uri uri = Uri.parse(message); if (uri.getScheme().equals("js")) { if (uri.getAuthority().equals("openActivity")) { //代表应用内部处理完成 result.confirm("success"); } return true; } return super.onJsPrompt(view, url, message, defaultValue, result); }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23- onJsAlert 方法是弹出警告框,一般情况下在 Android 中为 Toast,在文本里面加入\n就可以换行;
- onJsConfirm 弹出确认框,会返回布尔值,通过这个值可以判断点击时确认还是取消,true表示点击了确认,false表示点击了取消;
- onJsPrompt 弹出输入框,点击确认返回输入框中的值,点击取消返回 null。
- 但是这三种对话框都是可以本地拦截到的,所以可以从这里拦截做一些逻辑处理,prompt 方法调用如下所示:
function clickprompt(){ var result=prompt("js://openActivity?arg1=111&arg2=222"); alert("open activity " + result); }1
2
3
4
# 3.4 Js交互方案对比
- 以上三种方案都是可行的,在这里总结一下
- 第一种方式:使用广泛
- 是现在目前最普遍的用法,方便简洁,但是唯一的不足是在 4.2 系统以下存在漏洞问题;
- 第二种方式:拦截约定规范协议
- 通过拦截 url 并解析,如果是已经约定好的协议则进行相应规定好的操作,缺点就是协议的约束需要记录一个规范的文档,而且从 Native 层往 Web 层传递值比较繁琐,优点就是不会存在漏洞。
- 它也有一个很繁琐的地方是,如果 web 端想要得到方法的返回值,只能通过 WebView 的 loadUrl 方法去执行 JS 方法把返回值传递回去。
- 第三种方式:拦截3种对话框
- 和第二种方式的思想其实是类似的,只是拦截的方法变了,这里拦截了 JS 中的三种对话框方法。
- 而这三种对话框方法的区别就在于返回值问题,alert 对话框没有返回值,confirm 的对话框方法只有两种状态的返回值,prompt 对话框方法可以返回任意类型的返回值。
- 缺点就是协议的制定比较麻烦,需要记录详细的文档,但是不会存在第二种方法的漏洞问题。
- js调用java方法比较和区别分析
- 1.通过 addJavascriptInterface 方法进行添加对象映射。js最终通过对象调用原生方法
- 2.shouldOverrideUrlLoading拦截操作,获取scheme匹配,与网页约定好一个协议,如果匹配,执行相应操作
- 3.利用WebChromeClient回调接口onJsPrompt拦截操作。
- onJsAlert 是不能返回值的,而 onJsConfirm 只能够返回确定或者取消两个值,只有 onJsPrompt 方法是可以返回字符串类型的值,操作最全面方便。
# 3.5 Js调用时机分析
- onPageFinished()或者onPageStarted()方法中注入js代码
- 做过WebView开发,并且需要和js交互,大部分都会认为js在WebViewClient.onPageFinished()方法中注入最合适,此时dom树已经构建完成,页面已经完全展现出来。但如果做过页面加载速度的测试,会发现WebViewClient.onPageFinished()方法通常需要等待很久才会回调(首次加载通常超过3s),这是因为WebView需要加载完一个网页里主文档和所有的资源才会回调这个方法。
- 能不能在WebViewClient.onPageStarted()中注入呢?答案是不确定。经过测试,有些机型可以,有些机型不行。在WebViewClient.onPageStarted()中注入还有一个致命的问题——这个方法可能会回调多次,会造成js代码的多次注入。
- 从7.0开始,WebView加载js方式发生了一些小改变,官方建议把js注入的时机放在页面开始加载之后。
- WebViewClient.onProgressChanged()方法中注入js代码
- WebViewClient.onProgressChanged()这个方法在dom树渲染的过程中会回调多次,每次都会告诉我们当前加载的进度。
- 在这个方法中,可以给WebView自定义进度条,类似微信加载网页时的那种进度条
- 如果在此方法中注入js代码,则需要避免重复注入,需要增强逻辑。可以定义一个boolean值变量控制注入时机
- 那么有人会问,加载到多少才需要处理js注入逻辑呢?
- 正是因为这个原因,页面的进度加载到80%的时候,实际上dom树已经渲染得差不多了,表明WebView已经解析了标签,这时候注入一定是成功的。在WebViewClient.onProgressChanged()实现js注入有几个需要注意的地方:
- 1 上文提到的多次注入控制,使用了boolean值变量控制
- 2 重新加载一个URL之前,需要重置boolean值变量,让重新加载后的页面再次注入js
- 3 如果做过本地js,css等缓存,则先判断本地是否存在,若存在则加载本地,否则加载网络js
- 4 注入的进度阈值可以自由定制,理论上10%-100%都是合理的,不过建议使用了75%到90%之间可以。
- WebViewClient.onProgressChanged()这个方法在dom树渲染的过程中会回调多次,每次都会告诉我们当前加载的进度。
# 04.封装思路介绍
# 4.1 封装整体思路
# 4.4 Native和Js交互
- java调用js的流程图
- 第一步操作:mWebView.callHandler("functionInJs", "小杨逗比", new CallBackFunction() {//这里面是回调});
- 第二步操作:将handlerName,data,responseCallback,封装到Message对象中,然后开始分发数据,最后webView执行_handleMessageFromNative;
- 第三步操作:去WebViewJavascriptBridge.js类中找到_handleMessageFromNative方法,js根据"functionInJs"找到对应的js方法并且执行;
- 第四步操作:js把运行结果保存到message对象中,然后添加到js消息队列中;
- 第五步操作:在_dispatchMessageFromNative方法中,可以看到,js向native发送 "消息队列中有消息" 的通知;
- 第六步操作:webView执行js的_fetchQueue(WebViewJavascriptBridge.js类)方法;
- 第七步操作:js把消息队列中的所有消息都一起回传给webView;
- 第八步操作:webView收到所有的消息,一个一个串行处理,注意其中包括 "functionInJs"方法运行的结果的消息;
- js调用Android的流程图
- 第一步操作:mWebView.registerHandler("toPhone", new BridgeHandler() { //回调});
- 第二步操作:调用messageHandlers.put(handlerName, handler),将名称和BridgeHandler对象放到map集合中
- 第三步操作:在shouldOverrideUrlLoading方法中拦截url,与网页约定好一个协议,匹配则执行相应操作,也就是利用WebViewClient接口回调方法拦截url
- 第四步操作:如果是url.startsWith(BridgeUtil.YY_RETURN_DATA)则有数据返回;如果是BridgeUtil.YY_OVERRIDE_SCHEMA则刷新消息队列
- 第五步操作:通过BridgeHandler对象,将data和callBackFunction返回交给开发者
# 4.5 缓存设计思路
- 解决方案
- 前端H5的缓存机制(WebView 自带)
- 资源预加载
- 资源拦截
- shouldInterceptRequest入口
- 缓存方案的入口
- webView在加载网页的时候,用户能够通过系统提供的API干预各个中间过程。我们要拦截的就是网页资源请求的环节。主要是:shouldInterceptRequest,这个方法。
- 是在调用了WebView#loadUrl()之后,请求网页资源(包括html文件、js文件、css文件以及图片文件)的时候回调。
- 需要注意:回调不是发生在主线程,因此不能做一些处理UI的事情;接口的返回值是同步的;WebResourceResponse这个返回值可以自行构造,其中关键的属性主要是:代表资源内容的一个输入流InputStream以及标记这个资源内容类型的mMimeType。
- 替换资源操作
- 只要在这两个入口构造正确的WebResourceResponse对象,就可以替换默认的请求为我们提供的资源
- 因此,在每次请求资源的时候根据请求的URL/WebResourceRequest判断是否存在本地的缓存,并在缓存存在的情况下将缓存的输入流返回
- 缓存方案的入口
- 方案1:本地资源替换操作,不友好
- 操作思路
- 用本地文件js,css,png替换网络请求下来的文件。在shouldInterceptRequest方法中拦截资源,如果是js,png,css资源则直接替换成本地的。操作比较简单
- 该案例存在问题
- 需要提前在本地存放大量缓存文件,如果是服务器下发比较麻烦;如果直接放本地缓存文件,则需要app升级;
- 操作思路
- 方案2:处理预加载数据
- 大概思路介绍
- 预加载数据就是在客户端初始化WebView的同时,直接由native开始网络请求数据, 当页面初始化完成后,向native获取其代理请求的数据, 数据请求和WebView初始化可以并行进行,缩短总体的页面加载时间。
- 简单来说就是配置一个预加载列表,在APP启动或某些时机时提前去请求,这个预加载列表需要包含所需H5模块的页面和资源, 客户端可以接管所有请求的缓存,不走webView默认缓存逻辑, 自行实现缓存机制, 原理其实就是拦截WebViewClient的那两个shouldInterceptRequest方法。
- 存在的问题
- 待完善
- 大概思路介绍
- 方案3:缓存资源下发替换
- 大概的思路
- 通过拦截webView中渲染网页过程中各种资源(包括图片、js文件、css样式文件、html页面文件等)的下载,根据业务的场景考虑缓存的策略
- 实现步骤
- 事先将更新频率较低、常用 & 固定的H5静态资源 文件(如JS、CSS文件、图片等) 放到本地
- 拦截H5页面的资源网络请求 并进行检测
- 如果检测到本地具有相同的静态资源 就 直接从本地读取进行替换 而 不发送该资源的网络请求 到 服务器获取
- 拦截处理
- 在shouldInterceptRequest方法中拦截处理
- 步骤1:判断拦截资源的条件,即判断url里的图片资源的文件名
- 步骤2:创建一个输入流,这里可以先从内存中拿,拿不到从磁盘中拿,再拿不到就从网络获取数据
- 步骤3:打开需要替换的资源(存放在assets文件夹里),或者从lru中取出缓存的数据
- 步骤4:替换资源
- 有几个问题
- 如何判断url中资源是否需要拦截,或者说是否需要缓存
- 如何缓存js,css等
- 缓存数据是否有时效性,服务端下发,要是不是要和服务端一起做
- 大概的思路
- 方案4:资源请求拦截使用OkHttp缓存,很友好
- 核心任务就是拦截资源请求,下载资源并缓存资源,因此拦截缓存的设计就分为了下面三个核心点:
- 请求拦截
- 资源响应(下载/读取缓存)
- 缓存,直接使用OkHttp自带缓存
- 请求拦截
- 先判断是否需要缓存,然后从Url获取文件扩展名extension,从扩展中获取Mime类型,当mine类型为空时则不缓存
- 根据对应的资源请求定义是否参与拦截、以及选择性的自定义配置下载和缓存的行为
- 资源响应(下载/读取缓存)
- 资源响应有两种情况:
- 缓存响应
- 下载响应
- 当对应的资源缓存不存在的时候,会直接触发资源的下载。
- 资源响应有两种情况:
- 核心任务就是拦截资源请求,下载资源并缓存资源,因此拦截缓存的设计就分为了下面三个核心点:
# 4.6 OkHttp缓存策略
- 这个是使用方案4,采用OkHttp拦截资源缓存,下面是大概的思路。缓存的入口从shouldInterceptRequest出发
- 第一步,拿到WebResourceRequest对象中请求资源的url还有header,如果开发者设置不缓存则返回null
- 第二步,如果缓存,通过url判断拦截资源的条件,过滤非http,音视频等资源,这个是可自由配置缓存内容比如css,png,jpg,xml,txt等
- 第三步,判断本地是否有OkHttp缓存数据,如果有则直接读取本地资源,通过url找到对应的path路径,然后读取文件流,组装数据返回。
- 第四步,如果没有缓存数据,创建OkHttp的Request请求,将资源网络请求交给okHttp来处理,并且用它自带的缓存功能,当然如果是请求失败或者异常则返回null,否则返回正常数据
- 把缓存的复杂逻辑完全交给OkHttp
- 待完善
# 4.7 加载进度条设计
- 进度条场景分析:网页需要在我们加载完成后需要去关闭自定义进度条
- 如果是一个没有重定向的网页加载这样是没有问题的。如果你的页面重定向了并且还有可能是多次的,我们的在onPageStarted和onPageFinished会回调多次,就会导致进度条出现重复加载
- 为何会回调多次onPageFinished
- 重定向导致。举个例子,比如跳转天猫首页,会判断是否登陆,如果没有登陆则重定向到登陆注册页面。主要就要考虑如何避免重定向行为造成的多次加载这种情况。
- 方案1:不够优雅,且多次重定向不太友好
- 1、在onPageStarted()中设置为true,若加载样式没有开启,就开启进度条等加载样式,
- 2、在onPageFinished()中检测,如果为true,就说明已经是目的地址了,可以关闭加载样式了,如果是false,就不做处理,继续等待,
- 3、在shouldOverrideUrlLoading()中,设置为false,若加载样式没有开启,就开启进度条等加载样式
- 方案2:不够优雅
- 1、定义running记录次数,在shouldOverrideUrlLoading记录为running++,
- 2、在onPageStarted中记录running为Math.max(running, 1),
- 3、最后在onPageFinished中如果--running等于0则再隐藏进度条加载
- 方案3:在自定义进度条中,定义一个记录状态(已经开始状态,已经结束状态,不能继续开始状态)的临时变量。
- 1.在设置进度条进度动画的时候,标记为已经开始状态;
- 2.当进度条达到95以上时,表明页面几乎加载完成,这时候标记为已经结束状态;
- 3.当进度条动画监听结束后,将状态标记为不能继续开始状态;
- 这个时候,即使页面有多次重定向,执行多次onPageStarted->onPageFinished方法,也不会出现一次进度条没跑完又出现第二次进度条。具体代码看lib中的WebProgress代码!
# 07.其他设计说明
# 7.1 性能设计
# 7.1.1 性能现状分析
- 性能是它目前最大的问题,主要表现在以下两个方面:
- 启动白屏时间。WebView 是一个非常重量级的控件,无论是 WebView 的初始化,还是整个渲染流程都非常耗时。这导致界面启动的时候会出现一段白屏时间,体验非常糟糕。
- 响应流畅度。由于单线程、历史包袱等原因,页面的渲染和 JavaScript 的执行效率都不如原生。在一些重交互或者动画复杂的场景,H5 的性能还无法满足诉求。
- 回顾一下浏览器内核渲染的流程,我们其实可以把整个过程拆成三个部分:
- Native 时间。主要是 Activity、WebView 创建以及 WebView 初始化的时间。虽然首次创建 WebView 的时间会长一些,但总体 Native 时间是可控的。
- 网络时间。这里包括 DNS、TCP、SSL 的建连时间和下载主文档的时间。当解析主文档的时候,也需要同步去下载主文档依赖的 CSS 和 JS 资源,以及必要的数据。
- 渲染时间。浏览器内核构建 Render Tree、Layout 并渲染到屏幕的时间。
# 7.1.2 性能优化分析
- 加快请求速度。
- 整个启动过程中,网络时间是最不可控的。这里的优化方法有很多,例如预解析 DNS、减少域名数、减少 HTTP 请求数、CDN 分发、请求复用、懒加载、Gzip 压缩、图片格式压缩。
- 代码优化。
- 主文档的大小越小越好(要求小于 15KB),这里要求我们对 HTML、CSS 以及 JS 进行代码优化。以 JS 为例,前端的库和框架真的太多了,可能一不小心就引入了各种的依赖框架。对于核心页面,我们要求只能使用原生 JS 或者非常轻量级的 JS 框架,例如使用只有几 KB 的 Preact 代替庞大的 React 框架。
- SSR。
- 对于浏览器的渲染流程,上面描述的是 CSR 渲染模式,在这种模式下,服务器只返回页面的基本框架。
- 事实上还有一种非常流行的SSR(Server Side Rendering)渲染模式,服务器可以一次性生成直接进行渲染的 HTML。以做到只有一个网络请求,但是带来的代价就是服务器计算资源的增加。一般来说,我们会在服务器前置 CDN 来解决访问量的问题。
# 7.1.3 客户端优化分析
- WebView 预创建。
- 提前创建和初始化 WebView,以及实现 WebView 的复用,这块大约可以节省 100~200 毫秒。
- 缓存。
- H5 是有多级的缓存机制,例如 Memory Cache 存放在内存中,一般资源响应回来就会放进去,页面关闭就会释放。
- Client Cache 也就是客户端缓存,例如我们最常用的离线包方案,提前将需要网络请求的数据下发到客户端,通过拦截浏览器的资源请求实现加载。
- Http Cache是我们比较熟悉的缓存机制,而 Net Cache 就是指 DNS 解析结果的缓存,或预连接的缓存等。
- 从性能上看,Memory Cache > Client Cache >= Http Cache > Net Cache。
- 所谓的缓存,就是在用户真正点击打开页面之前,提前把数据、资源下载到本地内存或者磁盘中,并放到内核相应的缓存中。
# 7.1.4 X5内核优化
- 进一步往底层走,需要有定制修改甚至优化内核的能力。
- 例如很多接口官方的浏览器内核可能并没有暴露,而腾讯X5的内核里面都会有很多的特殊接口。
- 托管所有网络请求。
- 我们不仅可以托管浏览器的 Get 请求,其他的所有 Post 请求也能接管,这样我们可以做非常多的定制化优化。
- 私有接口。
- 暴露很多浏览器的一些非公开接口。以预渲染为例,我可以指定在内存直接渲染某个页面,当用户真正打开的时候,只需要直接做刷新就可以了,实现真正的“秒开”。
- 兼容性和安全。
- Android 的碎片化导致浏览器内核的兼容性实在令人头疼,而且旧版本内核还存在不少的安全漏洞。
- 在应用自带浏览器内核可以解决这些问题,而且高版本的内核特性也会更加完善,例如支持 TLS 1.3、QUIC 等。
- 但是带来的代价是安装包增大 20MB 左右,当然也可以采用动态下载的方式。
# 7.2 稳定性设计
# 7.3 灰度设计
# 7.4 降级设计
# 7.5 异常设计
# 7.5.1 自定义error状态页面
- 在onReceivedTitle方法接收到错误,当WebView加载页面出错时(一般为404 NOT FOUND),安卓WebView会默认显示一个出错界面。
- 在onReceivedError方法接收异常,网络链接超时,断网,代理,其他异常等,都可以通过listener暴露给开发者。
- 在onReceivedHttpError方法接收异常,比如404找不到资源,500等,都可以通过listener暴露给开发者。
- 在onReceivedSslError方法接收异常,webView加载一些别人的url时候,有时候会发生证书认证错误的情况,这时候我们希望能够正常的呈现页面给用户,我们需要忽略证书错误。
# 7.6 兼容性设计
# 7.6.1 各个版本特性
# 7.6.2 部分手机兼容性
# 7.7 安全设计
# 7.7.1 WebView白名单校验
- 说一个现象
- 有时候,使用公共网络,点击跳转页面。会发现突然跳转到外部某广告的链接页面,这个是怎么回事呢?这就是被劫持……
- 处理白名单的步骤
- 1.添加白名单,主要是添加host,通常添加到list集合中。这里主要存放白名单的链接……
- 2.校验白名单,在哪里检查,在loadUrl之前检查即可
- 3.如何判断白名单,通过格式化host检查
- 方案1:使用错误校验方式
- 通过indexOf简单校验
- 校验逻辑来判断调用方的域名是否在白名单内,不过这个校验逻辑并没有他当初想象的那么简单。
- 这种方式存在问题
- 这个校验逻辑错误比较低级,攻击者直接输入http://www.rebeyond.net/poc.htm?site1.com就可以绕过了。
- 因为URL中除了代表域名的字段外,还有路径、参数等和域名无关的字段,因此直接判断整个URL是不安全的。
http://www.site2.com.rebeyond.net/poc.htm1- 上述URL的host中包含site2.com字符串,但是www.site2.com并不是域名,而是rebeyond.net这个域名的一个子域名,所以最终还是指向了攻击者控制的服务器。
- 通过indexOf简单校验
- 方案2:截取域名进行校验
- 提取域名检验
- 想要匹配白名单中的域名,首先应该把用户输入的URL中的域名提取出来再进行校验才对,使用字符串substring提取
- 这种方式存在问题
- 由于缺乏对URL语法的了解,错误的认为://和第一个/之间的字符串即为域名(host),导致了这个检测逻辑可以通过如下payload绕过。
- 比如:http://site1.com@www.github.com/yangchong211,打开这个地址,发现竟然是github.com/yangchong211
- RFC中对URL格式的描述:
😕/ : @ : / - 攻击者利用URL不常见的语法,在URL中加入了Authority字段即绕过了这段校验。
- Authority字段是用来向所请求的访问受限资源提供用户凭证的,比如访问一个需要认证的ftp资源,用户名为test,密码为123456,可以直接在浏览器中输入URL:ftp://test:123456@nju.edu.cn/。
- 提取域名检验
- 方案3:格式化获取host检验
- 实现URL的格式化
- Uri.parse(url).getHost(),使用该种方式即可得到url真正的host地址
- 实现URL的格式化
- 可以总结为如下几条开发建议:
- 不要使用indexOf这种模糊匹配的函数;不要自己写正则表达式去匹配;
- 尽量使用Java封装好的获取域名的方法,比如java.net.URI,不要使用java.net.URL;
- 不仅要给域名设置白名单,还要给协议设置白名单,一般常用HTTP和HTTPS两种协议,不过强烈建议不要使用HTTP协议,因为移动互联网时代,手机被中间人攻击的门槛很低,搭一个恶意WiFi即可劫持手机网络流量;
- 权限最小化原则,尽量使用更精确的域名或者路径。
- 应该把白名单校验函数放在哪个环节校验?
- loadUrl之前
- shouldOverrideUrlLoading中
- 如果需要对白名单进行安全等级划分,还需要在JavascriptInterface中加入校验函数,JavascriptInterface中需要使用webview.getUrl()来获取webview当前所在域。
# 7.7.2 密码明文存储漏洞
- WebView 默认开启密码保存功能 mWebView.setSavePassword(true),如果该功能未关闭,在用户输入密码时,会弹出提示框,询问用户是否保存密码,如果选择”是”,密码会被明文保到 /data/data/com.package.name/databases/webview.db 中,这样就有被盗取密码的危险。
- 所以需要通过 WebSettings.setSavePassword(false) 关闭密码保存提醒功能。具体代码操作如下所示
/设置是否开启密码保存功能,不建议开启,默认已经做了处理,存在盗取密码的危险 mX5WebView.setSavePassword(false);1
2
# 01.概述
# 1.1 项目背景
- 通过简要的语言描述项目背景以及要达成的业务目标。
# 1.2 设计目标
- 需求的背后往往会带来技术的重构/优化,或者单纯的完成需求,如果有必要,需要从技术角度给出方案设计的目标
- 比如对于图片下载需求,需要完成相关的功能,那么设计目标主要有完成异步下载、存储、缓存设计、图片解码、渲染等功能。
- 比如对于优化需求,目标可以是达到一个什么效果?可以是帧率的、Crash率的、卡顿的等。
- 比如对于重构需求,目标可以是加强扩展、解决问题、提升效率等。
# 02.方案设计
- 方案设计是技术文档的最核心部分,主要包括了整体架构和功能设计,这里需要体现:
- 设计的初衷:概要描述方案设计的思考,可以是为了扩展性的考虑,可以是提升性能 关键技术点的思考:描述关键技术选型的思考,比如要解耦,业内解耦方案能有router、Target-Action等,讲清楚选择的思考
- 技术上的折中/取舍:在做技术设计的时候,往往要的很多,但时间有限,那么这个需要讲一下折中与取舍,以及接下来的规划、计划
# 2.1 整体架构
- 整体架构的组成需要有一张完成的架构设计图,描述清楚具体的分层以及层与层之间的关系
- 比如传统的开发会分为三层,展示层、逻辑层、数据层
- 展示层的设计:视图的构成、视图间的耦合关系、具体的交互逻辑
- 逻辑层的设计:支撑展示层所需要的数据、从数据层获取数据的加工、业务相关逻辑(比如轮询服务)
- 数据层的设计:数据的获取方式以及存储方式,文件、数据库、本地、网络
# 2.2 功能设计
- 功能设计包含但不限于以下几个部分:逻辑流程图、接口设计图、与外部的模块间依赖关系
# 2.2.1 关键流程图
- 设计中的最复杂、最关键的逻辑需要画出流程图,实在画不出的流程图需要用语言描述清楚。
- 关键流程需要有逻辑流程图,帮助其他同学理解功能的关键节点逻辑
# 2.2.2 接口设计图
- 通过UML类图来展示类间关系,描述清楚接口设计的一些思考原则
- 提供的接口,往往接口设计为了完成相关逻辑
# 2.2.3 模块间依赖关系
- 描述清楚和哪些模块存在依赖关系以及原因,比如首页依赖于购物车模块,需要解释清楚要强耦合,有没有办法解耦
- App内部模块间依赖
- App外部依赖
# 2.3 UI/动效设计
- 客户端开发有很大一部分精力在UI/动效上,对于复杂的静态UI和复杂动效,需要给出实现方案和选型逻辑
- 静态UI
- 只有复杂的UI才需要给出设计方案,例如核心页面大重构、复杂的协调布局等
- 复杂动效
- 复杂的动效是端上容易踩坑的关键点,需要给出实现方案的对比、选型等,为验证动效可行性,可以给出动效Demo
# 03.其他设计(Optional)
- 以下部分是可选项,主要是从异常、兼容性、性能、稳定性、灰度、降级等维护来设计。
# 3.1 性能设计
- 有些业务项目可能会考虑性能,比如列表页,卡顿、流畅度怎么样?如何评估?
- 有些技术项目可能也会考虑性能,比如数据库设计,检索性能如何?是否有瓶颈,如何评估?
# 3.2 稳定性设计
- 大的项目需要考虑性能如何保障?
- 比如方案 Review
- 比如自测Case Review,加强自测
- 比如单测
# 3.3 灰度设计
- 核心关键功能需要有A/B设计
- 比如UIWebview替换为WKWebview,其中存在很多不确定因素,需要做好灰度设计
# 3.4 降级设计
- 在做一些新技术尝试时,需要考虑降级设计
- 比如RN、swift、weex引入对原有业务造成影响的,需要有兜底,可降级
- 参考资料
- 需要列出方案设计过程的文档,包括但不局限于PM需求文档,技术参考文档等。
# 3.5 异常设计
- 大部分业务需求都会涉及到异常处理,在关心主流程的同时需要关注异常场景怎么保证正确性?
- 比如用户操作中途退出、网络异常、数据被清理等
# 3.6 兼容性设计
- 业务逻辑一般不会涉及到兼容性,但UI/动效需求容易遇到兼容性问题,也是提测时需要让QA关注的
- 比如独立端/嵌入端、高低版本API适配等
# 04.排期与计划
- 排期计划主要针对周期较长项目的时间补充,对于小型项目不需要,例如:
- 正常的版本业务需求,5pd以下,不需要给出排期计划;5pd或者以上,可以简单描述一下排期和提测时间
- 跨版本的大型业务需求、重构专项等,需要给出详细的排期计划
- 研发自驱的技术优化项目,需要给出详细的排期计划
# 05.参考资料
- 需要列出方案设计过程的文档,包括但不局限于PM需求文档,技术参考文档等。
# 参考
- Android 黑科技保活实现原理揭秘
- https://weishu.me/2020/01/16/a-keep-alive-method-on-android/
- https://www.cnblogs.com/rebeyond/p/10916076.html
# 03.WebView加载优化
# 3.1 那些因素影响加载
- 影响页面加载速度的因素有非常多,在对 WebView 加载一个网页的过程进行调试发现
- 每次加载的过程中都会有较多的网络请求,除了 web 页面自身的 URL 请求
- 有 web 页面外部引用的JS、CSS、字体、图片等等都是个独立的http请求。这些请求都是串行的,这些请求加上浏览器的解析、渲染时间就会导致 WebView 整体加载时间变长,消耗的流量也对应的真多。
# 3.3 DNS采用和客户端API相同的域名
- 建立连接/服务器处理;在页面请求的数据返回之前,主要有以下过程耗费时间。
DNS connection 服务器处理1
2
3 - DNS采用和客户端API相同的域名
- DNS会在系统级别进行缓存,对于WebView的地址,如果使用的域名与native的API相同,则可以直接使用缓存的DNS而不用再发起请求图片。
- 举个简单例子,客户端请求域名主要位于api.yc.com,然而内嵌的WebView主要位于 i.yc.com。
- 当我们初次打开App时:客户端首次打开都会请求api.yc.com,其DNS将会被系统缓存。然而当打开WebView的时候,由于请求了不同的域名,需要重新获取i.yc.com的IP。静态资源同理,最好与客户端的资源域名保持一致。
# 3.6 提前显示加载进度条
- 提前显示进度条不是提升性能,但是对用户体验来说也是很重要的一点。
- WebView.loadUrl("url") 不会立马就回调 onPageStarted 或者 onProgressChanged 。因为在这一时间段,WebView 有可能在初始化内核,也有可能在与服务器建立连接,这个时间段容易出现白屏,白屏用户体验是很糟糕的。
# 3.8 关于图片显示隐藏
- 方法一:
- 无图模式
mWebView.getSettings().setLoadsImagesAutomatically(boolean enable); mWebView.getSettings().setBlockNetworkImage(boolean enable);1
2 - 有图:正常加载显示所有图片
mWebView.getSettings().setLoadsImagesAutomatically(true) mWebView.getSettings().setBlockNetworkImage(false)1
2 - 始终无图:所有图片都不显示
mWebView.getSettings().setLoadsImagesAutomatically(false) mWebView.getSettings().setBlockNetworkImage(true)1
2 - 注:如果是先加载的网页图片,后设置的始终无图,则已加载的图片正常显示
- 数据网络无图
mWebView.getSettings().setLoadsImagesAutomatically(true) mWebView.getSettings().setBlockNetworkImage(true)1
2- 注:wifi网络,与有图模式一致;数据网络下,已经下载到缓存的图片正常显示,未下载到缓存的图片不去网络请求显示。
- 无图模式
- 方法二:(新版sdk新加接口,如果在用的sdk中没有该接口需要更新sdk) 设置接口如下:
mWebView.getSettingsExtension().setPicModel(model);//其中model位于IX5WebSettingsExtension中1- 有图:model设置为IX5WebSettingsExtension.PicModel_NORMAL正常加载显示所有图片;
- 始终无图:model设置为IX5WebSettingsExtension.PicModel_NoPic不再显示图片(包括已加载出的图片);
- 数据网络无图:model设置为IX5WebSettingsExtension.PicModel_NetNoPic数据网络下无图(已加载的图片正常显示);
- 加载webView中的资源时,加快加载的速度优化,主要是针对图片
- html代码下载到WebView后,webkit开始解析网页各个节点,发现有外部样式文件或者外部脚本文件时,会异步发起网络请求下载文件,但如果在这之前也有解析到image节点,那势必也会发起网络请求下载相应的图片。
- 在网络情况较差的情况下,过多的网络请求就会造成带宽紧张,影响到css或js文件加载完成的时间,造成页面空白loading过久。解决的方法就是告诉WebView先不要自动加载图片,等页面finish后再发起图片加载。
//初始化的时候设置,具体代码在X5WebView类中 if(Build.VERSION.SDK_INT >= KITKAT) { //设置网页在加载的时候暂时不加载图片 ws.setLoadsImagesAutomatically(true); } else { ws.setLoadsImagesAutomatically(false); } /** * 当页面加载完成会调用该方法 * @param view view * @param url url链接 */ @Override public void onPageFinished(WebView view, String url) { super.onPageFinished(view, url); //页面finish后再发起图片加载 if(!webView.getSettings().getLoadsImagesAutomatically()) { webView.getSettings().setLoadsImagesAutomatically(true); } }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 3.9 HttpDns优化
- HttpDns,使用http协议向特定的DNS服务器进行域名解析请求,代替基于DNS协议向运营商的Local DNS发起解析请求,可以降低运营商DNS劫持带来的访问失败。
- 阿里云HTTP-DNS是避免dns劫持的一种有效手段,在许多特殊场景如HTTPS/SNI、okhttp等都有最佳实践,事实上很多场景依然可以通过HTTP-DNS进行IP直连,这个方案具体可以看阿里的官方demo和文档,我自己本身也没有实践过,这里只是提一下。
# 04.Js和Native交互优化
# 4.2 onJsPrompt优化
- 说一下背景
- 在js调用window.alert,window.confirm,window.prompt时,会调用WebChromeClient对应方法,可以此为入口,作为消息传递通道,通常会选prompt作为入口。
- 提出问题
- 1.原生方法是否可以执行耗时操作,如果有会阻塞通信吗?
- 2.多线程中调用多个原生方法,如何保证原生方法每一个都会被执行到?
- 3.js会阻塞等待当前原生函数(耗时操作的那个)执行完毕再往下走,所以js调用java方法里面最好也不要做耗时操作
- prompt的一个坑导致js挂掉
- 从表现上来看,onJsPrompt必须执行完毕,prompt函数才会返回,否则js线程会一直阻塞在这里。实际使用中确实会发生这种情况,尤其是APP中有很多线程的场景下,怀疑是这么一种场景:
- 第一步:js线程在执行prompt时被挂起,
- 第二步:UI线程被调度,恰好销毁了WebView,调用了 (webView的destroy),destroy之后,导致 onJsPrompt不会被回调,prompt一直等着,js线程就一直阻塞,导致所有webView打不开,一旦出现可能需要杀进程才能解决。
- 解决方案
- 由于onJsPrompt是在UI线程执行,所以尽量不要做耗时操作,可以借助Handler灵活处理。利用Handler封装一下,让每个任务自己处理,耗时的话就开线程自己处理。
- JsPrompt方法message长度限制问题
- 在调用WebViewChromeClient的onJsPrompt()时,利用message来实现Js与native之间的交互,在不同的手机中,对于message的长度限制不同,华为超长会自动截取,目前发现最长10225 char。
# 4.3 @JavascriptInterface建议
- 在js调用Android原生方法时,会用@JavascriptInterface注解标注那些需要被调用的Android原生方法,那么思考一下,这些原生方法是否可以执行耗时操作,如果有会阻塞通信吗?
- JS会阻塞等待当前原生函数(耗时操作的那个)执行完毕再往下走,所以 @JavascriptInterface注解的方法里面最好也不要做耗时操作,最好利用Handler封装一下,让每个任务自己处理,耗时的话就开线程自己处理,这样是最好的。
- JavascriptInterface注入的方法被js调用时,可以看做是一个同步调用
- 虽然两者位于不同线程,但是应该存在一个等待通知的机制来保证,所以Native中被回调的方法里尽量不要处理耗时操作,否则js会阻塞等待较长时间。
# 4.6 无法释放js耗能
- 说一下背景
- 在有些手机你如果webView加载的html里,有一些js一直在执行比如动画之类的东西,如果此刻webView 挂在了后台这些资源是不会被释放用户也无法感知。导致一直占有cpu 耗电特别快,所以如果遇到这种情况,处理方式如下所示。
- 优化思路如下:
- 大概意思就是在后台的时候,会调用onStop方法,即此时关闭js交互,回到前台调用onResume再开启js交互。
//在onStop里面设置setJavaScriptEnabled(false); //在onResume里面设置setJavaScriptEnabled(true)。1
2
# 05.WebView缓存优化
# 5.2 缓存到哪里
- WebView缓存是在什么地方?
- 在项目中如果使用到 WebView 控件,当加载 html 页面时,会在/data/data/包名目录下生成 database 与 cache 两个文件夹。
- 请求的 url 记录是保存在 WebViewCache.db,而 url 的内容是保存在 WebViewCache 文件夹下。
- 会缓存那些内容?
- 当我们加载Html时候,会在我们data/应用package下生成database与cache两个文件夹:
- 请求的Url记录是保存在webViewCache.db里,而url的内容是保存在webViewCache文件夹下
- WebView中存在着两种缓存:网页数据缓存(存储打开过的页面及资源)、H5缓存(即AppCache)。
- 页面缓存:
- 指加载一个网页时的html、JS、CSS等页面或者资源数据。
- 这些缓存资源是由于浏览器的行为而产生,开发者只能通过配置HTTP响应头影响浏览器的行为才能间接地影响到这些缓存数据。
- 缓存的索引存放在/data/data/package_name/databases下。
- 文件存放在/data/data/package_name/cache/xxxwebviewcachexxx下。
- 数据缓存 :
- 数据缓存分为AppCache和DOM Storage两种。
- 这些缓存资源是由开发者的直接行为而产生,所有的缓存数据都由开发者直接完全地掌控。
- Android中Webkit使用一个db文件来保存AppCache数据(my_path/ApplicationCache.db)
- Android中Webkit会为DOM Storage产生两个文件(my_path/localstorage/xxx.db和my_path/localstorage/Databases.db)
- 多种缓存策略分析
- LOAD_CACHE_ONLY:不使用网络,只读本地缓存。
- LOAD_NORMAL:在 API Level 17 中已经被废弃,而在API Level 11 开始,策略如 LOAD_DEFALT。
- LOAD_NO_CACHE:不使用缓存,只从网络获取数据。
- LOAD_CACHE_ELSE_NETWORK:只要本地有缓存,就从缓存中读取数据。
- LOAD_DEFAULT:根据 Http 协议,决定是否从网络获取数据。
# 5.3 浏览器缓存机制
- response的headers中的参数, 注意到这么几个字段:Last-Modified、ETag、Expires、Cache-Control
- Cache-Control
- 例如Cache-Control:max-age=2592000, 表示缓存时长为2592000秒, 也就是一个月30天的时间。如果30天内需要再次请求这个文件,那么浏览器不会发出请求,直接使用本地的缓存的文件。这是HTTP/1.1标准中的字段。
- Expires
- 例如Expires:Tue,25 Sep 2018 07:17:34 GMT, 这表示这个文件的过期时间是格林尼治时间2018年9月25日7点17分。因为我是北京时间2018年8月26日15点请求的, 所以可以看出也是差不多一个月有效期。在这个时间之前浏览器都不会再次发出请求去获取这个文件。Expires是HTTP/1.0中的字段,如果客户端和服务器时间不同步会导致缓存出现问题,因此才有了上面的Cache-Control。当它们同时出现时,Cache-Control优先级更高。
- Last-Modified
- 标识文件在服务器上的最新更新时间, 下次请求时,如果文件缓存过期,浏览器通过If-Modified-Since字段带上这个时间,发送给服务器,由服务器比较时间戳来判断文件是否有修改。如果没有修改,服务器返回304(未修改)告诉浏览器继续使用缓存;如果有修改,则返回200,同时返回最新的文件。
- Etag
- Etag的取值是一个对文件进行标识的特征字串, 在向服务器查询文件是否有更新时,浏览器通过If-None-Match字段把特征字串发送给服务器,由服务器和文件最新特征字串进行匹配,来判断文件是否有更新:没有更新回包304,有更新回包200。Etag和Last-Modified可根据需求使用一个或两个同时使用。两个同时使用时,只要满足基中一个条件,就认为文件没有更新。
- Cache-Control
- 浏览器自身的缓存机制是基于http协议层的Header中的信息实现的
- Cache-control && Expires
- 这两个字段的作用是:接收响应时,浏览器决定文件是否需要被缓存;或者需要加载文件时,浏览器决定是否需要发出请求
- Cache-control常见的值包括:no-cache、no-store、max-age等。其中max-age=xxx表示缓存的内容将在 xxx 秒后失效, 这个选项只在HTTP 1.1可用, 并如果和Last-Modified一起使用时, 优先级较高。
- Last-Modified && ETag
- 这两个字段的作用是:发起请求时,服务器决定文件是否需要更新。服务端响应浏览器的请求时会添加一个Last-Modified的头部字段,字段内容表示请求的文件最后的更改时间。
- 而浏览器会在下一次请求通过If-Modified-Since头部字段将这个值返回给服务端,以决定是否需要更新文件
- Cache-control && Expires
- 一般设置为默认的缓存模式就可以了。关于缓存的配置, 主要还是靠web前端和后台设置。这些技术都是协议层所定义的,在Android的webView当中我们可以通过配置决定是否采纳这几个协议的头部属性
# 5.4 本地资源替换操作
# 07.体验使用优化说明
# 7.1 设置下载监听优化
- 添加下载监听操作。跳转外部浏览器进行下载……具体可以看setDownloadListener代码
# 7.2 设置字体优化
# 7.8 常见协议处理
- 网页中tel,sms,mailTo,Intent,Market协议,那么他们分别都是怎么用的呢
- tel:协议---拨打电话
- 在html中
<a href="tel:13667225184">电话给我</a>1- 在java中
//tel:协议---拨打电话 if(url.startsWith("tel:")) { //直接调出界面,不需要权限 Intent sendIntent = new Intent(Intent.ACTION_DIAL, Uri.parse(url)); startActivity(sendIntent); //或者 //直接拨打,需要权限<uses-permission android:name="android.permission.CALL_PHONE"/> //Intent sendIntent = new Intent(Intent.ACTION_CALL, Uri.parse(url)); //startActivity(sendIntent); //否则键盘回去,页面显示"找不到网页" return true; }1
2
3
4
5
6
7
8
9
10
11
12 - sms:协议---发送短信
- 在html中
<a href="sms:">调出发短信界面</a></br> <a href="sms:13667225184">调出发短信界面显示号码</a></br> <a href="sms:13667225184?body=contents">调出发短信界面显示号码和发送内容</a></br> <a href="sms:13667225184&body=contents1">ios调出发短信界面显示号码和发送内容</a></br> <a href="sms:13667225184;10010?body=contents2">调出发短信界面给多个号码发内容</a><br/> <a href="sms:+13667225184?body=contents3">调出发短信界面显示号码 </a></br> <a href="sms:+13667225184;10010?body=contents4">调出发短信界面给多个号码发内容 </a><br/>1
2
3
4
5
6
7- 在java中
if(url.startsWith("sms:")||url.startsWith("smsto:")||url.startsWith("mms:")||url.startsWith("mmsto:")) { //直接调出界面,不需要权限 Intent sendIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); startActivity(sendIntent); //或者 //打开短信页面,不需要权限 //Intent sendIntent = new Intent(Intent.ACTION_SENDTO, Uri.parse(url)); //startActivity(sendIntent); //或者 //import android.telephony.SmsManager; //SmsManager smsg = SmsManager.getDefault();//----看不到已发送信息。。。 //smsg.sendTextMessage("10086", null, "tttttt", null, null); //或者 //---可以看到已发的信息 //ContentValues values = new ContentValues(); //values.put("address", "10086"); //values.put("body", "contents"); //ContentResolver contentResolver = getContentResolver(); //contentResolver.insert(Uri.parse("content://sms/sent"), values); // contentResolver.insert(Uri.parse("content://sms/inbox"), values); //<uses-permission android:name="android.permission.SEND_SMS"/> //<uses-permission android:name="android.permission.READ_SMS"/> //<uses-permission android:name="android.permission.WRITE_SMS"/> //否则键盘回去,页面显示"找不到网页" return true; }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 - mailto:协议---发送邮件
- 在html中
<a href="mailto:yangchong211@163.com">邮件</a>1- 在java中
if (url.startsWith("mailto:")) { //打开发邮件窗口 Intent mailIntent = new Intent(Intent.ACTION_SENDTO, Uri.parse(url)); startActivity(mailIntent); //<uses-permission android:name="android.permission.SEND_TO"/> return true; }1
2
3
4
5
6
7
# 7.9 监控页面卡死
- https://www.itdaan.com/blog/2017/07/11/f0979eae58b1108710683c0c525adcc0.html
# 09.一些其他优化
# 9.1 多次获取web标题title
- 网上的部分解决思路
- 网上能查的大部分方法都是在WebChromeClient的onReceivedTitle(WebView view, String title)中拿到title。但是这个方法在网页回退时是无法拿到正确的上一级标题的,网上的处理方法是自己维护一个List去缓存标题,在执行完webView.goBack()后,移除List的最后一条,再将新的最后一条设置给标题栏。
- 这个方法当然是可行的,但是自己缓存时缓存时机和移除时机都不好确定,onReceivedTitle方法在一个页面打开时并不是仅调用一次,而是多次调用,前面拿到的title都为空。
- 采用原生的WebBackForwardList获取,不过下面这种仍然也会出现问题。
webView.setWebChromeClient(new WebChromeClient() { @Override public void onReceivedTitle(WebView view, String title) { getWebTitle(); } }); private void getWebTitle(){ WebBackForwardList forwardList = webView.copyBackForwardList(); WebHistoryItem item = forwardList.getCurrentItem(); if (item != null) { setActionBarTitle(item.getTitle()); } } private void onWebViewGoBack(){ webView.goBack(); getWebTitle(); } @Override public boolean onKeyDown(int keyCode, KeyEvent event) { if (webView.canGoBack()) { onWebViewGoBack(); return false; } return super.onKeyDown(keyCode, event); }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 9.2 隐藏标签操作
- 产品需求
- 要在App中打开xx的链接,并且要隐藏掉H5页面的某些内容,这个就需要在Java代码中操作WebView,让H5页面加载完成后能够隐藏掉某些内容。
- 需要几个前提条件
- 页面加载完成
- 在Java代码中执行Js代码
- 利用Js代码找到页面中的底部栏并将其隐藏
- 如何在h5中找隐藏元素
- 在H5页面中找到某个元素还是有很多方法的,比如getElementById()、getElementsByClassName()、getElementsByTagName()等,具体根据页面来选择
- 找到要隐藏的h5视图div,然后可以看到有id,或者class。这里用class举例子,比如div的class叫做'copyright'
- document.getElementsByClassName('copyright')[0].style.display='none'
- 可能出现的问题
- 等到页面加载完毕后,执行隐藏div标签方法,会造成控件闪屏,不抬友好。但是如果在onProgressChanged执行到85左右隐藏标签又会导致偶发性没有隐藏成功。
- 如果有重定向,则会出现执行多次。写了这个隐藏逻辑,会造成所有的页面都会执行,不知道是否会影响性能?待研究……
- 代码操作如下所示
/** * 可以等页面加载完成后,执行Js代码,找到底部栏并将其隐藏 * 如何找h5页面元素: * 在H5页面中找到某个元素还是有很多方法的,比如getElementById()、getElementsByClassName()、getElementsByTagName()等,具体根据页面来选择 * 隐藏底部有赞的东西 * 这个主要找到copyright标签,然后反射拿到该方法,调用隐藏逻辑 * 步骤操作如下: * 1.首先通过getElementByClassName方法找到'class'为'copyright'的所有元素,返回的是一个数组, * 2.在这个页面中,只有底部栏的'class'为'copyright',所以取数组中的第一个元素对应的就是底部栏元素 * 3.然后将底部栏的display属性设置为'none',表示底部栏不显示,这样就可以将底部栏隐藏 * * 可能存在问题: * onPageFinished没有执行,导致这段代码没有走 */ private void hideBottom() { try { if (mWebView!=null) { //定义javaScript方法 String javascript = "javascript:function hideBottom() { " + "document.getElementsByClassName('copyright')[0].style.display='none'" + "}"; //加载方法 mWebView.loadUrl(javascript); //执行方法 mWebView.loadUrl("javascript:hideBottom();"); } } catch (Exception e) { e.printStackTrace(); } }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 10.一些问题bug记录
# 10.1 shouldOverrideUrlLoading不执行
- 原因1:shouldOverrideUrlLoading不执行,原因是因为在js里面设置了计时器实现可以判断用户长按的功能,当android遇到html的js代码里面执行有计时器如:setTimeout就不会执行android WebView 里面的 shouldOverrideUrlLoading 。
- 原因2:
- https://blog.csdn.net/weixin_37806077/article/details/85488680
- https://blog.csdn.net/KevinsCSDN/article/details/89598789
# 01.概述
# 1.1 项目背景
- 通过简要的语言描述项目背景以及要达成的业务目标。
# 1.2 设计目标
- 需求的背后往往会带来技术的重构/优化,或者单纯的完成需求,如果有必要,需要从技术角度给出方案设计的目标
- 比如对于图片下载需求,需要完成相关的功能,那么设计目标主要有完成异步下载、存储、缓存设计、图片解码、渲染等功能。
- 比如对于优化需求,目标可以是达到一个什么效果?可以是帧率的、Crash率的、卡顿的等。
- 比如对于重构需求,目标可以是加强扩展、解决问题、提升效率等。
# 02.方案设计
- 方案设计是技术文档的最核心部分,主要包括了整体架构和功能设计,这里需要体现:
- 设计的初衷:概要描述方案设计的思考,可以是为了扩展性的考虑,可以是提升性能 关键技术点的思考:描述关键技术选型的思考,比如要解耦,业内解耦方案能有router、Target-Action等,讲清楚选择的思考
- 技术上的折中/取舍:在做技术设计的时候,往往要的很多,但时间有限,那么这个需要讲一下折中与取舍,以及接下来的规划、计划
# 2.1 整体架构
- 整体架构的组成需要有一张完成的架构设计图,描述清楚具体的分层以及层与层之间的关系
- 比如传统的开发会分为三层,展示层、逻辑层、数据层
- 展示层的设计:视图的构成、视图间的耦合关系、具体的交互逻辑
- 逻辑层的设计:支撑展示层所需要的数据、从数据层获取数据的加工、业务相关逻辑(比如轮询服务)
- 数据层的设计:数据的获取方式以及存储方式,文件、数据库、本地、网络
# 2.2 功能设计
- 功能设计包含但不限于以下几个部分:逻辑流程图、接口设计图、与外部的模块间依赖关系
# 2.2.1 关键流程图
- 设计中的最复杂、最关键的逻辑需要画出流程图,实在画不出的流程图需要用语言描述清楚。
- 关键流程需要有逻辑流程图,帮助其他同学理解功能的关键节点逻辑
# 2.2.2 接口设计图
- 通过UML类图来展示类间关系,描述清楚接口设计的一些思考原则
- 提供的接口,往往接口设计为了完成相关逻辑
# 2.2.3 模块间依赖关系
- 描述清楚和哪些模块存在依赖关系以及原因,比如首页依赖于购物车模块,需要解释清楚要强耦合,有没有办法解耦
- App内部模块间依赖
- App外部依赖
# 2.3 UI/动效设计
- 客户端开发有很大一部分精力在UI/动效上,对于复杂的静态UI和复杂动效,需要给出实现方案和选型逻辑
- 静态UI
- 只有复杂的UI才需要给出设计方案,例如核心页面大重构、复杂的协调布局等
- 复杂动效
- 复杂的动效是端上容易踩坑的关键点,需要给出实现方案的对比、选型等,为验证动效可行性,可以给出动效Demo
# 03.其他设计(Optional)
- 以下部分是可选项,主要是从异常、兼容性、性能、稳定性、灰度、降级等维护来设计。
# 3.1 性能设计
- 有些业务项目可能会考虑性能,比如列表页,卡顿、流畅度怎么样?如何评估?
- 有些技术项目可能也会考虑性能,比如数据库设计,检索性能如何?是否有瓶颈,如何评估?
# 3.2 稳定性设计
- 大的项目需要考虑性能如何保障?
- 比如方案 Review
- 比如自测Case Review,加强自测
- 比如单测
# 3.3 灰度设计
- 核心关键功能需要有A/B设计
- 比如UIWebview替换为WKWebview,其中存在很多不确定因素,需要做好灰度设计
# 3.4 降级设计
- 在做一些新技术尝试时,需要考虑降级设计
- 比如RN、swift、weex引入对原有业务造成影响的,需要有兜底,可降级
- 参考资料
- 需要列出方案设计过程的文档,包括但不局限于PM需求文档,技术参考文档等。
# 3.5 异常设计
- 大部分业务需求都会涉及到异常处理,在关心主流程的同时需要关注异常场景怎么保证正确性?
- 比如用户操作中途退出、网络异常、数据被清理等
# 3.6 兼容性设计
- 业务逻辑一般不会涉及到兼容性,但UI/动效需求容易遇到兼容性问题,也是提测时需要让QA关注的
- 比如独立端/嵌入端、高低版本API适配等
# 04.排期与计划
- 排期计划主要针对周期较长项目的时间补充,对于小型项目不需要,例如:
- 正常的版本业务需求,5pd以下,不需要给出排期计划;5pd或者以上,可以简单描述一下排期和提测时间
- 跨版本的大型业务需求、重构专项等,需要给出详细的排期计划
- 研发自驱的技术优化项目,需要给出详细的排期计划
# 05.参考资料
- 需要列出方案设计过程的文档,包括但不局限于PM需求文档,技术参考文档等。
# 参考
- Android 黑科技保活实现原理揭秘
- https://weishu.me/2020/01/16/a-keep-alive-method-on-android/
# 开源项目
- https://github.com/Ryan-Shz/FastWebView (23star)
- https://github.com/yale8848/CacheWebView (1.5k star)
- https://github.com/easilycoder/HybridCache (54star) 文档可以,但是不通
- https://github.com/NEYouFan/ht-candywebcache-android
# 技术博客
- https://www.jianshu.com/p/5e7075f4875f
- 安卓WebView修改DOM:https://www.jianshu.com/p/e320d6bb11e7
上次更新: 2026/06/10, 11:13:41