我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

WKWebView 中前端 Blob 下载失效原因与适配方案

WKWebView 中前端 Blob 下载失效原因与适配方案 现在很多业务的后台导出、报表下载、附件保存都跑在 App 的内置 WebView 里而前端最顺手的做法就是用 Blob 构造文件再挂个a[download]。这套写法在桌面浏览器上几乎不会出问题一到 iOS 的 WKWebView 就变成点了没反应。这篇内容就把前端 blob 下载在WKWebView容器里的失效原因、排查链路和几条适配路线完整摊开讲适合做 H5 业务、混合开发、以及需要维护原生容器的同学参考。文中给的代码都是可以照着改的骨架不是伪代码。1. 先搞清楚 blob 下载在桌面浏览器里到底做了什么1.1 URL.createObjectURL 造出来的不是一个网络地址很多人对URL.createObjectURL的理解停留在生成一个链接这个说法容易误导。它生成的blob:https://xxx.com/8f3a-...本质上是当前文档进程内的一个句柄指向浏览器内存或磁盘缓存里那块二进制数据。你把这个 URL 贴到 Network 面板里是抓不到任何请求的因为它压根不走网络栈。这一点非常关键后面排查 WKWebView 的时候抓包里什么都没有恰恰是判断问题层级的第一条线索。浏览器内部对 blob URL 的处理是这样的createObjectURL在渲染进程注册一条映射记录a[download]被点击时浏览器主进程或下载管理器拿着这条记录回渲染进程取数据流然后写入磁盘。整条链路里数据的生产者是渲染进程消费者是下载管理器两者跨进程。桌面浏览器上这条跨进程通道是通的所以你没感觉。容器一换通道可能就断了。还有一个容易忽略的点createObjectURL产生的 URL 在文档卸载前不会自动释放大数据量场景下不revokeObjectURL就是实打实的内存泄漏。我见过一个报表页面连续导出十几次之后标签页直接白屏最后定位就是每个 blob 都挂在内存里没释放。1.2 a[download] 这个属性并不是所有内核都买账download属性在 HTML 规范里属于建议性的各内核实现程度差别很大。桌面 Chrome、Edge、Firefox 支持得比较完整能改文件名、能跨 MIME 类型强制下载。但到了移动端容器内核情况就碎了环境a[download] blob说明桌面 Chrome / Edge支持文件名、类型都能识别Android 系统 WebView较新版本支持依赖内核版本老版本会退化成打开预览iOS Safari独立浏览器部分支持有下载管理器行为与系统版本强相关iOS WKWebViewApp 内置不支持属性被忽略点击后往往什么都不发生各类 App 内置容器视封装而定二次封装后行为更不可控注意表格最后两行的区别。Safari 作为独立浏览器系统层面有一套下载管理器在接 blob而 WKWebView 是给 App 嵌页面用的它默认没有下载这个用户动作的概念页面里的下载意图没有接收方。所以你会看到一个很割裂的现象同一个页面的同一个按钮用户用 Safari 打开能下载用 App 打开就不行。这不是代码写错了是容器能力不对等。2. WKWebView 上点击没反应问题出在容器而不是你的代码2.1 从一次真实现象说起桌面正常iOS 上像没点过我之前接手过一个后台管理系统的移动端适配导出按钮的原始实现非常标准const blob new Blob([respData], { type: application/vnd.ms-excel }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download 导出报表.xls; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);在浏览器里测试毫无问题文件正常落盘。打包进 App 之后iOS 用户反馈点了没反应安卓用户反馈能下但是文件名是一串乱码。更迷惑的是iOS 上偶尔会跳到一个全白的页面地址栏显示一长串blob:开头的东西。这种现象其实是三个不同的失败点叠在一起下载意图无人接收iOS 完全不响应、预览回退把 blob 当普通链接导航了、文件名丢失download属性被忽略后容器自己猜名字猜不出来就用 UUID。把这三个拆开看适配思路就清楚了。2.2 WKWebView 对 blob: 协议的处理链路断在哪WKWebView 的架构是多进程的页面渲染跑在 WebContent 进程网络与下载相关的调度跑在 UI 侧或独立的网络进程。blob:的数据存在 WebContent 进程的内存里而下载动作需要由 UI 侧发起。中间这条取数据的通道在 WKWebView 的默认实现里是没有打通的。具体表现是a.click()触发导航时WebKit 内部会判断这个 URL scheme 是否可下载。blob:属于可以显示但不可下载的类型于是它退化成一次同页面导航把二进制当文本渲染于是就出现了白屏或者乱码页。而在 iOS 14.5 之后系统引入了WKDownload情况有改善但改善的是 http(s) 链接的下载能力blob 依然不在覆盖范围内因为 blob 不产生真实的网络请求WKDownload拿不到可用的请求描述。还有一个常被问到的点data:URL 行不行答案是更不行。WKWebView 默认禁止data:的顶层导航点击后大概率直接被丢弃而且大文件转成 dataURL 之后字符串体积会膨胀三分之一以上内存扛不住。2.3 微信、企业微信这类内置容器的二次封装除了裸的 WKWebView更麻烦的是各种 App 自己的 WebView 封装。这类容器通常会在原生侧加一层 URL 拦截用来做白名单、做路由、做登录态校验。你点击 blob 链接可能被这层拦截当成非法跳转直接拦掉连日志都不打。判断方法很简单在页面里挂一个beforeunload或者用location.href主动跳一下blob:URL看容器有没有反应。如果连导航事件都不触发说明被容器上层拦了如果触发了但被渲染成白页说明是 WebKit 层面的问题。这两种情况的处理方向完全不同前者要找容器侧放开后者只能前端换方案。另外这类容器往往提供了自己的下载桥接方法比如挂在window上的某个对象里有个saveFile之类的方法。接手项目时先翻一遍已有的 bridge 定义很多时候答案就在那儿不需要自己造轮子。3. 排查链路怎么把没反应定位到具体一层3.1 先确认当前是不是 WKWebView这一步听起来废话但真的有人搞错。iOS 上所有第三方浏览器包括用户装的 Chrome、Firefox用的都是 WKWebView 内核所以用户说用 Chrome 也不行并不能说明问题不在容器上。要区分的是独立浏览器和App 内置容器。UA 判断是最常用的手段。WKWebView 的 UA 里通常不含Safari/字段而 Safari 会带。但这个规则不绝对因为容器可以自定义 UA 追加标识function detectEnv() { const ua navigator.userAgent || ; const isIOS /iPad|iPhone|iPod/.test(ua) || (navigator.platform MacIntel navigator.maxTouchPoints 1); const hasSafariToken /Safari\//.test(ua); const hasBridge !!(window.webkit window.webkit.messageHandlers); return { isIOS, // 有原生桥基本可以断定是内置容器 isEmbedded: isIOS hasBridge, // 无 Safari 字段的 iOS 环境大概率是 WKWebView isLikelyWKWebView: isIOS !hasSafariToken, hasBridge }; }我更推荐以桥的存在与否作为主判据。因为业务上真正要决策的是能不能走原生兜底而不是内核叫什么名字。只要window.webkit.messageHandlers.xxx存在就说明这条路能走其余的都是次要信息。3.2 Safari 远程调试里能看到什么、看不到什么在 Mac 上开 Safari 的开发菜单连上设备可以在开发里选到对应的 WebView 页面。这一步很多人做了但不知道重点看什么。重点看的是 Network 面板。你会发现点击导出按钮时Network 里一条记录都没有。这不是抓包工具坏了而是 blob URL 不走网络。这条空记录就是铁证说明前端代码执行到了a.click()但没有任何资源请求发出去。如果连 console 都没有任何报错那就更能确认——代码没错是容器的下载能力缺失。反过来如果 Network 里出现了一条请求但是返回 4xx/5xx那就是接口问题跟 blob 一点关系都没有别往容器方向排查。还有一个细节远程调试时可以在 console 里手动执行document.createElement(a)那一串代码观察点击后的行为。如果手动执行也没反应就能排除业务代码里某些条件判断导致的短路。3.3 用最小复现页把变量一个个掐掉我排查这类问题的习惯是先做一个最小复现页只有一段写死的字符串和一个按钮button idttest/button script document.getElementById(t).onclick () { const blob new Blob([hello], { type: text/plain }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download test.txt; a.click(); }; /script这个页面在目标容器里跑一遍三种结果对应三条路径能下载说明容器支持原业务的问题出在 MIME 类型、文件名编码或者数据构造上。跳白页容器把 blob 当页面导航了需要走原生桥或者服务端直链。毫无反应下载意图被完全丢弃只能换方案。用最小复现页的好处是能把接口返回慢数据量大文件名带中文这些干扰项全部剔除。我遇到过好几次最后发现是接口返回的Content-Type被前端错误地写成了text/html容器当成网页去渲染了跟 blob 本身没关系。4. 四条适配路线按改造成本从低到高排4.1 路线一把 blob 换成 dataURL成本最低但有大文件天花板思路很简单不进下载管理器直接把文件内容编码成data:字符串塞给a.href。这条路在部分容器上能骗过导航逻辑但前提是容器没有禁掉 dataURL 导航。function downloadByDataURL(blob, fileName) { return new Promise((resolve, reject) { const reader new FileReader(); reader.onload () { const a document.createElement(a); a.href reader.result; a.download fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); resolve(); }; reader.onerror reject; reader.readAsDataURL(blob); }); }这条路我不太推荐用在 WKWebView 上原因有两个。第一data:顶层导航在 WKWebView 里默认是被拦的只有部分版本宽松。第二体积问题很致命一个 10MB 的文件编码成 base64 之后字符串约 13.3MB再叠加 FileReader 的中间态内存峰值能到 30MB 以上低端机直接闪退。它适合做小文件的降级方案比如几 KB 的 txt、csv不适合做主方案。如果你确实要用务必加个体积阈值超过 5MB 直接走别的路if (blob.size 5 * 1024 * 1024) { return fallbackToBridge(blob, fileName); }4.2 路线二JS Bridge 把二进制交给原生落地这是成功率最高的方案前提是容器方愿意配合。核心逻辑是前端把文件读成 base64通过 bridge 传给原生原生负责写临时文件然后弹系统分享面板或者存相册/文件。前端侧async function saveViaBridge(blob, fileName, mimeType) { const base64 await blobToBase64(blob); const bridge window.webkit?.messageHandlers?.fileBridge; if (!bridge) throw new Error(bridge not ready); bridge.postMessage({ action: saveFile, fileName, mimeType, base64 }); } function blobToBase64(blob) { return new Promise((resolve, reject) { const reader new FileReader(); reader.onload () { // 去掉 data:xxx;base64, 前缀 const result String(reader.result); resolve(result.slice(result.indexOf(,) 1)); }; reader.onerror reject; reader.readAsDataURL(blob); }); }原生侧大致是接收 base64、解码、写临时目录、弹分享func handleSaveFile(_ body: [String: Any]) { guard let base64 body[base64] as? String, let fileName body[fileName] as? String, let data Data(base64Encoded: base64) else { return } let dir FileManager.default.temporaryDirectory let fileURL dir.appendingPathComponent(fileName) do { try data.write(to: fileURL, options: .atomic) DispatchQueue.main.async { let vc UIActivityViewController(activityItems: [fileURL], applicationActivities: nil) // iPad 上必须给 popover 锚点否则直接崩 if let pop vc.popoverPresentationController { pop.sourceView self.view pop.sourceRect CGRect(x: self.view.bounds.midX, y: self.view.bounds.midY, width: 0, height: 0) pop.permittedArrowDirections [] } self.present(vc, animated: true) } } catch { // 写失败要回传前端别静默吞掉 } }提示postMessage有消息体大小上限实测传递超过几十 MB 的 base64 字符串会出现消息丢失或者 WebContent 进程被杀。大文件必须分片每片控制在 2MB 以内原生侧按序号拼接。这个方案的代价是需要原生配合改代码、发版本节奏上可能排不上。而且分片逻辑要前端原生一起联调比想象中麻烦。好处是全平台统一安卓侧可以复用同一套前端代码只要原生侧各自实现一遍接收端。4.3 路线三服务端出直链前端只负责跳转如果后端能配合这条路其实最干净。思路是不在前端拼文件而是让后端生成一个带一次性 token 的下载地址前端用location.href跳过去剩下的事交给容器的导航能力。流程大致是前端把导出参数 POST 给后端后端生成文件并返回一个带签名的 URL。前端执行location.href downloadUrl。WKWebView 侧实现decidePolicyFor回调遇到shouldPerformDownload为真的导航返回.download。实现WKDownloadDelegate的decideDestination指定落地路径。func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: escaping (WKNavigationActionPolicy) - Void) { if navigationAction.shouldPerformDownload { decisionHandler(.download) } else { decisionHandler(.allow) } } func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { download.delegate self } func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: escaping (URL?) - Void) { let dest FileManager.default.temporaryDirectory .appendingPathComponent(suggestedFilename) completionHandler(dest) }这条路的坑在于shouldPerformDownload的判断逻辑。如果后端没在响应头里给对Content-Disposition: attachment这个值就是 false会变成页面内预览。所以后端头的设置必须配合到位缺一不可。响应头推荐值作用Content-Typeapplication/octet-stream或具体 MIME避免被当网页渲染Content-Dispositionattachment; filename*UTF-8xxx触发下载意图并传递文件名Content-Length真实字节数进度条与超时判断依赖它Cache-Controlno-store避免命中旧文件4.4 路线四iOS 14.5 之后的 WKDownload 能用到什么程度WKDownload从 iOS 14.5 开始提供但它解决的是网页里有真实 http(s) 链接要下载这个场景不解决 blob。这一点我必须说清楚因为网上很多文章把两者混为一谈照着改完之后发现还是不行。能用的边界是真实网络地址 Content-Disposition: attachment可以用且体验接近浏览器。blob:地址仍然不可用因为不产生 navigationAction。data:地址仍然不可用且被安全策略拦截。所以如果你的项目必须保留 blob 方案WKDownload帮不上忙如果你能改造后端出直链那它就是最优解。这也是我建议在架构评审阶段就把下载能力作为容器准入条件之一的原因——等到业务上线再来改成本翻好几倍。5. 文件名、MIME 和内存那些不报错但结果不对的细节5.1 中文文件名在分享面板里变成乱码这个问题在走原生桥的方案里特别常见。前端传导出报表.xlsx过去原生写文件的时候用了错误的编码最后落地成了%E5%AF%BC%E5%87%BA...用户看到一串百分号编码。原因通常是某一层做了encodeURIComponent但没有对应的解码。处理原则是跨层传递用 UTF-8 原样字符串只在生成 HTTP 响应头时做编码。Content-Disposition里的中文按 RFC 5987 走filename*UTF-8格式Content-Disposition: attachment; filenamereport.xlsx; filename*UTF-8%E6%8A%A5%E8%A1%A8.xlsx两个字段都写是为了兼容老客户端filename给 ASCII 兜底filename*UTF-8给新客户端用。前端侧拿到的a.download值直接写中文即可不需要额外编码浏览器自己会处理。如果发现容器里名称还是丢那就是容器忽略了download属性只能靠原生侧指定文件名。注意某些容器在分享面板里显示的标题取自文件的扩展名关联如果 MIME 和扩展名不匹配系统会显示成未知文件。比如你写的是.xlsx但 MIME 给的是application/octet-stream分享面板上图标和名称都会怪怪的。5.2 base64 传输的体积膨胀与分片策略base64 的膨胀率是 4/3也就是原始 3 字节变 4 字符。100MB 的文件转完是 133MB 的字符串这个量级在 JS 引擎里已经不是有点大的问题而是会直接触发内存回收风暴页面卡死十几秒。分片的实现思路是这样的const CHUNK 2 * 1024 * 1024; async function saveInChunks(blob, fileName, bridge) { const total Math.ceil(blob.size / CHUNK); const sessionId ${Date.now()}_${Math.random().toString(36).slice(2)}; bridge.postMessage({ action: begin, sessionId, fileName, total }); for (let i 0; i total; i) { const slice blob.slice(i * CHUNK, (i 1) * CHUNK); const base64 await blobToBase64(slice); bridge.postMessage({ action: chunk, sessionId, index: i, base64 }); // 给主线程喘息时间避免长时间阻塞 await new Promise(r setTimeout(r, 0)); } bridge.postMessage({ action: end, sessionId }); }这里的setTimeout(r, 0)不是凑数它让出主线程避免连续FileReader读取把页面卡成幻灯片。原生侧要按sessionId维护一个写入流边收边写磁盘不要全收完再写否则内存峰值一样扛不住。5.3 大文件在 WKWebView 里的内存红线WKWebView 的 WebContent 进程有独立的内存上限具体数值随设备和系统版本浮动但经验值是超过 100MB 到 150MB 的常驻占用就有被系统杀的风险表现出来就是页面突然变白、然后重新加载。你在日志里看到WebContent process crashed或者didWebContentProcessTerminate基本就是这个原因。所以判断标准很明确文件体积推荐方案理由 1MBblob a[download]或 dataURL内存压力小实现简单1MB ~ 20MB原生桥单次传 base64消息体能扛住体验可接受20MB ~ 100MB原生桥分片传输必须边收边写避免峰值 100MB服务端直链 WKDownload不经过 JS 内存最稳这张表是我踩了几轮之后总结的不是官方文档给的。边界值可以上下浮动但量级判断是靠谱的。真正做业务的时候我更倾向于在文件生成阶段就控制体积比如导出 Excel 时限制行数、分批导出而不是等文件撑爆了再想办法。6. 安卓侧顺带要补的坑6.1 onDownloadStart 对 blob 完全不触发很多人以为安卓侧只要能下载 blob 就万事大吉实际上系统 WebView 的DownloadListener在遇到blob:时压根不会回调因为拦截发生在更早的层面。表现出来和 iOS 类似点了没反应。所以前端如果只针对 iOS 做判断安卓用户一样会炸。在安卓侧的通用兜底是在DownloadListener里对 blob 做特殊处理注入 JS 读取内容再回传webView.setDownloadListener((url, userAgent, contentDisposition, mimeType, contentLength) - { if (url ! null url.startsWith(blob:)) { String js javascript:(function(){ var xnew XMLHttpRequest(); x.open(GET, url ,true); x.responseTypeblob; x.onloadfunction(){ var rnew FileReader(); r.onloadendfunction(){ window.AndroidBridge.onBlobBase64(r.result.split(,)[1]); };r.readAsDataURL(x.response); };x.send();})(); webView.evaluateJavascript(js, null); } else { // 走常规 http(s) 下载 } });注意XMLHttpRequest读取blob:URL 在安卓 WebView 里是可行的同源策略对 blob URL 是放行的。但这个法子对超大文件同样有内存问题思路还是先分片。更省事的做法是把前端代码统一起来不管 iOS 还是安卓只要检测到是内置容器就走同一套 bridge 分片方案。这样维护成本最低也不会出现iOS 修好了安卓又坏了的循环。6.2 下载目录与 scoped storage 的兼容安卓 10 之后的分区存储策略变化让把文件写到公共下载目录这件事变得比以前麻烦。如果你的原生侧实现是直接往Environment.getExternalStoragePublicDirectory写在 Android 10 以上会被拒。正确做法是用MediaStore.Downloads或者应用私有目录配合 FileProvider让系统文件管理器和分享面板能访问到。这个问题在走 bridge 方案时特别容易暴露iOS 上写完临时目录弹UIActivityViewController一切正常安卓上写完私有目录弹分享却提示文件不存在因为分享的 URI 没做 FileProvider 授权。处理方式是在AndroidManifest里声明 provider写文件后生成content://形式的 URI 再交给分享组件。说到底blob 下载这件事在前端代码里只占十几行真正花时间的是容器适配和文件落地这一段。我的建议是新项目在设计阶段就把下载当成一个独立能力来规划明确哪些场景走直链、哪些走桥、体积上限是多少写进接口文档里。等到业务方跑来说用户导不出报表再临时补往往要连着发两个版本的客户端中间还得给用户解释非常被动。如果你现在手上就有一个点了没反应的按钮先别急着改代码按第 3 章那套最小复现页跑一遍确认清楚卡在哪一层。这一步花二十分钟能省掉后面两天的瞎猜。我自己现在的习惯是在每个混合开发项目里都放一个debug/download-test.html专门用来验证容器的下载能力换容器、升系统版本的时候跑一遍比什么都直观。
返回列表