一、背景
H5是HTML5的简称,就是“HTML”的第5个版本,也就是第5个版本的“描述网页的标准语言”,网页通过url访问,网页内部使用了H5技术,为了统一含义,所以本文H5指的就是网页。
Android原生WebView有磁盘缓存最大上限20M,对应频繁使用H5业务的项目来说,还是太小,HTTP的缓存部分采用LRU缓存算法实现,我们在使用HTTP缓存协议对资源缓存时,太小的缓存空间很容易导致页面缓存被清除,从而重新加载。不仅浪费用户的流量,也会造成不好的用户体验。本文通过自定义本地缓存的方式,突破原生WebView缓存限制,提供多种缓存模式,支持预加载和离线加载,并友好的支持离线预推,可以大幅提升H5加载速度。
- 由于H5具备 开发周期短、灵活性好 的特点,所以现在 Android App大多嵌入了 Android Webview 组件进行 Hybrid 开发。
- 在使用今日头条,百度App打开新闻页时,感觉很流畅,基本做到了秒开。
- 我们项目是电商类的,平时活动页、推广页等使用了大量的H5页面,但原生WebView的渲染速度不是那么理想,于是我对 Android Webview 的性能问题,提出一些有效解决方案。
二、WebView存在哪些性能问题?
2.1、文件下载耗时
包括引入的第3方css、js、图片等,下载完成才能渲染
2.2、解析js、css耗时
js 本身的解析过程复杂、解析速度不快 & HTML涉及较多 js 代码文件,可能会碰到很多资源需要加载。这个时候,CSS和PNG等资源会开启异步线程去加载,不会打断HTML解析。而JS文件则会阻塞HTML解析,
2.3、页面资源加载缓慢
每加载一个 H5页面,都会产生较多网络请求,除了html 内部自身的业务请求;还有外部引用的JS、CSS、字体文件,图片也是一个独立的 HTTP 请求,如果每一个请求都串行的,这么多请求串起来,这导致 H5页面资源加载缓慢
2.4、WebView创建耗时
首次创建WebView耗时大约需要500ms左右,第二次创建耗时大约需要20ms左右
三、第3方解决方案
3.1、阿里云-mPaaS平台的-H5容器
mPaaS是一个平台,平台里有很多稳定且强大的组件,比如:H5容器组件库,支付宝扫码库,推送库,热更新库等等,我们只需要按需集成即可。H5 容器组件提供了Session页面管理、加载更快兼容性更好的UCWebView、离线包等功能,具体可以查看官网介绍,本文就不再叙述了。
3.2、腾讯VasSonic
不建议使用,多年未更新,该库的实现是在WebView内核的上层,支持系统原生WebView和腾讯X5内核。X5内核和mPaaS里的UCWebView都是为了解决Android系统WebView碎片化严重的问题。
四、本地解决方案
4.1、提高js解析效率
js
文件放在html的底部,因为html
是从上到下解析的,碰到头部有js文件引用时,浏览器就会开始下载这个js文件,下载后再执行,执行完毕后再继续解析后面的html
。如果这个js
文件很大,需要两秒来下载,那么浏览器就会停下来两秒,势必会影响用户体验。所以原则就是尽可能快地让首屏内容先展现,再加载其他资源。大部分前端同学都会把js
放底部的。
4.2、提高资源加载速度
- 如果一个H5网页引用了过多的第3方资源,可以事先将更新频率较低、常用 & 固定的
H5
静态资源 文件JS
、CSS
文件、图片
等,放到本地。如:vue.js
、JQuery.js
、axios.min.js
可以放到android/main/assets目录下,当H5加载vue.js
的时候,APP进行拦截,从assets返回vue.js
。这也是简单离线包的实现方案。
2. 拦截
H5
页面的图片请求,通过Android 的Glide图片库进行缓存。
webView.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? {
if (request != null) {
val res = GlideImgCacheManager.interceptRequest(view, request.getUrl().toString())
if (res != null) {
return res;
}
}
return super.shouldInterceptRequest(view, request)
}
object GlideImgCacheManager {
private const val TAG = "GlideCache"
private var sGlideImgCacheManager: GlideImgCacheManager? = null
//只缓存白名单中的图片资源
private val CACHE_IMG_TYPE: HashSet<*> = object : HashSet<Any?>() {
init {
add("png")
add("jpg")
add("jpeg")
add("bmp")
add("webp")
}
}
/**
* 拦截资源
* @param url
* @return 请求结果
*/
fun interceptRequest(webView: WebView?, url: String?): WebResourceResponse? {
try {
val extension = MimeTypeMap.getFileExtensionFromUrl(url)
if (TextUtils.isEmpty(extension) || !CACHE_IMG_TYPE.contains(extension.toLowerCase())) {
//不在支持的缓存范围内
return null
}
Timber.d(String.format("开始 glide cache img (%s),url:%s", extension, url))
val startTime = System.currentTimeMillis()
//String mimeType = MimeTypeMap.getMimeTypeFromUrl(url);
var inputStream: InputStream? = null
val bitmap = Glide.with(webView!!).asBitmap().diskCacheStrategy(DiskCacheStrategy.ALL).load(url).submit().get()
inputStream = getBitmapInputStream(bitmap, CompressFormat.JPEG)
val costTime = System.currentTimeMillis() - startTime
if (inputStream != null) {
Timber.d(String.format("glide cache img(%s ms): %s", costTime, url))
return WebResourceResponse("image/jpg", "UTF-8", inputStream)
} else {
Timber.e(String.format("glide cache error.(%s ms): %s", costTime, url))
}
} catch (t: Throwable) {
t.printStackTrace()
}
return null
}
/**
* 将bitmap进行压缩转换成InputStream
*
* @param bitmap
* @param compressFormat
* @return
*/
private fun getBitmapInputStream(bitmap: Bitmap, compressFormat: CompressFormat): InputStream? {
try {
val byteArrayOutputStream = ByteArrayOutputStream()
bitmap.compress(compressFormat, 80, byteArrayOutputStream)
val data = byteArrayOutputStream.toByteArray()
return ByteArrayInputStream(data)
} catch (t: Throwable) {
t.printStackTrace()
}
return null
}
}
-
把H5网页引用的第3方资源放入 CDN 。
-
借助OkHttp的缓存策略做H5 url级别的缓存&预加载
class MyOkHttpCacheInterceptor : Interceptor {
private var maxAga = 365 //default
private var timeUnit = TimeUnit.DAYS
fun setMaxAge(maxAga: Int, timeUnit: TimeUnit) {
this.maxAga = maxAga
this.timeUnit = timeUnit
}
override fun intercept(chain: Interceptor.Chain): Response {
val response: Response = chain.proceed(chain.request())
val cacheControl: CacheControl = CacheControl.Builder()
.maxAge(maxAga, timeUnit)
.build()
return response.newBuilder()
.removeHeader("Pragma")
.removeHeader("Cache-Control")
.header("Cache-Control", cacheControl.toString())
.build()
}
}
object OkHttpCacheManager {
private var allCount = 0
private var cacheCount = 0
private val mHttpClient: OkHttpClient
private val TAG = "OkHttpCacheManager"
private var sOkHttpCacheManager: OkHttpCacheManager? = null
//只缓存白名单中的资源
private val CACHE_MIME_TYPE = object : HashSet<String>() {
init {
add("html")
add("htm")
add("js")
add("ico")
add("css")
add("png")
add("jpg")
add("jpeg")
add("gif")
add("bmp")
add("ttf")
add("woff")
add("woff2")
add("otf")
add("eot")
add("svg")
add("xml")
add("swf")
add("txt")
add("text")
add("conf")
add("webp")
}
}
init {
//设置缓存的目录文件
val httpCacheDirectory = File(Utils.getApp().externalCacheDir, "x-webview-http-cache")
//仅作为日志使用
if (httpCacheDirectory.exists()) {
val result = FileUtils.listFilesInDir(httpCacheDirectory)
for (file in result) {
Timber.d("file = " + file.absolutePath)
}
}
//缓存的大小,OkHttp会使用DiskLruCache缓存
val cacheSize = 20 * 1024 * 1024 // 20 MiB
val cache = Cache(httpCacheDirectory, cacheSize.toLong())
//设置缓存
mHttpClient = OkHttpClient.Builder().addNetworkInterceptor(MyOkHttpCacheInterceptor()).cache(cache).build()
}
/**
* 针对url级别的缓存,包括主文档,图片,js,css等
*
* @param url
* @param headers
* @return
*/
fun interceptRequest(url: String, headers: Map<String?, String?>?): WebResourceResponse? {
try {
val extension = MimeTypeMap.getFileExtensionFromUrl(url)
if (TextUtils.isEmpty(extension) || !CACHE_MIME_TYPE.contains(extension.toLowerCase())) {
//不在支持的缓存范围内
Timber.w(TAG + "+" + url + " 's extension is " + extension + "!!not support...")
return null
}
val startTime = System.currentTimeMillis()
val reqBuilder: Request.Builder = Request.Builder().url(url)
if (headers != null) {
for ((key, value) in headers) {
Timber.d(String.format("header:(%s=%s)", key, value))
reqBuilder.addHeader(key!!, value!!)
}
}
val request: Request = reqBuilder.get().build()
val response = mHttpClient.newCall(request).execute()
if (response.code != 200) {
Timber.d("response code = " + response.code + ",extension = " + extension)
return null
}
// String mimeType = MimeTypeMapUtils.getMimeTypeFromUrl(url);
val mimeType: String = url.toMediaType().type
Timber.d("mimeType = $mimeType,extension = $extension,url = $url")
val okHttpWebResourceResponse = WebResourceResponse(mimeType, "", response.body!!.byteStream())
val cacheRes = response.cacheResponse
val endTime = System.currentTimeMillis()
val costTime = endTime - startTime
allCount++
if (cacheRes != null) {
cacheCount++
Timber.d(String.format("count rate = (%s),costTime = (%s);from cache: %s", 1.0f * cacheCount / allCount, costTime, url))
} else {
Timber.d(String.format("costTime = (%s);from server: %s", costTime, url))
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
var message = response.message
if (TextUtils.isEmpty(message)) {
message = "OK"
}
try {
okHttpWebResourceResponse.setStatusCodeAndReasonPhrase(response.code, message)
} catch (e: Exception) {
return null
}
val stringListMap: Map<String, List<String>?> = response.headers.toMultimap()
val header: MutableMap<String, String> = HashMap()
for ((key, value) in stringListMap) {
if (value != null && !value.isEmpty()) {
header[key] = value[0]
}
}
// Map<String, String> header = MimeTypeMap.multimapToSingle(response.headers().toMultimap());
okHttpWebResourceResponse.responseHeaders = header
}
return okHttpWebResourceResponse
} catch (t: Throwable) {
t.printStackTrace()
}
return null
}
}
4.4、预加载
-
在Appliation的onCreate() 里先创建一个WebView,后续第一次加载WebView时,会提高加载速度,但是弊端是一开始就创建WebView会增大内存,除非首页就是WebView才需这么做。
-
如果app客户端WebView页面会同时存在很多个,可以使用缓存池进行管理WebView
五、WebView自带缓存
WebView的本质是加载H5页面,所以WebView自带的缓存机制就是H5页面的缓存机制。
Android WebView自带的缓存机制有6种:
- 浏览器 缓存机制
- Application Cache 缓存机制
- Dom Storage 缓存机制
- Web SQL Database 缓存机制
- Indexed Database 缓存机制
- File System 缓存机制(H5页面新加入的缓存机制,虽然Android WebView暂时不支持,但会进行简单介绍)
5.1、浏览器 缓存机制
5.1.1、 原理
HTTP
协议头里的 Cache-Control
(或 Expires
)和 Last-Modified
(或 Etag
)等字段来控制文件缓存的机制
5.1.2、介绍
Cache-Control
& Expires
用于控制文件在本地缓存有效时长, 如服务器响应头包含:
Cache-Control:max-age=600
,则表示文件在本地应该缓存,且有效时长是600秒(从发出请求算起)。在接下来600秒内,如果有请求这个资源,浏览器不会发出 HTTP 请求,而是直接使用本地缓存的文件。Expires
是HTTP1.0
标准中的字段,Cache-Control
是HTTP1.1
标准中新加的字段,当这两个字段同时出现时,Cache-Control
优先级较高
Last-Modified
& Etag
标识文件在服务器上的最新更新时间,下次请求时,如果文件缓存过期,浏览器通过
If-Modified-Since
字段带上这个时间,发送给服务器,由服务器比较时间戳来判断文件是否有修改。如果没有修改,服务器返回304告诉浏览器继续使用缓存;如果有修改,则返回200,同时返回最新的文件。
Etag
的取值是一个对文件进行标识的特征字串,在向服务器查询文件是否有更新时,浏览器通过If-None-Match
字段把特征字串发送给服务器,由服务器和文件最新特征字串进行匹配,来判断文件是否有更新:没有更新返回304,有更新返回200。Etag
和Last-Modified
可根据需求使用一个或两个同时使用。两个同时使用时,只要满足基中一个条件,就认为文件没有更新。
5.1.3、实现
Cache-Control
与Last-Modified
一起使用;Expires
与Etag
一起使用;一个用于控制缓存有效时间,一个用于在缓存失效后,向服务查询是否有更新,特别注意:浏览器缓存机制 是 浏览器内核的机制,一般都是标准的实现 不需要咱们操心了。
优点:支持
Http
协议层,不足:缓存文件需要首次加载后才会产生;WebView缓存的存储空间有限,缓存有被清除的可能;缓存的文件没有校验。静态资源文件的存储,如JS、CSS
、字体、图片等。Android Webview
会将缓存的文件记录及文件内容会存在当前 app 的 data 目录中。Android
4.4后的WebView
浏览器版本内核:Chrome
。已自动实现。
5.2、 Application Cache 缓存机制
5.3、 Dom Storage 缓存机制
5.3.1、原理
通过存储字符串的 Key - Value
对来提供
DOM Storage 分为 sessionStorage & localStorage; 二者使用方法基本相同,区别在于作用范围不同。
sessionStorage:具备临时性,即存储与页面相关的数据,它在页面关闭后无法使用
localStorage:具备持久性,即保存的数据在页面关闭后也可以使用。
5.3.2、特点
存储空间大( 5MB):存储空间对于不同浏览器不同,如Cookies 才 4KB
存储安全、便捷: Dom Storage 存储的数据在本地,不需要经常和服务器进行交互
不像 Cookies每次请求一次页面,都会向服务器发送网络请求
5.3.3、应用场景
存储临时、简单的数据
代替 将不需要让服务器知道的信息 存储到 cookies的这种传统方法
Dom Storage 机制类似于 Android 的 SharedPreference机制
// 开启DOM storage
getWebView().settings.setDomStorageEnabled(true);
5.4、 Web SQL Database 缓存机制
5.4.1、原理
基于 SQL
的数据库存储机制,Android 官方已弃用
5.4.2、特点
充分利用数据库的优势,可方便对数据进行增加、删除、修改、查询
5.4.3、应用场景
存储适合数据库的结构化数据
// 通过设置WebView的settings实现
val settings = getSettings();
val cacheDirPath = context.getFilesDir().getAbsolutePath()+"cache/";
// 设置缓存路径
settings.setDatabasePath(cacheDirPath);
// 开启 数据库存储机制
settings.setDatabaseEnabled(true);
5.4.4、特别说明
- 根据官方说明,
Web SQL Database
存储机制不再推荐使用(不再维护) - 取而代之的是
IndexedDB
缓存机制,下面会详细介绍
5.5、IndexedDB 缓存机制
5.5.1、原理
属于 NoSQL
数据库,通过存储字符串的 Key - Value
对来提供
类似于
Dom Storage 存储机制
的key-value
存储方式
5.5.2、特点
- 使用数据库事务机制进行数据操作
- 可以对对象的熟悉生成索引,提高查询效率
- 存储空间默认250MB,比
Dom Storage
的5M大很多 - 异步api调用,避免造成等待而影响体验
5.5.3、应用场景
// 只需设置支持JS就自动打开IndexedDB存储机制
getWebView().settings.setJavaScriptEnabled(true);
六、WebView缓存模式
6.1、定义
缓存模式是一种 当加载 H5
网页时,Android WebView
什么时候去读缓存,以哪种方式去读缓存。自带的缓存模式有4种
- LOAD_CACHE_ONLY: 不使用网络,只读取本地缓存数据
- LOAD_NO_CACHE: 不使用缓存,只从网络获取数据.
- LOAD_DEFAULT:
(默认)
根据cache-control决定是否从网络上取数据。 - LOAD_CACHE_ELSE_NETWORK,只要本地有,无论是否过期,或者no-cache,都使用缓存中的数据。 如:www.taobao.com 的cache-control为no-cache,在模式 LOAD_DEFAULT 下,无论如何都会从网络上取数据,如果没有网络,就会出现错误页面;在 LOAD_CACHE_ELSE_NETWORK 模式下,无论是否有网络,只要本地有缓存,都使用缓存。本地没有缓存时才从网络上获取。 www.360.com 的 cache-control 为max-age=60,在两种模式下都使用本地缓存数据。
总结:根据以上两种模式,建议缓存策略为,判断是否有网络,有的话,使用 LOAD_DEFAULT ,无网络时,使用 LOAD_CACHE_ELSE_NETWORK
6.2、具体使用
if(当前有网络){
settings.setCacheMode(WebSettings.LOAD_DEFAULT)
}else{
settings.setCacheMode(WebSettings.LOAD_CACHE_ELSE_NETWORK)
}
七、结尾
从用户点击h5
到 显示
内容,会经历4
个阶段
- WebView初始化
- 解析Html,包括: header、body、css、css
- 渲染html
- 图片加载并显示
如果不使用第3方H5容器方案,app端进行H5容器优化,让WebView的加载效率提高的方法如下:
7.1、需要H5开发同学
- 提供常用的静态js、css等文件让Android 端打包进apk
- 客户端加载H5界面时在请求头 user-agent 中加入手机屏幕分辨率,前端H5根据分辨率来决定展示多大的图片,而不用每次加载全尺寸图片。(
可选
)
7.2、需要 Android 端
- 使用Glide图片加载框架对webview的图片进行缓存
- 使用OkHttp的缓存策略对h5 url级别的缓存&预加载
- 把前端提供的静态资源文件放进assets目录打包进apk
- WebView缓冲池和启动APP创建webView(
可选
)
使用腾讯提供的x5内核和上述优化是不冲突的,x5
使用的Webki
引擎,解决了Android 原生WebView在不同版本的碎片化问题,加载速度更快,崩溃率更低,有很多优点,具体上官网查看。
如果在使用x5内核
的过程中,对H5里的图片和静态资源优化,可以让app的WebView
加载速度更快,给用户带来更好体验。
对于上面代码,封装了一个库, 纯原生kotlin代码,无任何第3方库引入,图片加载是用Glide还是用Picasso缓存都由调用者决定,库只提供接口。
参考:
百度APP-Android H5首屏优化实践
Android:手把手教你构建 全面的WebView 缓存机制 & 资源加载方案**
WebView缓存原理分析和应用
H5 和移动端 WebView 缓存机制解析与实战
腾讯祭出大招VasSonic,让你的H5页面首屏秒开!
《移动端本地 H5 秒开方案探索与实现》
移动 H5 首屏秒开优化方案探讨
美团大众点评 Hybrid 化建设
H5 缓存机制浅析 移动端 Web 加载性能优化
QQ会员基于 Hybrid 的高质量 H5 架构实践
**美团: WebView性能、体验分析与优化