之前公司要求在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()來獲取絕對路徑。
上述問題在之前修改了之后因為時間關系并沒有第一時間在文章中更新,后面有讀者私信反饋這個問題,所以就一并修改了,同時對部分讀者造成了誤導表示抱歉。在此感謝提出問題的讀者,同時歡迎大家能繼續指正文中的不足。