做鸿蒙应用这两年,我经常遇到一个很现实的需求:把PDF文档转成图片。有人是为了做文档预览,有人是需要给PDF生成缩略图,还有人是为了把合同扫描归档成统一格式。这个需求听起来简单,真正落地的时候却有不少门道——尤其是"整个PDF文档"这几个字,意味着要处理页数不确定、尺寸不统一、内存压力大这些麻烦事。这篇文章就完整记录我在HarmonyOS上把一个PDF文档逐页转换成图片的全过程,包括最终的实现代码、性能数据和踩过的坑,适合正在做鸿蒙文档类应用、或者被"PDF预览""PDF转图片"卡住的朋友参考。
我最早接触这个需求是在做一个文件管理类的App,产品经理提了一个很朴素的需求:文件列表里要展示PDF的封面图。当时我第一反应是"直接用PDF组件预览不就行了",但实际调研后发现,要在列表页快速加载几十个PDF的封面,最稳妥的方案就是先把第一页转成图片,再走正常的图片加载链路。后面需求又进一步扩展成"把整个PDF转成图片序列",用于阅读器和分享卡片。这篇文章讲的,就是这一整套从单页到整本、从实现到优化的完整过程。
1. 为什么非要把PDF转成图片:场景梳理与方案选型
1.1 哪些业务场景真正需要"PDF转图片"
先说场景。很多人乍一听"PDF转图片"觉得多此一举,明明有PDF渲染能力,为什么还要转成图片?但实际业务里,转图片能解决很多棘手问题。
第一个场景是列表缩略图。一个文件夹里可能有几十个PDF,如果每个都用PDF组件去渲染预览,列表会卡成一帧一帧的。转成图片后,缩略图走Image组件加载,性能和普通图片完全一样。第二个场景是分享和外发。微信、钉钉这些外部平台对PDF支持程度不一,但图片几乎人人能看,分享PDF时附带一张预览图,用户体验会好很多。第三个场景是OCR和内容识别。图像识别流程基本都吃图片输入,PDF需要先转图片才能进入识别管线。第四个场景是统一归档。有些后端系统只接受图片格式的归档文件,PDF需要预处理成图片序列再上传。
还有一个容易被忽略的场景:PDF本身是矢量格式,在部分低端设备上直接渲染大尺寸PDF会出现卡顿甚至崩溃,但转成图片后,尺寸固定、渲染路径简单,兼容性和稳定性反而更可控。所以别看"PDF转图片"听起来低端,它在实际项目中的出场率非常高。
1.2 为什么选择系统PDFKit而不是自研解析
方案选型的时候,我对比过三条路:自研PDF解析、WebView截图、系统PDFKit。
自研PDF解析,听起来很酷,但PDF格式本身极其复杂:字体嵌入、跨页引用、CMYK颜色空间、透明混合模式,随便一个特性都够写几周。除非你的产品核心就是PDF底层处理,否则完全没有必要重复造轮子。WebView截图的方式问题更大,鸿蒙的Web组件对本地PDF的支持并不友好,需要搭本地HTTP服务或者转DataURI,截图还需要处理滚动拼接,逻辑既啰嗦又容易出边界问题。
最后我选了系统提供的PDFKit能力(HarmonyOS API 12开始提供,通过import { pdf } from '@kit.PdfKit'引入)。它的好处很直接:底层解析由系统完成,我只需要负责"打开文档、逐页渲染、拿到图像数据、编码保存"这四步。从PDF文件到可落盘的图片,整个链路最短,代码量最少,也最容易维护。如果你的应用最低支持版本低于API 12,那可能需要评估其他方案,但我的项目最低版本就是API 12,系统PDFKit几乎是唯一合理的选项。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 渲染链路拆解:从PDF文件到PixelMap再到图片文件
2.1 核心类和它们的分工
把PDF转图片,从抽象层面看,跟"把一张矢量图纸拍成照片"是一回事。PDF文件是矢量图纸,系统PDFKit负责把图纸解析出来,CPU/GPU负责按给定分辨率绘制成位图,最后我们把位图编码成JPEG或PNG保存。这个过程中有几个核心类,各管一段。
先看一张对照表,心里有个整体印象:
| 核心类 | 职责 | 关键方法/属性 |
|---|---|---|
| pdf.PDFDocument | 代表整个PDF文档,负责打开和关闭 | open(fd)、close()、pageCount |
| pdf.PDFPage | 代表文档中的某一页,负责渲染 | loadPage()、render(param)、getSize()、release() |
| pdf.PDFRenderParam | 渲染参数,控制缩放比例和色彩模式 | scale、colorMode |
| pdf.PDFRenderInfo | 渲染结果,持有渲染出的PixelMap | pixelMap |
| image.ImagePacker | 把PixelMap编码成文件数据 | packing()、release() |
调用关系是这样的:先用文件描述符fd打开PDFDocument,拿到文档对象后,通过pageCount获得总页数,然后一页一页地loadPage,拿到PDFPage对象后构造PDFRenderParam,调用page.render(param)得到PDFRenderInfo,从里面取出PixelMap,这是渲染出来的位图;再用ImagePacker把PixelMap编码成JPEG或PNG的二进制数据,最后写入文件。
这个链路看起来长,但每一步都是必要环节,缺一不可。如果只是预览不落盘,可以省掉ImagePacker那一步,直接拿PixelMap丢给Image组件显示;如果要保存成文件,就必须走完整个链路。
2.2 渲染参数scale与图片清晰度的换算逻辑
这里有一个非常关键的参数,scale。很多人第一次接触PDFKit时会忽略它,直接用默认值渲染,出来的图片在手机屏幕上糊得没法看。原因在于,PDF的尺寸单位是point(点),1 point等于1/72英寸。PDFKit默认以72 DPI渲染,也就是scale默认1.0,图片的实际像素尺寸等于PDF页面的point尺寸。
举一个最典型的例子,A4纸的规格是595 x 842 point。scale=1.0时,渲染出的图片就是595 x 842像素。这个尺寸在电脑屏幕上还能看,放到手机上简直惨不忍睹。手机屏幕的物理DPI普遍在300以上,一张595像素宽的图片全屏显示会被拉伸到1080甚至1440像素宽,不模糊才怪。
scale的实际含义是"渲染DPI相对于72的倍数"。scale=2.0时输出144 DPI,图片像素翻倍,A4变成1190 x 1684;scale=3.0时输出216 DPI,A4变成1785 x 2526。在我实际测试中,手机端预览用scale=2.0已经很清楚,如果图片要拿去打印或者做OCR,建议用scale=3.0。超过3.0之后,视觉提升已经不明显,但渲染耗时和内存占用会成倍往上飙,不划算。
还有一个参数是colorMode。我在代码里用的是COLOR_MODE_ARGB_8888,原因很简单:ARGB_8888支持透明通道。很多PDF页面其实是带透明背景的,如果渲染时去掉Alpha通道,后面转PNG还好,转JPEG就会出现黑色背景的尴尬情况。关于这一点,后面踩坑部分会详细说。
3. 核心代码落地:一整本PDF逐页转图片的完整实现
3.1 打开PDF文档前的文件处理:沙箱和文件描述符
鸿蒙应用有沙箱机制,App不能随意访问其他目录。如果要打开用户相册或文件管理器里选中的PDF,最推荐的做法是用FilePicker选择文件,拿到文件的uri,再通过fileIo.openSync获取fd。如果PDF本来就在应用自己的沙箱目录(比如filesDir),直接fileIo.openSync文件路径就可以。
这里有一个特别容易踩的坑:FilePicker返回的uri,不能直接当作本地路径去open。我第一次写的时候想当然地把它当成文件路径传给了fileIo.openSync,结果报"文件不存在"。正确做法是,让fileIo直接基于uri打开,或者先通过文件管理接口把uri转换成可操作的fd。代码如下:
typescript复制import { fileIo } from '@kit.CoreFileKit';
import { picker } from '@kit.CoreFileKit';
// 通过文件选择器获取pdf
async function pickPdfAndGetFd(): Promise<number> {
const documentPicker = new picker.DocumentViewPicker();
const result = await documentPicker.select({ maxSelectNumber: 1 });
if (!result || result.length === 0) {
throw new Error('用户未选择文件');
}
const uri = result[0].uri;
const file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
return file.fd;
}
打开后拿到的fd,后续要传给PDFDocument.open。注意用完fd之后,要记得关闭文件描述符。pdf.PDFDocument.open内部应该会持有或复制这个fd,但为了保险,我在文档完全处理完后再统一关闭。
3.2 页面遍历、渲染与图片编码保存
核心函数如下。这段代码我加了详细的注释,逻辑是:打开文档、读取总页数、逐页渲染、编码、落盘、释放资源,一页处理完再处理下一页。
typescript复制import { pdf } from '@kit.PdfKit';
import { image } from '@kit.ImageKit';
import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
async function pdfToImages(srcFd: number, outputDir: string, scale: number = 2.0): Promise<string[]> {
let document: pdf.PDFDocument | null = null;
const resultPaths: string[] = [];
try {
// 1. 打开PDF文档
document = await pdf.PDFDocument.open(srcFd);
const pageCount = document.pageCount;
if (pageCount <= 0) {
throw new Error('PDF文档页数为0');
}
// 2. 创建图片编码器,整个转换过程复用同一个Packer
const packer = image.createImagePacker();
// 3. 逐页处理
for (let i = 0; i < pageCount; i++) {
let page: pdf.PDFPage | null = null;
try {
// 加载第i页
page = await document.loadPage(i);
// 获取页面尺寸,用于后续可能的降级处理
const pageSize = await page.getSize();
console.info(`page ${i + 1} size: ${pageSize.width}x${pageSize.height}`);
// 配置渲染参数
const renderParam = new pdf.PDFRenderParam();
renderParam.scale = scale;
renderParam.colorMode = pdf.PDFColorMode.COLOR_MODE_ARGB_8888;
// 执行渲染,拿到PixelMap
const renderInfo = await page.render(renderParam);
const pixelMap = renderInfo.pixelMap;
// 编码为JPEG
const packOpts: image.PackingOption = {
format: 'image/jpeg',
quality: 92
};
const data = await packer.packing(pixelMap, packOpts);
// 写入文件
const outputPath = `${outputDir}/page_${String(i + 1).padStart(3, '0')}.jpg`;
const file = fileIo.openSync(outputPath,
fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
fileIo.writeSync(file.fd, data);
fileIo.closeSync(file.fd);
resultPaths.push(outputPath);
// 释放PixelMap,回收原生内存
pixelMap.release();
} finally {
// 无论成功失败都要释放page
if (page) {
page.release();
}
}
}
// 4. 释放编码器
packer.release();
return resultPaths;
} finally {
// 5. 必须关闭文档
if (document) {
document.close();
}
}
}
逐行解释几个关键点。
pdf.PDFDocument.open(srcFd)这一步是拿到文档的总入口。打开后document.pageCount可以直接读到总页数,不需要额外遍历。
document.loadPage(i)返回的是PDFPage对象,代表单页。注意这个操作也是异步的,遇到损坏的页面可能抛异常,所以放在内层try里,便于单页失败不影响整本书处理——虽然finally里还是会释放资源。
page.render(renderParam)是核心渲染调用。它接收一个PDFRenderParam,返回PDFRenderInfo,PDFRenderInfo.pixelMap就是渲染好的位图。这个pixelMap在鸿蒙里是一个PixelMap对象,可以直接交给Image组件显示,也可以交给ImagePacker编码。
packer.packing(pixelMap, packOpts)是把PixelMap编码成JPEG数据。这里有个经验:同一个PDF转换过程中,Packer可以复用,不需要每页重新创建。packOpts里的format支持'image/jpeg'、'image/png'、'image/webp'几种,我预览场景用JPEG,OCR归档场景用PNG。quality设为92是个平衡点,肉眼几乎看不出压缩痕迹,体积又不会太大。
文件名我用page_001.jpg这种前导零格式,而不是page_1.jpg。因为后面如果用Swiper组件做阅读器,需要按页码排序,前导零能保证字符串排序和数字排序一致。这个小细节能省掉后面排序的不少麻烦。
3.3 资源回收:泄不释放,内存迟早爆掉
整个代码里,resource release是我特别想强调的部分。PDFKit的底层实现是Native层,PDFDocument、PDFPage、PixelMap这些对象,并不是普通的ArkTS对象,而是持有Native内存的封装。ArkTS的垃圾回收器不会立即触发Native侧的内存释放,如果不显式调用release(),内存占用会随页数线性增长。
我在真机上测过:一页A4渲染成1190 x 1684的PixelMap,内存占用大约8MB(119016844字节)。100页就是800MB。如果不释放,转完一个100页的PDF,App基本就OOM崩溃了。所以我做了三层释放:
第一层是PixelMap,每页用完立刻release,这是内存大头。第二层是PDFPage,同样在finally里保证释放。第三层是PDFDocument和ImagePacker,在整本书处理完之后统一close或release。
有一个很隐蔽的问题:如果中途异常退出,document.close()可能不会执行。所以我把close放在最外层的finally里。同理,page.release()放在内层finally里,确保即使render失败也能释放。这些细节平时写业务代码可能注意不到,但在内存敏感的PDF转换场景里,少了任何一层,都会在长文档上暴露问题。
4. 实测性能数据与内存优化:处理几百页PDF的工程策略
4.1 一次真实测试:一页A4从渲染到落盘耗时多少
代码写完,我第一时间在真机上跑了一轮测试。测试设备是一台麒麟芯片的手机,系统版本HarmonyOS 5.0,测试文档是一个120页的合同扫描PDF,每页都是A4大小,内容以文字和表格为主,文件总大小约30MB。
导出结果如下:
| 测试项 | 数值 |
|---|---|
| scale=2.0,单页平均耗时 | 230ms左右 |
| scale=2.0,全局总耗时 | 28秒左右 |
| scale=3.0,单页平均耗时 | 410ms左右 |
| scale=3.0,全局总耗时 | 50秒左右 |
| 全局内存峰值(scale=2.0) | 约300MB |
| 全局内存峰值(scale=3.0) | 约600MB |
从数据可以明显看出,scale翻1.5倍,耗时和内存几乎是翻倍往上涨。所以scale的选择一定要克制,不是越大越好。手机预览2.0完全够用,PDF要投屏到大屏或者走打印,再考虑3.0。
另外注意到,单页230ms看起来不多,但120页累积起来就是28秒。这个时长用户不可能干等着,所以应用层必须做进度展示,最好再提供一个"取消转换"的按钮。
4.2 并发控制与内存峰值管理
第一版代码我采用的是顺序逐页处理,这样最稳妥,内存峰值也最低。但120页跑28秒,还是有些慢了。后来我尝试了并发渲染,一次同时处理3页、5页、8页,看看能不能提速。
结论是:并发能提速,但内存和并发数是强相关的。3页并发,总耗时能降到18秒左右,内存峰值大约500MB;5页并发,总耗时降到14秒,但内存峰值直接飙到800MB,已经逼近危险区。所以我的建议是,除非你的设备确定是旗舰机,内存大于8GB,否则并发数控制在3页以内。
实现并发有个细节:document.loadPage和page.render都是异步操作,你可以用Promise.all把3个页面的渲染任务丢出去。但要小心,PDFPage对象和PixelMap对象不能在任务间共享,每个页面必须独立创建、独立渲染、独立释放。用代码表示大致是这样:
typescript复制const BATCH_SIZE = 3;
for (let start = 0; start < pageCount; start += BATCH_SIZE) {
const end = Math.min(start + BATCH_SIZE, pageCount);
const tasks = [];
for (let i = start; i < end; i++) {
tasks.push(renderOnePage(document, i, outputDir, scale));
}
await Promise.all(tasks);
}
每一页的renderOnePage内部,依然遵循"加载页、渲染、编码、落盘、释放"的流程。批量处理的思想是,一次只处理3页,处理完统一回收这3页的内存,再处理下一批。这样既享受了并发的速度,又把内存峰值控制在一定范围内。
还有一个更彻底的优化方案是用taskpool把渲染放到独立线程。但PDFKit的Native对象能不能跨线程传递,官方文档没有说得特别清楚,我试过把fd传给taskpool任务,在任务内部打开PDF并渲染,发现部分设备上存在稳定性问题。所以最终线上版本我用的还是UI线程分批并发方案,稳定性和性能都能接受。
4.3 大页面降级策略:防止OOM的最后防线
除了页数多,还有一类PDF特别容易OOM:页面尺寸异常大的文档。比如CAD导出的PDF,单页尺寸可能是A4的十几倍,缩放后再乘以scale,生成的位图尺寸会爆炸。
举一个极端例子:某图纸页面是2000 x 3000 point,scale=2.0时,渲染图片是4000 x 6000像素,PixelMap内存就是400060004,接近96MB。再来3页并发,就是接近300MB。这种情况下,手机内存再大也扛不住。
对策是我在渲染前加了一个尺寸预估和降级逻辑。大概思路是:拿到page.getSize()之后,先算一下目标像素面积:
typescript复制const width = pageSize.width * scale;
const height = pageSize.height * scale;
const pixelArea = width * height;
const MAX_PIXEL_AREA = 4096 * 4096; // 最大允许1600万像素
let finalScale = scale;
if (pixelArea > MAX_PIXEL_AREA) {
finalScale = Math.sqrt(MAX_PIXEL_AREA / (pageSize.width * pageSize.height));
console.warn(`page ${i + 1} 尺寸过大,scale从${scale}降级到${finalScale.toFixed(2)}`);
}
我把这个降级策略叫做"最后一道防线"。它不是为了让图片更清晰,而是为了保证转换过程不崩溃。遇到少数超大页面,清晰度稍微牺牲一下可以接受,但App崩溃是不可接受的。
5. 实战踩坑记录:渲染质量、透明背景、页面尺寸不一致、加密文档
5.1 中文乱码和字体渲染问题
我最早在模拟器上调试的时候,遇到一个很头疼的问题:PDF里的中文全部渲染成方块或乱码,英文和数字正常。排查了半天,发现问题不在代码,而在模拟器环境。PDF中的中文字体如果没有嵌入字体文件,渲染时需要依赖系统字体库来渲染;模拟器的字库里往往缺少目标中文字体,系统就会用默认字体替代,替代失败就变成方块。
这在真机上几乎不会出现,因为真机的系统字库完整很多。但这提醒我一件事:PDF转图片的质量强依赖设备的字库环境。如果应用要处理大量第三方PDF,最好在发布前用不同品牌的真机各测一遍。还有一个缓解措施:如果PDF确定是固定模板生成的(比如自己App导出的报告),可以在生成PDF时强制嵌入字体,这样目标设备上无论有没有中文字体,都能渲染正确。
5.2 JPEG把透明背景渲染成黑色
这个坑藏得更深。有一批PDF页面,视觉上看起来是白色背景,但实际底色是透明的,PDF页面本身没有绘制白色底。我一开始统一用JPEG编码,转出来的图片里,原本透明的地方全部变成了黑色,整个页面像被涂了黑底。
原因很简单:JPEG格式不支持Alpha通道,透明像素在编码时必须被填充一个颜色,默认填充的是黑色。解决办法有两个。
第一个方案是直接用PNG格式输出,PNG支持透明通道,不会有这个问题。但PNG体积比JPEG大很多,如果页面内容复杂,一张A4的PNG可能2-3MB,100页就是两三百MB,不太适合做列表缩略图。
第二个方案是渲染完成后、编码之前,先给PixelMap填充白色背景。我采用的就是这个方案。在鸿蒙里可以创建一个白色底的PixelMap,然后把渲染出来的PixelMap绘制到白色底上,最后编码JPEG。这样既保留了JPEG的体积优势,又解决了透明变黑的问题。代码大致是:
typescript复制import { image } from '@kit.ImageKit';
// 假设renderedPixelMap是PDF渲染出来的图
const width = renderedPixelMap.getPixelMapWidth();
const height = renderedPixelMap.getPixelMapHeight();
// 创建一个白色底的PixelMap
const whiteBmp = await image.createPixelMap(
new Uint8Array(width * height * 4).fill(0xFF), // 全不透明,红色通道初始化后由下面填充
{
width: width,
height: height,
pixelFormat: image.PixelMapFormat.ARGB_8888
}
);
// 用白色填充
const fillColor = { argb: 0xFFFFFFFF };
await whiteBmp.fillColor(fillColor);
// 把PDF渲染结果绘制到白色底上
const drawContext = await whiteBmp.createImageDrawingContext();
drawContext.drawImage(renderedPixelMap, { left:0, top:0, width: width, height: height });
// 之后用whiteBmp去编码JPEG即可
这样处理之后,JPEG的输出永远是白底,不会再出现黑色块。
5.3 混合尺寸PDF不能假设所有页面一样大
我还遇到过一种PDF,前几页是A4,中间夹了几页A3,最后又回到A4。如果用"第一页的尺寸当所有页的尺寸"来优化,渲染出来的图片就会变形——A3页面的内容被挤压或者截断。
解决办法其实已经在核心代码里体现了:每一页都单独调用page.getSize()获取真实尺寸,再设置渲染参数。千万不要为了省一次调用而缓存第一页的尺寸。这个坑隐藏得很深,因为测试文档往往都是统一尺寸,只有遇到真实世界的混排文档才会暴露。
从设计层面看,混排文档的处理思路应该是:所有页面统一scale,但不统一输出尺寸。输出尺寸完全由每页的实际尺寸乘以scale决定。这样大小页面都能完整渲染,不会有任何裁剪或拉伸。
5.4 加密和损坏文档的异常处理
PDF文档不全是"好人"。有的PDF设置了打开密码,有的PDF文件本身损坏,有的PDF页面虽然存在但渲染不了。这些情况在pdf.PDFDocument.open或page.render的时候都会抛异常。
我的代码里,open阶段的异常会直接抛给上层,由UI层弹toast提示"无法打开PDF或文件已加密"。loadPage和render阶段的异常,如果直接让整个任务失败,用户转一本坏书就只能得到一个错误提示,体验很差。所以我内层try/catch的逻辑是:某一页渲染失败,记录错误日志,跳过这一页,继续处理后面的页。最终返回的图片列表里,这一页缺失。
这个决策从产品角度看是合理的:大部分PDF只是个别页损坏,前面的内容还能用。整体放弃不如部分可用。当然,如果连续失败页数超过一定比例(比如超过50%),我会主动中断整个任务,避免生成本质上不可用的结果。
5.5 沙箱路径与Uri的坑
前面提到过,FilePicker返回的是uri,不能直接当路径用。这里再补充一个相关细节:如果PDF是借助FilePicker选中的,它可能位于媒体库、云盘或者第三方文件管理器的目录。这些路径不一定是应用沙箱内可读的。
我已经验证过,在API 12及以后的版本上,fileIo.openSync可以直接基于uri打开文件,拿到fd。但在API 12之前的版本,可能要先用mediaLibrary或fileUri转换。所以如果你的应用要适配低版本,建议封装一个统一入口:优先用fileIo.openSync(uri),失败再尝试其他方式。
另外,如果PDF本身在应用沙箱目录里,比如filesDir下,那直接传绝对路径给fileIo.openSync即可。这一步通常不会有问题,但要注意沙箱目录在应用卸载后会清掉,跨应用传PDF时要把文件先复制到自己的沙箱再处理。
6. 从"转图片"到"预览书架"的扩展实践与一点个人体会
6.1 转换结果如何组织:从图片列表到阅读器预览
PDF转图片做完之后,我顺手把它接到了一个简单的预览书架上。生成好的图片按文件名排序,用Grid展示网格封面,点进去用Swiper左右滑动翻页。这样用户看到的效果,跟主流PDF阅读器的翻页体验很接近。
数据组织上,我给每一本转换完成的PDF建立了一个索引结构:
typescript复制interface PdfImageBook {
bookId: string; // 用pdf文件的hash作为id
title: string; // 书名
pageCount: number; // 总页数
imagePaths: string[]; // 每一页图片的沙箱路径
scale: number; // 当时使用的scale
}
bookId用PDF文件内容的hash而不是文件名,因为同名文件很常见,内容不同却用同一个id会导致缓存错乱。每次用户选择一个新的PDF,先算hash,再查缓存目录里有没有对应id的转换结果。如果有,直接加载图片列表,跳过重新转换。这个缓存命中率在实际使用中很高,尤其是用户反复打开同一份合同、同一本书的场景。
6.2 一处需要特别留意的细节:图片文件要不要清缓存
转换出来的图片很占空间。scale=2.0时,一页JPEG大概150-300KB,100页就是15-30MB,如果用户转换了十本书,光图片缓存就可能占用300MB。所以我在书架上加了一个"清除缓存"的入口,提供两个操作:清除单本书的图片、清除所有书的图片。每次进入书架时,也会检查缓存总大小,超过500MB就提示用户清理。
这个功能不算代码量很大,但在真机使用中非常实用。很多用户不会主动清理应用缓存,如果没有入口,应用体积会被转图片功能撑爆,严重的会被系统提示"存储空间不足"。
6.3 个人实际使用中的体会:稳定比性能更重要
整个PDF转图片功能从开发到上线,我最大的体会是:这种底层能力型的任务,稳定性和内存控制比单纯的性能优化更重要。性能不好,用户最多等久一点;但如果内存管理没做好,直接崩溃,用户会彻底失去信任。
所以我最终的线上版本,没有用最激进的8页并发,而是选择了3页并发加内存降级策略。实测下来,120页的PDF大约18秒转完,内存峰值控制在500MB以内,真机连续转换10本书也没有崩溃。这个成绩可能不是最优的,但足够稳定,符合"工具型功能宁可慢一点,不能崩一次"的原则。
如果你也想在项目里做类似的功能,我的建议是:先把最基础的顺序逐页版本跑通,再逐步加并发、加降级、加缓存。每一步都验证无误后再进入下一步。PDF格式的复杂度决定了它的边界情况特别多,稳扎稳打比一口气堆功能要可靠得多。
最后分享一个小技巧:如果你在真机上测试时发现某页渲染出来颜色偏淡,不要先怀疑代码,先检查PDF页面本身。很多PDF的设计源文件就是浅色系,渲染结果忠实还原了原色,这不算Bug。但如果你用JPEG编码后整体发灰,记得检查colorMode是不是ARGB_8888,以及编码时quality是不是被调得太低了。这两个因素,是我在调试中最常遇到的颜色异常来源。
