Android H5容器 WebView优化

·  阅读 754

一、背景

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、提高资源加载速度

  1. 如果一个H5网页引用了过多的第3方资源,可以事先将更新频率较低、常用 & 固定的H5静态资源 文件 JSCSS文件、图片等,放到本地。如: vue.jsJQuery.jsaxios.min.js 可以放到android/main/assets目录下,当H5加载vue.js的时候,APP进行拦截,从assets返回vue.js。这也是简单离线包的实现方案。

image.png 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
    }
}
复制代码
  1. 把H5网页引用的第3方资源放入 CDN

  2. 借助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、预加载

  1. 在Appliation的onCreate() 里先创建一个WebView,后续第一次加载WebView时,会提高加载速度,但是弊端是一开始就创建WebView会增大内存,除非首页就是WebView才需这么做。

  2. 如果app客户端WebView页面会同时存在很多个,可以使用缓存池进行管理WebView

五、WebView自带缓存

WebView的本质是加载H5页面,所以WebView自带的缓存机制就是H5页面的缓存机制。

Android WebView自带的缓存机制有6种:

  1. 浏览器 缓存机制
  2. Application Cache 缓存机制
  3. Dom Storage 缓存机制
  4. Web SQL Database 缓存机制
  5. Indexed Database 缓存机制
  6. 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个阶段

  1. WebView初始化
  2. 解析Html,包括: header、body、css、css
  3. 渲染html
  4. 图片加载并显示

如果不使用第3方H5容器方案,app端进行H5容器优化,让WebView的加载效率提高的方法如下:

7.1、需要H5开发同学

  1. 提供常用的静态js、css等文件让Android 端打包进apk
  2. 客户端加载H5界面时在请求头 user-agent 中加入手机屏幕分辨率,前端H5根据分辨率来决定展示多大的图片,而不用每次加载全尺寸图片。(可选)

7.2、需要 Android 端

  1. 使用Glide图片加载框架对webview的图片进行缓存
  2. 使用OkHttp的缓存策略对h5 url级别的缓存&预加载
  3. 把前端提供的静态资源文件放进assets目录打包进apk
  4. 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性能、体验分析与优化

分类:
Android
标签:
分类:
Android
标签: