离线包与预加载方案
# 26.离线包与预加载方案
本篇定位:离线包和预加载是 H5/Hybrid 性能的"加速器"——同样是打开一个 H5 页面,秒开和 3 秒白屏的差距就在这里。本文从一次"H5 页面 5 秒白屏被骂"的故事讲起,回答三个核心问题——离线包究竟解决了什么?预加载有哪几种姿势?怎么设计一套秒开级别的资源体系?
# 目录介绍
# 01.5 秒白屏的差评
# 1.1 用户骂"App 卡爆了"
某电商 App 双 11 大促主会场用 H5 写——用户点击后白屏 5 秒。社交媒体上炸开锅:
"什么垃圾 App,点个活动等 5 秒" "网络好的时候都这么慢,4G 直接转圈" "京东淘宝秒开,你们这是石器时代?"
监控数据令人震惊:
- 首屏白屏:P50 = 3.2s,P99 = 8.5s
- 白屏率(5s 内未渲染):18%
- 跳出率:32%——三分之一用户没等到加载完就走了
gantt
title H5 加载耗时拆解
dateFormat X
axisFormat %s s
section 网络
DNS + TCP :a, 0, 500
TLS 握手 :b, 500, 800
HTML 下载 :c, 800, 1500
解析 HTML :d, 1500, 1800
section 资源
JS 下载 :e, 1800, 3500
CSS 下载 :f, 1800, 2300
图片下载 :g, 2300, 4500
section 渲染
JS 执行 :h, 3500, 4500
首屏渲染 :crit, i, 4500, 100
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 1.2 性能瓶颈拆解
白屏 5 秒分布:
| 阶段 | 耗时 | 占比 |
|---|---|---|
| 网络(DNS + TCP + TLS) | 800ms | 16% |
| HTML 下载 + 解析 | 700ms | 14% |
| JS / CSS 下载 | 1.7s | 34% |
| 图片下载 | 1.2s | 24% |
| JS 执行 + 渲染 | 600ms | 12% |
| 合计 | 5s | 100% |
根因:所有资源每次都要从远端下——明明是同一个活动页,1000 万人访问就下载 1000 万次。
# 1.3 反思离线方案
事后这个团队总结了三个最深刻的教训:
- 离线包能省掉 80% 的网络耗时——HTML/CSS/JS/图片都内置
- WebView 预热能省掉冷启动时间——300-800ms
- 接口预请求能并行化——把"打开后再请求"提前到"打开前请求"
离线包的本质就是 把"网络下载"换成"本地读取"——速度差 100 倍以上。
# 02.要解决的核心矛盾
# 2.1 秒开是基本盘
业界 H5 秒开标准:
| 指标 | 优秀 | 良好 | 及格 |
|---|---|---|---|
| 白屏时间 | < 500ms | < 1s | < 2s |
| 首屏可见 | < 1s | < 2s | < 3s |
| 可交互 | < 2s | < 3s | < 5s |
| 白屏率 | < 1% | < 5% | < 10% |
# 2.2 包大小与覆盖
graph LR
A[全量内置<br/>所有页面打包] --> B[包大]
A --> C[App 安装包暴涨]
A2[完全在线<br/>都从远端下] --> B2[包小]
A2 --> C2[每次都慢]
A3[核心内置 + 边缘下载] --> B3[平衡]
A3 --> C3[首屏快]
style B fill:#fff3e0
style C2 fill:#fff3e0
style B3 fill:#e8f5e8
2
3
4
5
6
7
8
9
10
11
12
13
实战经验:核心 5-10 个高频页面打入安装包,其他页面用"在用户下次访问前预下载"。
# 2.3 实时与稳定
| 维度 | 全量更新 | 增量更新 |
|---|---|---|
| 包大小 | 大(每次完整下载) | 小(只下载差异) |
| 更新速度 | 慢 | 快 |
| 流量消耗 | 高 | 低 |
| 失败容错 | 容易回滚 | 复杂 |
| 典型节省 | - | 80%-95% |
# 2.4 离线包的本质
离线包 = 把网页"装"进 App 安装包/缓存里
它的核心价值 = WebView 加载本地文件比加载网络快 10-100 倍。
# 03.业界主流方案
# 03.1 主流离线包方案
| 方案 | 厂商 | 特点 |
|---|---|---|
| 支付宝 Nebula | 蚂蚁 | 原生集成、能力强 |
| 微信小程序框架 | 腾讯 | 严格沙箱 |
| 手淘 ZCache | 阿里 | 大规模实战 |
| 美团 Knb | 美团 | 集成 H5 容器 |
| JS Service Worker | 浏览器原生 | PWA 标准 |
| Cordova/Ionic 的 webview-cache | 开源 | 简单 |
# 03.2 横向对比矩阵
| 维度 | 安装内置 | 启动预下载 | 用户访问时下 | Service Worker |
|---|---|---|---|---|
| 首次访问速度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐ | ⭐⭐ |
| 后续访问速度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
| 包大小代价 | 大 | 小 | 0 | 0 |
| 更新延迟 | 发版 | 启动后 | 实时 | 实时 |
| 典型场景 | 核心页 | 高频页 | 长尾页 | Web 页 |
# 03.3 预加载分类
mindmap
root((预加载))
资源预加载
离线包内置
启动预下载
Wi-Fi 自动更新
容器预加载
WebView 预创建
RN 实例预热
引擎预初始化
数据预加载
接口预请求
关键数据本地缓存
图片预加载
路径预测
用户行为预测
首屏 Tab 预加载
下一步页面预加载
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 04.设计核心原则
# 04.1 边走边下原则
铁律:用户使用时不让他等——下载在用户没察觉的时候完成。
graph TB
A[App 启动] --> B[空闲时检查更新]
B --> C[Wi-Fi 下后台下载]
C --> D[下载完成校验]
D --> E[切换到新版本]
F[用户访问页面] --> G{包已就绪?}
G -->|是| H[本地秒开]
G -->|否| I[fallback 到在线]
style H fill:#e8f5e8
style I fill:#fff3e0
2
3
4
5
6
7
8
9
10
11
12
# 04.2 增量更新原则
只下载变化的部分——bsdiff、xdelta 等算法。
flowchart LR
Old[v1.0 包<br/>2MB] --> Diff[diff 计算]
New[v1.1 包<br/>2.1MB] --> Diff
Diff --> Patch[patch 包<br/>50KB]
Note[节省 95% 流量]
style Patch fill:#e8f5e8
style Note fill:#fff3e0
2
3
4
5
6
7
8
9
典型节省:
| 包大小变化 | 全量 | 增量 | 节省 |
|---|---|---|---|
| 2MB → 2.1MB | 2.1MB | 50KB | 97% |
| 5MB → 5.5MB | 5.5MB | 200KB | 96% |
# 04.3 安全校验原则
离线包必须签名校验——防止中间人替换。
sequenceDiagram
participant App as App
participant CDN as CDN
App->>CDN: 下载离线包 + 签名
CDN-->>App: 包 + signature
App->>App: 1. 校验包哈希(防损坏)
App->>App: 2. 校验签名(防篡改)
App->>App: 3. 校验来源(白名单域名)
alt 校验通过
App->>App: 应用新包
else 校验失败
App->>App: 丢弃 + 上报
end
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 04.4 命中率优先原则
命中率 = 离线包成功命中 / 页面访问总数——业界优秀水平 > 90%。
mindmap
root((命中率优化))
覆盖核心页
首页
列表
详情
下单
Wi-Fi 主动预下载
启动时
App 后台时
用户行为预测
搜索后预加载详情
列表页预加载下一页
版本兜底
旧版本仍可用
渐进升级
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 05.方案落地实战
# 05.1 整体架构
graph TB
subgraph "管理后台"
Console[运营后台]
Build[构建系统]
Console --> Build
end
subgraph "下发系统"
CDN[CDN]
VersionAPI[版本查询接口]
Build --> CDN
end
subgraph "客户端"
Update[更新管理器]
Cache[本地缓存]
Hijack[资源拦截]
Container[H5 容器]
Update --> Cache --> Hijack --> Container
end
Update --> VersionAPI
Update --> CDN
Container --> Page[H5 页面<br/>从 Cache 加载]
Monitor[监控] --> Update
style Update fill:#fff3e0
style Cache fill:#e8f5e8
style Hijack fill:#e3f2fd
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 05.2 离线包结构
典型离线包结构:
offline-pkg-v1.2.zip
├── manifest.json ← 包描述(版本、入口、签名)
├── index.html ← 页面 HTML
├── assets/
│ ├── main.js
│ ├── main.css
│ └── img/
│ ├── banner.png
│ └── icons.png
└── pages/ ← 多页面应用
├── list.html
└── detail.html
2
3
4
5
6
7
8
9
10
11
12
manifest.json 示例:
{
"name": "promotion-page",
"version": "1.2.0",
"appId": "promotion",
"entry": "index.html",
"files": {
"index.html": "sha256:abc...",
"assets/main.js": "sha256:def...",
"assets/main.css": "sha256:ghi..."
},
"signature": "RSA-SHA256-encoded-signature"
}
2
3
4
5
6
7
8
9
10
11
12
# 05.3 资源拦截加载
WebView 拦截网络请求,从本地加载:
class OfflinePackageInterceptor(private val pkg: OfflinePackage) {
fun shouldInterceptRequest(view: WebView, req: WebResourceRequest): WebResourceResponse? {
val url = req.url.toString()
val path = extractPath(url)
// 1. 检查是否在离线包里
val localFile = pkg.findFile(path) ?: return null
// 2. 校验完整性
if (!verifyHash(localFile, pkg.manifest.files[path])) {
return null // fallback 到在线
}
// 3. 返回本地资源
return WebResourceResponse(
getMimeType(path),
"UTF-8",
FileInputStream(localFile)
)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 05.4 增量包设计
bsdiff 增量包流程:
sequenceDiagram
participant App as App<br/>当前 v1.0
participant Server as 版本服务
App->>Server: 查询更新 当前 v1.0
Server-->>App: 最新 v1.2 + diff URL
Note over App: diff 路径: 1.0 → 1.2 ?<br/>1.0 → 1.1 → 1.2 ?
App->>Server: 下载 diff 包 50KB
App->>App: 用 bsdiff 算法合并<br/>v1.0 + diff = v1.2
App->>App: 校验合成包哈希
alt 哈希一致
App->>App: 切换到 v1.2
else 哈希不对
App->>Server: 降级全量下载
end
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 05.5 预加载策略
多层预加载组合:
graph TB
L1[L1 安装内置<br/>核心页]
L2[L2 启动预下载<br/>高频页]
L3[L3 用户行为预测<br/>下一步可能页]
L4[L4 实时下载<br/>长尾页]
L1 --> L2 --> L3 --> L4
Note1[100% 命中]
Note2[90% 命中]
Note3[60% 命中]
Note4[fallback]
L1 -.- Note1
L2 -.- Note2
L3 -.- Note3
L4 -.- Note4
style L1 fill:#e8f5e8
style L2 fill:#fff3e0
style L3 fill:#e3f2fd
style L4 fill:#f3e5f5
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
接口预请求 + 数据预加载:
// 用户在列表页时,预测下一步操作
class PreloadManager {
fun onListPageVisible(items: List<Item>) {
// 预加载前 3 个详情接口数据
items.take(3).forEach { item ->
scope.launch {
val data = api.getDetail(item.id)
cache.put("detail:${item.id}", data)
}
}
}
fun onDetailPageOpen(itemId: String): Item {
// 优先用预加载的数据
return cache.get("detail:$itemId")
?: api.getDetail(itemId)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 06.关键问题解决
# 06.1 包冲突管理
多业务团队各自做离线包,可能冲突:
graph TB
Issue[100 个业务包<br/>各自更新] --> Risk{问题}
Risk --> R1[存储爆炸]
Risk --> R2[启动检查接口风暴]
Risk --> R3[版本管理混乱]
Solution[统一离线包平台] --> S1[统一构建]
Solution --> S2[统一存储 LRU]
Solution --> S3[批量版本检查]
Solution --> S4[全局灰度]
style Risk fill:#ffebee
style Solution fill:#e8f5e8
2
3
4
5
6
7
8
9
10
11
12
13
14
# 06.2 流量与电量
用户流量敏感的考虑:
| 策略 | 描述 |
|---|---|
| Wi-Fi 优先 | 默认仅 Wi-Fi 下载 |
| 大包警告 | > 10MB 提示用户 |
| 分时段 | 夜间静默更新 |
| 流量上限 | 每月最多 N MB 后台下载 |
| 电量阈值 | 低电量时停止下载 |
# 06.3 命中率优化
命中率监控指标:
graph LR
A[页面访问] --> B{离线包就绪?}
B -->|是| Hit[命中 ✅]
B -->|否, 在下载| Miss1[未命中 - 在下载]
B -->|否, 没下| Miss2[未命中 - 未启用]
Hit --> Stats[命中率统计]
Miss1 --> Stats
Miss2 --> Stats
Stats --> Optimize[优化方向<br/>提前预下载<br/>扩大 Wi-Fi 覆盖]
style Hit fill:#e8f5e8
style Optimize fill:#fff3e0
2
3
4
5
6
7
8
9
10
11
12
13
14
# 07.常见陷阱与反例
# 07.1 全量打包反例
反例:某电商把 50 个 H5 页面全部打入安装包 → App 包从 50MB 涨到 120MB → 安装转化率掉 25%。
教训:
- 只内置真正的"高频核心页"
- 边缘页面用"动态下载"
- 安装包大小 > 100MB 是大忌
# 07.2 不限并发反例
反例:App 启动后,100 个业务包同时请求 CDN → 网关被打、用户主流程加载慢。
教训:
- 控制并发(最多 3-5 个)
- 优先级排队(核心包优先)
- 主流程加载完才下载离线包
# 07.3 没监控反例
反例:上线了离线包系统,但不知道命中率——以为效果很好,其实命中率才 30%。
教训:必须监控:
- 离线包命中率
- 加载耗时
- 失败率(哈希校验失败、解压失败)
- 流量消耗
mindmap
root((三大反例))
全量打包
安装包暴涨
安装转化下降
只打高频核心
不限并发
启动风暴
网关被打
限并发 + 优先级
没监控
命中率不知
白屏率不知
建立监控大盘
2
3
4
5
6
7
8
9
10
11
12
13
14
# 08.演进路线
# 08.1 V1 简单缓存
特征:业务起步、H5 不多。
做法:
- WebView 默认缓存
- HTTP cache-control
- 无主动管理
适用阶段:少量 H5 页面
# 08.2 V2 离线包系统
特征:H5 业务规模化。
做法:
- 离线包 + manifest
- 资源拦截加载
- 全量包下发
- WebView 预创建池
适用阶段:中型 App
# 08.3 V3 智能预加载
特征:超大规模 / 极致体验追求。
做法:
- 增量更新(bsdiff)
- 用户行为预测
- 接口预请求
- AI 辅助命中率优化
- 全链路监控
适用阶段:头部 App / 电商大促
flowchart LR
V1[V1 简单缓存<br/>起步] --> V2[V2 离线包系统<br/>主流]
V2 --> V3[V3 智能预加载<br/>头部]
style V1 fill:#e3f2fd
style V2 fill:#e8f5e8
style V3 fill:#fff3e0
2
3
4
5
6
7
# 09.总结与决策
# 09.1 上线检查表
离线包系统上线前对照:
- [ ] 包结构标准化(manifest + 资源)
- [ ] 包签名 + 哈希校验
- [ ] WebView 资源拦截
- [ ] 增量更新机制
- [ ] Wi-Fi 优先下载
- [ ] 并发控制(最多 N 个)
- [ ] LRU 存储管理(不无限膨胀)
- [ ] 灰度发布机制
- [ ] 失败回退在线版本
- [ ] WebView 预创建池
- [ ] 接口预请求
- [ ] 命中率监控
- [ ] 白屏率 / 加载耗时监控
- [ ] 流量消耗监控
# 09.2 选型决策树
flowchart TD
Start([我要做离线包]) --> Q1{H5 业务规模?}
Q1 -->|< 5 个 H5 页面| L1[WebView 缓存就够<br/>简单]
Q1 -->|5-50 个页面| L2[简单离线包<br/>+ 资源拦截]
Q1 -->|> 50 个页面| L3[完整离线包平台<br/>+ 增量更新<br/>+ 预加载]
Q2([首屏要求?]) --> Speed{秒开?}
Speed -->|是| Aggr[+ 安装内置核心页<br/>+ 启动预下载]
Speed -->|普通| Norm[访问时下载即可]
style L1 fill:#e3f2fd
style L2 fill:#e8f5e8
style L3 fill:#fff3e0
style Aggr fill:#ffebee
2
3
4
5
6
7
8
9
10
11
12
13
14
15
最后一句话:离线包是 H5 性能的"作弊器"——开篇 5 秒白屏的根因,就是把"网络下载"放在了"用户等待"的关键路径上。
好的离线包方案 = 核心内置、增量更新、安全可靠、命中率高。