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内嵌方案可能有所不同。
相关推荐
沙尘暴炒饭6 分钟前
vuex持久化vuex-persistedstate,存储的数据刷新页面后导致数据丢失
开发语言·前端·javascript
2401_837088509 分钟前
CSS清楚默认样式
前端·javascript·css
zwjapple20 分钟前
React 的 useEffect 清理函数详解
前端·react.js·前端框架
Jewel10530 分钟前
如何配置Telegram Mini-App?
前端·vue.js·app
s11show_1631 小时前
hz修改后台新增keyword功能
android·java·前端
二个半engineer1 小时前
Web常见攻击方式及防御措施
前端
co松柏1 小时前
程序员必备——AI 画技术图技巧
前端·后端·ai编程
前端大白话2 小时前
前端人速码!10个TypeScript神仙技巧,看完直接拿捏项目实战
前端·javascript·typescript
五号厂房2 小时前
React 异步回调中产生的闭包问题剖析及解决
前端
用户2031196600962 小时前
GeometryProxy 和 GeometryReader 的区别
前端