移动端H5页面静态资源文件缓存

缓存目的

现如今,很多开发团队在开发模式上都会选择 Native/H5 混合开发,随之而来的问题就是怎样加快 H5 页面加载的速度,提升用户的体验?如何做到 H5 页面与 Native 页面流畅度相媲美?这也是很多移动端开发者面临的困难点之一,所以今天就给大家来介绍下橙果错题本在这方面的一些积累。

在没有这整套方案落地之前,移动端 App 经常会收到用户反馈 H5 页面白屏或者加载慢的问题。由于每个用户所处的网络环境都有很大差异,所以这类问题在技术团队这里复现并不容易。但是基本上可以归结于网络问题,因此我们急于寻求一种方案可以解决这种痛点,对 H5 页面的静态资源文件做缓存在此环境下应运而生。

当然,现在市面上对于静态资源文件缓存的方案已经多如牛毛,那为什么还要重复造轮子呢?因为橙果错题本的方案略有不同,可以同时解决以下两种问题:

  1. 移动端打开 H5 页面白屏/加载慢;
  2. 原先 H5 文件的版本完全交由开发人员来控制,容易造成线上事故;

而这,也是写本篇博客的初衷之一。

目前,这套方案已经开源,欢迎大家来交流:

https://github.com/HangZhouShuChengKeJi/H5Cache-Android

下面,就详细展开介绍下吧。

缓存目标

移动端 App 在打包时会内置一份最新的 H5 页面静态资源文件 zip 包。包中主要包含以下几种文件类型:

  • html
  • js
  • css
  • png/jpg

后续的静态资源文件动态更新,也是以这几种文件类型为主。

服务端

服务端的工作主要是提供三个 API 接口

  • 上传 zip 接口
  • 下载 zip 接口
  • 更新资源文件接口

上传 zip 接口

H5 端开发人员把所有的静态资源文件都打成一个 zip 包,然后服务端会提供一个上传 zip 包的接口。

上传之后,服务端把包解压缩,然后遍历里面的每一个文件,插入数据库中。

数据库中新建一张表 h5_resource_cache ,用来保存所有解压缩出来的文件数据。

field type comment
id int id
path varchar 文件在 zip 包中的路径
url varchar 文件的下载地址
md5 varchar 文件的 md5 值
create_time datetime 创建时间
update_time datetime 更新时间

比如,zip 包中有一个文件为 /h5/login/html/index.html ,那么表中就有一条对应的数据

field value
id 1
path /h5/login/html/index.html
url http://www.91chengguo.com/h5/login/html/index.html
md5 378f4280c25e9e58f0a268840ce6edee
create_time 2019-07-10 15:25:55
update_time 2019-07-10 15:25:55

后续 H5 页面如果有更新,重新上传最新的 zip 包即可。每个资源文件的版本都是交由它的 md5 值来控制,不需要开发人员人为控制。

另外,服务端还会保存 zip 包的 md5 值,当作整体的 global version ,方便和移动端比较,后续在更新资源文件接口中会讲到。

下载 zip 接口

下载 zip 接口是上传 zip 的逆操作,不同点在于下载 zip 时,zip 包中所有文件的命名会包含 md5 值。比如原来 index.html 文件会被重新命名为 index.html?378f4280c25e9e58f0a268840ce6edee

更新资源文件接口

移动端调用此接口来维护后期的资源文件更新下载。

请求参数:

  • version :移动端本地静态资源文件的 global version

      {
          "version":"c7d6d754d80d2cdd1ee96e9d5ba8c186"
      }
    

响应参数:

  • latestVersion :最新的静态资源文件 global version ;
  • needUpdate :是否需要更新;
  • resourceList :所有的静态资源文件列表。需要注意的是,这里的 url 后面是带有文件 md5 值;

      {
          "latestVersion": "c34895364072b73edb10136a3db47f0c",
          "needUpdate": true,
          "resourceList": [{
                  "path": "/h5/login/html/index.html",
                  "md5": "378f4280c25e9e58f0a268840ce6edee",
                  "url": "http://www.91chengguo.com/h5/login/html/index.html?v=378f4280c25e9e58f0a268840ce6edee"
              },
              {
                  "path": "/h5/login/js/index.js",
                  "md5": "97f0337af05bf3e3f130da20e58565ed",
                  "url": "http://www.91chengguo.com/h5/login/js/index.js?v=97f0337af05bf3e3f130da20e58565ed"
              },
              {
                  "path": "/h5/login/css/index.css",
                  "md5": "bc88d1d7d37d71c470342292bae3381c",
                  "url": "http://www.91chengguo.com/h5/login/css/index.css?v=bc88d1d7d37d71c470342292bae3381c"
              },
              ...
          ]
      }
    

移动端

移动端的工作在大方向上可以分为三步:

  • 初始化,解压 H5 资源文件 zip 包;
  • 对 H5 静态资源文件的维护和更新;
  • 拦截符合要求的 WebView 请求,然后返回对应的本地静态资源文件流;

下面的介绍以 Android 端的代码为例,基于 RxJava 实现。

初始化

之前提到过,移动端 App 在安装包中会事先预置一份最新的 H5 静态资源文件 zip 包。当第一次打开或者版本升级时,就会去解压该 zip 包。除此之外,还有一份 zip 包中所有文件一一对应的配置文件,和服务端更新资源文件接口返回的数据格式很类似。例如:

{
	"version": "981b2b3e79af4f84b09f98858d2e9b79",
	"resourceList": [{
		"path": "/h5/login/js/index.js",
		"md5": "7a2b958ae9978b1abd6209e0ffce049c"
	},
	...
	]
}

接下来,就是初始化的代码。H5CacheManager.init 方法会在 Application.onCreate() 中被调用。

object H5CacheManager {

	fun init(ctx: Context) {
	    val context = ctx.applicationContext
	    // 静态资源文件保存路径 /data/data/com.orange.note/files/h5cache
	    cachePathDir = "${context.filesDir}/h5cache"
	    // 读取 SharedPreference 中记录的 App 版本号
	    val versionCode = SharedPreferenceUtil.getInt(context, SP_H5_CACHE_VERSION_CODE, DEFAULT_VERSION_CODE)
	    // 当前 App 的版本号
	    val currentVersionCode = AppUtil.getVersionCode(context)
	    // 第一次打开或者版本升级
	    if (versionCode == DEFAULT_VERSION_CODE || currentVersionCode > versionCode) {
	        // 解压 assets 中的 zip 包到 cachePathDir 中
	        unZipFromAssets(context)
	    }
	    // 读取配置文件到内存中
	    readMappingFile(context, currentVersionCode)
	}

}

unZipFromAssets(context) 方法比较简单,在这里就不过多讲解了。

readMappingFile(context, currentVersionCode) 中主要做的事就是把配置文件中的数据读到内存中。

private fun readMappingFile(context: Context, versionCode: Int) {
    // 配置文件
    val file = File(cachePathDir + File.separator + H5_CACHE_JSON)
    Observable.just(file)
        .observeOn(Schedulers.io())
        .map {
            // 如果配置文件存在,直接读取;否则从 assets 中复制过来读取
            if (file.isFile && file.exists()) {
                return@map file.readText()
            } else {
                val input = context.resources.assets.open(H5_CACHE_JSON)
                val filePath = "$cachePathDir/$H5_CACHE_JSON"
                val saveFile = FileUtil.saveFile(input, filePath)
                return@map saveFile.readText()
            }
        }
        .map {
            // 然后把内容读取到 cacheMapping 中
            // cacheMapping 是一个 ConcurrentHashMap 对象
            // 比如 key = /h5/login/js/index.js , value = 7a2b958ae9978b1abd6209e0ffce049c
            val h5Cache = GsonUtil.fromJson(it, H5CacheMapping::class.java)
            version = h5Cache.version
            if (h5Cache.resourceList?.isNotEmpty() == true) {
                h5Cache.resourceList!!.forEach { item ->
                    cacheMapping[item.path] = item.md5
                }
            }
        }
        .subscribe(object : Subscriber<Unit>() {
            override fun onNext(t: Unit?) {
                SharedPreferenceUtil.put(context, SP_H5_CACHE_VERSION_CODE, versionCode)
            }

            override fun onCompleted() {
                unsubscribe()
            }

            override fun onError(e: Throwable?) {
                e?.printStackTrace()
                SharedPreferenceUtil.put(context, SP_H5_CACHE_VERSION_CODE, DEFAULT_VERSION_CODE)
                unsubscribe()
            }
        })
}

资源文件更新

当用户打开 App 后,会定时向服务端检查是否有新的资源文件需要更新。在这里我们就以五分钟检查一次为准。

直接来看代码:

fun checkUpdateInterval() {
    Observable.interval(0, 5 * 60, TimeUnit.SECONDS)
        .subscribeOn(Schedulers.io())
        .subscribe(object : Subscriber<Long>() {

            override fun onNext(t: Long?) {
                checkUpdate()
            }

            override fun onCompleted() {
            }

            override fun onError(e: Throwable?) {
                e?.printStackTrace()
            }

        })
}

fun checkUpdate() {
    // 这里会去调用服务端的更新资源文件接口
    H5CacheTask.checkUpdate(version)
        .observeOn(Schedulers.io())
        .filter {
            return@filter it.needUpdate
        }
        .flatMap {
            return@flatMap Observable.from(it.resourceList)
                .flatMap inner@{ item ->
                    val file = File("$cachePathDir${item.path}?v=${item.md5}")
                    // 如果文件存在并且md5一致,就不用重新下载了
                    if (file.exists() && file.isFile && MD5Util.md5(file).toLowerCase() == item.md5?.toLowerCase()) {
                        return@inner Observable.just(true)
                    }
                    // 否则就去下载
                    return@inner H5CacheTask.download(item.url!!)
                        .map { body ->
                            val uri = URI(item.url)
                            // 注意:这里文件保存时会把后面的 ?v=xxxx 一起作为文件名
                            val path = item.url.replace("${uri.scheme}://${uri.host}", "")
                            // 比如:文件路径为 /data/data/com.orange.note/files/h5cache/h5/login/js/index.js?v=7a2b958ae9978b1abd6209e0ffce049c
                            return@map FileUtil.saveFile(body.byteStream(), cachePathDir + path)
                        }
                        .map { newFile ->
                            // 如果文件下载成功后,检查 md5 值
                            if (item.md5?.toLowerCase() == MD5Util.md5(newFile).toLowerCase()) {
                                return@map true
                            } else {
                                newFile.delete()
                                return@map false
                            }
                        }
                        .onErrorReturn {
                            return@onErrorReturn false
                        }
                        .doOnNext {
                            // 更新内存 cacheMapping
                            cacheMapping[item.path] = item.md5
                        }
                }
                .toList()
                .flatMap inner@{ list ->
                    // 如果其中有某些资源文件下载失败了,就抛出DownloadErrorException
                    return@inner if (list.contains(false))
                        Observable.error<H5CacheResponse>(DownloadErrorException("something download failed"))
                    else
                        Observable.just(it)
                }
        }
        .subscribe(object : Subscriber<H5CacheResponse>() {

            override fun onNext(t: H5CacheResponse?) {
                // 如果全部下载成功了,就更新磁盘上的配置文件
                if (t?.needUpdate == true) {
                    // 更新 global version
                    version = t.latestVersion
                    val h5CacheMapping = H5CacheMapping(t.latestVersion, t.resourceList)
                    val json = GsonUtil.toJson(h5CacheMapping)
                    val file = File(cachePathDir + File.separator + H5_CACHE_JSON)
                    file.writeText(URLDecoder.decode(json, "UTF-8"))
                }
            }

            override fun onCompleted() {
                unsubscribe()
            }

            override fun onError(e: Throwable?) {
                e?.printStackTrace()
                // 如果有下载失败的情况,也更新配置文件,直接读取 cacheMapping 来构造 resourceList 列表
                // 但是需要注意的是,global version 仍然是旧的,不是最新的,方便下次再更新
                if (e is DownloadErrorException) {
                    val h5List = mutableListOf<H5CacheItem>()
                    cacheMapping.forEach {
                        val item = H5CacheItem(it.key, it.value)
                        h5List.add(item)
                    }
                    // global version will not change
                    val h5CacheMapping = H5CacheMapping(version, h5List)
                    val json = GsonUtil.toJson(h5CacheMapping)
                    val file = File(cachePathDir + File.separator + H5_CACHE_JSON)
                    file.writeText(URLDecoder.decode(json, "UTF-8"))
                }
                unsubscribe()
            }

        })
}

拦截 WebView 请求

重写 WebViewClient.shouldInterceptRequest 进行拦截,根据 url 来判断是否需要读取缓存文件流。

具体的流程可以参考下图:

流程图

举个例子,比如现在拦截到 http://www.91chengguo.com/h5/login/js/index.js 。我们获取 url 的 path :

/h5/login/js/index.js

然后去 cacheMapping 中查找是否有包含这个 key 。如果包含的话,从 cacheMapping 中获取对应的 md5 值。那么在磁盘上再去查找是否有以下路径的资源文件存在:

/data/data/com.orange.note/h5cache/h5/login/js/index.js?v=7a2b958ae9978b1abd6209e0ffce049c

如果文件存在并且 md5 值一致,就证明找到了资源文件缓存。读取文件流并构造 WebResourceResponse 对象返回。

相关代码:

object H5CacheInterceptor {

	fun shouldInterceptRequest(url: String?): WebResourceResponse? {
	    if (TextUtils.isEmpty(url)) {
	        return null
	    }
	    val uri = URI(url!!)
	    // 判断 url 的 host 是否是我们需要拦截的
	    if (!hostSet.contains(uri.host)) {
	        return null
	    }
	    var path = uri.path
	    if (!H5CacheManager.cacheMapping.containsKey(path)) {
	        return null
	    }
	    
	    val md5 = H5CacheManager.cacheMapping[path] ?: ""
	    if (TextUtils.isEmpty(md5)) {
	        return null
	    }
	    path = "$path?v=$md5"
	    val file = File(H5CacheManager.cachePathDir + path)
	    if (!file.exists() || !file.isFile) {
	        return null
	    }
	    val fileMd5 = MD5Util.md5(file) ?: ""
	    if (fileMd5.toLowerCase() != md5.toLowerCase()) {
	        return null
	    }
	    // 获取 url 对应的 mimeType
	    val mimeType = MimeTypeMapUtil.getMimeTypeFromUrl(url) ?: ""
	    val webResourceResponse = WebResourceResponse(mimeType, "", file.inputStream())
	
	    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
	        webResourceResponse.setStatusCodeAndReasonPhrase(CODE_200, MESSAGE_OK)
	    }
	    return webResourceResponse
	}
	
}

使用方法:

webView.webViewClient = object : WebViewClient() {

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
        return H5CacheInterceptor.shouldInterceptRequest(request?.url.toString()) ?: super.shouldInterceptRequest(view, request)
    }

    override fun shouldInterceptRequest(view: WebView?, url: String?): WebResourceResponse? {
        return H5CacheInterceptor.shouldInterceptRequest(url) ?: super.shouldInterceptRequest(view, url)
    }
}

写在最后

总体来说,H5 页面加速并不是仅仅依靠静态资源文件缓存就能大幅提升的。除此之外,橙果错题本还对 WebView 其他方面也做了一些优化:

  • 开启 WebView 内置缓存;
  • WebView 使用 HTTPDNS 进行解析;
  • WebView 提前初始化,加快加载内核速度;

如果您有更好的建议,欢迎来互相交流。