前端 blob 下载这件事,桌面端写起来是五行代码的事:URL.createObjectURL造一个临时地址,挂个a[download],点一下,完事。真正让我连着几天泡在调试里的,是把它搬进 WKWebView 之后的一连串适配问题——点击没有任何报错、download属性像被吞掉、控制台干干净净,用户那边反馈"点了没反应"。这篇就把前端 blob 下载和 WKWebView 适配这两块揉在一起讲清楚,从底层链路、方案选型、分片参数计算,到原生侧怎么接、哪些坑我亲自踩过。不管你是只写 H5 的前端,还是需要改原生壳的客户端同学,或者只是想搞清楚"为什么同样的代码在安卓上能跑、在 iOS 上就死",应该都能从里面抄到可以直接用的东西。
1. 先把故障面拆开:Blob 下载在 WebView 里的真实链路
1.1 Blob URL 是个进程内的临时门牌号
很多人对URL.createObjectURL(blob)的理解停留在"生成了一个临时链接",这个理解不够精确,会导致后面排查方向完全跑偏。
blob:https://example.com/8f3c...这个地址,本质上不是网络地址,它是渲染进程内存里一块数据的引用标识。它的特点是:同源策略上它继承创建者的源,但它没有对应的网络请求,不会出现在 Network 面板里,也不能被复制到另一个进程或另一台设备上去访问。你可以把它理解成公司内部的一个工位号——在公司大楼里问"3 楼 17 号工位在哪",前台能带你过去;但你把这个工位号发给外面的人,对方完全不知道你在说什么。
这个特性直接推导出两个结论:
第一,它没法跨进程传递。WKWebView 的架构是 WebContent 进程负责跑 JS 和持有 Blob 数据,App 主进程(你的原生代码)负责 UI 和文件系统。当你在网页里点一个blob:链接,导航动作发生在 WebContent 进程里,而真正的下载落盘必须由主进程的下载代理来做。中间隔着进程边界,blob:地址传过去只是一串字符串,背后的字节流根本不在那个进程里。
第二,它不产生网络流量。所以任何基于"拦截网络请求"思路的方案,对 blob 天然失效。这也是为什么WKDownload能接管https://的 PDF 下载,却对blob:无能为力——它压根没发起过一个可被接管的请求。
顺便说一句data:协议,它比 blob 更"死"。data:URL 是把字节直接编码进地址字符串里的,理论上可以被原生侧解析出来,但 WKWebView 对顶层导航到data:有严格限制,而且一个 50MB 文件编码成的 data URL 字符串长度会到 6700 万字符,WebContent 进程基本撑不住。
1.2 桌面能跑通、WKWebView 跑不通的根因
我把"为什么桌面行的代码在 WKWebView 不行"拆成三层,一层一层看会清楚很多。
第一层是download属性的支持度。HTMLAnchorElement.download在桌面 Chrome、Firefox、Safari 桌面版上都是可用的,触发的是一次"浏览器内置下载管理器接管"的动作。而在 WKWebView 里,早期版本(iOS 14.5 之前)完全没有下载管理器这个概念,download属性会被解析、会挂到 DOM 上,但点击之后就是个空操作,连警告都没有。iOS 14.5 引入了WKDownload,情况好转了一点,但官方文档说得很明确:它接管的是"可以下载的响应",blob:和data:不在其中。
第二层是 MIME 类型决定的行为分叉。WebView 拿到一个资源,第一反应是"我能不能显示它"。PDF、图片、纯文本,canShowMIMEType返回 true,它就直接内联渲染了,用户看到的是打开了一个页面而不是下载了一个文件。zip、apk、office 文档这类,canShowMIMEType返回 false,这时候才会走下载分支。而 blob 下载的常见场景恰恰是导出 Excel、导出 PDF、打包 zip——其中 PDF 这一项在 iOS 上极容易被"内联预览"截胡,用户以为下载失败,其实是打开了预览页。
第三层是用户手势与异步的时序问题。这个坑在桌面端也存在,但在移动端更致命。如果你在点击事件里先await一个接口请求拿到数据,再createObjectURL再a.click(),这个click()已经脱离了用户手势的调用栈,iOS 会判定为非用户发起,直接拦掉。桌面 Chrome 相对宽松,很多人本地测不出来,一上真机就复现。
三层叠在一起,表现出来的现象就高度一致:点击无反应、无报错、无网络请求、无控制台输出。这种"四无"故障是最难查的,因为你没有任何线索,只能靠对链路的理解去推。
1.3 各容器下载能力的真实差异
我把手上接触过的几类容器整理成一张表,这张表是后面选型的基础。注意这里说的是"默认行为",不代表配置后不能改。
| 容器类型 | a[download]+ blob | WKDownload接管 https | JS 桥接原生落盘 | 备注 |
|---|---|---|---|---|
| 桌面 Chrome / Edge | 支持 | 不涉及 | 不涉及 | 最省心,直接可用 |
| 桌面 Safari | 支持 | 不涉及 | 不涉及 | 对 blob 的 revoke 时机敏感 |
| iOS Safari(独立浏览器) | 部分支持 | 支持 | 不涉及 | 会走系统下载列表,PDF 可能预览 |
| WKWebView(App 内嵌) | 基本不可用 | iOS 14.5+ 支持 | 支持 | 主力方案就是 JS 桥 |
| Android WebView | 基本不可用 | 不涉及 | 支持 | setDownloadListener只认 http(s) |
| 各家小程序容器 | 不可用 | 不涉及 | 依赖平台 API | 一般用saveFile类接口 |
| 桌面端内嵌 WebView(部分内核) | 视内核而定 | 不涉及 | 支持 | 行为差异大,必须真机验证 |
这张表里有个细节值得单独说:Android WebView 的setDownloadListener只能收到http/https的下载请求,blob:它一样收不到。所以别以为"iOS 麻烦一点、安卓应该没事",两边的坑长得一模一样,只是解决方式不同。安卓那边通常用addJavascriptInterface注入一个对象,JS 直接调它的方法传数据;iOS 用WKScriptMessageHandler。
还有一个容易忽略的点:同一个 App 的不同页面,可能跑在不同的容器里。比如主业务页面在原生壳的 WKWebView,某个营销活动页在另一套容器,行为不一致是很常见的。做下载能力封装时必须把容器探测做进去,不能假设"我们 App 都是同一个环境"。
2. 四条可选路线:选型比实现更重要
2.1 路线 A:a[download] + Blob URL,桌面优先
这是最轻的方案,没有之一。核心代码就几行:
function downloadByAnchor(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.style.display = 'none'; document.body.appendChild(a); a.click(); document.body.removeChild(a); // 关键:不要立刻 revoke setTimeout(() => URL.revokeObjectURL(url), 60 * 1000); }选它的理由很单纯:零依赖、零原生改动、零上传流量。适合的场景是 PC 端后台管理系统、桌面浏览器为主的 B 端产品。
但它有三个必须接受的代价。第一个是revokeObjectURL的时机,我见过太多人写完a.click()下一行就revoke,在 Chrome 上大概率没事(因为下载已经开始),但 Safari 和部分内核会直接失败。稳妥做法是延迟 60 秒以上,或者干脆不主动 revoke,靠页面刷新回收——代价是一点内存泄漏,但对于"下载完就跳走"的页面完全可接受。
第二个代价是文件名在跨域场景下会被忽略。如果你的 blob 数据来自跨域请求,浏览器出于安全考虑会无视download属性指定的文件名,用一个随机名或者服务端的Content-Disposition文件名。想避免,要么让接口同源,要么在响应头里把文件名带回来。
第三个代价是它压根不考虑移动端 WebView,所以必须有一个能力探测做前置判断,不能无脑走这条路。
2.2 路线 B:JS 桥把字节流交给原生落盘
这是 WKWebView 场景下的主力方案,也是我最终选的那条。思路很直白:既然 blob 的数据出不了 WebContent 进程,那就主动把它读出来,编码成字符串,通过消息通道交给原生,原生负责解码、写文件、调起分享或保存。
这条路线最大的争议点是性能,因为它涉及两次数据膨胀:一次是二进制转 base64(体积 +33%),一次是字符串跨进程传递(序列化又产生一份)。所以很多人第一反应是"大文件肯定不行"。但实测下来,只要分片做得对,500MB 以内的文件是可以稳定跑完的,关键在参数。
它的劣势也很明确:依赖原生发版。前端封装得再好,桥接对象没有,功能就是不可用。所以必须有服务端兜底(路线 C)。
我选它的理由是:离线可用、不占服务器带宽、不依赖网络质量、失败可控。对于"用户点了导出就应该拿到文件"这种强预期场景,它比上传方案体验好太多。
2.3 路线 C:前端回传服务端,换成直链下载
思路是:前端把 blob 通过FormData上传到一个临时存储接口,服务端返回一个带签名的直链,然后location.href = 直链交给 WebView 的常规下载流程处理。这条路线的妙处在于——它把 blob 问题转化成了普通 https 下载问题,而 https 下载是所有容器都支持的。
优点很突出:不依赖原生、纯前端可控、任何容器都能跑、大文件也不受 WebContent 内存限制(因为数据是流式上传的)。
缺点同样突出:要花服务器带宽和存储、上传耗时受网络影响、需要清理临时文件、上传失败要重试。100MB 的文件在弱网下上传可能要几分钟,用户早就关页面了。
我的定位是:把它当兜底,不当主力。只有在原生桥不可用(比如某个新容器还没适配、或者老版本 App 没升级)的时候才走这条路。
2.4 路线 D:WKDownload 接管 http(s) 响应
这条路和 blob 下载是"互补"关系,不是替代。它解决的是:当下载源本身就是一个https://地址(比如服务端生成的直链、PDF 报表地址),怎么让 WKWebView 不内联预览、而是真的下载下来。
核心是WKNavigationDelegate的两个回调和WKDownloadDelegate:
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if #available(iOS 14.5, *), navigationAction.shouldPerformDownload { decisionHandler(.download) return } decisionHandler(.allow) } @available(iOS 14.5, *) func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { download.delegate = self }shouldPerformDownload这个属性是根据响应头和 MIME 推断出来的,Content-Disposition: attachment会让它为 true。但实践里我更倾向于主动判断 URL 特征或者 MIME 白名单,因为不是所有服务端都规规矩矩设置这个头。判断逻辑放在decidePolicyFor navigationResponse里更可靠,那里能拿到response.mimeType和canShowMIMEType。
选它的理由是:和系统下载能力对齐、支持断点数据恢复、大文件不占 JS 内存。代价是 iOS 14.5 的版本门槛,低于这个版本只能退化到"用原生网络库重新请求一次 URL"。
2.5 选型决策表
把四条路线放一起对比,其实选择逻辑非常清晰:
| 维度 | 路线 A | 路线 B | 路线 C | 路线 D |
|---|---|---|---|---|
| 适用下载源 | blob | blob | blob | https |
| 需要原生改动 | 否 | 是 | 否 | 是 |
| 大文件友好度 | 中 | 中(需分片) | 好 | 好 |
| 离线可用 | 是 | 是 | 否 | 否 |
| 服务器成本 | 无 | 无 | 高 | 低 |
| 版本门槛 | 无 | 需桥已存在 | 无 | iOS 14.5+ |
| 我的定位 | 桌面默认 | 移动主力 | 兜底 | http 补充 |
实际代码里,这四条不是四选一,而是串联成一条降级链:先探测容器能力,能走 A 走 A;不行看桥在不在,在就走 B;桥不在就走 C;而 D 是独立的一支,专门处理服务端直链的下载需求。
3. 落地实现:从封装一个统一下载入口开始
3.1 前端下载能力探测与路由
所有方案的前提是能准确判断"我现在在哪个容器里"。这一步做错,后面全白搭。
const env = { get isIOSWebView() { const ua = navigator.userAgent; const isIOS = /iP(hone|ad|od)/.test(ua); const notSafari = !/Safari/.test(ua) || /CriOS|FxiOS/.test(ua); return isIOS && notSafari; }, get hasIOSBridge() { return !!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.fileDownload); }, get hasAndroidBridge() { return !!(window.AndroidDownload && typeof window.AndroidDownload.start === 'function'); }, get hasNativeBridge() { return this.hasIOSBridge || this.hasAndroidBridge; } };这里有两个经验:不要用 UA 里的 App 标识做判断,那个字符串随时可能被改;以桥对象是否存在为准。桥存在意味着原生版本已经支持,比 UA 靠谱得多。
然后是统一入口:
async function saveBlob(blob, filename, mime) { // 优先级 1:桌面浏览器,走原生 anchor if (!env.isIOSWebView && !isAndroidWebView()) { return downloadByAnchor(blob, filename); } // 优先级 2:有桥,分片传给原生 if (env.hasNativeBridge) { return streamToNative(blob, filename, mime); } // 优先级 3:兜底,上传换直链 const url = await uploadAndGetSignedUrl(blob, filename); window.location.href = url; }注意saveBlob是async的,这意味着调用方必须在用户点击的同步栈里就把它调起来,中间不要插入await。我一般的写法是:点击事件的第一个语句就是saveBlob(...),数据准备放在 Promise 里作为参数传进去,而不是先await数据再调用。
3.2 Blob 分片读取的关键参数计算
这是整个方案里最需要动脑子的部分,参数选错就是线上崩溃。
先把内存账算清楚。假设文件 100MB:
- 原始字节:100MB
- 转成 base64 字符串:约 133MB(4/3 膨胀)
- 如果一次性传给原生,JSON 序列化/反序列化又各产生一份,峰值轻松超过 400MB
- iOS 的 WebContent 进程有严格的内存上限,几百 MB 就可能被系统直接杀掉,表现为"页面突然白屏重载"
所以必须分片。分片的参数有三个:片大小、并发数、编码方式。
片大小的约束来自两边。太小的话,每片的消息传递开销(序列化、跨进程 IPC)占比过高,100MB 分成 1KB 一片就是 10 万次 IPC,直接卡死。太大又回到内存问题。我的经验值是2MB 到 4MB 之间,实测 3MB 左右最平衡。
但这里藏着一个极其容易踩的坑:如果原生侧是把所有 base64 片段拼成一个长字符串再统一解码,那么每片的字节数必须是 3 的倍数。因为 base64 的编码单位是 3 字节 → 4 字符,片尾不整除就会补=填充符,拼接后中间的填充符会让整段解码错位,最终文件损坏——而且这种损坏往往只在文件尾部体现,前面几百 KB 看着都正常,非常隐蔽。
两种解法:
- 解法一:片长取 3 的倍数,比如
3 * 1024 * 1024 = 3145728字节。 - 解法二(我更推荐):原生侧逐片解码后直接 append 到文件句柄,压根不做字符串拼接。这样完全没有对齐问题,而且原生内存占用也是恒定的。
const CHUNK_SIZE = 3 * 1024 * 1024; // 3MB,同时是 3 的倍数 async function streamToNative(blob, filename, mime) { const total = blob.size; const token = `${Date.now()}_${Math.random().toString(36).slice(2)}`; // 开场:告诉原生要开始写了 await callNative('begin', { token, filename, mime, total }); try { let offset = 0; let seq = 0; while (offset < total) { const end = Math.min(offset + CHUNK_SIZE, total); const buf = await blob.slice(offset, end).arrayBuffer(); const b64 = uint8ToBase64(new Uint8Array(buf)); await callNative('chunk', { token, seq: seq++, data: b64 }); offset = end; } await callNative('end', { token }); return { ok: true }; } catch (e) { await callNative('abort', { token }); throw e; } } function uint8ToBase64(bytes) { let binary = ''; const step = 0x8000; // 分块避免 apply 参数过多 for (let i = 0; i < bytes.length; i += step) { binary += String.fromCharCode.apply(null, bytes.subarray(i, i + step)); } return btoa(binary); }uint8ToBase64里的step是个细节:直接String.fromCharCode.apply(null, bytes)在字节数组很大时会因为参数个数超限抛RangeError。0x8000 是经验安全值。
关于并发:一定不要并发传片。原因有两个:一是原生写文件的顺序必须严格保证,并发会导致数据错乱;二是并发会同时占用多份内存,反而更容易崩。串行传,配合await,稳稳当当。
再算一下不同文件大小的内存峰值(估算值,实际受引擎实现影响):
| 文件大小 | 一次性 base64 峰值 | 3MB 分片峰值 | 说明 |
|---|---|---|---|
| 10MB | 约 40MB | 约 10MB | 一次性也能扛 |
| 100MB | 约 400MB | 约 12MB | 一次性高危 |
| 500MB | 必崩 | 约 14MB | 只能分片 |
这张表是我推荐分片方案最直接的理由:分片把内存峰值从"随文件线性增长"变成了"恒定常量"。
3.3 原生侧桥接与文件落盘
WKWebView 侧注册消息处理器,注意用弱引用避免循环引用,这是个老生常谈但总有人忘的点。
final class DownloadBridge: NSObject, WKScriptMessageHandler { private weak var webView: WKWebView? private var sessions: [String: FileSession] = [:] private let queue = DispatchQueue(label: "download.bridge") init(webView: WKWebView) { self.webView = webView super.init() } func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { guard let body = message.body as? [String: Any], let action = body["action"] as? String, let token = body["token"] as? String else { return } switch action { case "begin": guard let name = body["filename"] as? String else { return } let dir = FileManager.default.temporaryDirectory .appendingPathComponent("downloads", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let safeName = sanitize(name) let url = dir.appendingPathComponent("\(token)_\(safeName)") FileManager.default.createFile(atPath: url.path, contents: nil) if let handle = try? FileHandle(forWritingTo: url) { sessions[token] = FileSession(url: url, handle: handle) } case "chunk": guard let session = sessions[token], let b64 = body["data"] as? String, let data = Data(base64Encoded: b64) else { return } queue.sync { try? session.handle.write(contentsOf: data) } case "end": guard let session = sessions[token] else { return } try? session.handle.close() sessions.removeValue(forKey: token) DispatchQueue.main.async { [weak self] in self?.presentShareSheet(for: session.url) } case "abort": guard let session = sessions[token] else { return } try? session.handle.close() try? FileManager.default.removeItem(at: session.url) sessions.removeValue(forKey: token) default: break } } private func sanitize(_ name: String) -> String { let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|") return name.components(separatedBy: invalid).joined(separator: "_") } }这里有三个实操细节。
第一,写文件一定要串行。我用了一个专用串行队列,因为WKScriptMessageHandler的回调虽然通常在同一个线程,但不要赌这个假设。文件句柄并发写会导致数据交错,最终文件损坏。
第二,文件名必须做净化。用户或后端给的文件名里带/、:、*这类字符,appendingPathComponent会直接把它当成路径分隔符,写着写着写到别的目录去了,甚至可能覆盖其他文件。这是安全底线,不能省。
第三,落盘目录用临时目录而不是 Documents。临时目录不会被系统备份到云端(省用户空间),而且系统会在空间不足时自动清理。用户真正需要的动作是通过分享面板导出,所以写完立刻弹UIActivityViewController让用户"存到文件"或者发给别人,比直接塞进沙盒更符合使用习惯。
private func presentShareSheet(for url: URL) { guard let root = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }) .first?.windows.first(where: { $0.isKeyWindow })?.rootViewController else { return } let vc = UIActivityViewController(activityItems: [url], applicationActivities: nil) root.present(vc, animated: true) }安卓侧对应的是addJavascriptInterface注入对象,方法签名用@JavascriptInterface标注,参数同样传 base64 字符串,用Base64.decode解出字节再FileOutputStream追加。逻辑一致,不再展开。
3.4 WKDownload 处理常规 http 下载的配置
路线 D 的完整配置,重点在WKDownloadDelegate的落点指定:
extension DownloadCoordinator: WKDownloadDelegate { @available(iOS 14.5, *) func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("downloads", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let dest = dir.appendingPathComponent(sanitize(suggestedFilename)) try? FileManager.default.removeItem(at: dest) // 重名先清掉 completionHandler(dest) } @available(iOS 14.5, *) func downloadDidFinish(_ download: WKDownload) { // 通知前端 / 弹分享 } @available(iOS 14.5, *) func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { // resumeData 可缓存用于续传 } }suggestedFilename是系统从Content-Disposition或者 URL 末段推断的,中文文件名在这里大概率是乱码,因为 HTTP 头里的中文需要filename*=UTF-8''...这种 RFC 5987 编码格式,服务端不规范就会出问题。所以要么后端把编码做对,要么原生侧忽略它、自己从 URL 参数里取文件名。
3.5 端到端时序与失败回退
把整条链路串起来看,一次完整的 blob 下载是这样的:
- 用户点击"导出",JS 同步调用
saveBlob,此时仍在手势栈内 - 探测环境,命中 WKWebView + 有桥,进入
streamToNative - 发
begin消息,原生创建文件句柄和会话 - 循环
slice → arrayBuffer → base64 → chunk,串行发送 - 发
end,原生关闭句柄、弹分享面板 - 任何一步抛错,发
abort,原生删掉半成品文件
失败回退的顺序是:桥调用超时(比如 10 秒无响应)→ 判定桥不可用 → 降级到上传方案。这个超时判断很重要,因为老版本 App 里可能桥对象存在但方法签名对不上,属于"看着有实际调不通"的情况,光判断typeof不够。
还有一个被忽略的回退场景:用户中途取消。用户点了导出之后又反悔,切走了页面,这时候分片还在传。要在beforeunload或者页面隐藏时发一个abort,否则临时目录里会堆一堆半截文件。
4. 踩坑实录:那些没写在文档里的细节
4.1 静默失败与排查顺序
"点了没反应"是这类问题最典型的症状,我整理了一套固定的排查顺序,按这个走基本十分钟内能定位。
第一步,看有没有任何网络请求。打开 Safari 的远程调试连真机,Network 面板如果干干净净,说明根本没发起请求,那基本可以断定是 blob URL 走不了原生下载,方向锁定在"必须用桥"。
第二步,在点击回调里打日志。如果日志都没打出来,问题在事件绑定层面(比如按钮被透明层遮住了、或者用了pointer-events: none),跟下载没关系。如果日志打了但代码没进到a.click(),看是不是前面有await把手势栈断了。
第三步,检查createObjectURL是否成功。URL.createObjectURL传错参数(比如传了个普通对象而不是 Blob)会抛TypeError,但这个错如果被try/catch吞了,表现就是完全静默。
第四步,验证文件本身有效。有个很阴的情况:blob 是拿到了,但内容其实是后端返回的错误 JSON(比如 401 的响应体被当成文件内容下载了)。用户打开一看是乱码,反馈"下载的文件坏了"。所以下载前最好校验一下响应状态和Content-Type。
我把这几个现象和根因做成了对照表:
| 现象 | 最可能根因 | 快速验证方式 |
|---|---|---|
| 点击完全无反应无日志 | 事件未触发 / 被遮挡 | 在回调首行console.log |
| 有日志但无下载 | blob URL 在该容器不可下载 | 看 Network 有无请求 |
| 有下载但文件损坏 | 分片 base64 对齐问题 | 比对文件大小与原始 size |
| 有下载但文件是 JSON | 错误响应被当文件 | 检查response.ok |
| 下载中途页面白屏 | WebContent 内存超限 | 看文件大小是否超 100MB |
| 文件名变成随机串 | 跨域导致 download 被忽略 | 检查请求是否同源 |
4.2 大文件内存与崩溃
这是最需要提前设计的一块。我遇到过最典型的一次:导出 200MB 的日志包,页面在进度到 60% 左右直接白屏重载,日志全都断了。
原因是当时还没做分片,用的是FileReader.readAsDataURL一次性读整个 blob。readAsDataURL的结果是一个 267MB 的字符串,再加上原始的 blob 数据、以及后续postMessage时的结构化克隆,峰值直接冲破 WebContent 的限制。
改进后的做法:
- 强制分片,片大小 3MB,串行发送
- 每片发完主动
await一个微任务,给 GC 一点喘息机会 - 进度条按片更新,不要按字节更新(否则 DOM 操作本身也是负担)
- 超过阈值(我定的是 200MB)直接拒绝走前端方案,提示用户"文件较大,将改用链接下载",直接切到路线 C
关于阈值怎么定,可以这样估:设单次允许的额外内存为M(保守取 100MB),片大小为C,则峰值约为3C(原始片 + base64 片 + 序列化副本),所以C只要不超过M/3 ≈ 33MB就在安全区。取 3MB 是留了十倍余量。
4.3 文件名、MIME 与打开方式
这三件事经常被混为一谈,其实各管一摊,缺一个用户就懵。
文件名决定用户看到什么。中文文件名在 JS 侧没问题(a.download直接接受),但一旦经过桥传到原生,要注意两件事:JS 字符串到 SwiftString是正常的 UTF-8,不需要额外解码;但如果文件名是从 URL 或者响应头里取出来的,那就要先decodeURIComponent。我自己定的规范是:前端统一传已经解码后的纯文件名,原生侧只做字符净化不做解码,责任清晰,不容易重复解码出错。
MIME 类型决定文件打开后是什么样子。iOS 的分享面板会读文件扩展名来决定图标和可用的分享方式,所以文件名后缀必须和真实格式一致。常见错误是后端返回application/octet-stream,前端原样透传,结果系统识别不出类型,分享出去对方打不开。我的做法是维护一张扩展名到 MIME 的映射,begin消息里同时传文件名和 MIME,原生侧以扩展名为准,MIME 只作为兜底。
打开方式,这个最容易被忽略。iOS 上UIActivityViewController给出的选项取决于文件类型:Excel 会给出"拷贝到某办公应用",PDF 会给"打印",图片会给"存储图像"。如果文件类型识别错了,用户看到的选项会非常奇怪,进而认为"你们这个功能做坏了"。所以别在类型上偷懒。
4.4 常见问题速查表
再补一组实际被问到最多的,都是些边角但很烦人的事:
| 问题 | 现象 | 处理方式 |
|---|---|---|
| Safari 上重复下载同一个文件失败 | 第二次点击无效 | 每次新建 objectURL,别复用 |
| 下载后立刻 revoke 导致失败 | 部分内核下载中断 | 延迟 60s 或依赖页面卸载回收 |
| 分享面板弹不出来 | rootViewController拿错 | 从keyWindow的 root 往上找顶层 presented |
| 文件名带 emoji 或特殊符号乱码 | 字符集不一致 | 前端encodeURIComponent后传,原生removingPercentEncoding |
| 分片传到一半 App 被切后台 | 写入中断、文件不完整 | 监听didEnterBackground,中断即 abort 清理 |
| 安卓侧 base64 解码报错 | JS 侧传了 URL-safe 变体 | 统一用标准 base64,+/不替换 |
| 下载的 CSV 用 Excel 打开乱码 | 缺少 BOM | 内容开头加\uFEFF |
表格里最后一条虽然不是 WebView 专属,但特别高频。CSV 加 BOM 这件事,前端在拼 blob 的时候顺手做了就行:
const csvBlob = new Blob(['\uFEFF' + csvText], { type: 'text/csv;charset=utf-8' });5. 把它做成基建:可复用的下载层设计
5.1 接口约定与降级策略
单点实现之后,下一步应该是抽成一层。我给下载层定的对外接口只有两个方法:
// 从已有 Blob 下载 downloadBlob(blob, { filename, mime, timeout = 10000 }) // 从远端地址下载(走路线 D 或直链) downloadUrl(url, { filename })内部实现把前面四条路线按优先级串起来,并且有明确的降级顺序和超时控制。关键是降级要快,不能让用户干等。我的超时定在 10 秒:begin消息发出去 10 秒没收到原生的任何响应,就判定桥失效,立刻切到上传方案,同时上报一条监控埋点。
埋点这块建议至少记三个字段:容器类型、命中的路线、文件大小区间。这三个维度足够定位绝大部分线上问题。我做过的统计里,移动端真正走通的绝大多数是路线 B,路线 C 的占比不到 10%,但因为它的失败率高,兜底价值反而更大——用户不会管你走的哪条路,他只要文件。
另外,接口设计上有个小地方值得斟酌:要不要返回 Promise。我倾向于返回,因为调用方需要知道成功还是失败来做 Toast 提示。但要注意,Promises 的 resolve 时机应该是"原生确认收到 end 消息",而不是"分片发完了"——后者只是发出去了,落盘可能还在进行。
5.2 回归验证与灰度
这块功能最难的地方在于它没法靠单元测试覆盖,因为一半逻辑在原生、一半在 WebContent,中间还隔着进程。我的做法是维护一份设备矩阵,每次改动都跑一遍:
- iOS 老版本系统(低于 14.5,验证降级)
- iOS 新版本系统(验证路线 B 和 D)
- 安卓各主流内核版本(验证 JS 接口注入)
- 桌面 Chrome / Safari(验证路线 A 不被破坏)
- 各种文件大小:10KB / 1MB / 50MB / 300MB
每个组合至少验证三件事:文件能下下来、文件内容正确(比对 MD5 或大小)、文件名和类型正确。第三条最容易被跳过,但它恰恰是用户最先看到的。
灰度上建议按功能开关控制,新方案先对内部账号开,再放 5%,观察埋点里的失败率再逐步放开。这里面有个细节:老版本 App 的用户会一直停在旧逻辑上,所以降级链里的路线 C 至少在两个大版本周期内不能删。
最后分享一个我在实际适配里踩出来的体会:这个需求的技术难点其实不在"怎么把文件传下去",而在"怎么在传不下去的时候不让用户看出来"。前端 blob 下载和 WKWebView 适配这两件事,真正耗时的永远是边界情况——老系统、弱网、超大文件、中途切后台。把降级路径设计得和主路径一样认真,这个功能才算真的稳。另外,如果你手上的项目还没做分片,建议先别急着上大文件功能,把 200MB 以上的入口直接关掉、引导到链接下载,比事后救火便宜得多。