WebView离线包方案从0到1:增量更新、灰度发布与容灾降级实战

0 阅读9分钟

在上一篇文章中,我们聊了WebView的安全防护实战,从XSToken到CSP策略,构建了一套相对完整的防御体系。但安全做好的同时,性能问题依然是个老大难——首屏加载白屏时间过长、弱网环境下体验极差、离线状态直接不可用。这些问题的根源在于:H5资源必须从服务器拉取,而网络状况是不可控的。

今天这篇文章,我们来聊聊离线包方案,这是解决上述痛点的核心技术方案。我会从实际踩坑出发,详细讲解如何从0到1搭建一套完整的离线包系统。

一、为什么需要离线包

1.1 H5加载的性能瓶颈

先来看一个典型场景:用户点击App内的一个H5页面,WebView需要经历以下步骤:

  1. DNS解析 → 2. TCP连接 → 3. TLS握手 → 4. HTTP请求 → 5. 服务器处理 → 6. 响应传输 → 7. 资源解析 → 8. 页面渲染

在4G网络下,这套流程走完通常需要2-4秒;如果是弱网环境,可能要8-10秒甚至更长。用户看到的就是那个该死的白屏转圈圈。

更致命的是,很多业务H5页面承载了核心交易流程,用户在这种页面的流失率极高。我们团队曾做过统计,H5页面的加载时长每增加1秒,转化率下降约7%。

1.2 离线包的核心价值

离线包方案的本质是将H5资源提前打包到客户端,在用户访问时直接从本地加载,完全绕过网络请求这个不确定因素。

┌─────────────────────────────────────────────────────────┐
│                      传统方案                            │
│  User Click → 网络请求 → DNS/TCP/TLS → 等待响应 → 渲染   │
│              (耗时2-10秒,不可靠)                        │
├─────────────────────────────────────────────────────────┤
│                      离线包方案                          │
│  User Click → 本地读取 → 资源解析 → 渲染                 │
│              (耗时<500ms,极度可靠)                      │
└─────────────────────────────────────────────────────────┘

核心价值体现在三个方面:

  • 秒开体验:本地加载耗时通常在100-500ms,用户几乎感知不到等待
  • 弱网可用:即使没有网络,只要资源已下载,页面依然可用
  • 服务器压力:所有流量变成CDN的下载分发,首包时间不再受服务器性能影响

二、离线包架构设计

2.1 包结构定义

一个完整的离线包由以下几部分组成:

├── base.zip              # 基础包(公共资源,跨版本复用)
├── business_v1.2.3.zip   # 业务包(特定业务的页面资源)
└── manifest.json         # 清单文件(包元信息)

manifest.json结构

{
  "package_name": "mall",
  "version": "1.2.3",
  "version_code": 123,
  "base_version": "1.0.0",
  "resources": [
    {
      "path": "index.html",
      "hash": "a1b2c3d4e5f6...",
      "size": 12345
    }
  ],
  "create_time": "2024-03-15T10:30:00Z",
  "min_app_version": "5.0.0"
}

关键字段说明

  • version:语义化版本号,用于展示和灰度规则匹配
  • version_code:整型版本号,用于版本大小比较
  • base_version:依赖的基础包版本,支持基础包复用
  • hash:文件的SHA256值,用于完整性校验
  • min_app_version:离线包依赖的最低App版本

2.2 资源打包规则

打包时需要遵循几个原则:

1)按业务维度拆分 不同业务的H5资源打包成独立的业务包,好处是:

  • 业务解耦,单个业务更新不影响其他业务
  • 用户只需下载自己使用过的业务包
  • 可以实现精准的灰度发布

2)资源去重与共享 将多个业务公共使用的资源(如React/Vue框架、公共组件库)抽取到基础包:

// 打包脚本示例(Node.js)
const AdmZip = require('adm-zip');

function buildOfflinePackage(config) {
  const { businessId, entryFiles, commonDirs } = config;
  
  const packageDir = path.join(__dirname, `../packages/${businessId}`);
  const tempDir = path.join(packageDir, '_temp');
  
  // 1. 复制公共资源
  commonDirs.forEach(dir => {
    copyRecursive(dir, path.join(tempDir, 'common'));
  });
  
  // 2. 复制业务资源
  entryFiles.forEach(file => {
    resolveDependencies(file, tempDir);
  });
  
  // 3. 生成manifest
  const manifest = generateManifest(tempDir);
  
  // 4. 压缩打包
  const zip = new AdmZip();
  zip.addLocalFolder(tempDir);
  zip.writeZip(path.join(packageDir, `business.zip`));
  
  // 5. 清理临时目录
  rmrf(tempDir);
  
  return manifest;
}

3)文件大小限制 单个离线包建议控制在20MB以内。过大的包会导致:

  • 下载失败率上升(移动网络不稳定)
  • 用户存储空间压力
  • 更新时流量消耗大

2.3 本地存储方案

离线包的存储需要考虑三个维度:存储位置、存储结构、清理策略

Android存储方案

class OfflinePackageManager(private val context: Context) {
    
    companion object {
        // 存储根目录
        private const val PACKAGE_ROOT = "offline_packages"
        // 单包最大体积
        private const val MAX_PACKAGE_SIZE = 20 * 1024 * 1024L
    }
    
    // 获取包的存储路径
    fun getPackagePath(packageName: String, version: String): File {
        return File(
            File(context.filesDir, PACKAGE_ROOT),
            "$packageName/$version"
        )
    }
    
    // 存储离线包
    fun savePackage(
        packageName: String, 
        version: String, 
        zipInputStream: InputStream
    ): Boolean {
        val targetDir = getPackagePath(packageName, version)
        targetDir.mkdirs()
        
        val zipFile = File(targetDir, "resources.zip")
        
        return try {
            zipInputStream.use { input ->
                FileOutputStream(zipFile).use { output ->
                    input.copyTo(output, bufferSize = 8192)
                }
            }
            // 验证压缩包完整性
            verifyZipIntegrity(zipFile)
            // 解压资源
            unzipPackage(packageName, version)
            // 记录版本
            recordVersion(packageName, version)
            true
        } catch (e: Exception) {
            // 清理失败的包
            targetDir.deleteRecursively()
            false
        }
    }
    
    // 验证ZIP完整性
    private fun verifyZipIntegrity(zipFile: File) {
        val zip = ZipFile(zipFile)
        val entries = zip.entries()
        while (entries.hasMoreElements()) {
            entries.nextElement()
        }
        zip.close()
    }
    
    // 解压资源包
    private fun unzipPackage(packageName: String, version: String) {
        val packageDir = getPackagePath(packageName, version)
        val zipFile = File(packageDir, "resources.zip")
        val outputDir = File(packageDir, "content")
        
        ZipInputStream(FileInputStream(zipFile)).use { zis ->
            var entry: ZipEntry? = zis.nextEntry
            while (entry != null) {
                val outputFile = File(outputDir, entry.name)
                if (entry.isDirectory) {
                    outputFile.mkdirs()
                } else {
                    outputFile.parentFile?.mkdirs()
                    FileOutputStream(outputFile).use { fos ->
                        zis.copyTo(fos)
                    }
                }
                zis.closeEntry()
                entry = zis.nextEntry
            }
        }
        
        // 删除原始ZIP,节省空间
        zipFile.delete()
    }
    
    // 获取已下载的包列表
    fun getInstalledPackages(): List<InstalledPackage> {
        val packages = mutableListOf<InstalledPackage>()
        val rootDir = File(context.filesDir, PACKAGE_ROOT)
        
        rootDir.listFiles()?.forEach { packageDir ->
            packageDir.listFiles()?.forEach { versionDir ->
                val manifestFile = File(versionDir, "manifest.json")
                if (manifestFile.exists()) {
                    val manifest = JSON.parseObject(
                        manifestFile.readText(), 
                        Manifest::class.java
                    )
                    packages.add(InstalledPackage(
                        name = manifest.packageName,
                        version = manifest.version,
                        versionCode = manifest.versionCode,
                        contentDir = File(versionDir, "content")
                    ))
                }
            }
        }
        
        return packages
    }
}

iOS存储方案

class OfflinePackageManager {
    
    static let shared = OfflinePackageManager()
    
    private let fileManager = FileManager.default
    
    // 存储根目录
    private var packageRootURL: URL {
        let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
        return documentsURL.appendingPathComponent("OfflinePackages")
    }
    
    // 保存离线包
    func savePackage(
        packageName: String,
        version: String,
        data: Data
    ) throws {
        let packageDir = packageRootURL
            .appendingPathComponent(packageName)
            .appendingPathComponent(version)
        
        try fileManager.createDirectory(at: packageDir, withIntermediateDirectories: true)
        
        // 直接保存ZIP,后续按需解压
        let zipURL = packageDir.appendingPathComponent("resources.zip")
        try data.write(to: zipURL)
        
        // 验证完整性
        try verifyZipIntegrity(at: zipURL)
        
        // 记录版本
        try recordVersion(packageName: packageName, version: version)
    }
    
    // 获取资源文件
    func getResourceFile(
        packageName: String,
        version: String,
        relativePath: String
    ) -> URL? {
        let contentDir = packageRootURL
            .appendingPathComponent(packageName)
            .appendingPathComponent(version)
            .appendingPathComponent("content")
            .appendingPathComponent(relativePath)
        
        return fileManager.fileExists(atPath: contentDir.path) ? contentDir : nil
    }
    
    // 清理过期包(保留最近N个版本)
    func cleanupOldVersions(packageName: String, keepCount: Int = 3) throws {
        let packageDir = packageRootURL.appendingPathComponent(packageName)
        
        guard let versions = try? fileManager.contentsOfDirectory(
            at: packageDir,
            includingPropertiesForKeys: [.creationDateKey]
        ).sorted(by: { url1, url2 in
            let date1 = (try? url1.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? Date.distantPast
            let date2 = (try? url2.resourceValues(forKeys: [.creationDateKey]).creationDate) ?? Date.distantPast
            return date1 > date2
        }) else { return }
        
        // 删除超出保留数量的版本
        for (index, versionURL) in versions.enumerated() {
            if index >= keepCount {
                try fileManager.removeItem(at: versionURL)
            }
        }
    }
}

踩坑经历1:iOS解压失败导致的崩溃

在iOS早期实现中,我使用NSData的writeToFile后立即解压,但发现解压经常失败。排查后发现原因:

  1. App在后台时,系统可能终止进程,导致写入不完整
  2. 文件系统缓存未及时刷写

解决方案:

  • 使用NSData.write(to:options:)时添加.atomic选项,确保写入原子性
  • 写入后验证文件大小与Content-Length是否一致
  • 添加完整性校验后再解压

三、增量更新机制

3.1 差分包原理

如果每次更新都让用户下载完整包,20MB的包每月更新4次,就是80MB的流量消耗。引入差分包后,通常可以将更新包体积降低70%-90%。

差分包的核心原理是找出旧包和新包之间的差异,只传输差异部分:

旧包 (v1.0.0):  ABCDEFGHIJ
新包 (v1.0.1):  ABCXYZGHIJK

差异:           ..XXX..YYY.Z.

3.2 BSDiff算法实现

BSDiff(Binary Diff)是业界最常用的差分算法,由Colin Percival提出。它特别适合二进制文件(如编译后的JS/CSS)的差分生成。

差分包生成服务(Node.js)

const { bsdiff } = require('bsdiff-native');
const fs = require('fs');

async function generatePatch(oldZipPath, newZipPath, outputPath) {
    // 读取新旧包
    const oldData = fs.readFileSync(oldZipPath);
    const newData = fs.readFileSync(newZipPath);
    
    // 生成差分
    const patch = bsdiff.diff(oldData, newData);
    
    // 保存差分包
    fs.writeFileSync(outputPath, patch);
    
    console.log(`Patch generated: ${oldZipPath} -> ${newZipPath}`);
    console.log(`Old size: ${oldData.length}`);
    console.log(`New size: ${newData.length}`);
    console.log(`Patch size: ${patch.length}`);
    console.log(`Compression: ${((1 - patch.length / newData.length) * 100).toFixed(1)}%`);
}

// 使用示例
// generatePatch('./v1.0.0.zip', './v1.0.1.zip', './patch_1.0.0_to_1.0.1.bsdiff');

Android端差分合并

object BsdiffUtil {
    
    init {
        System.loadLibrary("bsdiff")
    }
    
    external fun bspatch(
        oldFile: String,
        newFile: String,
        patchFile: String
    ): Int
    
    /**
     * 合并差分包生成新包
     */
    fun applyPatch(
        basePackage: File,
        patchFile: File,
        outputFile: File
    ): Boolean {
        return try {
            val result = bspatch(
                basePackage.absolutePath,
                outputFile.absolutePath,
                patchFile.absolutePath
            )
            result == 0
        } catch (e: Exception) {
            Log.e("BsdiffUtil", "Patch apply failed", e)
            false
        }
    }
}

// 使用
class PatchApplier {
    
    fun applyIncrementalUpdate(
        baseVersion: String,
        patchUrl: String
    ): Boolean {
        // 1. 获取基础包路径
        val baseDir = offlinePackageManager.getPackagePath(packageName, baseVersion)
        val baseZip = File(baseDir, "resources.zip")
        
        if (!baseZip.exists()) {
            Log.e("PatchApplier", "Base package not found: $baseVersion")
            return false
        }
        
        // 2. 下载差分包
        val patchFile = downloadPatch(patchUrl) ?: return false
        
        // 3. 合并生成新包
        val newZip = File(cacheDir, "new_package.zip")
        val success = BsdiffUtil.applyPatch(baseZip, patchFile, newZip)
        
        if (success) {
            // 4. 验证新包完整性
            if (verifyZipIntegrity(newZip)) {
                // 5. 替换为新包
                return offlinePackageManager.upgradePackage(newZip, targetVersion)
            }
        }
        
        return false
    }
}

踩坑经历2:BSDiff大文件内存溢出

有一次线上告警,显示部分用户在差分更新时崩溃。排查日志发现,bsdiff在处理超过15MB的包时,native层出现了内存分配失败。

原因分析:BSDiff算法在内存中会创建旧文件大小0.7倍+新文件大小0.1倍的临时缓冲区,20MB的包可能需要14MB+2MB=16MB的连续内存空间。

解决方案:

  • 设置单包大小上限为15MB
  • 对大包采用分片差分策略
  • 或者降级到全量更新

3.3 增量vs全量的策略选择

不是所有场景都适合增量更新,需要根据实际情况选择:

场景推荐策略原因
常规迭代(体积<10MB)增量优先收益明显,用户流量省
大版本更新(框架升级)全量改动大,增量收益不明显
首次安装全量没有旧包可对比
紧急bug修复全量增量失败风险高,优先保证成功率
弱网环境全量增量多次请求失败率更高

四、版本管理与灰度发布

4.1 版本号规则

采用语义化版本(Semantic Versioning)的变体:

主版本号.次版本号.修订号-里程碑
例如:2.1.0-beta、2.1.0-rc1、2.1.0

version_code = 主版本*10000 + 次版本*100 + 修订号
例如:2.1.0 -> 20100

版本兼容规则:

  • 2.1.0 → 2.1.5:向前兼容,只需增量更新
  • 2.1.0 → 2.2.0:可能有破坏性变更,检查min_app_version
  • 2.1.0 → 3.0.0:强制全量更新

4.2 多版本共存策略

有时候需要让多个版本同时存在于用户设备上:

class VersionStrategy {
    
    enum class Strategy {
        REPLACE_ALL,      // 新版本替换旧版本(大多数场景)
        KEEP_LATEST_N,    // 保留最近N个版本
        KEEP_BY_CHANNEL,  // 按渠道保留(如不同App版本需要不同包)
    }
    
    fun resolveVersion(
        currentVersion: String?,
        availableVersions: List<String>,
        strategy: Strategy
    ): String {
        return when (strategy) {
            Strategy.REPLACE_ALL -> {
                // 直接返回最新版本
                availableVersions.maxByOrNull { it.versionCode } ?: ""
            }
            Strategy.KEEP_LATEST_N -> {
                // 返回比当前版本新的最新版本
                availableVersions
                    .filter { it.versionCode > (currentVersion?.versionCode ?: 0) }
                    .maxByOrNull { it.versionCode } ?: ""
            }
            Strategy.KEEP_BY_CHANNEL -> {
                // 根据App版本和渠道选择
                val appVersion = getAppVersion()
                val channel = getChannel()
                availableVersions
                    .filter { it.checkCompatibility(appVersion, channel) }
                    .maxByOrNull { it.versionCode } ?: ""
            }
        }
    }
}

4.3 灰度发布策略

离线包更新的灰度通常与App的灰度策略联动:

// 灰度规则配置
const grayscaleConfig = {
    rules: [
        {
            name: "版本优先",
            condition: (user) => user.appVersion.startsWith("5.1."),
            weight: 30,  // 30%用户
        },
        {
            name: "用户ID尾号",
            condition: (user) => parseInt(user.userId.slice(-1), 16) % 10 < 3,
            weight: 30,
        },
        {
            name: "新用户",
            condition: (user) => user.isNewUser,
            weight: 20,
        },
        {
            name: "白名单",
            condition: (user) => user.isInWhitelist,
            weight: 100,
        },
        {
            name: "全量",
            condition: () => true,
            weight: 0,  // 兜底
        }
    ],
    
    // 执行灰度
    resolveVersion(user, versions) {
        for (const rule of this.rules) {
            if (rule.condition(user)) {
                return versions.getVersionForGrayscale(rule.weight);
            }
        }
        return versions.latest;
    }
};

灰度执行流程:

  1. 先对内测用户(5%)发布,观察48小时无异常
  2. 扩大到20%,观察24小时
  3. 全量发布,监控错误率
  4. 如有问题,快速回滚到上一稳定版本

五、降级与容灾策略

5.1 离线包加载失败降级

这是离线包方案的保底机制——即使离线包不可用,也要让页面能正常打开。

class WebViewResourceLoader {
    
    enum class LoadSource {
        OFFLINE_PACKAGE,  // 离线包
        FALLBACK_CDN,     // CDN降级
        LOCAL_BUNDLE,     // 本地兜底资源
        EMBEDDED_ASSETS,  // App内置资源
    }
    
    data class LoadResult(
        val source: LoadSource,
        val resource: WebResourceResponse?,
        val latencyMs: Long
    )
    
    fun loadResource(request: WebResourceRequest): LoadResult {
        val startTime = System.currentTimeMillis()
        
        // 1. 尝试离线包
        val offlineResult = tryLoadFromOfflinePackage(request)
        if (offlineResult != null) {
            return LoadResult(
                source = LoadSource.OFFLINE_PACKAGE,
                resource = offlineResult,
                latencyMs = System.currentTimeMillis() - startTime
            )
        }
        
        // 2. 降级到CDN(带超时控制)
        val cdnResult = tryLoadFromCDN(request, timeoutMs = 3000)
        if (cdnResult != null) {
            // 记录降级事件,用于后续优化
            recordFallback(request.url, "CDN fallback")
            return LoadResult(
                source = LoadSource.FALLBACK_CDN,
                resource = cdnResult,
                latencyMs = System.currentTimeMillis() - startTime
            )
        }
        
        // 3. 尝试本地兜底资源
        val localResult = tryLoadFromLocalBundle(request)
        if (localResult != null) {
            recordFallback(request.url, "Local bundle fallback")
            return LoadResult(
                source = LoadSource.LOCAL_BUNDLE,
                resource = localResult,
                latencyMs = System.currentTimeMillis() - startTime
            )
        }
        
        // 4. 最后兜底:App内置的通用资源
        return LoadResult(
            source = LoadSource.EMBEDDED_ASSETS,
            resource = tryLoadFromAssets(request),
            latencyMs = System.currentTimeMillis() - startTime
        )
    }
}

5.2 本地兜底资源

每个离线包都应该附带一个最小可用的兜底资源包:

fallback/
├── index.html      # 兜底首页(极简版)
├── offline.js      # 离线状态提示逻辑
└── manifest.json   # 兜底版本信息

兜底页面的设计原则:

  • 体积控制在50KB以内
  • 展示用户友好的离线提示
  • 提供刷新按钮或跳转原生页面的入口
<!-- fallback/offline.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <style>
        body { font-family: -apple-system, sans-serif; padding: 40px 20px; text-align: center; }
        .icon { font-size: 64px; margin-bottom: 20px; }
        .title { font-size: 18px; color: #333; margin-bottom: 8px; }
        .desc { font-size: 14px; color: #999; }
        .btn { display: inline-block; margin-top: 20px; padding: 10px 24px; 
               background: #007AFF; color: white; border-radius: 8px; 
               text-decoration: none; }
    </style>
</head>
<body>
    <div class="icon">📡</div>
    <div class="title">网络不给力</div>
    <div class="desc">请检查网络后点击刷新</div>
    <a href="javascript:location.reload()" class="btn">刷新页面</a>
</body>
</html>

5.3 紧急修复流程

当线上出现严重问题需要紧急修复时:

┌─────────────────────────────────────────────────────────────┐
│                     紧急修复流程                              │
│                                                             │
│  1. 问题发现(监控告警/用户反馈)                              │
│           ↓                                                  │
│  2. 评估影响范围,决定是否需要禁用离线包                         │
│           ↓                                                  │
│  3. 操作后台禁用问题版本离线包                                 │
│           ↓                                                  │
│  4. 客户端拉取最新配置,降级到CDN                              │
│           ↓                                                  │
│  5. 同步发布热修复版本(如果是前端问题)                         │
│           ↓                                                  │
│  6. 验证修复效果,重新灰度                                    │
│           ↓                                                  │
│  7. 全量恢复离线包                                           │
└─────────────────────────────────────────────────────────────┘

紧急禁用接口:

class OfflinePackageConfigManager {
    
    private val configCache = AtomicReference<PackageConfig>()
    
    // 拉取配置(包含禁用列表)
    suspend fun fetchConfig(): PackageConfig {
        return api.get<PackageConfig>("/api/v1/package/config")
            .also { configCache.set(it) }
    }
    
    // 检查指定版本是否被禁用
    fun isVersionDisabled(packageName: String, version: String): Boolean {
        val config = configCache.get() ?: return false
        return config.disabledVersions.any { 
            it.packageName == packageName && it.version == version 
        }
    }
    
    // 获取降级URL
    fun getFallbackUrl(packageName: String): String? {
        return configCache.get()?.fallbackUrls?.get(packageName)
    }
}

六、实战踩坑总结

坑1:编码问题导致的中文文件名乱码

问题现象:部分Android设备上,离线包解压后中文文件名变成乱码,导致资源加载404。

根因:ZIP格式本身不包含编码信息,Windows上默认用GBK编码,而Android上Java层默认用UTF-8解码。

解决方案

// 正确的跨平台解压方式
fun unzipWithEncoding(zipFile: File, outputDir: File, encoding: String = "UTF-8") {
    val buffer = ByteArray(8192)
    
    ZipFile(zipFile, Charset.forName(encoding)).use { zip ->
        zip.entries().asSequence().forEach { entry ->
            val file = File(outputDir, entry.name)
            
            if (entry.isDirectory) {
                file.mkdirs()
            } else {
                file.parentFile?.mkdirs()
                
                FileOutputStream(file).use { fos ->
                    zip.getInputStream(entry).use { input ->
                        var len: Int
                        while (input.read(buffer).also { len = it } > 0) {
                            fos.write(buffer, 0, len)
                        }
                    }
                }
                
                // 保持文件时间戳
                file.setLastModified(entry.time)
            }
        }
    }
}

关键点:

  • 打包时统一使用UTF-8编码
  • 使用Charset.forName()显式指定编码
  • 添加编码检测逻辑,尝试多种编码兼容老设备

坑2:大包下载超时导致更新失败

问题现象:弱网环境下,20MB的包下载经常超时,用户一直停留在旧版本。

解决方案:采用分段下载+断点续传

class分段DownloadManager {
    
    private const val CHUNK_SIZE = 1024 * 1024  // 1MB分片
    
    suspend fun downloadWithResume(
        url: String,
        targetFile: File,
        progressCallback: (Int, Int) -> Unit
    ): Result<File> = withContext(Dispatchers.IO) {
        try {
            val tempFile = File(targetFile.parent, "${targetFile.name}.tmp")
            val downloadedSize = tempFile.exists() ? tempFile.length() : 0L
            
            val request = Request.Builder()
                .url(url)
                .addHeader("Range", "bytes=$downloadedSize-")  // 断点续传
                .build()
            
            client.newCall(request).execute().use { response ->
                if (!response.isSuccessful && response.code != 206) {
                    return@withContext Result.failure(IOException("Download failed: ${response.code}"))
                }
                
                val contentLength = response.body?.contentLength() ?: -1
                val totalSize = downloadedSize + (if (contentLength > 0) contentLength else 0)
                
                tempFile.outputStream().use { output ->
                    // 追加模式写入
                    if (downloadedSize > 0) {
                        val raf = RandomAccessFile(tempFile, "rw")
                        raf.seek(downloadedSize)
                        // 后续写入通过raf...
                    }
                    
                    response.body?.byteStream()?.use { input ->
                        val buffer = ByteArray(8192)
                        var bytesRead: Int
                        var totalRead = downloadedSize
                        
                        while (input.read(buffer).also { bytesRead = it } != -1) {
                            output.write(buffer, 0, bytesRead)
                            totalRead += bytesRead
                            
                            if (totalSize > 0) {
                                val progress = ((totalRead * 100) / totalSize).toInt()
                                progressCallback(progress, 100)
                            }
                        }
                    }
                }
                
                // 下载完成,重命名
                tempFile.renameTo(targetFile)
                Result.success(targetFile)
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

同时设置合理的超时参数和重试策略:

  • 连接超时:15秒
  • 读取超时:60秒(单次请求)
  • 总下载超时:10分钟
  • 自动重试:最多3次,指数退避

坑3:多业务线资源冲突

问题现象:A业务和B业务都包含相同名称的第三方库(如lodash),打包后互相覆盖。

解决方案:引入命名空间隔离

// 打包配置
const bundlerConfig = {
    namespaceStrategy: 'scope',
    
    // 资源重命名规则
    rename: (filePath, packageName) => {
        // 将第三方库隔离到各自命名空间
        if (isThirdParty(filePath)) {
            return `vendors/${packageName}/${filePath}`;
        }
        // 业务代码保持原结构
        return filePath;
    },
    
    // 冲突检测
    detectConflicts: (allResources) => {
        const conflictGroups = {};
        
        allResources.forEach(({ path, packageName }) => {
            const baseName = path.split('/').pop();
            if (!conflictGroups[baseName]) {
                conflictGroups[baseName] = [];
            }
            conflictGroups[baseName].push(packageName);
        });
        
        // 标记冲突
        return Object.entries(conflictGroups)
            .filter(([_, pkgs]) => pkgs.length > 1)
            .map(([name, pkgs]) => ({ name, packages: pkgs }));
    }
};

坑4:更新时机选择不当导致存储告警

问题现象:用户在清理手机存储时,看到我们的App占用了大量空间(离线包累积),给App打了一星。

解决方案

  1. 设置包的最大缓存时间(如30天未使用自动清理)
  2. 监控存储使用量,快达到阈值时优先清理旧包
  3. 在App设置页面展示离线包大小,提供手动清理入口
class StorageManager {
    
    private val maxStorageBytes = 200 * 1024 * 1024L  // 200MB上限
    
    fun checkAndCleanup(): CleanupResult {
        val currentUsage = calculateTotalUsage()
        
        if (currentUsage > maxStorageBytes) {
            // 按最后使用时间排序,删除最旧的包
            val packages = offlinePackageManager.getInstalledPackages()
                .sortedBy { it.lastAccessTime }
            
            var freedSpace = 0L
            val removed = mutableListOf<String>()
            
            for (pkg in packages) {
                if (currentUsage - freedSpace <= maxStorageBytes * 0.8) break
                
                val size = pkg.size
                offlinePackageManager.removePackage(pkg)
                freedSpace += size
                removed.add("${pkg.name}/${pkg.version}")
            }
            
            return CleanupResult(
                freedBytes = freedSpace,
                removedPackages = removed
            )
        }
        
        return CleanupResult(0, emptyList())
    }
}

七、总结

离线包方案是WebView性能优化的杀手锏,它让H5页面的体验接近原生App。但完整的离线包系统涉及到:

  • 架构设计:合理的包结构、存储策略
  • 更新机制:增量更新降低流量成本
  • 版本管理:灰度发布、快速回滚
  • 容灾降级:多重兜底保障可用性
  • 工程实践:编码兼容、存储管理、冲突处理

每一个环节都有实际的坑需要踩。希望这篇文章的经验总结,能帮助你在实际项目中少走弯路。