之前公司要求在android项目上实现一个简单的富文本编辑功能,搞的要死不活,好不容易搞出一个凑活着能用的,结果落下了一个PDF导出功能(其实是之前搞这一块搞得很火大,根本就不知道还有个PDF导出功能),所以在得知这一消息时,我是拒绝承认有这么一个功能的。时间一久,这一功能也就默认没有了。导出PD什么?不存在的。
这几天闲着没事,就顺手来研究了一下android生成PDF文件的方案,踩了一些坑,最后也算搞出了一个能用的方案出来。
-
目前主流的生成PDF文件的方法
- 用iText三方库解决
扒了一下相关的帖子,貌似对中文支持不好,有乱码问题。不过具体会遇到什么问题我并没有尝试过,因为我在逛stackoverflow时了解到这玩意儿是基于GPLv3协议的(有兴趣可自行了解),简单来说就是你用了这个库,你的相关模块也得开源,所以一般商用开发很少回去用GPL协议的三方库。
因为这个也是和头争了很久,他的意思就是先不刁什么协议,搞出来把什么中文乱码一并解决了再说,出了纠纷他负责。奈何我也是一个老ass mong男了,我的想法是我们这种小作坊,没有大流氓的底子,就不要玩大流氓的手段。当然最关键的问题在于,出了问题负责有什么用,最后还不是得我改,这锅不能背,所以坚决不妥协。
其他的三方库或多或少也是有各种问题,这里也就不在一一列举,也不知这篇文章的重点。
这里扯句题外的,比如开发音视频常用的FFmpeg,这也是一个GPL协议的三方库。不过协议归协议,架不住别人耍流氓,所以FFmpeg上有一个耻辱墙,所有违反了这一协议的公司及其产品都被biao在了上面,其中不乏我们很熟悉的一些名字。 - 采用android原生的系统打印功能
在android API19,即4.4版本开始,谷歌引入了打印API。鉴于当前时间下android4.4以下版本市场占有率已经不足10%,所以决定采取版本差异化处理,仅支持API19及其以上的版本。
- 用iText三方库解决
-
Android原生的主要打印类及API介绍
- PrintHelper
这是专门用于打印bitmap的一个类,举一个栗子:
PrintHelper photoPrinter = new PrintHelper(getActivity());
photoPrinter.setScaleMode(PrintHelper.SCALE_MODE_FIT);
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.droids);
photoPrinter.printBitmap("droids.jpg - test print", bitmap);
当我们执行photoPrinter.printBitmap()方法后系统的打印界面会弹出,用户可以自行设置一些参数(如纸张尺寸、方向、页数等),然后点击打印或者取消。另外,这里打印的bitmap不仅仅限与通过资源获取到的bitmap,例如你通过view获取到的bitmap也可以打印。
- PdfDocument
这个类可以让我们通过原生的android View生成PDF文件,这里贴一下官方的例子:
// create a new document
PdfDocument document = new PdfDocument();
// crate a page description
PageInfo pageInfo = new PageInfo.Builder(new Rect(0, 0, 100, 100), 1).create();
// start a page
Page page = document.startPage(pageInfo);
// draw something on the page
View content = getContentView();
content.draw(page.getCanvas());
// finish the page
document.finishPage(page);
. . .
// add more pages
. . .
// write the document content
document.writeTo(getOutputStream());
//close the document
document.close();
可以看到,这里是将View的直接通过canvas画到了document的Page上面,然后通过writeTo()方法保存到指定位置。这里和下面讲到的PrintedPdfDocument都需要注意两点:
一 这种方式将不再启动系统的打印界面,即可以用户操作直接生成PDF文件。
二 这种方式是直接通过canvas简单粗暴地把content直接画到了PDF上,如果你的内容包含文字的话,生成的PDF是无法选中文字的,你可以理解为直接将整个View保存成了一张图片然后生成了PDF。如果包含文字的话,需要用到TextPaint:
TextPaint textPaint = new TextPaint();
textPaint.setColor(Color.BLACK);
textPaint.setTextSize(12);
textPaint.setTextAlign(Paint.Align.LEFT);
Typeface textTypeface = Typeface.create(Typeface.MONOSPACE, Typeface.NORMAL);
textPaint.setTypeface(textTypeface);
String text = "some text";
StaticLayout mTextLayout = new StaticLayout(text, textPaint, page.getCanvas().getWidth(),
Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false);
mTextLayout.draw(page.getCanvas());
- PrintedPdfDocument
继承于PdfDocument,区别在于可以为打印策略设置参数,比如纸张大小、外边距等。这里同样举个例子嗦一下:
PrintAttributes attributes = new PrintAttributes.Builder()
.setMediaSize(PrintAttributes.MediaSize.ISO_A4)
.setResolution(new PrintAttributes.Resolution("id", Context.PRINT_SERVICE, 300, 300))
.setColorMode(PrintAttributes.COLOR_MODE_COLOR)
.setMinMargins(new PrintAttributes.Margins(0, 0, 0, 0))
.build();
PdfDocument document = new PrintedPdfDocument(context, attributes);
for (int i = 0; i < numberOfPages; i++) {
int webMarginTop = i * letterSizeHeight;
PdfDocument.PageInfo pageInfo = new PdfDocument.PageInfo.Builder(webViewWidth, letterSizeHeight, i + 1).create();
PdfDocument.Page page = document.startPage(pageInfo);
page.getCanvas().translate(0, -webMarginTop);
webView.draw(page.getCanvas());
document.finishPage(page);
}
document.writeTo(getOutputStream());
document.close();
- PrintDocumentAdapter
这是一个自定义打印文档的基础适配器类,而且是一个抽象类,需要我们自己去实现具体的打印过程。这个类主要是在打印自定义文档时配合PrintManager类使用。其内部生命周期主要有以下方法:
onStart():开始打印时调用,注意这个方法需是在主线程调用的
onLayout():在打印设置改变时调用,即当PrintAttributes发生变化时会调用这个方法以重新布局来适应新的打印参数
onWrite():在将内容写入PDF文件时调用,这个方法同样是在主线程调用的
onFinish():打印结束时调用
通俗地讲,你要打印一个文件,扔过来一个View,你总得告诉别人怎么具体打印对吧,这就是这个适配器干的事。 - PrintManager
不多BB了,直接上代码:
PrintManager printManager = (PrintManager) getSystemService(Context.PRINT_SERVICE);
// Get a print adapter instance
// MyCreatePrintDocumentAdapter为继承PrintDocumentAdapter抽象类的子类
PrintDocumentAdapter printAdapter = new MyCreatePrintDocumentAdapter();
// Create a print job with name and adapter instance
String jobName = context.getString(com.sumxiang.noteapp.R.string.app_name) + " Document";
PrintAttributes attributes = new PrintAttributes.Builder()
.setMediaSize(PrintAttributes.MediaSize.ISO_A4)
.setResolution(new PrintAttributes.Resolution("id", Context.PRINT_SERVICE, 300, 300))
.setColorMode(PrintAttributes.COLOR_MODE_COLOR)
.setMinMargins(PrintAttributes.Margins.NO_MARGINS)
.build();
// 这里第三个参数可以传自定义attributes,也可以直接传null,此时将使用默认配置。
printManager.print(jobName, printAdapter, attributes);
上面的代码就是一个调用系统打印自定义文档的过程。当执行printManager.print()方法后就会调用系统打印界面,而打印的具体策略和方式由传递的attributes设置和我们具体实现printAdapter内部方法决定。
-
采用原生打印API将Webview内容生成PDF的具体实现思路
- 低配版方案
首先我实现了一个低配版方案,即调用自定义打印服务,然后手动选择打印设置、打印并保存。使用自定义打印方式的原因很明显,因为Webview中的元素实在是过于复杂,如果将其当做普通的View采用PdfDocument来自行实现打印过程对我来说是不现实的。更重要的是,自定义打印过程中最关键的PrintDocumentAdapter,webview是已经帮我们实现了的,无需我们再自己实现。废话不多说,贴上代码:
PrintManager printManager = (PrintManager) context.getSystemService(Context.PRINT_SERVICE);
// Get a print adapter instance
PrintDocumentAdapter printAdapter = webView.createPrintDocumentAdapter();
// Create a print job with name and adapter instance
String jobName = context.getString(com.sumxiang.noteapp.R.string.app_name) + " Document";
PrintAttributes attributes = new PrintAttributes.Builder()
.setMediaSize(PrintAttributes.MediaSize.ISO_A4)
.setResolution(new PrintAttributes.Resolution("id", Context.PRINT_SERVICE, 200, 200))
.setColorMode(PrintAttributes.COLOR_MODE_COLOR)
.setMinMargins(PrintAttributes.Margins.NO_MARGINS)
.build();
printManager.print(jobName, printAdapter, attributes);
基本上就是把自定义打印那一段照搬了过来,只是这里适配器是调webView的createPrintDocumentAdapter()方法拿到的。实现到这里可用性其实已经很高了,而打印出来的效果还不错,拿去和公司iOS版本的比较了一下,排版基本上没有差异,连图片换页都帮我们处理好了。
- 改进交互时遇到的问题
当然如果只是止步于这种程度,那前面的一大堆东西就白写了,大家也白看了。在公司里,这个玩意儿一掏出来用脚想也知道,产品肯定会说,哎。那个sei,我们能不能省掉用户自己选择打印这个过程。给他一个按钮,他一按,duang~,PDF就蹦出来了。
这么一duang,我们的第一反应就是去看看能不能自己调用开始打印自定义文档的方法,给个路径不就完了嘛。不过很可惜,官方似乎并不支持这种做法,没有提供相应的函数。PrintManager只提供了getPrintJobs()和print()两个方法。
所以我们转而来研究PrintDocumentAdapter ,如果能自行调用PrintDocumentAdapter 相应的声明周期方法不就完事了嘛。所以就有了下面的尝试:
printAdapter = webView.createPrintDocumentAdapter();
printAdapter.onStart();
printAdapter.onLayout(attributes, attributes, new CancellationSignal(), new PrintDocumentAdapter.LayoutResultCallback() {
@Override
public void onLayoutFinished(PrintDocumentInfo info, boolean changed) {
super.onLayoutFinished(info, changed);
}
@Override
public void onLayoutFailed(CharSequence error) {
super.onLayoutFailed(error);
}
@Override
public void onLayoutCancelled() {
super.onLayoutCancelled();
}
},new Bundle());
写到这里就写不下去了。第一,PrintDocumentAdapter.LayoutResultCallback按照我们的逻辑应该是由Webview提供的适配器实现的,而PrintDocumentAdapter又是一个抽象类,webview的具体实现子类里面长啥样我们并不知道。第二,PrintDocumentAdapter.LayoutResultCallback同样也是个抽象类,所以上面的代码编译都别想过。到这里就懵逼了。
于是乎,又开始满大街的翻帖子。不过翻过去翻过来,就像我初中化学老师经常说的一句话,你们班的作业就几个版本。要么起手式就是iText,要么就是上面打印服务的那一段代码。最后同样是在stackoverflow的这篇回答下找到了答案,答案是最后的那条0支持的回答,而非第一条,核心就是用DexMaker黑科技来解决。
-
DexMaker介绍
github地址:https://github.com/linkedin/dexmaker
这玩意儿干嘛的呢,用官方文档的话来说,就是来动态生成DEX字节码的API(其实我也不知道这句话是什么意思)。还是举个例子来说明吧:比如说我们要显示一个activity,那你总得写一个实打实的activity类吧。但是设想一下,假如说我们要启动的activity在写程序时并不知道要怎么写,要等程序运行起来之后才生成这么一个activity,这怎么玩儿呢?这时就可以用到DexMaker了。上面的表述可能并不准确,但是大概就是这个一个意思,即动态编码生成需要的类。因为DexMaker我只是初次接触,了解并不全面,而且三言两语也不可能解释的清楚,所以只是简单说一下在这里是干嘛用的,篇幅原因就不再深入介绍。
-
最终解决方案
通过之前自定义打印文档的过程,我们可以推断:在弹出系统打印界面并且我们确定打印之后,后续的打印操作即是系统控制调用了PrintDocumentAdapter的生命周期方法完成了打印任务。所以当前我们的重点就是获取到系统内部实现的继承于PrintDocumentAdapter.LayoutResultCallback的实例。通过上文提到的stackoverflow的解决思路,这里用到了ProxyBuilder这么一个类,通过官方文档的描述可以知道这个类可以为我们创建一个动态生成的代理类来替代真正的实体类。
所以,我们就先来生成一个PrintDocumentAdapter.LayoutResultCallback的动态代理类:
public static PrintDocumentAdapter.LayoutResultCallback getLayoutResultCallback(InvocationHandler invocationHandler, File dexCacheDir) throws IOException {
return ProxyBuilder.forClass(PrintDocumentAdapter.LayoutResultCallback.class)
.dexCache(dexCacheDir)
.handler(invocationHandler)
.build();
}
dexCacheDir参数是动态编码保存的路径,因为DexMaker的原理就是在程序运行的时候才去生成需要编译的文件,所以得指定一个路径。
invocationHandler的作用是监听内部方法的调用,这里的作用自然就是监听PrintDocumentAdapter内部的生命周期方法的调用情况
到这里,核心问题就解决了,下面再来梳理一下打印的具体逻辑:
- 首先,我们获取到webview的PrintDocumentAdapter实现子类,然后手动触发其onStart()方法,之后执行onLayout()完成布局,这里利用DexMaker的机制传递一个代理的回调参数。
- 在PrintDocumentAdapter.LayoutResultCallback的代理类中,我们通过监听LayoutResultCallback内部方法的调用情况来判断打印任务的状态。LayoutResultCallback有三个抽象方法:
onLayoutFinished():表示打印完成
onLayoutFailed():表示打印失败
onLayoutCancelled():表示打印取消 - 根据不同的方法调用情况,我们就可以得到具体的打印结果然后做进一步操作了。其中,在监听到打印完成后,我们就可以调用PrintAdapter.onWrite()方法写入本地保存了,这里同样需要我们传递一个PrintDocumentAdapter.WriteResultCallback的实现子类对写入过程和结果进行监听,方式和上面一样我们可以利用DexMaker来完成
到这里整个打印和写入本地的逻辑就完成了,这里贴一下整个过程的代码:
private void printPDFFile() {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) {
/**
* android 5.0之后,出于对动态注入字节码安全性德考虑,已经不允许随意指定字节码的保存路径了,需要放在应用自己的包名文件夹下。
*/
//创建DexMaker缓存目录
//File dexCacheFile = new File(dexCacheDirPath);
//if (!dexCacheFile.exists()) {
//file.mkdir();
//}
//新的创建DexMaker缓存目录的方式,直接通过context获取路径
File dexCacheFile = context.getDir("dex", 0);
if (!dexCacheFile.exists()) {
dexCacheFile.mkdir();
}
try {
//创建待写入的PDF文件,pdfFilePath为自行指定的PDF文件路径
File pdfFile = new File(pdfFilePath);
if (pdfFile.exists()) {
pdfFile.delete();
}
pdfFile.createNewFile();
descriptor = ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_WRITE);
// 设置打印参数
PrintAttributes attributes = new PrintAttributes.Builder()
.setMediaSize(PrintAttributes.MediaSize.ISO_A4)
.setResolution(new PrintAttributes.Resolution("id", Context.PRINT_SERVICE, 300, 300))
.setColorMode(PrintAttributes.COLOR_MODE_COLOR)
.setMinMargins(PrintAttributes.Margins.NO_MARGINS)
.build();
// 计算webview打印需要的页数
int numberOfPages = (webviewHeight / pageHeight) + 1;
ranges = new PageRange[]{new PageRange(1, numberOfPages)};
// 获取需要打印的webview适配器
printAdapter = webView.createPrintDocumentAdapter();
// 开始打印
printAdapter.onStart();
printAdapter.onLayout(attributes, attributes, new CancellationSignal(), getLayoutResultCallback(new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().equals("onLayoutFinished")) {
// 监听到内部调用了onLayoutFinished()方法,即打印成功
onLayoutSuccess();
} else {
// 监听到打印失败或者取消了打印
do something...
}
return null;
}
}, dexCacheFile.getAbsoluteFile()), new Bundle());
} catch (IOException e) {
e.printStackTrace();
}
}
}
private void onLayoutSuccess() throws IOException {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
PrintDocumentAdapter.WriteResultCallback callback = getWriteResultCallback(new InvocationHandler() {
@Override
public Object invoke(Object o, Method method, Object[] objects) throws Throwable {
if (method.getName().equals("onWriteFinished")) {
// PDF文件写入本地完成,导出成功
do something for succeed...
} else {
// 导出失败
do something for extract failed...
}
return null;
}
}, dexCacheFile.getAbsoluteFile());
printAdapter.onWrite(ranges, descriptor, new CancellationSignal(), callback);
}
}
public static PrintDocumentAdapter.LayoutResultCallback getLayoutResultCallback(InvocationHandler invocationHandler, File dexCacheDir) throws IOException {
return ProxyBuilder.forClass(PrintDocumentAdapter.LayoutResultCallback.class)
.dexCache(dexCacheDir)
.handler(invocationHandler)
.build();
}
public static PrintDocumentAdapter.WriteResultCallback getWriteResultCallback(InvocationHandler invocationHandler, File dexCacheDir) throws IOException {
return ProxyBuilder.forClass(PrintDocumentAdapter.WriteResultCallback.class)
.dexCache(dexCacheDir)
.handler(invocationHandler)
.build();
}
代码中部分基础的变量并未做出声明,需要自行补充。
-
补充注意事项
在PDF导出功能的后续测试中,发现了android4.4版本生成的PDF文件异常巨大的问题,在android5.0以上版本中,同样的数据生成的文件大概在600KB左右,而4.4居然达到了17MB。即使是纯文本,差距也在10倍左右,去stackoverflow上又逛了一圈,最后发现android4.4版本的PDF打印是没有经过压缩的。我重新调用系统的打印页面,生成的文件同样大的吓死人。不过这也仅限于android4.4版本,目前也没找到什么有效的解决方案。如果大家有什么好的解决办法欢迎留言交流。
-
总结
这篇文章基本上算是自己扒了各种帖子、文章、问答之后根据自己爬坑之路写的,主要在于分享自己的实现过程和方法,并没有对源码进行深入探究。自己水平有限,对于一些概念的理解肯能会有偏差,欢迎大家指出文中的错误,也欢迎留言交流。
-
2017.07.24问题反馈与改进
1.原文中创建动态字节码缓存目录处://创建DexMaker缓存目录 File dexCacheFile = new File(dexCacheDirPath); if (!dexCacheFile.exists()) { file.mkdir(); }
在后续的测试过程中,测试反应了部分手机无法生成PDF的情况,发现是android在高版本中已经对动态注入字节码这种编译方式做出了限制。想想也是,可以随意指定动态字节码的路径意味着可以完全无压力的动态注入修改,对应用来说是非常不安全的,所以现在DexMaker的缓存目录推荐通过context.getDir("dex", 0)方法获取,原来的方法在高版本中应该会抛出异常。
现修改为:
//新的创建DexMaker缓存目录的方式,直接通过context获取路径
File dexCacheFile = context.getDir("dex", 0);
if (!dexCacheFile.exists()) {
dexCacheFile.mkdir();
}
2.原文中创建pdf文件缓存目录(现时间点已经删除)
// 创建pdf文件缓存目录
cacheFolder = new File(pdfFileDirPath);
if (!cacheFolder.exists()) {
cacheFolder.mkdir();
}
这里由于当时疏忽,写错了,这段代码并没有什么吊用。。。pdfFileDirPath也是不存在的。cacheFolder和dexCacheFile实际上是一个东西,新的代码把cacheFolder改为dexCacheFile就可以了。
- dexCacheFile在回调中使用时需要用dexCacheFile.getAbsoluteFile()来获取绝对路径。
上述问题在之前修改了之后因为时间关系并没有第一时间在文章中更新,后面有读者私信反馈这个问题,所以就一并修改了,同时对部分读者造成了误导表示抱歉。在此感谢提出问题的读者,同时欢迎大家能继续指正文中的不足。