《成为大前端》进阶 - 4. 版本更新与离线包技术

1,596 阅读14分钟

概述

在 Native + H5 的混合(Hybrid)开发模式下,经常会遇到以下问题:

  • 无网络时无法加载 H5 开发的功能
  • 网络正常下,WebView 加载网页速度慢带来的体验和 Native 差别太远

我们可以先将H5项目的静态资源打包到客户端的安装包中,WebView加载本地资源可以解决打开速度问题, 但这样就缺乏的H5的灵活部署更新的功能,因此我们可以在这个基础上加上离线包更新的技术,解决灵活更新问题。

前面完成了在线部署和开发环境。那么当开发了新版本之后,发布了同时更新了manifest.json的版本, 客户端就需要更新这个manifest.json.

这篇主要是讲如何更新,更新时机目前放在了 WebView 加载页面之前

离线包前端打包

首先我们需要做的是为H5项目生成离线包,我们需要开发打包工具

拿项目home来举例,webpack编译后的dist目录:

dist/home/1/
  css/
  js/
  index.html

我们将 css js index.html 等等这些资源打包成一个zip包:

dist/home/1/
  css/
  js/
  index.html
  home.zip     // zip包位置

并且也一样部署到我们的服务端上,约定[项目名].zip就是当前版本的所有资源

以上功能我们开发一个工具脚本,实际公司项目中会更为复杂,但我们这里使用最简单可用的方式

build_and_copy.sh

我们将web/deploy/build_and_copy.sh,简化为如下:

target="../../../../book-to-be-big-fe-deploy/"

for name in "home" "news" "weather"
do
    echo building project $name
    cd ../$name
    rm -R dist
    npm run build
    node ../deploy/pack.js
    cp -r -f ./dist/ $target
done

利用循环以方便我们日后添加新的项目

同时我们为每个项目增加了一行打包流程代码

node ../deploy/pack.js

pack.js

web/deploy目录下创建pack.js,代码为:

const path = require("path");
const { exec } = require("child_process");

const prjPath = process.cwd();
const packageJSON = require(path.join(prjPath, "package.json"));
const name = packageJSON.name;
const version = packageJSON.deploy.version;
const zipDir = path.join(prjPath, "dist", name, version);

exec('zip -q -r ' + name + '.zip *', {
    cwd: zipDir
}, (err, stdout, stderr) => {
  if (stderr) {
    console.error(`error: ${stderr}`);
  }
  console.log(`${stdout}`)
})

以上是利用node脚本更容易读取package.json里的属性,并运行zip命令打包

更新manifest.json

最后我们在脚本后面生成manifest.json

build_and_copy.sh

target="../../../../book-to-be-big-fe-deploy/"

for name in "home" "news" "weather"
do
    echo building project $name
    cd ../$name
    rm -R dist
    npm run build
    node ../deploy/pack.js
    cp -r -f ./dist/ $target
done

cd ../deploy
node generateManifest.js

generateManifest.js

const path = require("path")
const fs = require('fs')

const prjList = ['home', 'news', 'weather']
const manifest = {
    projects: []
}
prjList.forEach(prj => {
    let version = require(path.join(__dirname, '..', prj, 'package.json')).deploy.version
    manifest.projects.push({
        name: prj,
        version: version
    })
})

let manifestDeployPath = path.join(__dirname, "../../../../book-to-be-big-fe-deploy/manifest.json")
fs.writeFileSync(manifestDeployPath, JSON.stringify(manifest, null, 2))

最后提交Git,并更新gitee pages,这样完成了前端的zip包打包和部署

Android

添加 okhttp 依赖

implementation("com.squareup.okhttp3:okhttp:4.4.0")

WebManager.kt

typealias ManifestUpdatedCallback = (error: Throwable?) -> Unit

/*

管理项目化之后的逻辑:
1. manifest.json
2. 正式的url的拼接

 */

object WebManager {

    // 项目名 => 版本
    private val versionMap = mutableMapOf<String, String>()
    private lateinit var preferences: SharedPreferences

    // 创建一个OkHttpClient
    private val httpClient: OkHttpClient = OkHttpClient()
    // 防止重复调用的标识
    private var manifestUpdating: Boolean = false
    // 重复调用时的callback数组
    private var updateCallbacks = mutableListOf<ManifestUpdatedCallback>()

    // 初始化
    fun init(context: Context) {
        preferences = PreferenceManager.getDefaultSharedPreferences(context)

        // 1. 从assets中获取manifest.json的InputStream
        val input = context.assets.open("manifest.json")
        // 2. 读取完整内容
        val manifestStr = readAll(input)
        // 3. 从json一步步解析到versionMap
        parseManifest(manifestStr)
    }

    // 更新manifest,完成后callback
    fun updateManifest(callback: ManifestUpdatedCallback?): Call? {
        if (manifestUpdating) {
            if (callback != null) {
                updateCallbacks.add(callback)
            }
            return null
        }

        Log.e("WebManager", "update manifest")

        manifestUpdating = true
        if (callback != null) {
            updateCallbacks.add(callback)
        }

        val url = "${WebConst.WEB_BASE_URL}/manifest.json?t=${System.currentTimeMillis()}"
        val request = Request.Builder()
            .url(url)
            .build()

        val call = httpClient.newCall(request)
        call.enqueue(object: Callback {

            fun callbackAll(error: Throwable?) {
                Log.e("WebManager", "update manifest complete. ${error?.localizedMessage ?: ""}")
                updateCallbacks.forEach {
                    it.invoke(error)
                }
                updateCallbacks.clear()
                manifestUpdating = false
            }

            override fun onFailure(call: Call, e: IOException) {
                callbackAll(e)
            }

            override fun onResponse(call: Call, response: Response) {
                if (!response.isSuccessful) {
                    callbackAll(IOException("Request fail: ${response.code}."))
                    return
                }

                val bodyStr = response.body?.string()
                if (bodyStr == null) {
                    callbackAll(IOException("Response body error."))
                    return
                }

                try {
                    parseManifest(bodyStr)
                } catch (error: Throwable) {
                    callbackAll(error)
                    return
                }
                callbackAll(null)
            }
        })
        return call
    }

    ...

    // 提出一个解析manifest的方法
    private fun parseManifest(content: String) {
        val manifest = JSONObject(content)
        val projects = manifest.getJSONArray("projects")
        for (i in 0 until projects.length()) {
            val prj = projects.getJSONObject(i)
            versionMap[prj.getString("name")] = prj.getString("version")
        }
    }

}

WebActivity.kt

open class WebActivity ... {

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        // 更新 manifest
        WebManager.updateManifest {
            // 判断这个activity有没有被关闭
            if (isDestroyed) {
                return@updateManifest
            }
            webView.post {
                webView.loadUrl(getLoadUrl())
            }
        }
    }

}

iOS

添加 Alamofire 依赖

Alamofire 是纯swift的http库

XCode -> File -> Swift Packages -> Add Package Dependency

输入:https://github.com/Alamofire/Alamofire.git,按提示一步步完成

WebManager.swift

//
//  WebManager.swift
//  ToBeBigFE
//
//  Created by mingo on 2020/2/24.
//  Copyright © 2020 com.example. All rights reserved.
//

import Foundation
import Alamofire

// manifest更新错误
enum ManifestError : Error {
    case responseBodyEmpty
    case parseError
}

// manifest更新的callback定义
typealias ManifestUpdatedCallback = (_ error: ManifestError?) -> Void


/*
管理项目化之后的逻辑:
1. manifest.json
2. 正式的url的拼接
 */
class WebManager {
    
    // 单例模式
    public static let shared = WebManager()
    
    public let settings = UserDefaults(suiteName: "WebDev")!
    
    // 项目名 => 版本
    private var versionDict = [String:String]()
    
    // 防止重复调用的标识
    private var manifestUpdating = false
    // 重复调用时的callback数组
    private var manifestUpdateCallbacks = [ManifestUpdatedCallback]()
    
    // 初始化
    init() {
        // 读取解析manifest.json的过程
        let path = Bundle.main.path(forResource: "manifest", ofType: "json")!
        let data = FileManager.default.contents(atPath: path)!
        try! parseManifest(data)
    }
    
    func updateManifest(callback: @escaping ManifestUpdatedCallback) {
        if (manifestUpdating) {
            manifestUpdateCallbacks.append(callback)
            return
        }

        print("WebManager update manifest")

        manifestUpdating = true
        manifestUpdateCallbacks.append(callback)
        
        let callbackAll = { (error: ManifestError?) in
            print("WebManager update manifest complete")
            self.manifestUpdateCallbacks.forEach { callback in
                callback(error)
            }
            self.manifestUpdateCallbacks.removeAll()
            self.manifestUpdating = false
        }
        
        // 下面使用Alamofire请求manifest
        let url = "\(WebConst.WEB_BASE_URL)manifest.json?t=\(Int64(Date().timeIntervalSince1970))"
        AF.request(url).responseData { resp in
            guard let data = resp.data else {
                callbackAll(.responseBodyEmpty)
                return
            }
            do {
                try self.parseManifest(data)
            } catch {
                callbackAll(.parseError)
                return
            }
            callbackAll(nil)
        }
    }
    
    ...
    
    // 提出一个解析manifest的方法
    private func parseManifest(_ data: Data) throws {
        let json = try JSONSerialization.jsonObject(with: data, options: []) as! [String:Any?]
        let projects = json["projects"] as! [[String:Any?]]
        projects.forEach { item in
            versionDict[item["name"] as! String] = item["version"] as? String
        }
    }
    
}

WebViewController.swift

class WebViewController: ... {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        ....
        
        // 更新manifest之后再加载网页
        WebManager.shared.updateManifest { [weak self] _ in
            guard let strong = self else { return }
            let url = URL(string: strong.getLoadUrl())!
            print("WebView load \(url)")
            let request = URLRequest(url: url)
            strong.webView.load(request)
        }
    }

离线包更新和使用本地缓存 (Android)

将离线包和 manifest.json 打包进 assets 下

离线包版本控制和管理

我们将离线包的代码分成三个部分:

  • VersionManager: 管理 manifest.json 和更新下载
  • ZipManager: 管理 assets 下的 zip 包,和更新离线包
  • WebManager: 管理 VersionManager 和 ZipManager,对其他模块提供 API

VersionManager

VersionManager 关键是管理了两份版本

  • builtinMap 内置离线包的版本,即 assets/manifest.json 的内容,和 assets 下的 zip 包的版本是对应的,用于初次打开
  • versionMap 更新到的最新 manifest.json,并且用 preferences 存放,用于下次打开应用时使用
package com.example.tobebigfe.web

import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import okhttp3.*
import okio.buffer
import okio.source
import org.json.JSONObject
import java.io.IOException
import java.io.InputStream

typealias ManifestUpdatedCallback = (error: Throwable?) -> Unit

class VersionManager(val context: Context, val httpClient: OkHttpClient, val preferences: SharedPreferences) {


    private val manifestKey = "web.manifest"

    // 项目名 => 版本
    private val builtinMap = mutableMapOf<String, String>()
    private val versionMap = mutableMapOf<String, String>()
    private var manifestUpdating: Boolean = false
    private var updateCallbacks = mutableListOf<ManifestUpdatedCallback>()

    init {
        // 1. 从assets中获取manifest.json的InputStream
        val input = context.assets.open("manifest.json")
        // 2. 读取完整内容到builtinMap
        val builtinManifestStr = readAll(input)
        parseManifest(builtinManifestStr, builtinMap)
        // 3. 拷贝一份到versionMap
        builtinMap.forEach { versionMap[it.key] = it.value }

        // 4. 解析本地更新到的版本
        val manifestStr = preferences.getString(manifestKey, null)
        if (!manifestStr.isNullOrBlank()) {
            parseManifest(manifestStr, versionMap)
        }
    }

    fun getVersion(id: String): String? {
        return versionMap[id]
    }

    fun getBuiltinVersion(id: String): String? {
        return builtinMap[id]
    }

    fun updateManifest(callback: ManifestUpdatedCallback?): Call? {
        if (manifestUpdating) {
            if (callback != null) {
                updateCallbacks.add(callback)
            }
            return null
        }

        Log.e("WebManager", "update manifest")

        manifestUpdating = true
        if (callback != null) {
            updateCallbacks.add(callback)
        }

        val url = "${WebConst.WEB_BASE_URL}/manifest.json?t=${System.currentTimeMillis()}"
        val request = Request.Builder()
            .url(url)
            .build()

        val call = httpClient.newCall(request)
        call.enqueue(object: Callback {

            fun callbackAll(error: Throwable?) {
                Log.e("WebManager", "update manifest complete. ${error?.localizedMessage ?: ""}")
                updateCallbacks.forEach {
                    it.invoke(error)
                }
                updateCallbacks.clear()
                manifestUpdating = false
            }

            override fun onFailure(call: Call, e: IOException) {
                callbackAll(e)
            }

            override fun onResponse(call: Call, response: Response) {
                if (!response.isSuccessful) {
                    callbackAll(IOException("Request fail: ${response.code}."))
                    return
                }

                val bodyStr = response.body?.string()
                if (bodyStr == null) {
                    callbackAll(IOException("Response body error."))
                    return
                }

                try {
                    parseManifest(bodyStr, versionMap)
                    preferences.edit().putString(manifestKey, bodyStr).apply()
                } catch (error: Throwable) {
                    callbackAll(error)
                    return
                }
                callbackAll(null)
            }
        })
        return call
    }

    private fun parseManifest(content: String, output: MutableMap<String, String>) {
        val manifest = JSONObject(content)
        val projects = manifest.getJSONArray("projects")
        for (i in 0 until projects.length()) {
            val prj = projects.getJSONObject(i)
            output[prj.getString("name")] = prj.getString("version")
        }
    }

    // 工具函数,读取一个InputStream的所有内容,这里使用到了okio库,非常方便
    @Throws(IOException::class)
    private fun readAll(input: InputStream): String {
        return input.source().use { fileSource ->
            fileSource.buffer().use { buffer ->
                buffer.readUtf8()
            }
        }
    }

}

ZipManager

ZipManager 的提供的关键功能是:

  • 判断本来有没有某个版本的离线包
  • 解压内置的离线包
  • 更新和解压最新的离线包
package com.example.tobebigfe.web

import android.content.Context
import android.util.Log
import com.example.tobebigfe.utils.ZipUtils
import okhttp3.*
import java.io.File
import java.io.FileOutputStream
import java.io.IOException


typealias OfflineZipUpdatedCallback = (error: Throwable?) -> Unit

class ZipManager(val context: Context, val httpClient: OkHttpClient) {

    private val unzipOkFileName = "unzipOK.file"
    private val zipBasePath: File = File(context.cacheDir, "WebZip")
    private val unzipBasePath: File = context.getDir("web", Context.MODE_PRIVATE)

    private val zipUpdatingCallbackMap = mutableMapOf<String, MutableList<OfflineZipUpdatedCallback>>()

    fun hasOfflineFiles(id: String, version: String): Boolean {
        return getOfflineFile(id, version, unzipOkFileName).exists()
    }

    fun getOfflineFile(id: String, version: String, filePath: String): File {
        return File(unzipBasePath, "$id/$version/$filePath")
    }

    fun unzipBuiltin(id: String, version: String) {
        Log.e("ZipManager", "unzip builtin $id $version")
        val input = context.assets.open("$id.zip")
        val destPath = File(unzipBasePath, "$id/$version")
        ZipUtils.unzip(input, destPath)
        File(destPath, unzipOkFileName).createNewFile()
    }

    fun updateOfflineZip(id: String, version: String, calblack: OfflineZipUpdatedCallback): Call? {
        if (hasOfflineFiles(id, version)) {
            calblack(null)
            return null
        }

        if (zipUpdatingCallbackMap.containsKey(id)) {
            val callbackList = zipUpdatingCallbackMap[id]
            callbackList!!.add(calblack)
            return null
        }

        Log.e("ZipManager", "update zip")

        val callbackList = mutableListOf<OfflineZipUpdatedCallback>()
        callbackList.add(calblack)
        zipUpdatingCallbackMap[id] = callbackList

        if (!zipBasePath.exists()) {
            zipBasePath.mkdirs()
        }

        val zipUrl = "${WebConst.WEB_BASE_URL}$id/$version/$id.zip"
        val zipDownloadFile = File(zipBasePath, "$id-$version.zip")
        if (zipDownloadFile.exists()) {
            zipDownloadFile.delete()
        }

        Log.e("ZipManager", "download $zipUrl")

        val request = Request.Builder()
            .url(zipUrl)
            .build()

        val call = httpClient.newCall(request)
        call.enqueue(object: Callback {

            fun callbackAll(error: Throwable?) {
                Log.e("ZipManager", "update zip complete. ${error?.localizedMessage ?: ""}")
                callbackList.forEach {
                    it.invoke(error)
                }
                zipUpdatingCallbackMap.remove(id)
            }

            override fun onFailure(call: Call, e: IOException) {
                callbackAll(e)
            }

            override fun onResponse(call: Call, response: Response) {
                if (!response.isSuccessful) {
                    callbackAll(IOException("Request fail: ${response.code}."))
                    return
                }

                val bodyStream = response.body?.byteStream()
                if (bodyStream == null) {
                    callbackAll(IOException("Response body error."))
                    return
                }

                try {
                    bodyStream.use {
                        val outputStream = FileOutputStream(zipDownloadFile)
                        val buf = ByteArray(DEFAULT_BUFFER_SIZE)
                        while (true) {
                            val len = it.read(buf)
                            if (len == -1) {
                                break
                            }
                            outputStream.write(buf, 0, len)
                        }
                        val destPath = File(unzipBasePath, "$id/$version")
                        ZipUtils.unzip(zipDownloadFile, destPath)
                        File(destPath, unzipOkFileName).createNewFile()
                    }
                } catch (error: Throwable) {
                    callbackAll(error)
                    return
                }
                callbackAll(null)
            }
        })
        return call
    }
}

WebManager

WebManager 改为使用 versionManager 和 zipManager 对外提供离线的 API

package com.example.tobebigfe.web

import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import com.example.tobebigfe.BuildConfig
import okhttp3.*
import java.io.File


object WebManager {

    private lateinit var preferences: SharedPreferences
    private val httpClient: OkHttpClient = OkHttpClient()
    private lateinit var versionManager: VersionManager
    private lateinit var zipManager: ZipManager

    // 初始化
    fun init(context: Context) {
        preferences = PreferenceManager.getDefaultSharedPreferences(context)
        versionManager = VersionManager(context, httpClient, preferences)
        zipManager = ZipManager(context, httpClient)

    }

    fun updateProject(id: String) {
        versionManager.updateManifest {
            updateOfflineZip(id) {}
        }
    }

    // 根据id和page获取正式的url
    fun getWebUrl(id: String, page: String): String {
        if (isDebugProject(id)) {
            return "http://${preferences.webDevServer()}/$id/dev/$page?t=" + System.currentTimeMillis()
        }
        // 拿到最新的版本
        var version = versionManager.getVersion(id)
        // 如果版本没有离线包
        if (version.isNullOrBlank() || !zipManager.hasOfflineFiles(id, version)) {
            // 拿到内置的离线包版本
            version = versionManager.getBuiltinVersion(id)!!
            // 判断是否解压内置的离线包
            if (!zipManager.hasOfflineFiles(id, version)) {
                zipManager.unzipBuiltin(id, version)
            }
        }
        return "${WebConst.WEB_BASE_URL}$id/$version/$page"
    }

    fun getOfflineFile(id: String, version: String, filePath: String): File {
        return zipManager.getOfflineFile(id, version, filePath)
    }

    private fun updateOfflineZip(id: String, callback: OfflineZipUpdatedCallback) {
        if (isDebugProject(id)) {
            callback(null)
            return
        }
        if (id.isBlank()) {
            callback(IllegalArgumentException("project id is blank"))
            return
        }
        val version = versionManager.getVersion(id)
        if (version.isNullOrBlank()) {
            callback(IllegalArgumentException("project id is invalid"))
            return
        }
        zipManager.updateOfflineZip(id, version, callback)
    }

    private fun isDebugProject(id: String): Boolean {
        return BuildConfig.DEBUG && preferences.isWebDevEnabled() && id == preferences.webDevProject()
    }

}

private fun SharedPreferences.isWebDevEnabled(): Boolean {
    return getBoolean("webDev.enabled", false)
}

private fun SharedPreferences.webDevProject(): String? {
    return getString("webDev.project", null)
}

private fun SharedPreferences.webDevServer(): String? {
    return getString("webDev.server", null)
}

ZipUtils

解压工具类

package com.example.tobebigfe.utils

import java.io.*
import java.util.zip.ZipFile
import java.util.zip.ZipInputStream


object ZipUtils {

    fun unzip(input: InputStream, destFile: File) {
        val zis = ZipInputStream(input)
        while (true) {
            val entry = zis.nextEntry ?: break
            if (entry.isDirectory) {
                File(destFile, entry.name).mkdirs()
            } else {
                val outputFile = File(destFile, entry.name)
                if (!outputFile.exists()) {
                    val parentFile = outputFile.parentFile
                    if (!parentFile.exists()) {
                        parentFile.mkdirs()
                    }
                }
                val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
                val br = zis.buffered()
                val bw = FileOutputStream(outputFile)
                var length = 0
                while ({ length = br.read(buffer);length != -1 }()) {
                    bw.write(buffer, 0, length)
                }
                bw.flush()
            }
        }
    }

    fun unzip(sourceFile: File, destFile: File) {
        val zipFile = ZipFile(sourceFile)
        for (entry in zipFile.entries()) {
            if (entry.isDirectory) {
                File(destFile, entry.name).mkdirs()
            } else {
                val outputFile = File(destFile, entry.name)
                if (!outputFile.exists()) {
                    val parentFile = outputFile.parentFile
                    if (!parentFile.exists()) {
                        parentFile.mkdirs()
                    }
                }
                val input = zipFile.getInputStream(entry)
                input.use { input
                    outputFile.outputStream().use {
                        val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
                        val br = input.buffered()
                        val bw = it.buffered()
                        var length = 0
                        while ({ length = br.read(buffer);length != -1 }()) {
                            bw.write(buffer, 0, length)
                        }
                        bw.flush()
                    }
                }
            }
        }
    }

}

WebActivity 改动

open class WebActivity ... {
    ...

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        // 原来的 updateManifest可以不要了,使用新的方式
        // 因为有离线包,直接加载
        webView.loadUrl(getLoadUrl())
        // 更新离线资源,异步的,下次打开能加载到离线包
        WebManager.updateProject(getProjectId())
    }

    open fun getProjectId(): String {
        return intent.getStringExtra("project") ?: ""
    }
}

JSBridgeNavigation open 时传递 project id

class JSBridgeNavigation ... {

    ...
    private fun open(callbackId: String, arg: JSONObject) {
        val intent = Intent(activity, WebActivity::class.java)
        if (arg.has("id")) {
            val id = arg.getString("id")
            val page = arg.getString("page")
            val url = WebManager.getWebUrl(id, page)
            intent.putExtra("url", url)
            // 传递project id给WebActivity
            intent.putExtra("project", id)
        }
        else if (arg.has("url")) {
            intent.putExtra("url", arg.getString("url"))
        }
        if (arg.has("params")) {
            val params = arg.get("params") as JSONObject?
            params?.let {
                intent.putExtra("params", it.toString())
            }
        }
        activity.startActivity(intent)
    }
    ...

}

MainActivity 改动

class MainActivity ... {
    // 覆盖,project id写死为home
    override fun getProjectId(): String {
        return "home"
    }
}

WebView 拦截离线包相关的 url,访问本地资源

改动 CustomWebViewClient,支持离线包的拦截

package com.example.tobebigfe.jsbridge

import android.content.Context
import android.net.Uri
import android.util.Log
import android.webkit.MimeTypeMap
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import com.example.tobebigfe.web.WebConst
import com.example.tobebigfe.web.WebManager
import java.io.File
import java.io.FileInputStream
import java.nio.charset.StandardCharsets

class CustomWebViewClient(val context: Context) : WebViewClient() {

    override fun shouldInterceptRequest(view: WebView, url: String): WebResourceResponse? {
        Log.e("WebView", "intercept $url")
        val uri = Uri.parse(url)
        // 解析拦截的url,判断域名
        if (uri.host == "file.local") {
            return interceptFileLocal(uri)
        }
        // 如果是离线包 url
        if (url.startsWith(WebConst.WEB_BASE_URL)) {
            return interceptWebOfflineFile(uri)
        }
        return null
    }

    // 拦截离线包 url
    private fun interceptWebOfflineFile(uri: Uri): WebResourceResponse? {
        val segs = uri.pathSegments
        if (segs.size < 4) {
            return null
        }
        val id = segs[1]
        val version = segs[2]
        val filePath = segs.subList(3, segs.size).joinToString("/")
        val file = WebManager.getOfflineFile(id, version, filePath)
        Log.e("WebView", "offline ${file.exists()} ${file.absolutePath}")
        if (file.exists()) {
            val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)
            val fis = FileInputStream(file)
            return WebResourceResponse(mimeType, StandardCharsets.UTF_8.name(), fis)
        }
        return null
    }

    private fun interceptFileLocal(uri: Uri): WebResourceResponse? {
        val filePath = uri.getQueryParameter("file") ?: return null
        Log.e("WebView", "file $filePath")
        if (filePath.startsWith("file://")) {
            val file = File(filePath.substring("file://".length))
            val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)
            val fis = FileInputStream(file)
            return WebResourceResponse(mimeType, StandardCharsets.UTF_8.name(), fis)
        }
        // content协议
        else if (filePath.startsWith("content://")) {
            val fileUri = Uri.parse(filePath)
            val mimeType = context.contentResolver.getType(fileUri)
            val fis = context.contentResolver.openInputStream(fileUri)
            return WebResourceResponse(mimeType, StandardCharsets.UTF_8.name(), fis)
        }

        // 其他情况
        return null
    }
}

结语

离线包的主要逻辑:

  1. WebView 加载 url,这个 url 是通过 WebManager 拿到的存在离线资源的 url
  2. 异步地触发更新离线包版本
  3. WebView 加载内容,拦截到 url 得到 id 和 verion
  4. 从 WebManager 里获取 url 对应的离线资源返回 WebResponse

离线包更新和使用本地缓存 (iOS)

将离线包和 manifest.json 打包进 Assets 下

离线包版本控制和管理

我们将离线包的代码分成三个部分:

  • VersionManager: 管理 manifest.json 和更新下载
  • ZipManager: 管理 assets 下的 zip 包,和更新离线包
  • WebManager: 管理 VersionManager 和 ZipManager,对其他模块提供 API

VersionManager

VersionManager 关键是管理了两份版本

  • builtinDict 内置离线包的版本,即 Assets/manifest.json 的内容,和 Assets 下的 zip 包的版本是对应的,用于初次打开
  • versionDict 更新到的最新 manifest.json,并且用 preferences 存放,用于下次打开应用时使用
import Foundation
import Alamofire

// manifest更新错误
enum ManifestError : Error {
    case responseBodyEmpty
    case parseError
}

// manifest更新的callback定义
typealias ManifestUpdatedCallback = (_ error: ManifestError?) -> Void

class VersionManager {
    
    private let userDefaults: UserDefaults
    private let manifestKey = "web.manifest"
    // 项目名 => 版本
    private var builtinDict = [String:String]()
    private var versionDict = [String:String]()
    
    // 防止重复调用的标识
    private var manifestUpdating = false
    // 重复调用时的callback数组
    private var manifestUpdateCallbacks = [ManifestUpdatedCallback]()
    
    init(userDefaults: UserDefaults) {
        self.userDefaults = userDefaults
        
        // 读取解析manifest.json的过程
        let path = Bundle.main.path(forResource: "manifest", ofType: "json")!
        let data = FileManager.default.contents(atPath: path)!
        try! parseManifest(data, output: &builtinDict)
        builtinDict.forEach {
            versionDict[$0.key] = $0.value
        }
        
        // 解析userDefaults里更新到的
        if let manifestData = userDefaults.data(forKey: manifestKey) {
            try? parseManifest(manifestData, output: &versionDict)
        }
    }
    
    func getVersion(id: String) -> String? {
        return versionDict[id]
    }
    
    func getBuiltinVersion(id: String) -> String? {
        return builtinDict[id]
    }
    
    func updateManifest(callback: @escaping ManifestUpdatedCallback) {
        if (manifestUpdating) {
            manifestUpdateCallbacks.append(callback)
            return
        }

        print("WebManager update manifest")

        manifestUpdating = true
        manifestUpdateCallbacks.append(callback)
        
        let callbackAll = { (error: ManifestError?) in
            print("WebManager update manifest complete")
            self.manifestUpdateCallbacks.forEach { callback in
                callback(error)
            }
            self.manifestUpdateCallbacks.removeAll()
            self.manifestUpdating = false
        }
        
        // 下面使用Alamofire请求manifest
        let url = "\(WebConst.WEB_BASE_URL)manifest.json?t=\(Int64(Date().timeIntervalSince1970))"
        AF.request(url).responseData { resp in
            guard let data = resp.data else {
                callbackAll(.responseBodyEmpty)
                return
            }
            do {
                try self.parseManifest(data, output: &self.versionDict)
                self.userDefaults.set(data, forKey: self.manifestKey)
            } catch {
                callbackAll(.parseError)
                return
            }
            callbackAll(nil)
        }
    }
    
    private func parseManifest(_ data: Data, output: inout [String:String]) throws {
        let json = try JSONSerialization.jsonObject(with: data, options: []) as! [String:Any?]
        let projects = json["projects"] as! [[String:Any?]]
        projects.forEach { item in
            output[item["name"] as! String] = item["version"] as? String
        }
    }
}

ZipManager

ZipManager 的提供的关键功能是:

  • 判断本来有没有某个版本的离线包
  • 解压内置的离线包
  • 更新和解压最新的离线包

关于离线包解压,使用:github.com/weichsel/ZI… 可以使用前面介绍过的 Swift Package Manager 添加

import Foundation
import Alamofire
import ZIPFoundation


typealias OfflineZipUpdatedCallback = (_ error: ZipError?) -> Void

enum ZipError : Error {
    case argumentError
    case downloadFail
    case unzipFail
}

class ZipManager {
    
    private let unzipOkFileName = "unzipOK.file"
    private let unzipBasePath: NSString

    private var zipUpdatingCallbackDict = [String:[OfflineZipUpdatedCallback]]()
    
    init() {
        let documentDir = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!
        unzipBasePath = (documentDir as NSString).appendingPathComponent("Web") as NSString
        
        let fileMgr = FileManager.default
        var isDir: ObjCBool = false
        if (!fileMgr.fileExists(atPath: unzipBasePath as String, isDirectory: &isDir) || !isDir.boolValue) {
            try! fileMgr.createDirectory(atPath: unzipBasePath as String, withIntermediateDirectories: true, attributes: nil)
        }
        
        var unzipBaseURL = URL(fileURLWithPath: unzipBasePath as String)
        var resourceValues = URLResourceValues()
        resourceValues.isExcludedFromBackup = true
        try! unzipBaseURL.setResourceValues(resourceValues)
    }

    func hasOfflineFiles(id: String, version: String) -> Bool {
        let filePath = getOfflineFile(id: id, version: version, filePath: unzipOkFileName)
        var isDir: ObjCBool = false
        let isExists = FileManager.default.fileExists(atPath: filePath, isDirectory: &isDir)
        return isExists && !isDir.boolValue
    }

    func getOfflineFile(id: String, version: String, filePath: String) -> String {
        return unzipBasePath.appendingPathComponent("\(id)/\(version)/\(filePath)")
    }

    func unzipBuiltin(id: String, version: String) {
        print("ZipManager unzip builtin \(id) \(version)")
        let fileMgr = FileManager.default
        let builtinZipURL = Bundle.main.url(forResource: id, withExtension: "zip")!
        let destPath = unzipBasePath.appendingPathComponent("\(id)/\(version)")
        if fileMgr.fileExists(atPath: destPath) {
            try! fileMgr.removeItem(atPath: destPath)
        }
        try? fileMgr.unzipItem(at: builtinZipURL, to: URL(fileURLWithPath: destPath))
        let unzipOkFilePath = (destPath as NSString).appendingPathComponent(unzipOkFileName)
        fileMgr.createFile(atPath: unzipOkFilePath, contents: nil, attributes: nil)
    }
    
    func updateOfflineZip(id: String, version: String, callback: @escaping OfflineZipUpdatedCallback) {
        if (hasOfflineFiles(id: id, version: version)) {
            callback(nil)
            return
        }

        if var callbackList = zipUpdatingCallbackDict[id] {
            callbackList.append(callback)
            return
        }

        print("ZipManager update zip")

        var callbackList = [OfflineZipUpdatedCallback]()
        callbackList.append(callback)
        zipUpdatingCallbackDict[id] = callbackList

        let fileMgr = FileManager.default
        var isDir: ObjCBool = false
        if (!fileMgr.fileExists(atPath: unzipBasePath as String, isDirectory: &isDir) || !isDir.boolValue) {
            try! fileMgr.createDirectory(atPath: unzipBasePath as String, withIntermediateDirectories: true, attributes: nil)
        }

        let zipUrl = "\(WebConst.WEB_BASE_URL)\(id)/\(version)/\(id).zip"

        print("ZipManager download \(zipUrl)")
        
        let callbackAll = { (error: ZipError?) in
            callbackList.forEach { callback in
                callback(error)
            }
            self.zipUpdatingCallbackDict.removeValue(forKey: id)
            print("ZipManager download complete. \(error == nil ? "" : error.debugDescription)")
        }

        AF.download(zipUrl).response { resp in
            if let _ = resp.error {
                callbackAll(.downloadFail)
                return
            }
            guard let fileURL = resp.fileURL else {
                callbackAll(.downloadFail)
                return
            }
            

            let destPath = self.unzipBasePath.appendingPathComponent("\(id)/\(version)")
            let destURL = URL(fileURLWithPath: destPath)
            do {
                if fileMgr.fileExists(atPath: destPath) {
                    try fileMgr.removeItem(atPath: destPath)
                }
                try fileMgr.unzipItem(at: fileURL, to: destURL)
                let unzipOkFilePath = (destPath as NSString).appendingPathComponent(self.unzipOkFileName)
                FileManager.default.createFile(atPath: unzipOkFilePath, contents: nil, attributes: nil)
                callbackAll(nil)
            } catch {
                callbackAll(.unzipFail)
            }
        }
    }
}

WebManager

WebManager 改为使用 versionManager 和 zipManager 对外提供离线的 API

import Foundation
import Alamofire

class WebManager {
    
    // 单例模式
    public static let shared = WebManager()
    
    public let settings = UserDefaults(suiteName: "WebDev")!
    
    private let versionManager = VersionManager(userDefaults: UserDefaults(suiteName: "Web")!)
    private let zipManager = ZipManager()
    
    func updateManifest(callback: @escaping ManifestUpdatedCallback) {
        versionManager.updateManifest(callback: callback)
    }
    
    func updateProject(id: String) {
        updateManifest() { _ in
            self.updateOfflineZip(id: id) { _ in }
        }
    }
    
    // 根据id和page获取正式的url
    func getWebUrl(id: String, page: String) -> String {
        if isDebugProject(id),
            let server = settings.string(forKey: "server"),
            let project = settings.string(forKey: "project")
        {
            return "http://\(server)/\(project)/dev/\(page)"
        }
        var version = versionManager.getVersion(id: id)
        if version == nil || !zipManager.hasOfflineFiles(id: id, version: version!) {
            version = versionManager.getBuiltinVersion(id: id)
            if (!zipManager.hasOfflineFiles(id: id, version: version!)) {
                zipManager.unzipBuiltin(id: id, version: version!)
            }
        }
        return "\(WebConst.WEB_BASE_URL)\(id)/\(version!)/\(page)"
    }
    
    func getOfflineFile(id: String, version: String, filePath: String) -> String {
        return zipManager.getOfflineFile(id: id, version: version, filePath: filePath)
    }
    
    private func updateOfflineZip(id: String, callback: @escaping OfflineZipUpdatedCallback) {
        if isDebugProject(id) {
            callback(nil)
            return
        }
        if id.isEmpty {
            callback(.argumentError)
            return
        }
        guard let version = versionManager.getVersion(id: id) else {
            callback(.argumentError)
            return
        }
        zipManager.updateOfflineZip(id: id, version: version, callback: callback)
    }
    
    private func isDebugProject(_ id: String) -> Bool {
        if settings.bool(forKey: "enabled"),
            let _ = settings.string(forKey: "server"),
            let project = settings.string(forKey: "project"),
            project == id
        {
            return true
        }
        return false
    }
    
}

WebViewController 改动

class WebViewController... {

    var webView: WKWebView!
    var url: String? = nil
    var project: String? = nil // 增加一个project参数,用于传递project id
    var params: [String:Any?] = [:]

    override func viewDidLoad() {
        ...

        // 原来的 updateManifest可以不要了,使用新的方式
        // 因为有离线包,直接加载
        self.view.addSubview(webView)
        let url = URL(string: self.getLoadUrl())!
        print("WebView load \(url)")
        let request = URLRequest(url: url)
        self.webView.load(request)

        // 更新离线资源,异步的,下次打开能加载到离线包
        if let project = project {
            WebManager.shared.updateProject(id: project)
        }
    }
}

JSBridgeNavigation open 时传递 project id

class JSBridgeNavigation ... {

    ...

    private func open(callbackId: String, arg: [String : Any?]) {
        guard let vc = self.viewController else { return }
        guard let url = parseWebUrl(arg) else { return }
        let newVC = WebViewController()
        newVC.url = url
        newVC.project = arg["id"] as? String // 增加project参数传递给WebViewController
        if let params = arg["params"] as? [String:Any?] {
            newVC.params = params
        }
        vc.present(newVC, animated: true, completion: nil)
    }

    ...

    private func push(callbackId: String, arg: [String : Any?]) {
        guard let vc = self.viewController else { return }
        guard let navVC = vc.navigationController else { return }
        guard let url = parseWebUrl(arg) else { return }
        let newVC = WebViewController()
        newVC.url = url
        newVC.project = arg["id"] as? String  // 增加project参数传递给WebViewController
        if let params = arg["params"] as? [String:Any?] {
            newVC.params = params
        }
        navVC.pushViewController(newVC, animated: true)
    }

    ...
}

ViewController 改动

class ViewController : WebViewController {

    override func getLoadUrl() -> String {
        return WebManager.shared.getWebUrl(id: "home", page: "index.html")
    }

    override func viewDidLoad() {
        project = "home" // 加一行
        super.viewDidLoad()

        ...
    }
    ...
}

WebView 拦截离线包相关的 url,访问本地资源

我们将使用 URLProtocol 拦截 App 内的HTTP请求

在iOS8.0之后,WKWebView希望代替UIWebView,但是存在一些缺陷,例如:URLProtocol无法处理来自WKWebView的请求,这里使用苹果私有API修复这个问题

我们将创建2个问:

  • NSURLProtocol+WKWebViewSupport.h
  • NSURLProtocol+WKWebViewSupport.m

这两个文件是Objective-C语言的内容,具体功能可以忽略,主要就是调用苹果私有API解决问题

放心,所有公司在这块基本都是这么做的

放置位置如下:

NSURLProtocol+WKWebViewSupport.h

XCode -> File -> File... -> Header File

期间XCode将询问你,如下:

#import <Foundation/Foundation.h>

@interface NSURLProtocol (WKWebViewSupport)

+ (void)wk_registerScheme:(NSString *)scheme;
+ (void)wk_unregisterScheme:(NSString *)scheme;

@end

NSURLProtocol+WKWebViewSupport.m

XCode -> File -> New File... -> Objective-C File

#import "NSURLProtocol+WKWebViewSupport.h"
#import <WebKit/WebKit.h>

Class WK_ContextControllerClass() {
  static Class cls;
  if (!cls) {
    cls = [[[WKWebView new] valueForKey:@"browsingContextController"] class];
  }
  return cls;
}

SEL WK_RegisterSchemeSelector() {
  return NSSelectorFromString(@"registerSchemeForCustomProtocol:");
}

SEL WK_UnregisterSchemeSelector() {
  return NSSelectorFromString(@"unregisterSchemeForCustomProtocol:");
}

@implementation NSURLProtocol (WKWebViewSupport)

+ (void)wk_registerScheme:(NSString *)scheme {
  Class cls = WK_ContextControllerClass();
  SEL sel = WK_RegisterSchemeSelector();
  if ([(id)cls respondsToSelector:sel]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
    [(id)cls performSelector:sel withObject:scheme];
#pragma clang diagnostic pop
  }
}

+ (void)wk_unregisterScheme:(NSString *)scheme {
  Class cls = WK_ContextControllerClass();
  SEL sel = WK_UnregisterSchemeSelector();
  if ([(id)cls respondsToSelector:sel]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
    [(id)cls performSelector:sel withObject:scheme];
#pragma clang diagnostic pop
  }
}

@end

ToBeBigFE-Bridging-Header.h

#import "NSURLProtocol+WKWebViewSupport.h"

WebOfflineURLProtocol实现拦截

准备好上面的工作后,我们可以开始实现拦截了

import Foundation

class WebOfflineURLProtocol: URLProtocol {
    
    private static var urlProtocolEnabled = false
    
    // 这个是调用私有API的入口
    class func enableURLProtocol() {
        if urlProtocolEnabled {
            return
        }
        URLProtocol.registerClass(WebOfflineURLProtocol.self)
        URLProtocol.wk_registerScheme("http")
        URLProtocol.wk_registerScheme("https")
        urlProtocolEnabled = true
    }
    
    open class override func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }
    
    open class override func canInit(with request: URLRequest) -> Bool {
        guard let url = request.url else {
            return false
        }
        
        // 如果是我们要拦截的url
        if url.absoluteString.starts(with: WebConst.WEB_BASE_URL) {
            // 分析 url的path
            let pathComps = url.pathComponents
            if pathComps.count < 4 {
                return false
            }
            // 看看本地有没有离线文件
            let filePath = getOfflineFile(url: url)
            let fileExists = FileManager.default.fileExists(atPath: filePath)
            print("WebOffline intercept local=\(fileExists) \(url.absoluteString)")
            if fileExists {
                // 有就返回true, 这个请求就会被
                return true
            }
        }
        return false
    }
    
    public override func startLoading() {
        // 这里是拦截的代码
        guard let url = self.request.url else { return }
        let filePath = WebOfflineURLProtocol.getOfflineFile(url: url)
        if !FileManager.default.fileExists(atPath: filePath) {
            let error = NSError(domain: "WebOfflineURLProtocolError", code: NSURLErrorFileDoesNotExist, userInfo: nil)
            self.client?.urlProtocol(self, didFailWithError: error)
            return
        }
        do {
            // 返回本地数据
            let data = try Data(contentsOf: URL(fileURLWithPath: filePath))
            let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!
            self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            self.client?.urlProtocol(self, didLoad: data)
            self.client?.urlProtocolDidFinishLoading(self)
        } catch {
            self.client?.urlProtocol(self, didFailWithError: error)
        }
    }
    
    
    private class func getOfflineFile(url: URL) -> String {
        let pathComps = url.pathComponents
        let project = pathComps[2]
        let version = pathComps[3]
        let filePath = pathComps.dropFirst(4).joined(separator: "/")
        return WebManager.shared.getOfflineFile(id: project, version: version, filePath: filePath)
    }
    
    public override func stopLoading() {
        
    }
}

WebManager改动

激活拦截

class WebManager {

    ...

    init() {
        WebOfflineURLProtocol.enableURLProtocol()
    }

    ...
}

到这里就实现了完整的拦截

结语

离线包的主要逻辑:

  1. WebView 加载 url,这个 url 是通过 WebManager 拿到的存在离线资源的 url
  2. 异步地触发更新离线包版本
  3. WebView 加载内容,拦截到 url 得到 id 和 verion
  4. 从 WebManager 里获取 url 对应的离线资源返回本地数据