H5秒开且不影响版本更新

核心思想

离线包的核心思想是:将H5页面的静态资源(HTML、CSS、JavaScript、图片、字体等)预先打包,通过某种机制(通常是App启动时、或后台静默)下载到用户设备本地。当用户访问H5页面时,客户端(通常是原生App的WebView容器)拦截网络请求,优先从本地加载对应的离线资源,从而跳过网络请求和下载时间,达到"秒开"的效果。

同时,为了解决新功能上线后的缓存问题,必须引入一套完善的版本管理和更新机制。客户端需要知道当前线上最新的资源版本,并与本地缓存的版本进行比对,决定是加载本地资源还是去线上拉取最新资源。

整体架构

一个完整的离线包方案通常包括以下几个部分:

  1. 服务端 (Server):

    • 资源打包: 负责将前端构建产物(HTML/CSS/JS/Images等)打包成一个压缩文件(如ZIP)。
    • 版本管理: 维护每个离线包的版本号、对应的业务标识、下载地址、文件校验码(MD5/SHA1)、更新策略(全量/增量)、是否强制更新等信息。
    • 配置下发API: 提供接口供客户端查询最新的离线包配置信息。
    • 资源包托管: 将打包好的资源包文件存放在CDN或文件服务器上,供客户端下载。
  2. 客户端 (Client - 通常是 Native App):

    • 配置拉取: 定期(如App启动时、后台定时)调用服务端的API,获取最新的离线包配置。

    • 下载管理: 根据配置信息,下载新的或更新的离线包文件。支持断点续传、后台下载、失败重试等。

    • 包管理:

      • 存储: 将下载的资源包安全地存储在本地(如App的私有目录)。
      • 校验: 下载完成后,根据配置中的校验码验证文件的完整性,防止包损坏或被篡改。
      • 解压: 将验证通过的资源包解压到指定目录,供后续加载使用。
      • 版本控制: 记录本地已有的资源包版本,管理多个版本(可选,用于回滚或灰度),清理旧版本。
    • 资源拦截与加载:

      • 拦截机制: 拦截WebView发出的资源请求(HTML主文档、CSS、JS、图片等)。这是最关键的一步。Android通常使用WebViewClientshouldInterceptRequest方法,iOS使用NSURLProtocol或WKWebView的新API。
      • 本地优先策略: 对被拦截的请求,根据请求URL和当前业务应该使用的离线包版本,判断本地是否有对应的、且版本号符合要求的资源文件。
      • 本地加载: 如果本地存在有效资源,则直接读取本地文件内容,构建响应并返回给WebView,绕过网络。
      • 网络加载(Fallback): 如果本地没有对应资源、资源版本过旧、或离线包加载失败,则允许请求继续通过网络加载。
      • 更新时机: 决定何时启用新下载的资源包(如下次启动生效、立刻生效等)。
  3. 前端 H5 (Web):

    • 资源路径: 通常需要配合客户端的拦截规则,使用相对路径或特定的URL格式。
    • 构建配合: 前端构建流程需要产出符合离线包要求的资源结构,并配合服务端生成包含版本信息、文件列表等的manifest文件(通常包含在离线包内)。
    • (可选) JSBridge: H5页面可以通过JSBridge与客户端通信,查询当前离线包状态、版本,或触发更新检查等。

解决缓存导致无法看到新功能的核心机制:

关键在于请求拦截时的版本判断更新策略

  1. 启动时/定时检查更新: 客户端在合适的时机(如App冷启动、进入相关业务模块前)向服务端查询对应业务标识的最新离线包版本信息。

  2. 版本比对: 将服务端返回的最新版本号与本地当前使用的版本号进行比较。

  3. 决策加载逻辑:

    • 本地版本 == 最新版本: 拦截请求,直接加载本地离线资源(实现秒开)。

    • 本地版本 < 最新版本:

      • 策略一(推荐,优先保证体验): 本次访问仍然加载本地旧版本资源 (保证秒开),同时后台静默下载最新版本的离线包。下载、校验、解压完成后,标记新版本为"准备就绪"。下次用户再访问该页面时,由于本地版本已更新,就会加载新版本的资源。这是最常见的策略,兼顾了首次加载速度和后续更新。
      • 策略二(强制更新): 如果服务端配置了"强制更新",或者业务要求必须展示最新内容,客户端可以阻止加载旧资源,立即触发新包下载(可能需要给用户提示)。下载完成后再加载新资源。这种方式会牺牲本次访问的"秒开"体验,但能确保用户看到最新内容。
      • 策略三(提示更新): 加载旧版本资源,同时给用户一个"有新版本可用,立即更新?"的提示,用户点击后才执行下载和切换。
    • 本地无离线包 或 离线包损坏: 直接走网络加载,同时尝试后台下载最新版本的离线包。

  4. 资源粒度校验 (可选,更精细): 离线包内可以包含一个manifest.json文件,列出包内所有资源及其对应的hash值。客户端在拦截具体资源(如app.js)时,除了检查包版本,还可以根据URL在manifest.json中查找该文件的hash,与线上配置中该文件的最新hash对比。如果单个文件hash不一致,可以选择仅更新这个文件(增量更新),或者回退到网络加载该文件,或者触发整个包的更新。这增加了复杂度,但更新更灵活。

代码示例讲解 (Conceptual & Platform-Specific Snippets)

由于提供跨平台(iOS & Android)且功能完备的1000+行代码非常困难,并且涉及大量原生开发细节,下面将提供更详细的 核心逻辑伪代码关键部分的代码片段 (以Android为例,iOS原理类似但API不同),重点在于阐述实现思路。

1. 服务端 API (/checkUpdate)

  • 请求 (Request - GET or POST):

    JSON

    json 复制代码
    {
      "appId": "YourAppId", // App标识
      "platform": "android", // 或 ios
      "appVersion": "1.2.0", // App版本
      "offlinePackages": [ // 客户端当前拥有的离线包信息
        {
          "businessId": "homepage", // 业务标识,如首页、活动页
          "currentVersion": "1.0.1" // 当前本地该业务的离线包版本
        },
        {
          "businessId": "userCenter",
          "currentVersion": "2.1.0"
        }
      ]
    }
  • 响应 (Response - JSON):

    JSON

    json 复制代码
    {
      "status": 0, // 0: 成功, 其他: 失败
      "message": "Success",
      "data": [
        {
          "businessId": "homepage",
          "latestVersion": "1.0.2", // 最新的版本号
          "downloadUrl": "https://cdn.example.com/packages/homepage_v1.0.2.zip", // 下载地址
          "checksum": "a1b2c3d4e5f6...", // 文件校验和 (MD5 or SHA1)
          "fileSize": 102400, // 文件大小 (Bytes)
          "updateType": "full", // 更新类型: full(全量), incremental(增量 - 更复杂)
          "isForceUpdate": false, // 是否强制更新
          "activeType": "nextLaunch" // 何时生效: nextLaunch(下次启动), immediate(立即)
          // "patchUrl": "...", // (如果是增量更新) 补丁包下载地址
          // "baseVersion": "1.0.1" // (如果是增量更新) 基于哪个版本
        },
        {
          "businessId": "userCenter",
          "latestVersion": "2.1.0" // 版本相同,无需更新
          // 其他字段可以省略或给特定值表示无需更新
        }
        // ... 其他业务包的信息
      ]
    }

2. 客户端 - 核心逻辑 (Android - Java/Kotlin)

  • 配置管理类 (OfflinePackageManager.java - 伪代码/简化):

    Java

    typescript 复制代码
    import android.content.Context;
    import java.util.Map;
    import java.util.concurrent.ConcurrentHashMap;
    
    public class OfflinePackageManager {
    
        private static OfflinePackageManager instance;
        private Context context;
        // 存储本地已安装并验证通过的离线包信息 (业务ID -> 版本信息)
        private Map<String, PackageInfo> localPackages = new ConcurrentHashMap<>();
        // 存储正在使用的离线包信息 (WebView加载时以此为准)
        private Map<String, String> activeVersions = new ConcurrentHashMap<>();
        // 下载管理器 (需要实现下载、校验、解压逻辑)
        private DownloadManager downloadManager;
        // API 客户端 (需要实现网络请求逻辑)
        private ApiClient apiClient;
        // 本地存储路径
        private String baseStoragePath;
    
        private OfflinePackageManager(Context context) {
            this.context = context.getApplicationContext();
            this.baseStoragePath = context.getFilesDir().getAbsolutePath() + "/offline_packages";
            // 初始化:加载本地已有的包信息、配置下载器、API客户端等
            loadLocalPackageInfo();
            this.downloadManager = new DownloadManager(context, baseStoragePath);
            this.apiClient = new ApiClient();
        }
    
        public static synchronized OfflinePackageManager getInstance(Context context) {
            if (instance == null) {
                instance = new OfflinePackageManager(context);
            }
            return instance;
        }
    
        // 加载本地存储的包信息 (App启动时调用)
        private void loadLocalPackageInfo() {
            // TODO: 从本地持久化存储(如SharedPreferences, 数据库)读取已安装包的信息
            // 示例: localPackages.put("homepage", new PackageInfo("1.0.1", "/path/to/homepage/v1.0.1", "checksum"));
            // 同时,需要确定哪些版本是当前"活跃"的,加载到 activeVersions
            // 一般是每个 businessId 最新安装且校验通过的版本
            updateActiveVersionsBasedOnLocal();
        }
    
        // 根据本地包更新活跃版本信息
        private void updateActiveVersionsBasedOnLocal() {
             // TODO: 遍历 localPackages, 找出每个 businessId 的最新有效版本,更新 activeVersions
             // 例如,如果 homepage 有 v1.0.1 和 v1.0.2 (已下载解压),则 activeVersions.put("homepage", "1.0.2")
        }
    
    
        // 检查更新 (App启动、或特定时机调用)
        public void checkUpdates() {
            // 1. 构造请求体 (包含当前本地各业务包的版本)
            Map<String, String> currentVersions = new HashMap<>();
            for (Map.Entry<String, String> entry : activeVersions.entrySet()) {
                 currentVersions.put(entry.getKey(), entry.getValue());
            }
    
            // 2. 调用API
            apiClient.checkUpdate(currentVersions, new ApiCallback<List<ServerPackageInfo>>() {
                @Override
                public void onSuccess(List<ServerPackageInfo> serverPackages) {
                    handleUpdateResponse(serverPackages);
                }
    
                @Override
                public void onError(Exception e) {
                    // TODO: 处理错误,如网络问题
                    Log.e("OfflinePackage", "Check update failed", e);
                }
            });
        }
    
        // 处理API返回的更新信息
        private void handleUpdateResponse(List<ServerPackageInfo> serverPackages) {
            for (ServerPackageInfo serverInfo : serverPackages) {
                String businessId = serverInfo.getBusinessId();
                String latestVersion = serverInfo.getLatestVersion();
                String currentActiveVersion = activeVersions.get(businessId);
    
                // 版本比较 (需要实现版本号比较逻辑, e.g., "1.0.2" > "1.0.1")
                if (latestVersion != null && (currentActiveVersion == null || compareVersion(latestVersion, currentActiveVersion) > 0)) {
                    // 发现新版本,或者本地没有这个包
                    Log.i("OfflinePackage", "New version available for " + businessId + ": " + latestVersion);
    
                    // 检查是否已下载但未激活
                    PackageInfo localPkg = findLocalPackage(businessId, latestVersion);
                    if (localPkg != null && localPkg.isValid()) {
                         Log.i("OfflinePackage", "Version " + latestVersion + " already downloaded for " + businessId);
                         // 如果激活策略是立即生效,可以在这里就更新 activeVersions
                         if ("immediate".equals(serverInfo.getActiveType())) {
                             activateVersion(businessId, latestVersion);
                         }
                         continue; // 已下载,无需重复下载
                    }
    
                    // 检查是否正在下载
                    if (downloadManager.isDownloading(businessId, latestVersion)) {
                         Log.i("OfflinePackage", "Version " + latestVersion + " is already downloading for " + businessId);
                         continue;
                    }
    
                    // 触发下载
                    downloadManager.startDownload(serverInfo, new DownloadCallback() {
                        @Override
                        public void onDownloadSuccess(ServerPackageInfo info, String filePath) {
                            // 下载成功后:校验 -> 解压 -> 更新本地记录 -> (根据策略)更新activeVersions
                            handleDownloadSuccess(info, filePath);
                        }
    
                        @Override
                        public void onDownloadFailed(ServerPackageInfo info, Exception e) {
                             Log.e("OfflinePackage", "Download failed for " + info.getBusinessId() + " v" + info.getLatestVersion(), e);
                             // TODO: 添加重试逻辑?
                        }
                        // ... onProgress update
                    });
    
                } else {
                    Log.d("OfflinePackage", "No update needed for " + businessId + " (current: " + currentActiveVersion + ", latest: " + latestVersion + ")");
                }
            }
        }
    
         // 处理下载成功
         private void handleDownloadSuccess(ServerPackageInfo info, String zipFilePath) {
             // 1. 校验 Checksum
             boolean checksumValid = verifyChecksum(zipFilePath, info.getChecksum());
             if (!checksumValid) {
                 Log.e("OfflinePackage", "Checksum validation failed for " + info.getBusinessId() + " v" + info.getLatestVersion());
                 // TODO: 删除下载的错误文件
                 return;
             }
    
             // 2. 解压
             String extractPath = getExtractPath(info.getBusinessId(), info.getLatestVersion());
             boolean unzipSuccess = unzipPackage(zipFilePath, extractPath);
             if (!unzipSuccess) {
                  Log.e("OfflinePackage", "Unzip failed for " + info.getBusinessId() + " v" + info.getLatestVersion());
                  // TODO: 清理可能不完整的解压文件
                  return;
             }
    
             // 3. 更新本地包信息记录 (持久化)
             PackageInfo newPackageInfo = new PackageInfo(info.getLatestVersion(), extractPath, info.getChecksum(), true /*isValid*/);
             saveLocalPackageInfo(info.getBusinessId(), newPackageInfo);
             localPackages.put(info.getBusinessId(), newPackageInfo); // 更新内存缓存
    
             // 4. (可选) 清理旧版本资源包 (保留最近几个或只保留当前活跃和最新下载的)
             cleanupOldVersions(info.getBusinessId(), info.getLatestVersion());
    
             // 5. 根据激活策略决定是否更新 activeVersions
             if ("immediate".equals(info.getActiveType()) || "nextLaunch".equals(info.getActiveType())) {
                  // nextLaunch 的激活逻辑通常放在 App 启动时 loadLocalPackageInfo 中完成
                  // 如果是 immediate,则立即激活
                  if ("immediate".equals(info.getActiveType())) {
                       activateVersion(info.getBusinessId(), info.getLatestVersion());
                  }
                  Log.i("OfflinePackage", "Package " + info.getBusinessId() + " v" + info.getLatestVersion + " ready. Active type: " + info.getActiveType());
             }
    
             // 6. 删除 موقت ZIP 文件
             deleteFile(zipFilePath);
         }
    
        // 激活指定版本 (更新 activeVersions)
        private void activateVersion(String businessId, String version) {
            PackageInfo pkg = findLocalPackage(businessId, version);
            if(pkg != null && pkg.isValid()) {
                activeVersions.put(businessId, version);
                Log.i("OfflinePackage", "Activated version " + version + " for " + businessId);
                // (可选) 持久化 activeVersions 的状态
            } else {
                Log.w("OfflinePackage", "Attempted to activate invalid or non-existent package: " + businessId + " v" + version);
            }
        }
    
    
        // 根据 URL 获取对应的本地资源路径 (供 WebViewClient 使用)
        // 返回 null 表示本地无匹配资源
        public String getLocalResourcePath(String requestUrl) {
            // 1. 解析 URL,确定 businessId 和 资源相对路径
            //    这需要约定好 URL 结构,例如 https://m.example.com/homepage/index.html -> businessId="homepage", resourcePath="index.html"
            //    或者通过映射规则表来查找
            Pair<String, String> parsed = parseUrl(requestUrl);
            if (parsed == null) {
                return null; // URL 不符合离线包规则
            }
            String businessId = parsed.first;
            String resourceRelativePath = parsed.second;
    
            // 2. 获取该业务当前活跃的离线包版本
            String activeVersion = activeVersions.get(businessId);
            if (activeVersion == null) {
                return null; // 该业务没有活跃的离线包
            }
    
            // 3. 获取该版本的本地存储路径
            PackageInfo activePackage = findLocalPackage(businessId, activeVersion);
             if (activePackage == null || !activePackage.isValid()) {
                 Log.w("OfflinePackage", "Active package not found or invalid for " + businessId + " v" + activeVersion);
                 // 可能需要触发一次检查或清理
                 return null;
             }
            String packageBasePath = activePackage.getExtractPath();
    
    
            // 4. 拼接完整的本地文件路径
            String localPath = packageBasePath + "/" + resourceRelativePath;
    
            // 5. 检查本地文件是否存在 (非常重要!)
            File file = new File(localPath);
            if (file.exists() && file.isFile()) {
                Log.d("OfflinePackage", "Serving local resource: " + requestUrl + " -> " + localPath);
                return localPath;
            } else {
                Log.w("OfflinePackage", "Local resource file not found: " + localPath + " for url " + requestUrl);
                 // 文件丢失可能意味着包损坏或解压不完整
                 // 可以考虑:标记包失效、删除包记录、回退到网络
                 // invalidatePackage(businessId, activeVersion);
                return null;
            }
        }
    
        // --- Helper methods ---
        private Pair<String, String> parseUrl(String url) { /* ... 实现URL解析逻辑 ... */ return null;}
        private int compareVersion(String v1, String v2) { /* ... 实现版本号比较 ... */ return 0;}
        private PackageInfo findLocalPackage(String businessId, String version) { /* ... 在 localPackages 中查找 ... */ return null;}
        private boolean verifyChecksum(String filePath, String expectedChecksum) { /* ... 计算文件校验和并比较 ... */ return true;}
        private boolean unzipPackage(String zipFilePath, String destDir) { /* ... 实现解压逻辑 ... */ return true;}
        private void saveLocalPackageInfo(String businessId, PackageInfo info) { /* ... 持久化存储包信息 ... */}
        private void cleanupOldVersions(String businessId, String keepVersion) { /* ... 删除旧版本文件和记录 ... */}
        private String getExtractPath(String businessId, String version) { return baseStoragePath + "/" + businessId + "/" + version; }
        private void deleteFile(String path) { /* ... 删除文件 ... */}
    
        // --- Inner classes ---
        static class PackageInfo {
            String version; String extractPath; String checksum; boolean isValid;
            // constructor, getters...
             public String getExtractPath() { return extractPath;}
             public boolean isValid() { return isValid;}
        }
        static class ServerPackageInfo { /* ... 服务端返回的包信息结构 ... */
             String getBusinessId() { return null;}
             String getLatestVersion() { return null;}
             String getDownloadUrl() { return null;}
             String getChecksum() { return null;}
             String getActiveType() { return null;}
        }
        interface ApiCallback<T> { void onSuccess(T data); void onError(Exception e); }
        class ApiClient { void checkUpdate(Map<String, String> currentVersions, ApiCallback<List<ServerPackageInfo>> callback) {/* ... HTTP 请求 ... */} }
        class DownloadManager { /* ... 下载、进度、回调等 ... */
              boolean isDownloading(String businessId, String version) { return false;}
              void startDownload(ServerPackageInfo info, DownloadCallback callback) {}
        }
        interface DownloadCallback { void onDownloadSuccess(ServerPackageInfo info, String filePath); void onDownloadFailed(ServerPackageInfo info, Exception e); }
    }
  • WebView 资源拦截 (MyWebViewClient.java):

    Java

    java 复制代码
    import android.webkit.WebResourceRequest;
    import android.webkit.WebResourceResponse;
    import android.webkit.WebView;
    import android.webkit.WebViewClient;
    import androidx.annotation.Nullable;
    import java.io.File;
    import java.io.FileInputStream;
    import java.io.FileNotFoundException;
    import java.io.IOException;
    import android.net.Uri;
    import android.util.Log;
    import java.util.HashMap;
    import java.util.Map;
    import android.webkit.MimeTypeMap; // 用于获取MIME类型
    
    public class MyWebViewClient extends WebViewClient {
    
        private Context context;
        private OfflinePackageManager packageManager;
    
        public MyWebViewClient(Context context) {
            this.context = context;
            this.packageManager = OfflinePackageManager.getInstance(context);
        }
    
        @Nullable
        @Override
        public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
            Uri url = request.getUrl();
            String urlString = url.toString();
            String method = request.getMethod();
    
            // 只拦截 GET 请求,且是 http/https 协议
            if (!"GET".equalsIgnoreCase(method) || (!urlString.startsWith("http://") && !urlString.startsWith("https://"))) {
                return super.shouldInterceptRequest(view, request);
            }
    
            // 尝试从离线包获取资源
            String localPath = packageManager.getLocalResourcePath(urlString);
    
            if (localPath != null) {
                File file = new File(localPath);
                if (file.exists()) {
                    try {
                        FileInputStream inputStream = new FileInputStream(file);
                        String mimeType = getMimeType(urlString); // 根据URL或文件扩展名获取MIME类型
    
                        // 构建本地资源的响应
                        WebResourceResponse response = new WebResourceResponse(
                                mimeType, // e.g., "text/html", "text/css", "application/javascript", "image/png"
                                "UTF-8", // 假设是UTF-8编码, 对于二进制文件此参数会被忽略
                                inputStream
                        );
    
                        // 设置响应头 (可选, 但建议设置 Cache-Control)
                        Map<String, String> headers = new HashMap<>();
                        headers.put("Access-Control-Allow-Origin", "*"); // 处理跨域问题(如果需要)
                        // 可以设置一个较短的缓存时间,或者不缓存,因为资源已经是本地的了
                        headers.put("Cache-Control", "no-cache, no-store, must-revalidate");
                        response.setResponseHeaders(headers);
    
                         Log.d("OfflineIntercept", "Intercepted and served from local: " + urlString);
                        return response;
    
                    } catch (FileNotFoundException e) {
                        Log.e("OfflineIntercept", "File not found for local resource: " + localPath, e);
                        // 文件虽然记录存在但实际读取不到,可能被清理或损坏,让请求继续走网络
                    } catch (Exception e) {
                         Log.e("OfflineIntercept", "Error serving local resource: " + localPath, e);
                         // 发生其他错误,也回退到网络
                    }
                } else {
                     Log.w("OfflineIntercept", "Local path resolved but file doesn't exist: " + localPath);
                     // 路径存在但文件不在,可能包不完整,回退到网络
                }
            }
    
            // 如果本地没有找到资源或发生错误,则不拦截,让WebView自己去网络加载
            Log.d("OfflineIntercept", "Not intercepted (using network): " + urlString);
            return super.shouldInterceptRequest(view, request);
        }
    
        // 简单的根据URL获取MIME类型的方法
        private String getMimeType(String url) {
            String type = null;
            String extension = MimeTypeMap.getFileExtensionFromUrl(url);
            if (extension != null) {
                type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.toLowerCase());
            }
            // 提供一些默认值或常见类型的处理
            if (type == null) {
                 if (url.endsWith(".js")) return "application/javascript";
                 if (url.endsWith(".css")) return "text/css";
                 if (url.endsWith(".html")) return "text/html";
                 // ... 其他类型
                 type = "application/octet-stream"; // 默认二进制流
            }
            return type;
        }
    }
  • 在 Activity/Fragment 中使用:

    Java

    scss 复制代码
    WebView myWebView = findViewById(R.id.my_webview);
    WebSettings settings = myWebView.getSettings();
    settings.setJavaScriptEnabled(true);
    settings.setDomStorageEnabled(true);
    settings.setAllowFileAccess(true); // 可能需要允许访问文件系统
    
    // 设置自定义的 WebViewClient
    myWebView.setWebViewClient(new MyWebViewClient(this));
    
    // 启动时触发一次更新检查 (可以在 Application 类或主 Activity 做)
    OfflinePackageManager.getInstance(getApplicationContext()).checkUpdates();
    
    // 加载 H5 页面 (URL需要能被 parseUrl 解析出 businessId)
    myWebView.loadUrl("https://m.example.com/homepage/index.html");

3. 前端 H5 (注意事项)

  • 资源引用路径: 最好使用相对路径。例如,在index.html中引用./css/style.css./js/app.js。客户端拦截器需要能根据主文档的URL和资源的相对路径,正确映射到离线包内的文件路径。

  • 构建产物: 构建工具(如Webpack/Vite)的输出目录结构需要规划好,方便服务端打包和客户端解压后查找。

  • Manifest 文件 (可选但推荐): 在构建时生成一个manifest.json文件,包含在离线包的根目录。

    JSON

    json 复制代码
    // manifest.json (示例)
    {
      "version": "1.0.2", // 包版本
      "businessId": "homepage",
      "files": [
        { "path": "index.html", "hash": "md5_hash_of_index.html", "size": 1234 },
        { "path": "css/style.css", "hash": "md5_hash_of_style.css", "size": 5678 },
        { "path": "js/app.js", "hash": "md5_hash_of_app.js", "size": 91011 },
        { "path": "images/logo.png", "hash": "md5_hash_of_logo.png", "size": 1213 }
        // ... 其他文件
      ]
    }

    客户端可以在拦截时读取这个manifest,进行更细粒度的文件校验或支持增量更新。

iOS 实现关键点:

  • 资源拦截:

    • 旧方案:使用NSURLProtocol。需要注册一个自定义的NSURLProtocol子类,在canInitWithRequest:中判断是否要拦截此请求,在startLoading中实现加载本地资源或转发网络请求的逻辑。全局注册,可能影响所有网络请求,需谨慎处理。
    • 新方案 (iOS 11+): WKWebViewConfiguration 提供了 setURLSchemeHandler(_:forURLScheme:) 方法。可以为自定义的URL Scheme(如myapp-offline://homepage/index.html)或标准的 http/https Scheme 注册一个 Handler (WKURLSchemeHandler)。在 Handler 的 webView(_:start:) 方法中加载本地资源并调用 didReceive response / didReceive data / didFinish / didFailWithError 等方法将数据返回给 WKWebView。这是目前推荐的方式,控制粒度更好。
  • 下载、解压、文件管理: 使用iOS提供的URLSession进行下载,SSZipArchive或其他库进行解压,文件存储在NSDocumentDirectoryNSCachesDirectory下的应用私有目录。

  • 版本管理与更新逻辑: 思路与Android类似,通过UserDefaults或数据库管理本地版本信息,调用API检查更新,下载新包,在合适的时机切换。

总结与注意事项:

  1. 复杂度: 实现一套稳定、高效的离线包方案复杂度较高,涉及客户端(双平台)、服务端、前端构建等多个环节的配合。
  2. 更新策略是关键: 如何处理版本更新直接影响用户体验和能否看到最新内容。后台静默下载+下次启动时激活是常用且对用户干扰较小的策略。
  3. URL 规则: 需要制定清晰的URL规则或映射表,让客户端能从请求URL中识别出业务标识(businessId)和资源相对路径。
  4. 入口HTML: 通常离线包方案主要优化非入口HTML页面及其引用的静态资源。入口HTML(如App内直接加载的第一个H5页)本身可能还是需要网络请求来获取最新版本信息或动态数据。也可以将入口HTML也纳入离线包管理。
  5. 动态数据: 离线包只缓存静态资源。页面中的动态数据仍需通过AJAX/Fetch等方式从API获取。需要确保这些API请求不被错误地拦截。
  6. 存储空间: 离线包会占用用户设备的存储空间,需要有合理的清理策略(如限制总大小、只保留最近N个版本、LRU淘汰等)。
  7. 增量更新: 全量更新包体积可能较大。增量更新(只下载变更的文件)能节省流量和下载时间,但实现更复杂,需要服务端支持生成diff包,客户端支持应用patch。
  8. 异常处理与回退: 必须有健壮的错误处理机制。如下载失败、校验失败、解压失败、文件丢失等情况,都需要能安全地回退到线上加载,保证页面可用性。
  9. 监控: 对离线包的下载成功率、命中率、加载时间、失败回退情况等进行监控,有助于发现问题并持续优化。
  10. PWA / Service Worker: 对于纯Web App或PWA,也可以使用 Service Worker 的 Cache API 来实现类似的离线缓存和秒开效果,这是Web标准的一部分,但其更新机制和控制力相比原生App内嵌方案可能有所不同。
相关推荐
崔庆才丨静觅4 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60614 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了5 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅5 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅5 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment5 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax