核心思想
离线包的核心思想是:将H5页面的静态资源(HTML、CSS、JavaScript、图片、字体等)预先打包,通过某种机制(通常是App启动时、或后台静默)下载到用户设备本地。当用户访问H5页面时,客户端(通常是原生App的WebView容器)拦截网络请求,优先从本地加载对应的离线资源,从而跳过网络请求和下载时间,达到"秒开"的效果。
同时,为了解决新功能上线后的缓存问题,必须引入一套完善的版本管理和更新机制。客户端需要知道当前线上最新的资源版本,并与本地缓存的版本进行比对,决定是加载本地资源还是去线上拉取最新资源。
整体架构
一个完整的离线包方案通常包括以下几个部分:
-
服务端 (Server):
- 资源打包: 负责将前端构建产物(HTML/CSS/JS/Images等)打包成一个压缩文件(如ZIP)。
- 版本管理: 维护每个离线包的版本号、对应的业务标识、下载地址、文件校验码(MD5/SHA1)、更新策略(全量/增量)、是否强制更新等信息。
- 配置下发API: 提供接口供客户端查询最新的离线包配置信息。
- 资源包托管: 将打包好的资源包文件存放在CDN或文件服务器上,供客户端下载。
-
客户端 (Client - 通常是 Native App):
-
配置拉取: 定期(如App启动时、后台定时)调用服务端的API,获取最新的离线包配置。
-
下载管理: 根据配置信息,下载新的或更新的离线包文件。支持断点续传、后台下载、失败重试等。
-
包管理:
- 存储: 将下载的资源包安全地存储在本地(如App的私有目录)。
- 校验: 下载完成后,根据配置中的校验码验证文件的完整性,防止包损坏或被篡改。
- 解压: 将验证通过的资源包解压到指定目录,供后续加载使用。
- 版本控制: 记录本地已有的资源包版本,管理多个版本(可选,用于回滚或灰度),清理旧版本。
-
资源拦截与加载:
- 拦截机制: 拦截WebView发出的资源请求(HTML主文档、CSS、JS、图片等)。这是最关键的一步。Android通常使用
WebViewClient
的shouldInterceptRequest
方法,iOS使用NSURLProtocol
或WKWebView的新API。 - 本地优先策略: 对被拦截的请求,根据请求URL和当前业务应该使用的离线包版本,判断本地是否有对应的、且版本号符合要求的资源文件。
- 本地加载: 如果本地存在有效资源,则直接读取本地文件内容,构建响应并返回给WebView,绕过网络。
- 网络加载(Fallback): 如果本地没有对应资源、资源版本过旧、或离线包加载失败,则允许请求继续通过网络加载。
- 更新时机: 决定何时启用新下载的资源包(如下次启动生效、立刻生效等)。
- 拦截机制: 拦截WebView发出的资源请求(HTML主文档、CSS、JS、图片等)。这是最关键的一步。Android通常使用
-
-
前端 H5 (Web):
- 资源路径: 通常需要配合客户端的拦截规则,使用相对路径或特定的URL格式。
- 构建配合: 前端构建流程需要产出符合离线包要求的资源结构,并配合服务端生成包含版本信息、文件列表等的
manifest
文件(通常包含在离线包内)。 - (可选) JSBridge: H5页面可以通过JSBridge与客户端通信,查询当前离线包状态、版本,或触发更新检查等。
解决缓存导致无法看到新功能的核心机制:
关键在于请求拦截时的版本判断 和更新策略。
-
启动时/定时检查更新: 客户端在合适的时机(如App冷启动、进入相关业务模块前)向服务端查询对应业务标识的最新离线包版本信息。
-
版本比对: 将服务端返回的最新版本号与本地当前使用的版本号进行比较。
-
决策加载逻辑:
-
本地版本 == 最新版本: 拦截请求,直接加载本地离线资源(实现秒开)。
-
本地版本 < 最新版本:
- 策略一(推荐,优先保证体验): 本次访问仍然加载本地旧版本资源 (保证秒开),同时后台静默下载最新版本的离线包。下载、校验、解压完成后,标记新版本为"准备就绪"。下次用户再访问该页面时,由于本地版本已更新,就会加载新版本的资源。这是最常见的策略,兼顾了首次加载速度和后续更新。
- 策略二(强制更新): 如果服务端配置了"强制更新",或者业务要求必须展示最新内容,客户端可以阻止加载旧资源,立即触发新包下载(可能需要给用户提示)。下载完成后再加载新资源。这种方式会牺牲本次访问的"秒开"体验,但能确保用户看到最新内容。
- 策略三(提示更新): 加载旧版本资源,同时给用户一个"有新版本可用,立即更新?"的提示,用户点击后才执行下载和切换。
-
本地无离线包 或 离线包损坏: 直接走网络加载,同时尝试后台下载最新版本的离线包。
-
-
资源粒度校验 (可选,更精细): 离线包内可以包含一个
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
typescriptimport 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
javaimport 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
scssWebView 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
或其他库进行解压,文件存储在NSDocumentDirectory
或NSCachesDirectory
下的应用私有目录。 -
版本管理与更新逻辑: 思路与Android类似,通过
UserDefaults
或数据库管理本地版本信息,调用API检查更新,下载新包,在合适的时机切换。
总结与注意事项:
- 复杂度: 实现一套稳定、高效的离线包方案复杂度较高,涉及客户端(双平台)、服务端、前端构建等多个环节的配合。
- 更新策略是关键: 如何处理版本更新直接影响用户体验和能否看到最新内容。后台静默下载+下次启动时激活是常用且对用户干扰较小的策略。
- URL 规则: 需要制定清晰的URL规则或映射表,让客户端能从请求URL中识别出业务标识(
businessId
)和资源相对路径。 - 入口HTML: 通常离线包方案主要优化非入口HTML页面及其引用的静态资源。入口HTML(如App内直接加载的第一个H5页)本身可能还是需要网络请求来获取最新版本信息或动态数据。也可以将入口HTML也纳入离线包管理。
- 动态数据: 离线包只缓存静态资源。页面中的动态数据仍需通过AJAX/Fetch等方式从API获取。需要确保这些API请求不被错误地拦截。
- 存储空间: 离线包会占用用户设备的存储空间,需要有合理的清理策略(如限制总大小、只保留最近N个版本、LRU淘汰等)。
- 增量更新: 全量更新包体积可能较大。增量更新(只下载变更的文件)能节省流量和下载时间,但实现更复杂,需要服务端支持生成diff包,客户端支持应用patch。
- 异常处理与回退: 必须有健壮的错误处理机制。如下载失败、校验失败、解压失败、文件丢失等情况,都需要能安全地回退到线上加载,保证页面可用性。
- 监控: 对离线包的下载成功率、命中率、加载时间、失败回退情况等进行监控,有助于发现问题并持续优化。
- PWA / Service Worker: 对于纯Web App或PWA,也可以使用 Service Worker 的 Cache API 来实现类似的离线缓存和秒开效果,这是Web标准的一部分,但其更新机制和控制力相比原生App内嵌方案可能有所不同。