做 uni-app 的同学大概率都踩过这个坑——H5 端跑得好好的 PDF 生成,真机调试也正常,结果打成正式包之后要么点了没反应,要么直接报错,要么提示保存成功却翻遍手机找不到文件。我最早碰到这个问题是在一个包含订单报表导出的项目里,用户需要在 App 内把购物清单生成 PDF 分享给客户,测试部门打完安卓包反馈“PDF 打不开、生成失败”,当时我把能踩的坑几乎全踩了一遍。这篇就把 uni-app 打包后 PDF 无法生成的完整排查思路和可落地解决方案整理出来,重点覆盖 App 端打包差异、文件保存链路、Android 权限、原生插件配置这几个最容易翻车的环节,前端开发、混合应用开发者、正在做 uniapp 离线打包或整包发布的朋友都可以直接参照。
1. 先定位问题:打包后 PDF 生成失败常见的三种表现
1.1 场景一:H5 端正常,打包成 App 后生成 PDF 直接报错
这是反馈最多的一种情况。在浏览器里用 html2canvas 加 jspdf 生成 PDF 完全没问题,打包成 App 后在 WebView 里跑同一套 JS 代码,控制台报错五花八门,最常见的是canvas.toDataURL is not a function、pdf.save is not a function、Unable to get image data from canvas because the canvas has been tainted by cross-origin data。
这类问题的核心原因是:App 端 WebView 对 canvas 安全策略、本地资源加载方式和浏览器环境不完全一致。尤其是打包后页面里的图片路径从网络 URL 变成了本地资源路径,或者是相对路径解析失败,canvas 被跨域数据污染,toDataURL一执行就抛异常。很多同学在这一步会误判为 jspdf 的版本问题,实际是截图数据源出问题了。
1.2 场景二:App 端能弹出生成成功提示,但找不到文件
生成 PDF 的代码逻辑没报错,页面也弹了“保存成功”,但用户去文件管理器里翻,死活找不到 PDF 文件。这种情况在 Android 10 及以上系统尤其常见。
原因也很直接:旧代码习惯把 PDF 写到公共存储目录,比如/storage/emulated/0/Download/xxx.pdf,但 Android 10 开始强制分区存储,应用只能直接访问自家专属目录和系统媒体库允许的文件类型,直接写公共目录会被系统静默拦截,代码不报错但文件压根没落盘。另一个原因是用了uni.saveFile把文件保存到了应用私有目录,这个目录在部分手机上文件管理器默认看不到,用户以为没生成,实际上文件躺在/data/data/包名/files/下面。
1.3 场景三:调试包正常,正式签名包或混淆包才失败
这种最让人头疼。开发阶段用 HBuilder 基座运行没问题,自定义调试基座也没问题,打成正式包之后就出问题。而且出问题的地方通常不是 JS 层,而是原生层。
常见根源有两个:一是原生插件没有正确打包进正式包,尤其使用离线打包时,开发者把插件 SDK 加到了 debug 的 gradle 配置里,但 release 编译时被排除了;二是开启了代码混淆,原生插件的类名被混淆器重命名,JS 层调用原生方法时找不到对应类,直接静默失败。这类问题 JS 代码一行都不用改,问题全在打包配置上。
2. 逐层拆解根因:这五个环节最容易出问题
2.1 生成方案先天缺陷:前端 DOM 截图天生不稳定
很多项目用的 PDF 生成方案是“DOM 转 canvas,canvas 再转 PDF”,本质上是截图,不是真正的 PDF 排版。这个方案在浏览器里表现尚可,但在 App 的 WebView 里会有几个先天问题:
- 页面是滚动容器时,html2canvas 只能截取当前视口区域,多页内容会丢。
- WebView 里字体渲染方式和浏览器不完全一致,截图可能出现文字错位、空白块。
- 高分辨率手机下 canvas 尺寸过大,内存占用飙升,低端机直接闪退或白屏。
不是说这个方案不能用,而是你要搞清楚它适合什么场景。短内容、单页、样式简单的 PDF 用它没问题;长报表、多页、图文混排的内容用它就容易翻车。如果你现在已经被“打包后 PDF 无法生成”困扰,第一件事不是改代码,而是确认你的内容适不适合走前端截图方案。
2.2 跨域图片污染 canvas,toDataURL 直接抛异常
这是前端生成 PDF 时最经典的报错。html2canvas 会把 DOM 里的图片绘制到 canvas 上,如果图片来自跨域地址,并且服务器没有返回正确的 CORS 响应头,canvas 就会被标记为“被污染”。一旦 canvas 被污染,调用canvas.toDataURL()就会抛安全异常。
打包成 App 后这个问题会放大,原因有两个:
- H5 端图片用的相对路径
/static/xxx.png,浏览器能正常加载;App 端 WebView 加载本地资源的机制不同,相对路径可能解析到错误的 origin,导致图片被当成跨域资源。 - 有些项目图片 src 直接写死了
http://localhost:端口或局域网 IP,打包后这个地址无法访问,html2canvas 内部加载图片失败,canvas 绘制内容缺失甚至报错。
排查方法很简单:在打包后的页面上,打开 WebView 调试,把渲染前后的图片请求 Network 面板打开看一眼,凡是加载失败的图片,就是污染源。处理方式要么让图片服务器开启 CORS,要么把图片转成 base64 嵌入,要么使用useCORS: true且确保图片地址可公网访问。
2.3 文件保存链路的路径差异:H5 与 App 完全不同
H5 端生成 PDF 后,调pdf.save('xxx.pdf')会触发浏览器下载,这是浏览器行为,跟文件系统没关系。但 App 端没有“浏览器下载”这个概念,必须自己把 PDF 的 blob 或 base64 写到文件系统里。
很多项目失败就失败在把 H5 的保存逻辑直接搬到 App。比如:
- 调
pdf.save()在 App 的 WebView 里根本无效,既不会调起下载,也不会写文件。 - 用
uni.downloadFile下载一个 base64 生成的临时链接,但tempFilePath只是临时文件,不处理就会丢失。 - 用
uni.saveFile保存,但没搞清楚savedFilePath到底落在哪个目录,用户找不到。
正确的保存思路要看情况:如果你用的是 jspdf 这类前端库,生成的 PDF 要拿到 blob,再用uni.getFileSystemManager().writeFile或plus.io写文件;如果你生成的是临时文件路径,再考虑是否需要移动到公共目录。
App 端保存 PDF 还要面对一个绕不开的问题:没有通用 API 把 PDF 写进系统相册或公共 Download 目录。uni.saveImageToPhotosAlbum只能存图片,存不了 PDF。要存公共目录需要原生插件或 native.js 调用原生方法,否则只能存在应用私有目录。
2.4 Android 动态权限与分区存储拦截写入
Android 6.0 及以上,危险权限必须运行时动态申请,其中就包括存储读写权限。如果你的 App 还没适配动态权限,或者申请权限的代码没在用户授权后再写文件,就会出现“保存成功但文件没写进去”的情况。
Android 10 及以上还有分区存储。简单理解就是,系统给每个 App 划了一个专属目录(应用沙箱),App 访问自己沙箱内的文件不需要权限,但直接访问公共目录(Download、Documents、Pictures)会受到限制。如果你的代码还按老的思路硬写公共路径,在 Android 10+ 上大概率会失败。
之前遇到过一种情况:测试手机是 Android 9,文件写得好好的;正式用户手机是 Android 12,全废了。这就是分区存储适配的问题。最稳的做法是先把 PDF 写到_doc或应用沙箱目录,再引导用户通过“文件管理器 -> 内部存储 -> Android/data/包名/files/”去查看,或者用系统文件选择器把 PDF 分享出去,而不是强行写公共目录。
2.5 原生插件未随包编译:整包、离线包、wgt 热更新的区别
如果项目里用了 PDF 相关原生插件(比如 PDF 生成组件、原生打印组件),打包方式直接决定插件能不能用:
- 云打包:HBuilderX 里勾选模块,云端会集成插件,只要不离线打包一般没问题。
- 离线打包:需要手动将插件 SDK 集成到 Android 原生工程里,然后再编译,漏掉依赖库或 SDK 版本不对都会导致运行时找不到插件。
- wgt 热更新:只更新前端 JS 和页面资源,原生插件和原生代码不会更新。如果新版前端代码调用了原生插件,而用户当前安装的原生包没有这个插件,调用就直接失败。热词里提到的“uni-app wgt包热更新不生效”很多时候就是这么来的。
所以只要 PDF 生成依赖原生能力,必须有心理准备:wgt 热更新救不了原生依赖,必须整包发布,否则用户更新完资源包后一生成 PDF 就崩溃。
3. 完整解决方案:从生成、保存到打包配置一条龙
3.1 方案 A:纯前端生成 PDF,修正 canvas 与保存链路
适用于单页或几页、样式不复杂的 PDF 内容。整体思路是:html2canvas 截取节点 -> canvas 转图片 -> jspdf 逐页写入 -> 导出 blob -> 写文件。
先看生成部分的核心代码:
import html2canvas from 'html2canvas'; import jsPDF from 'jspdf'; async function generatePDF(domId) { // 拿到要导出的 DOM const dom = document.getElementById(domId); if (!dom) throw new Error('未找到导出节点'); // 配置 useCORS: true,允许跨域图片绘制;scale 控制清晰度 const canvas = await html2canvas(dom, { scale: 2, useCORS: true, allowTaint: false, backgroundColor: '#ffffff', logging: false, }); const imgData = canvas.toDataURL('image/jpeg', 0.95); const pdf = new jsPDF('p', 'mm', 'a4'); const pdfWidth = pdf.internal.pageSize.getWidth(); const pdfHeight = pdf.internal.pageSize.getHeight(); // 计算图片等比例缩放后的高度 const imgWidth = pdfWidth; const imgHeight = (canvas.height * imgWidth) / canvas.width; let heightLeft = imgHeight; let position = 0; pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight); heightLeft -= pdfHeight; // 多页时逐页增加 while (heightLeft > 0) { position -= pdfHeight; pdf.addPage(); pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight); heightLeft -= pdfHeight; } // 输出 blob,之后统一走保存逻辑 const blob = pdf.output('blob'); return blob; }几个容易踩的细节:
allowTaint: false和useCORS: true要同时存在,否则 canvas 可能被污染。- 图片域名必须支持 CORS,且图片地址不能是 localhost。
- 如果用
pdf.output('datauri')在 App 端不一定能触发预览,最好走 blob 再写文件。 - 如果 PDF 内容超过一页,
position的递减逻辑要写对,否则第二页是空白。
拿到 blob 之后,App 端保存的代码要区分平台。这里给出一个兼容写法:
function saveBlobToFile(blob, fileName) { // #ifdef APP-PLUS // App 端用 plus.io 写入应用私有文档目录 const reader = new FileReader(); reader.onload = function (e) { const base64 = e.target.result.split(',')[1]; // 写入 _doc 目录,这个目录在应用私有目录内,不需要存储权限 plus.io.requestFileSystem(plus.io.PUBLIC_DOCUMENTS, (fs) => { fs.root.getFile(fileName, { create: true }, (fileEntry) => { fileEntry.createWriter((writer) => { writer.onwrite = function () { uni.showToast({ title: '已保存到应用文档目录' }); }; writer.onerror = function (err) { console.error('写入失败', err); }; writer.writeAsBase64(base64, 'base64'); }); }); }); }; reader.readAsDataURL(blob); // #endif // #ifdef H5 // 浏览器直接用下载方式 const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = fileName; link.click(); // #endif }注意:这段代码里plus.io只在 App 端生效,H5 端不能调用。PUBLIC_DOCUMENTS对应的是应用沙箱内文档目录,不需要申请存储权限,这是规避 Android 分区存储最省事的办法。
3.2 方案 B:后端生成 PDF,彻底绕开客户端兼容问题
如果你的 PDF 内容复杂、页数多、对排版要求高,或者需要嵌入自定义字体、动态图表,我非常推荐走服务端生成。这也是我在经历过几次客户端翻车之后最终选择的方案,因为客户端不管前端方案还是原生插件方案,总有各种环境限制,而后端生成 PDF 的思路是最简单的:
前端把生成 PDF 所需的数据(订单信息、报表数据、模板 ID)传给后端接口,后端用程序生成 PDF 文件,返回一个可下载的临时地址或文件流,前端再走uni.downloadFile下载。
后端生成 PDF 的技术选型有很多,常见的有:
- Node.js 生态:
puppeteer加载 HTML 模板打印 PDF,排版能力强,适合复杂报表。 - Java 生态:
iText、Apache PDFBox,适合低层精确控制。 - Python 生态:
ReportLab、WeasyPrint,适合批量生成。 - 云函数:如果项目部署在 uniCloud,可以用云函数调用
PDFKit或html-pdf,前端拿临时文件地址去下载,缺点是不支持超大的 PDF 和超大并发。
前端这边的处理逻辑可以直接用 uni-app 的uni.downloadFile,一个典型的实现:
uni.downloadFile({ url: 'https://your-api.com/api/generate-pdf', method: 'POST', data: { orderId: '123456', templateType: 'order', }, success: (res) => { if (res.statusCode === 200) { // 下载到临时文件后,保存到应用目录 uni.saveFile({ tempFilePath: res.tempFilePath, success: (saveRes) => { console.log('PDF saved at:', saveRes.savedFilePath); }, }); } }, });这招的核心优势在于:客户端的系统差异、WebView 差异、权限差异基本被屏蔽,PDF 在服务端生成,内容稳定可控。生成的临时地址一般有效期为几小时到几天,注意及时下载并保存。这里的geo通信可以配合安全签名,避免接口被刷。
后端方案唯一的问题是需要服务端资源和接口开发成本。项目里如果没有后端配合,或者只想快速实现一个小工具的导出功能,那还是得回方案 A。
3.3 方案 C:原生插件生成 PDF,适合对质量要求高的场景
如果要在 App 端离线生成高质量的 PDF,又不想走服务端,可以考虑 uni-app 插件市场里的原生 PDF 生成插件。这类插件本质上是封装的 Android 或 iOS 原生代码,通过 uni-app 的 JSBridge 暴露成 JS API 给前端调用。
使用原生插件的好处很明显:
- 支持真正的高质量矢量渲染,不是截图,文字不模糊。
- 可以调用系统打印能力,直接调起系统分享和打印面板。
- 对中文字体支持比 html2canvas 截图好得多。
使用原生插件的步骤大概是:
- 在插件市场选一个维护活跃的 PDF 生成/打印插件,注意看它的兼容性说明,是否支持 App 离线打包、是否支持 iOS 和 Android。
- 在 HBuilderX 里点击“使用 HBuilderX 导入插件”,页面里按文档初始化。
- 调用插件提供的 API 传入数据或页面节点,插件内部生成 PDF 后返回文件路径。
- 如果是离线打包,必须按插件文档把对应的原生 SDK 加入 Android 工程,并在
dcloud_properties.xml里注册插件。 - 云打包模式下直接在 manifest.json 的 App 模块配置里勾选对应插件,勾选后云端编译才会打包进去。
需要强调一个点:使用原生插件后,wgt 热更新永远不会生效到你新加的插件上。插件是原生代码,必须整包更新。如果你的产品经理喜欢用热更新快速发版,一定要提前告诉他这个限制,否则热更上去后用户一点生成就崩溃,到时候锅从天上降。
3.4 打包配置与权限声明清单
无论你选择哪种方案,打包前的配置漏配都会导致“H5 能行、打包就废”。这里列一个我每次打包前都会逐项检查的清单:
Android 权限声明(manifest.json -> App 模块配置 -> 权限配置):
{ "permissions": { "Android": [ "android.permission.INTERNET", "android.permission.READ_EXTERNAL_STORAGE", "android.permission.WRITE_EXTERNAL_STORAGE" ] } }注意 Android 13(targetSdk 33)之后,存储权限变得比较复杂,不再建议强制申请 WRITE_EXTERNAL_STORAGE。如果只是保存到_doc应用目录,不需要任何存储权限,这一点在方案 A 的代码里已经体现了。
打包前还需要检查的几项:
- HTML2PDF 或原生插件的模块必须勾选,云打包时模块遗漏最常见。
- 图片资源不能放在会被混淆改名的地方,离线打包时 assets 目录里的资源路径要留意。
- 使用原生插件时,离线打包工程的
proguard-rules.pro要加入插件类的 keep 规则,防止 release 混淆后找不到类。 - iOS 打包要注意 PDF 插件是否支持 arm64 架构,老插件在 iPhone 5s 之后基本都需要 64 位,插件文档都会写。
4. 实操排障手册:问题与对策对照速查表
4.1 常见报错与解决方案对照表
我把实际项目里遇到过的报错和排查方向整理成了下表,每一条都对应真实的踩坑记录:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
toDataURL is not a function | html2canvas 执行失败,canvas 变量不是真正的 canvas 对象,或库被 WebView 静默拦截 | 先打印 canvas 对象确认,再检查 html2canvas 版本与 WebView 兼容性 |
canvas has been tainted by cross-origin data | 跨域图片污染 canvas | 图片转 base64,或开启 CORS 支持,或去掉被污染的图片节点 |
| 点击生成 PDF 无任何反应 | jsPDF 的 save 方法在 WebView 里不生效 | 改为pdf.output('blob'),配合 plus.io 或 saveFile 保存 |
| 提示保存成功但找不到文件 | 文件写到应用私有目录,或分区存储拦截了公共目录 | 改用_doc目录,或通过系统分享面板发送 PDF |
| 正式包无法调用 PDF 插件 | 插件未打包进正式包,或 release 混淆移除插件类 | 核对云打包模块勾选,离线打包检查原生工程,补 keep 规则 |
| 热更新后 PDF 功能失效 | 原生插件依赖没随 wgt 更新 | 原生插件必须整包发布,杜绝 wgt 更新原生依赖 |
| Android 9 正常,Android 12 失败 | 分区存储行为变化 | 所有文件写到应用沙箱,不要硬写公共目录 |
| 生成 PDF 时 App 崩溃闪退 | canvas 过大,内存压力过高 | 调低 html2canvas 的 scale,限制导出 DOM 的大小,或换后端方案 |
| 中文字体在 PDF 里变成方块 | 前端截图方案缺少字体渲染,或 jspdf 不支持非嵌入字体 | 换后端生成 PDF,或引入支持全线字体的原生插件 |
4.2 几点实际经验与建议
第一,方案选型最好在编码前定下来。如果只是做一个长图级的小 PDF,前端截图方案够用,但一旦涉及多页排版、自定义字体、动态图表、用户签名这类需求,直接上后端或原生插件,别硬抗。前端截图方案在打包后暴露的各种不确定性,远比你想的要多。
第二,保存路径一定要在 App 上清清楚楚地展示给用户。很多用户根本不知道应用私有目录在哪,你弹一个“保存成功”的时候,最好把文件的绝对路径一并弹出,或提供一个“查看文件”按钮,用系统文件选择器定位到那个路径。否则用户找不到文件就是你的锅,这个体验问题不解决,方案再对也会被投诉。
第三,开发阶段用自定义调试基座提前验证正式包环境。不要只依赖 HBuilder 自带的标准基座,标准基座只包含内置模块,不包含你选的原生插件。用自定义基座调试能提前暴露插件没打包、权限没声明、混淆后找不到类等等问题,别等提审前或上线后被用户发现才处理。实测下来,这一步能省掉至少一半的“打包后废掉”的坑。
第四,PDF 生成的日志在 App 端很难看到,建议在关键步骤加上全局错误捕获和上报,至少把错误信息存入本地日志文件。这样即使测试在真机上翻车,也能拿到错误堆栈去定位问题,不用干瞪眼猜人。
这个内容后续如果项目形态升级,还可以扩展成“PDF 模板在线编辑 + 服务端统一渲染”的方向,把客户端彻底解放出来,只负责传参数和展示结果。如果你现在正卡在打包后 PDF 生成失败,建议先把今天列的五个环节逐项过一遍,尤其注意保存链路和插件配置,绝大多数问题都能在前面这几位“惯犯”身上找到答案。