1. 先搞清楚"打不开"到底是哪一步断了
做 uni-app 项目这些年,被问得最多的一类问题就是"资源下载一直走 fail",而且问的人往往给的信息极少,只有一句"我这边下载不了,你能帮我看看吗"。我接过太多这样的现场,最后发现真正的原因五花八门:有的是小程序后台没配下载域名,有的是服务端返回了一个 302 跳到了另一个域,有的是 PDF 下载成功了但 openDocument 传的路径没带后缀,还有的是文件确实落盘了,但用户拿到的是一段 HTML 错误页,打开自然失败。
在 uni-app 里,uni.downloadFile走的是一条跨端差异极大的链路,小程序端、App 端、H5 端三套实现几乎是三份不同的代码。同一个 URL,在开发者工具里能下、在真机上 fail,在 Android 上能开、在 iOS 上白屏,这些都不是玄学,而是链路某一环在某一端不满足条件。所以第一步永远不是改代码,而是把"下载"和"打开"拆成两条独立的链路分别验证——下载失败和打开失败,报错长得像,根因完全不在一个层面。
这篇内容我打算按我自己排查的顺序来讲:先拆链路,再分类根因,然后给一套可复现的五步排查法,最后上一份能直接抄进项目的健壮封装。适合正在被这个问题卡住的同学,也适合想把下载模块一次性做扎实的团队参考。文中涉及的路径、参数、限制,我会尽量标清楚适用端,避免你照着抄却在另一个端翻车。
1.1 下载链路的四个环节与各自的失败表现
我把 uni-app 里"查看下载资源"这件事拆成四段:请求发出 → 响应接收 → 文件落盘 → 打开预览。这四段任何一段断了,用户的体感都是"打不开",但表现完全不同,学会看表现就能反推环节。
第一段请求发出,失败通常是fail url not in domain list、fail -1202、fail timeout这类,回调里连 statusCode 都没有,说明请求根本没到服务端或者 TLS 握手就死了。第二段响应接收,典型是statusCode: 403/404/302,或者 statusCode 是 200 但文件大小是几百字节——这基本可以断定拿到的是错误页而不是真文件。第三段落盘,报错多是saveFile:fail、exceed max file size、the maximum size of the file storage,或者安卓上权限相关的permission denied。第四段打开,报错形态又变了,openDocument:fail filetype not supported、openDocument:fail file not exist、openDocument:fail后面一串空字符串。
我习惯在封装里给每一段打一个阶段标记,比如stage: 'request' | 'response' | 'save' | 'open'。用户截图给我,我一眼就知道该往哪个方向查,而不是在四个环节里瞎猜。这个标记成本极低,但收益极高,强烈建议你在项目里也加上。
1.2 为什么 fail 的回调信息总是那么"没用"
很多人抱怨uni.downloadFile的 fail 回调信息太少,只有一个 errMsg 字符串。这确实是现实:小程序端把底层错误做了收敛,很多细节被吞掉了。但 errMsg 里其实藏着不少线索,只是大部分人没耐心看完整个字符串。
比如downloadFile:fail createDownloadTask:fail url not in domain list,关键信息在最后半句,是域名白名单问题;downloadFile:fail -118是连接超时;downloadFile:fail ssl hand shake error是证书链不完整或者用了自签证书;downloadFile:fail statusCode: 500是服务端炸了。这些信息如果不原样打出来,只打印一句"下载失败",那你等于自己把线索扔了。
我的做法是封装里把errMsg完整地console.log出来,并且在开发环境把它拼到 toast 里,真机调试时用 vConsole 看。生产环境则把 errMsg 上报到日志系统,按 errMsg 前缀做聚合,很快就能看出是白名单问题扎堆还是服务端 5xx 扎堆。这比一个笼统的"下载失败"有用一百倍,也是我后来复盘问题时最依赖的数据源。
2. 下载失败的六大类根因拆解
2.1 域名与网络层:白名单、证书、重定向
这是最高频的一类,尤其在小程序端。微信小程序的uni.downloadFile必须走 HTTPS,且域名要在管理后台的 downloadFile 合法域名里配置。这里有个经典陷阱:开发者工具里勾了"不校验合法域名",一切正常,一上真机全挂。我见过不止一个团队在提测当天才发现这个问题,原因就是没人用真机跑过一次下载。
配置域名时有几个细节容易被忽略。一是域名必须是备案过的、不能是 IP、不能带端口号(严格来说端口必须省略)、不能是 localhost。二是如果资源放在 CDN 上,CDN 域名和业务域名是两个完全不同的东西,你得把 CDN 域名也加进去。三是域名配置生效有延迟,改完之后不会立刻生效,我一般会等几分钟再验证,避免误判。
证书层面,小程序端要求 TLS 1.2 及以上,证书链要完整。有些 CDN 只下发叶子证书不下发中间证书,浏览器会自动补,但小程序客户端不一定补,结果就是ssl hand shake error。验证方法很简单:用openssl s_client -connect 域名:443 -servername 域名看一下返回的证书链是否完整,能不能 verify OK。这个命令我在排查证书问题上用了不知道多少次,比在代码里加一堆日志快得多。
重定向是另一个隐形杀手。服务端 302 跳到另一个域名时,部分端的实现会丢掉原始 header,尤其是带签名的资源,跳转后签名失效直接 403。还有一种情况是跳到 http,小程序端直接拒绝。我的建议是:下载链接一律返回最终地址,不要依赖跳转,让服务端在生成签名 URL 的时候就把真实地址算好。
2.2 服务端握手层:Content-Type、Content-Length 与状态码
文件"下载成功"但"打不开",十有八九是服务端返回的东西根本不是你以为的那个文件。最常见的场景是:资源需要登录态,没带 token 就返回 200 的登录页 HTML;或者防盗链校验失败,返回一个提示页。这两种情况下downloadFile的 success 回调照样会触发,因为 HTTP 层面确实是 200。
所以我在封装里做了一件事:下载完成后校验文件头字节。PDF 的头是%PDF,PNG 是89 50 4E 47,ZIP 系(包括 docx、xlsx)是50 4B 03 04,JPG 是FF D8 FF。小程序端可以用wx.getFileSystemManager().readFile读前几个字节,App 端也能用 plus.io 读。这一步能拦住大部分"假成功",而且实现成本就十几行代码。
Content-Type 也要看一眼。服务端如果返回text/html,基本可以判定是错误页;返回application/octet-stream是最稳的,表示"我就是二进制流";返回application/pdf、image/jpeg这类明确类型也好。有些运营配置的静态资源服务器没设 Content-Type,返回text/plain,小程序端有时会因此在打开阶段出怪问题。
Content-Length 缺失会导致进度条不准,但不会导致下载失败。不过如果你在 success 里用res.tempFilePath去读文件大小,发现和 Content-Length 差很多,那就要怀疑传输被截断了——这在弱网下不罕见,尤其是文件超过几十 MB 的时候。
2.3 客户端存储层:临时目录、保存策略与空间
uni.downloadFile成功后拿到的是tempFilePath,这是个临时路径。小程序端的临时文件只在本次小程序运行期间有效(官方说法是当前生命周期内),小程序被销毁后就没了。很多人把它存到 data 里,用户下次进来再点"打开"就file not exist,还以为是下载坏了,其实文件早被系统清了。
正确做法是下载完立刻uni.saveFile存到本地用户目录,拿到 savedFilePath 再持久化保存。这里又有一个坑:uni.saveFile的存储空间是有上限的,小程序端本地文件总大小约 200MB(不同基础库版本略有差异),写满之后 saveFile 会失败。我遇到过一次,用户反复下载同一批 PDF,每次都是新 URL 带不同签名参数,导致缓存无限增长,最后整个模块瘫痪。后来改成按业务 ID 做去重,同一个文件只存一份,问题才解决。
App 端(尤其是 Android)还涉及权限和分区存储。Android 10 以后,往公共目录写文件需要走系统的媒体库或者 SAF,直接写/sdcard/xxx会失败。uni-app 提供了uni.env.USER_DATA_PATH这类应用私有目录,走这个路径基本不会踩权限坑。iOS 则是沙箱机制,只能写自己沙箱内的目录,路径不能跨应用。所以我的封装里统一把落盘路径收敛到应用私有目录,不做"下载到手机下载文件夹"这种花活,稳定第一。
还有一个细节:uni.saveFile如果已经存在同路径文件,部分端会直接失败而不是覆盖。所以保存前最好用getSavedFileList查一下,或者每次用时间戳做文件名前缀,避免冲突。
2.4 打开环节:openDocument 支持哪些格式
下载成功但打不开,第二个高频原因是uni.openDocument的格式限制。小程序端支持的格式是有限的:doc、docx、xls、xlsx、ppt、pptx、pdf,图片和压缩包是不支持的。你要打开 png,得用uni.previewImage;要打开 zip,小程序端基本没戏,只能引导用户另存。
另一个大坑是filePath 必须带后缀名。openDocument是靠文件扩展名来判断用哪个渲染器打开的。临时路径有时候带后缀,有时候不带,尤其是服务端 URL 没有明确后缀的时候(比如/download?id=123),tempFilePath 可能是个没有扩展名的随机串,这时候openDocument会直接报filetype not supported。
解决办法有两个:一是保存文件时自己拼上正确的后缀,比如uni.env.USER_DATA_PATH + '/report_' + Date.now() + '.pdf';二是打开时显式传fileType参数,例如fileType: 'pdf'。我一般两个都做,双保险。fileType 的值要小写,不要写成PDF,部分版本对大小写敏感。
H5 端则是另一个世界:uni.downloadFile在 H5 上是基于 fetch/XHR 拿 blob,openDocument在 H5 上表现不稳定甚至不存在。H5 端更靠谱的做法是自己生成一个<a download>触发浏览器下载,或者用 pdf.js 这类库内联预览。如果你的项目多端发布,一定要在打开逻辑里按平台分支,不要指望一套代码走天下。
2.5 并发、大小与基础库版本限制
小程序端对downloadFile有并发限制,同一时间最多 10 个下载任务。批量下载场景里,如果你一次性发起 20 个请求,后面的会排队甚至直接失败。我一般会把并发控制在 3 到 5 个,既快又不容易触发限制。做一个简单的任务队列,用一个数组加计数器就能实现,几十行代码的事。
单文件大小也有上限。小程序端单个下载文件官方限制在 200MB 左右(不同版本有调整),超过就会 fail。如果你的资源里真有大的视频或者安装包,小程序端就不是合适的载体,应该改成在线播放或者引导到外部。这个判断要在产品设计阶段就做掉,别等开发完了才发现下不动。
基础库版本这一点经常被忽略。有些参数或者行为是新版本才支持的,比如某些 header 处理方式、某些错误码的具体含义。用户端的基础库版本参差不齐,老版本可能行为完全不一样。我在排查线上问题时,第一件事是让用户提供"微信版本 + 基础库版本",很多时候一对比就发现是版本差异导致的。开发时也建议在manifest.json里设置一个合理的最低基础库版本,把太老的挡在门外。
3. 可复现的排查流程:从 fail 到定位的五步法
3.1 第一步:把 fail 的完整对象打出来
很多人排查问题的第一步是猜,这是最低效的。我的第一步永远是让错误信息完整暴露。具体做法是在fail回调里打印JSON.stringify(err),不要只打印err.errMsg。有些端会在 err 上挂额外的字段,比如 statusCode、header,这些信息在 JSON 里能看到,在字符串拼接里就丢了。
打印出来之后,按我前面说的分类对号入座:有domain list就是白名单,有ssl就是证书,有statusCode就是服务端返回异常,什么都没有那就是网络层或者被系统拦了。这一步能把问题范围从"所有可能"缩小到"两三种可能",通常五分钟内能完成。
同时把请求的完整 URL 也打出来。我遇到过一个特别隐蔽的案例:URL 里带中文文件名,前端没有做 encodeURIComponent,某些端自动编码了一次,服务端又解码了一次,最后拿到的路径是错的,服务端返回 404。这种问题不看完整 URL 根本发现不了。所以我的调试日志模板固定是:[download] url=... stage=... err=...,一行看全。
3.2 第二步:用真机加抓包确认请求到底有没有出去
开发者工具的行为和真机差异非常大,白名单、证书、CORS 这三类问题在工具里基本都被屏蔽了。所以只要涉及下载,必须上真机。我的习惯是 Android 和 iOS 各测一台,因为这两端的网络栈、证书校验策略都不一样。
抓包的目的不是看返回值,而是看请求有没有真正发出去。如果抓包工具里根本看不到这条请求,那问题一定在客户端本地(域名没配、被拦截、协议不支持)。如果看到了请求但没看到响应,那是网络中断或者超时。如果看到了响应但状态码异常,那是服务端问题。这三种情况对应的排查方向完全不同,抓包能一次分清。
提一句,抓包工具在真机上的配置方法各平台不同,团队里最好有一个人专门维护这份配置文档,新人来了直接用,能省掉大量重复劳动。配置的时候注意只抓自己的测试环境流量,别把用户真实数据带进来,这是基本的规范意识。
3.3 第三步:落盘之后先验字节,再谈打开
前面提过文件头校验,这里展开说一下具体怎么落地。小程序端可以这样拿到前 4 个字节:
const fs = uni.getFileSystemManager() fs.readFile({ filePath: tempFilePath, position: 0, length: 4, success(res) { const bytes = new Uint8Array(res.data) const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') console.log('文件头:', hex) } })拿到十六进制串之后对照一下:25504446是%PDF,504b0304是 ZIP 系,89504e47是 PNG,ffd8ffe0到ffd8ffef是 JPG。如果读到的是3c21444f(<!DO)开头,那百分之百是 HTML 错误页,直接去查服务端鉴权和防盗链,别在客户端浪费时间了。
这一步的价值在于把"下载"和"打开"彻底解耦。文件头不对,说明下载环节就有问题,此时去调 openDocument 只会在打开环节报一个误导性的错,让你以为格式不支持,实际上是文件本身就不是那个格式。我见过太多人卡在这里,最后发现下载的是 403 页面。
App 端没有getFileSystemManager,可以用plus.io.resolveLocalFileSystemURL拿到 FileEntry,再用 FileReader 读,思路一样。也可以直接判断文件大小:如果下载的 PDF 只有 200 字节,那不用验字节也知道有问题。
3.4 第四步:分平台验证打开能力
确认文件是真的、字节是对的,再去验证打开。这一步要按平台分别测,因为三端实现完全不同。
小程序端重点看 filePath 的后缀和 fileType 参数,以及文件是否还在临时目录里。如果从下载到点击打开中间隔了很久,临时文件可能已经被清理,这时候要重新下载或者改成持久化保存。App 端重点看路径是否是应用沙箱内可访问的路径,另外 Android 上打开 office 文档依赖系统里有没有装对应的查看器,没装的话 openDocument 会失败,这种失败不是你的代码问题,但用户体验上需要兜底提示。H5 端基本别指望 openDocument,用新窗口打开 blob URL 或者后端转成可预览的链接更实际。
我的建议是写一个openFile的兼容函数,内部按uni.getSystemInfoSync().uniPlatform分支,每端走各自的实现,对外暴露统一接口。这样业务代码里只调一次,平台差异全在函数内部消化,后期维护成本低很多。
3.5 第五步:把结论固化成一份自检清单
排查完一个问题,我最怕的是下次又从头来一遍。所以每解决一个下载问题,我都会往团队的自检清单里加一条。清单长这样,分享给你:
| 检查项 | 判断方法 | 常见现象 |
|---|---|---|
| 域名是否在白名单 | 后台配置 + 真机验证 | 工具正常、真机 fail |
| 是否 HTTPS 且证书链完整 | openssl s_client 验证 | ssl hand shake error |
| 是否发生跨域重定向 | 抓包看 302 链路 | 403 或签名失效 |
| 返回内容是否是真文件 | 读文件头字节 | 打开提示格式不支持 |
| 文件后缀是否正确 | 打印 filePath | filetype not supported |
| 临时文件是否还在 | 检查文件是否存在 | file not exist |
| 本地存储是否写满 | 查已保存文件列表 | saveFile:fail |
| 并发是否超限 | 统计同时在跑的任务数 | 排队或直接失败 |
这份清单我贴在项目 wiki 里,新人第一次遇到下载问题就照着过一遍,基本能自己解决八成。剩下两成再去抓包和查日志。
4. 一套可直接抄的健壮下载封装
4.1 封装设计思路:状态机加分层重试
裸调uni.downloadFile最大的问题是状态分散,成功回调里干一堆事,失败回调里再干一堆,代码很快失控。我习惯把它收敛成一个带状态的对象,大致是idle → downloading → downloaded → saved → opened,每一步失败退回到上一步并带上原因。
分层重试是另一个关键设计。不是所有失败都值得重试:域名白名单失败重试一万次也没用,但网络超时重试两次通常就好了。所以我把错误分成两类,可重试(超时、连接中断、5xx)和不可重试(白名单、404、格式不支持),只有前者才进重试逻辑,重试间隔用指数退避,第一次 1 秒、第二次 2 秒,最多三次。
const RETRYABLE = ['timeout', '-118', 'statusCode: 500', 'statusCode: 502', 'statusCode: 503'] function isRetryable(errMsg = '') { return RETRYABLE.some(k => errMsg.includes(k)) } function downloadWithRetry(url, options = {}, retries = 3) { return new Promise((resolve, reject) => { const task = uni.downloadFile({ url, header: options.header || {}, timeout: options.timeout || 30000, success: (res) => { if (res.statusCode !== 200) { const e = `statusCode: ${res.statusCode}` return retries > 0 && isRetryable(e) ? setTimeout(() => downloadWithRetry(url, options, retries - 1).then(resolve, reject), 1000) : reject(new Error(e)) } resolve(res.tempFilePath) }, fail: (err) => { if (retries > 0 && isRetryable(err.errMsg)) { setTimeout(() => downloadWithRetry(url, options, retries - 1).then(resolve, reject), 1000) } else { reject(new Error(err.errMsg)) } } }) task.onProgressUpdate((p) => { options.onProgress && options.onProgress(p.progress) }) }) }这段代码没什么花哨的地方,但它把"重试"从散落各处的 if 里抽出来了。实际项目里我会再往上一层加一个任务队列,控制并发数,同时对上层的业务调用保持 Promise 接口,业务侧只关心 await 成功还是失败。
4.2 关键参数怎么定:超时、并发、重试次数
超时设多少合适?我一般设 30 秒。理由是小程序端默认超时偏短,大文件在弱网下容易误判失败。30 秒能覆盖大部分几十 MB 的文件在 4G 下的下载时间,同时不会让用户等太久。如果文件确实很大,可以按文件大小动态算:timeout = 10000 + fileSize / 50KB * 1000,粗算每 50KB 给 1 秒,再加 10 秒的基础值。
并发我控制在 3。10 个的理论上限是硬限制,但跑到 10 的时候单任务速度会明显下降,而且一旦有一个卡住,后面的全排着。3 到 5 是个比较舒服的区间,实测在批量下载 20 个小文件时,并发 3 比并发 10 的总耗时只多一点点,但成功率明显更高。这个数没有绝对标准,跟你的资源服务器能力和网络环境有关,可以先设 3,上线后看日志再调。
重试次数我设 3 次,加上首次一共 4 次机会。再多意义不大,如果是持续性问题,重试只会拖长用户的等待时间。而且要记住,重试期间要给用户可见的反馈,比如"正在重试(2/3)",让用户知道系统在工作,而不是卡死了。这个细节看起来小,但在客服工单里能减少一大半"点了没反应"的投诉。
4.3 三端差异化处理的落地写法
我把下载到打开的完整流程封装成一个openRemoteFile函数,内部按平台分支。核心逻辑是:先下载,再按需保存,最后打开。小程序端必须保存到持久目录再打开,App 端可以直接用临时路径打开,H5 端走浏览器下载。
async function openRemoteFile({ url, fileName, fileType }) { const platform = uni.getSystemInfoSync().uniPlatform const tempPath = await downloadWithRetry(url, { timeout: 30000 }) if (platform === 'web') { // H5 端:直接用 a 标签触发下载 const a = document.createElement('a') a.href = tempPath a.download = fileName a.click() return } // 保证有正确后缀,openDocument 靠后缀识别 const ext = fileType || (fileName || '').split('.').pop() const targetPath = `${uni.env.USER_DATA_PATH}/${Date.now()}_${fileName || 'file.' + ext}` if (platform === 'mp-weixin' || platform === 'mp-alipay') { const saved = await new Promise((resolve, reject) => { uni.saveFile({ tempFilePath: tempPath, success: r => resolve(r.savedFilePath), fail: reject }) }) await uni.openDocument({ filePath: saved, fileType: ext, showMenu: true }) } else { // App 端:复制到私有目录,避免临时文件被清理 await copyToPrivateDir(tempPath, targetPath) await uni.openDocument({ filePath: targetPath, fileType: ext, showMenu: true }) } }几点说明。showMenu: true让用户可以在打开后转发或另存,体验上更完整。App 端的copyToPrivateDir用 plus.io 实现,核心是resolveLocalFileSystemURL加copyTo,注意目标目录要先创建。H5 端有个前提,downloadWithRetry拿到的必须是 blob URL 或者同源地址,跨域资源要服务端配 CORS,否则 a 标签下载会失败。
uni.env.USER_DATA_PATH在小程序端和 App 端都可用的应用私有目录,是落盘的首选。别去拼/storage/emulated/0/这种绝对路径,Android 高版本上大概率写不进去。
5. 常见问题速查表与踩坑实录
5.1 问题速查表
把我在实际项目里遇到过的、能对上号的问题整理成一张表,遇到问题先扫一眼:
| 报错或现象 | 大概率原因 | 处理方式 |
|---|---|---|
url not in domain list | 下载域名未配置 | 后台加白名单,真机复测 |
ssl hand shake error | 证书链不完整或 TLS 版本低 | 补齐中间证书,升 TLS 1.2 |
statusCode: 403 | 签名过期或防盗链拦截 | 服务端重算签名,去掉 Referer 校验 |
statusCode: 404 | URL 编码问题或路径错误 | 检查中文和特殊字符编码 |
| 下载成功但打不开 | 拿到的是 HTML 错误页 | 校验文件头字节 |
filetype not supported | 路径无后缀或格式不支持 | 补后缀 + 传 fileType |
file not exist | 临时文件已被清理 | 改用 saveFile 持久化 |
saveFile:fail | 本地存储超限 | 清理旧文件,按业务 ID 去重 |
| 批量下载部分失败 | 并发超过 10 | 加任务队列,并发控到 3-5 |
| 打开后白屏 | 系统无对应查看器 | 兜底提示或改用在线预览 |
5.2 几个不写进文档的细节
第一个细节,临时文件的生命周期比你想的短。不光是小程序重启会清,有些机型在内存紧张时也会主动回收。所以链接点击到打开之间的间隔别太长,如果业务上确实需要"先下后看、隔天再看",那就老老实实 saveFile,把它当成缓存来管理,加一个 LRU 清理策略。
第二个细节,同名文件反复保存会失败。我吃过这个亏,用户下载一份报表,第一次成功,第二次点就报错,原因就是 savedFilePath 一样导致冲突。后来改成文件名带时间戳加业务 ID,问题消失。顺带解决了另一个问题:用户可以同时保有多份不同时间的报表,业务上反而更合理。
第三个细节,服务端返回的 Content-Disposition 别乱设。有些后端习惯设attachment; filename="报告.pdf",中文不编码,客户端解析时可能出乱码,进而影响文件名推断。稳妥的做法是文件名用 URL 编码后的 ASCII 形式放在 header 里,前端自己决定展示名,不要依赖服务端给的名字。
第四个细节,下载进度到了 100% 不等于文件完整。我遇到过一次,进度回调报 100,但 success 里拿到的文件比预期小,最后查出来是服务端在传输中途断开了连接,客户端把已收到的部分当成了完整文件。加一道大小校验(和服务端返回的 Content-Length 对比,允许 1% 误差),能拦住这类问题。
最后一个心得:下载模块的日志一定要留痕。我现在的封装会把每次下载的 URL、阶段、耗时、结果、错误码都打一条结构化日志,量不大但排查时极其好用。线上出问题时,用户报一个单号,我把日志一捞,链路清清楚楚,比反复沟通十轮有效率得多。这件事在项目初期做,成本很低;等到问题堆起来再补,就变成一项大工程了。