手机上完成XML转PDF,不是把文件后缀改一下就能实现。XML关注的是内容和层级,PDF关注的是每一页上文字、线条、图片的绝对坐标。移动端没有现成的HTML渲染引擎可供直接打印,所以整个过程可以拆成三步:解析XML得到节点树,把节点转换为带坐标的绘制指令,再把指令提交给PDF生成库逐页输出。下面按这个顺序展开,重点说明在Android和Flutter环境下如何落地。

一、先解析XML,得到适合移动端处理的节点结构
移动端内存有限,解析XML前需要先判断文件大小和层级深度,再选择解析方式。DOM解析器会把整份文档加载进内存,生成完整树,小文件使用方便,但遇到大文件或嵌套层级较深时容易触发OOM。SAX基于事件流,内存占用低,但编码时需要手动维护上下文,递归逻辑容易写乱。Android自带的XmlPullParser介于两者之间,按需拉取事件,不需要一次性载入全部内容,更适合移动端转换工具。iOS上可以使用XMLParser,同样是事件驱动,思路基本一致。
下面以Android为例,从应用私有目录读取XML文件,递归收集节点名称、属性和文本内容。解析时会维护当前节点的父级引用,这样在遇到结束标签时可以正确回退。
val factory = XmlPullParserFactory.newInstance()
val parser = factory.newPullParser()
parser.setInput(FileInputStream(file), "UTF-8")
var eventType = parser.eventType
val nodes = mutableListOf<XmlNode>()
var current: XmlNode? = null
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> {
val node = XmlNode(parser.name)
for (i in 0 until parser.attributeCount) {
node.attrs[parser.getAttributeName(i)] = parser.getAttributeValue(i)
}
current?.children?.add(node)
current = node
nodes.add(node)
}
XmlPullParser.TEXT -> {
if (!parser.isWhitespace) {
current?.text = parser.text
}
}
XmlPullParser.END_TAG -> {
current = current?.parent
}
}
eventType = parser.next()
}
节点模型可以简单设计为name、attrs、text、children四个字段,层级关系在解析时通过parent指针维护。递归构建结构时要注意过滤空白文本节点,避免后续PDF中出现没有实际内容的空行。如果XML中包含CDATA或特殊实体引用,还需要在解析器初始化时开启对应特性,并在TEXT事件中根据parser.isWhitespace判断是否保留空白。
解析完成后不要直接进入渲染。建议先对节点做一次规范化处理,例如把字符串属性统一转为实际字体、字号、颜色值,把尺寸单位从dp、sp换算成PDF磅值。这样后续测量分页就不用反复关心XML原始格式。移动端不同屏幕密度下,1dp并不是固定像素,通常可以先换算为1/72英寸的磅,或者按打印页面实际宽度比例换算。这一步容易被忽略,导致同一份XML在不同设备上生成的PDF行数不一致。
二、将节点转换为页面坐标,并计算分页
PDF页面坐标原点在左下角,而移动端Canvas一般以左上角为原点。开始绘制前需要统一坐标方向,否则第一页内容可能从底部开始。常用做法是把PDF页面高度作为基准,绘制文本时用pageHeight减去baseline得到实际y坐标。A4页面按72dpi计算是595乘以842磅,如果目标页面是A4,所有测量都应基于这个尺寸,而不是手机屏幕像素。
文本换行不能依赖TextView,因为TextView依赖View体系,在后台线程操作有风险。可以直接使用Paint的measureText和breakText方法完成测量。breakText会按照可用宽度返回每行能放下的字符数量,适合在生成PDF时做自动换行。
private fun wrapText(text: String, paint: Paint, maxWidth: Float): List<String> {
val lines = mutableListOf<String>()
var start = 0
while (start < text.length) {
val count = paint.breakText(text, start, text.length, true, maxWidth, null)
if (count == 0) break
lines.add(text.substring(start, start + count))
start += count
}
return lines
}
测得行高后累加y坐标,当剩余空间放不下下一行,或者遇到分页标记节点时,就新建一页,并把当前节点的后续内容放到新页。图片、表格等非文本节点需要单独处理。图片节点可以读取base64或本地路径,解析为Bitmap后使用canvas.drawBitmap绘制,宽度超过可用区域时按比例缩放。表格需要根据列宽属性计算每一列的x起点,并给每个单元格加上边框。边框可以借助drawRect完成,但跨页时需要避免同一行被上下两条边框重复绘制。
三、Android原生生成PDF文件的完整示例
Android从API 19开始提供PdfDocument类,可以不依赖第三方库完成基础PDF生成。先创建PageInfo,指定页面宽高和页码,再通过startPage获得Canvas,所有drawText、drawLine、drawBitmap最终都会写入PDF。下面是一个生成单页文本PDF的示例。
val pdf = PdfDocument()
val pageInfo = PdfDocument.PageInfo.Builder(595, 842, 1).create()
val page = pdf.startPage(pageInfo)
val canvas = page.canvas
val paint = Paint().apply {
color = android.graphics.Color.BLACK
textSize = 28f
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL)
}
var baseline = 100f
for (line in lines) {
canvas.drawText(line, 60f, baseline, paint)
baseline += 32f
}
pdf.finishPage(page)
val file = File(context.filesDir, "output.pdf")
FileOutputStream(file).use { fos ->
pdf.writeTo(fos)
}
pdf.close()
内容写完后必须调用finishPage,否则当前页面不会写入文档。writeTo可以输出到FileOutputStream,建议写到context.filesDir或getExternalFilesDir目录,这些目录不需要动态申请存储权限。只有保存到系统下载目录或公共文档目录时,才需要走MediaStore或SAF授权流程。生成PDF的过程涉及大量测量和绘制,不要放在主线程,可以使用协程或线程池执行,完成后切回主线程提示用户。
还需要注意中文字体问题。PdfDocument底层依赖系统字体,中文在大多数Android设备上可以显示,但如果用户修改了系统字体,或者某些设备缺少对应字体文件,就可能出现空白或方块。更稳妥的做法是加载自定义字体文件,通过Typeface.createFromAsset创建字体,并在每次绘制时传入Paint。跨平台方案中也应当嵌入必要的中文字体,而不是依赖设备字体。
四、跨平台方案与常见移动端陷阱
Flutter开发者通常使用pdf和printing包完成类似功能。pdf包提供Document、MultiPage和Widget系统,可以用接近Widget的写法生成PDF,并且支持字体嵌入。下面示例展示如何加载字体、创建多页文档并写入应用文档目录。
final doc = pw.Document();
final font = await rootBundle.load('assets/fonts/SourceHanSansCN-Regular.ttf');
final ttf = pw.Font.ttf(font);
doc.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
build: (context) => [
pw.Text('从 XML 解析出的内容', style: pw.TextStyle(font: ttf, fontSize: 14)),
],
),
);
final bytes = await doc.save();
final dir = await getApplicationDocumentsDirectory();
await File('${dir.path}/result.pdf').writeAsBytes(bytes);
跨平台方案的优势是一次编写可以同时覆盖Android和iOS,但需要注意pdf包的文本测量与字体加载有关。不嵌入中文字体时,在iOS真机上也可能出现乱码或空白。字体文件通常较大,建议选择子集化字体或只嵌入必要的字形,避免应用包体积过度膨胀。iOS原生则可以使用UIGraphicsPDFRenderer,思路与Android类似,先创建PDFRenderer,再通过CoreText或UIKit完成绘制。
另一个常见问题是文件分享。生成PDF后,用户通常希望从手机里打开或发送。Android 7.0以上需要通过FileProvider提供URI,不能直接使用file://路径。iOS可以通过UIActivityViewController弹出分享面板。开发完成后建议在低端设备上测试50页以上带图片的XML文件,观察内存峰值和分页准确性。如果出现空白页,优先检查finishPage是否匹配、分页时是否重复创建PageInfo;如果中文字体丢失,检查Typeface是否每次绘制都重新创建,以及字体资源是否随包发布。
手机端XML转PDF的核心不是某个库的调用,而是测量与分页策略。解析XML后,把所有可变内容先转成明确的绘制指令,再让PDF库只负责输出。这种分层方式可以显著降低内存占用,也方便后续在不同平台之间复用逻辑。只要把解析、测量、绘制三个阶段分开处理,移动端也能稳定输出排版准确的PDF文件。
XML转PDF移动端PDF生成Android PDF渲染修改时间:2026-10-01 04:52:00