1、背景 近期,公司希望實現安卓原生端的PDF功能,要求:高效、實用。 經過兩天的調研、編碼,實現了一個簡單Demo,如上圖所示。 關於安卓原生端的PDF功能實現,技術點還是很多的,為了咱們安卓開發的同學少走彎路,通過此文章,簡單講解下Demo的實現原理和主要技術點,並附上源碼。 2、安卓PDF現狀 ...
1、背景
近期,公司希望實現安卓原生端的PDF功能,要求:高效、實用。
經過兩天的調研、編碼,實現了一個簡單Demo,如上圖所示。
關於安卓原生端的PDF功能實現,技術點還是很多的,為了咱們安卓開發的同學少走彎路,通過此文章,簡單講解下Demo的實現原理和主要技術點,並附上源碼。
2、安卓PDF現狀
目前,PDF功能仍然是安卓的一個短板,不像iOS,有官方強大的PDF Kit可供集成。
不過,安卓也有一些主流的方案,不過各有優缺點:
1、google doc 線上閱讀,基於webview,國內需翻牆訪問(不可行)
2、跳轉設備中預設pdf app打開,前提需要手機安裝了pdf 軟體(可按需選擇)
3、內置 android-pdfview,基於原生native, apk增加約15~20M(可行,不過安裝包有點大)
4、內置 mupdf,基於原生native, 集成有點麻煩,增加約9M(可行,不過安裝包稍有點大)
5、內置 pdf.js,功能豐富,apk增加5M(基於Webview,性能低,js實現,功能定製複雜)
6、使用x5內核,需要客戶端完全使用x5內核(基於Webview,性能低,不能定製功能)
查閱官方資料,這些方案雖然能實現基本的PDF閱讀功能,但是多數方案,集成過程較複雜,且性能低下,容易記憶體溢出造成App閃退。
3、方案選擇
經過對各方案的反覆比對,本次實現PDF Demo,決定使用:android-pdfview。
原因:
1、android-pdfview基於PDFium實現(PDFium是谷歌 + 福昕軟體的PDF開源項目);
2、android-pdfview Github仍在維護;
3、android-pdfview Github獲得的星星較多;
4、客戶端集成較方便;
問題分析:
運行android-pdfview官方demo,問題也很多:
1、僅實現了pdf滑動閱讀、手勢伸縮的功能;
2、缺少pdf目錄樹、縮略圖等功能;
3、安裝包過大;
4、UI不美觀;
5、記憶體問題;
6、其他...
不過,不用擔心,解決了這些問題不就沒有問題了嘛,哈、哈、哈(笑聲有點勉強哈)
下麵,咱們開始實現Demo吧。
4、Demo設計
4.1、工程結構
在設計之前,應明確Demo的實現目標:
1、android-pdfview已實現了pdfview,可用於閱讀pdf文件,手勢伸縮pdf頁面、跳轉pdf頁面,
那麼,咱們基於android-pdfview擴展功能即可,功能包括:目錄樹、縮略圖等;
2、擴展的功能應邏輯解耦,不能影響android-pdfview代碼的可替換性
(即:如果android-pdfview有新版本,直接替換即可)
3、客戶端應很方便集成
(如:客戶端僅需要傳遞過來pdf文件,所有的載入、操作、記憶體管理均無需關心)
Demo工程如何設計:
下載android-pdfview最新源碼,可以看到共包含兩個Moudle:
android-pdf-viewer(最新源碼)
sample (示例app)如果,我們要接管封裝pdf的所有功能,讓sample只傳遞pdf文件即可,且不影響將來替換android-pdf-viewer的源碼,那麼我們創建一個modle即可,如下圖:
sample (依賴pdfui)
pdfui (依賴android-pdf-viewer)
android-pdf-viewer
4.2、PDF功能設計
為了便於用戶閱讀PDF,應該包含以下功能:
1、PDF閱讀(包含:手指滑動pdf頁面、手勢伸縮頁面內容、跳轉pdf指定頁面)
2、PDF目錄導航功能(包含:目錄展示、目錄節點摺疊、展開、點擊跳轉pdf頁面)
3、PDF縮略圖導航功能(包含:縮略圖展示、手指滑動、圖片緩存管理、點擊跳轉pdf頁面)
5、編碼之前,先解決安裝包過大的問題
反編譯Demo的安裝包,可以看到,安裝包中預設集成了各cpu平臺對應的so庫文件,安裝包過大的原因也就在這兒。其實正常項目開發中,對於各cpu平臺對應的so庫的保留或捨棄,主要考慮cpu平臺相容性、設備覆蓋率。
通常情況下,僅保留armeabi-v7a可以相容市面上絕大多數安卓設備,那麼,如何編譯時刪除其他的so呢?
可在android gradle中配置,如下:
android{
......
splits {
abi {
enable true
reset()
include 'armeabi-v7a' //如果想包含其他cpu平臺使用的so,修改這裡即可
}
}
}
重新編譯,生成的安裝包,僅剩5M左右了。
註意:如果項目中還有其他so庫,要根據項目實際需求,認真思考如何取捨了。
6、實現PDF閱讀功能
很簡單,因為android-pdf-viewer源碼中已經實現了該功能,我們寫一份精簡版的吧。
6.1、功能點:
1、可載入assets中的pdf文件
2、可載入uri類型的pdf文件(如果是線上的pdf文件,可通過網路庫先下載到本地,取其uri,本次Demo就不寫網路下載了)
3、pdf的基本展示功能(使用android-pdf-viewer的控制項實現:PDFView)
4、可跳轉至目錄頁面(目錄數據可通過intent直接傳遞過去)
5、可跳轉至預覽頁面(pdf文件信息可通過intent直接傳遞過去)
6、根據目錄頁面、預覽頁面帶回的頁碼,跳轉至指定的pdf頁面
6.2、代碼實現
重點內容:
1、PDFView控制項的使用;(比較簡單,詳見代碼)
2、如何從PDF文件中獲得目錄信息;(如何獲得目錄信息、什麼時機獲取,詳見代碼)
PDF閱讀頁面的代碼:PDFActivity
/**
* UI頁面:PDF閱讀
* <p>
* 主要功能:
* 1、接收傳遞過來的pdf文件(包括assets中的文件名、文件uri)
* 2、顯示PDF文件
* 3、接收目錄頁面、預覽頁面返回的PDF頁碼,跳轉到指定的頁面
* <p>
* 作者:齊行超
* 日期:2019.08.07
*/
public class PDFActivity extends AppCompatActivity implements
OnPageChangeListener,
OnLoadCompleteListener,
OnPageErrorListener {
//PDF控制項
PDFView pdfView;
//按鈕控制項:返回、目錄、縮略圖
Button btn_back, btn_catalogue, btn_preview;
//頁碼
Integer pageNumber = 0;
//PDF目錄集合
List<TreeNodeData> catelogues;
//pdf文件名(限:assets里的文件)
String assetsFileName;
//pdf文件uri
Uri uri;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
UIUtils.initWindowStyle(getWindow(), getSupportActionBar());//設置沉浸式
setContentView(R.layout.activity_pdf);
initView();//初始化view
setEvent();//設置事件
loadPdf();//載入PDF文件
}
/**
* 初始化view
*/
private void initView() {
pdfView = findViewById(R.id.pdfView);
btn_back = findViewById(R.id.btn_back);
btn_catalogue = findViewById(R.id.btn_catalogue);
btn_preview = findViewById(R.id.btn_preview);
}
/**
* 設置事件
*/
private void setEvent() {
//返回
btn_back.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
PDFActivity.this.finish();
}
});
//跳轉目錄頁面
btn_catalogue.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent intent = new Intent(PDFActivity.this, PDFCatelogueActivity.class);
intent.putExtra("catelogues", (Serializable) catelogues);
PDFActivity.this.startActivityForResult(intent, 200);
}
});
//跳轉縮略圖頁面
btn_preview.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent intent = new Intent(PDFActivity.this, PDFPreviewActivity.class);
intent.putExtra("AssetsPdf", assetsFileName);
intent.setData(uri);
PDFActivity.this.startActivityForResult(intent, 201);
}
});
}
/**
* 載入PDF文件
*/
private void loadPdf() {
Intent intent = getIntent();
if (intent != null) {
assetsFileName = intent.getStringExtra("AssetsPdf");
if (assetsFileName != null) {
displayFromAssets(assetsFileName);
} else {
uri = intent.getData();
if (uri != null) {
displayFromUri(uri);
}
}
}
}
/**
* 基於assets顯示 PDF 文件
*
* @param fileName 文件名稱
*/
private void displayFromAssets(String fileName) {
pdfView.fromAsset(fileName)
.defaultPage(pageNumber)
.onPageChange(this)
.enableAnnotationRendering(true)
.onLoad(this)
.scrollHandle(new DefaultScrollHandle(this))
.spacing(10) // 單位 dp
.onPageError(this)
.pageFitPolicy(FitPolicy.BOTH)
.load();
}
/**
* 基於uri顯示 PDF 文件
*
* @param uri 文件路徑
*/
private void displayFromUri(Uri uri) {
pdfView.fromUri(uri)
.defaultPage(pageNumber)
.onPageChange(this)
.enableAnnotationRendering(true)
.onLoad(this)
.scrollHandle(new DefaultScrollHandle(this))
.spacing(10) // 單位 dp
.onPageError(this)
.load();
}
/**
* 當成功載入PDF:
* 1、可獲取PDF的目錄信息
*
* @param nbPages the number of pages in this PDF file
*/
@Override
public void loadComplete(int nbPages) {
//獲得文檔書簽信息
List<PdfDocument.Bookmark> bookmarks = pdfView.getTableOfContents();
if (catelogues != null) {
catelogues.clear();
} else {
catelogues = new ArrayList<>();
}
//將bookmark轉為目錄數據集合
bookmarkToCatelogues(catelogues, bookmarks, 1);
}
/**
* 將bookmark轉為目錄數據集合(遞歸)
*
* @param catelogues 目錄數據集合
* @param bookmarks 書簽數據
* @param level 目錄樹級別(用於控制樹節點位置偏移)
*/
private void bookmarkToCatelogues(List<TreeNodeData> catelogues, List<PdfDocument.Bookmark> bookmarks, int level) {
for (PdfDocument.Bookmark bookmark : bookmarks) {
TreeNodeData nodeData = new TreeNodeData();
nodeData.setName(bookmark.getTitle());
nodeData.setPageNum((int) bookmark.getPageIdx());
nodeData.setTreeLevel(level);
nodeData.setExpanded(false);
catelogues.add(nodeData);
if (bookmark.getChildren() != null && bookmark.getChildren().size() > 0) {
List<TreeNodeData> treeNodeDatas = new ArrayList<>();
nodeData.setSubset(treeNodeDatas);
bookmarkToCatelogues(treeNodeDatas, bookmark.getChildren(), level + 1);
}
}
}
@Override
public void onPageChanged(int page, int pageCount) {
pageNumber = page;
}
@Override
public void onPageError(int page, Throwable t) {
}
/**
* 從縮略圖、目錄頁面帶回頁碼,跳轉到指定PDF頁面
*
* @param requestCode
* @param resultCode
* @param data
*/
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (resultCode == RESULT_OK) {
int pageNum = data.getIntExtra("pageNum", 0);
if (pageNum > 0) {
pdfView.jumpTo(pageNum);
}
}
}
@Override
protected void onDestroy() {
super.onDestroy();
//是否記憶體
if (pdfView != null) {
pdfView.recycle();
}
}
}
PDF閱讀頁面的佈局文件:activity_pdf.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<RelativeLayout
android:id="@+id/rl_top"
android:layout_width="match_parent"
android:layout_height="70dp"
android:layout_alignParentTop="true"
android:background="#03a9f5">
<Button
android:id="@+id/btn_back"
android:layout_width="60dp"
android:layout_height="30dp"
android:background="@drawable/shape_button"
android:text="返回"
android:textColor="#ffffff"
android:textSize="18sp"
android:layout_alignParentBottom="true"
android:layout_marginBottom="10dp"
android:layout_marginLeft="10dp"/>
<Button
android:id="@+id/btn_catalogue"
android:layout_width="60dp"
android:layout_height="30dp"
android:background="@drawable/shape_button"
android:text="目錄"
android:textColor="#ffffff"
android:textSize="18sp"
android:layout_alignParentRight="true"
android:layout_alignParentBottom="true"
android:layout_marginBottom="10dp"
android:layout_marginRight="10dp"/>
<Button
android:id="@+id/btn_preview"
android:layout_width="60dp"
android:layout_height="30dp"
android:background="@drawable/shape_button"
android:text="預覽"
android:textColor="#ffffff"
android:textSize="18sp"
android:layout_toLeftOf="@+id/btn_catalogue"
android:layout_alignParentBottom="true"
android:layout_marginBottom="10dp"
android:layout_marginRight="10dp"/>
</RelativeLayout>
<com.github.barteksc.pdfviewer.PDFView
android:id="@+id/pdfView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@+id/rl_top"/>
</RelativeLayout>
7、PDF目錄樹的實現
目錄樹的數據(目錄名稱、頁碼...),已在上個頁面獲取了,所以此頁面只需考慮目錄樹控制項的實現。
註意:之所以沒在這個頁面單獨獲取目錄樹的數據,主要考慮到android-pdfview、pdfium記憶體占用太大了,不想再次創建Pdf的相關對象。
7.1、PDF目錄樹效果圖
7.2、樹形控制項如何實現?
安卓預設沒有樹形控制項,不過我們可以使用RecyclerView或ListView實現。
如上圖所示:
列表每一行為一條目錄數據,主要包括:名稱、頁碼;
如果有子目錄,則出現箭頭圖片,該項可摺疊、展開,箭頭方向隨之改變;
子目錄的名稱文本隨目錄樹級別遞增向右偏移;
當前Demo實現方式為RecyclerView,應該如何實現上面的效果?
可在adapter中處理頁面效果、事件效果:
1、列表項內容展示
1、使用垂直線性佈局管理器;
2、每個item包含:箭頭圖片(如果有子目錄,則顯示)、命令名稱文本、頁碼文本;
2、摺疊效果
1、控制adapter數據集合的內容即可,如果某節點摺疊了,就把對應的子目錄數據刪除即可,
反之,加上,再notifyDataSetChanged通知數據源改變;
2、除此之外,還需有一個狀態來標記當前節點是展開還是摺疊,用於控制箭頭圖片方向的顯示;
3、目錄文本向右偏移效果
可通過目錄樹層級 * 固定左側間隔(如: 20dp),然後為目錄的textview控制項設置偏移即可;
目錄樹層級樹如何獲取? 可選方案:
1、遞歸集合自動獲取(需要遍歷,效率低一點,如果是可編輯的目錄結構,建議選擇)
2、創建數據的時候,直接寫死(因當前demo的PDF目錄結構不會被編輯,所以直接選擇這個方案吧)
7.3、代碼實現:
樹形控制項的數據對象TreeNodeData:
/**
* 樹形控制項數據類(會用於頁面間傳輸,所以需實現Serializable 或 Parcelable)
* 作者:齊行超
* 日期:2019.08.07
*/
public class TreeNodeData implements Serializable {
//名稱
private String name;
//頁碼
private int pageNum;
//是否已展開(用於控制樹形節點圖片顯示,即箭頭朝向圖片)
private boolean isExpanded;
//展示級別(1級、2級...,用於控制樹形節點縮進位置)
private int treeLevel;
//子集(用於載入子節點,也用於判斷是否顯示箭頭圖片,如集合不為空,則顯示)
private List<TreeNodeData> subset;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getPageNum() {
return pageNum;
}
public void setPageNum(int pageNum) {
this.pageNum = pageNum;
}
public boolean isExpanded() {
return isExpanded;
}
public void setExpanded(boolean expanded) {
isExpanded = expanded;
}
public int getTreeLevel() {
return treeLevel;
}
public void setTreeLevel(int treeLevel) {
this.treeLevel = treeLevel;
}
public List<TreeNodeData> getSubset() {
return subset;
}
public void setSubset(List<TreeNodeData> subset) {
this.subset = subset;
}
}
樹形控制項適配器 : TreeAdapter
/**
* 樹形控制項適配器
* 作者:齊行超
* 日期:2019.08.07
*/
public class TreeAdapter extends RecyclerView.Adapter<TreeAdapter.TreeNodeViewHolder> {
//上下文
private Context context;
//數據
public List<TreeNodeData> data;
//展示數據(由層級結構改為平面結構)
public List<TreeNodeData> displayData;
//treelevel間隔(dp)
private int maginLeft;
//委托對象
private TreeEvent delegate;
/**
* 構造函數
*
* @param context 上下文
* @param data 數據
*/
public TreeAdapter(Context context, List<TreeNodeData> data) {
this.context = context;
this.data = data;
maginLeft = UIUtils.dip2px(context, 20);
displayData = new ArrayList<>();
//數據轉為展示數據
dataToDiaplayData(data);
}
/**
* 數據轉為展示數據
*
* @param data 數據
*/
private void dataToDiaplayData(List<TreeNodeData> data) {
for (TreeNodeData nodeData : data) {
displayData.add(nodeData);
if (nodeData.isExpanded() && nodeData.getSubset() != null) {
dataToDiaplayData(nodeData.getSubset());
}
}
}
/**
* 數據集合轉為可顯示的集合
*/
private void reDataToDiaplayData() {
if (this.data == null || this.data.size() == 0) {
return;
}
if(displayData == null){
displayData = new ArrayList<>();
}else{
displayData.clear();
}
dataToDiaplayData(this.data);
notifyDataSetChanged();
}
@Override
public TreeNodeViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view = LayoutInflater.from(context).inflate(R.layout.tree_item, null);
return new TreeNodeViewHolder(view);
}
@Override
public void onBindViewHolder(TreeNodeViewHolder holder, int position) {
final TreeNodeData data = displayData.get(position);
//設置圖片
if (data.getSubset() != null) {
holder.img.setVisibility(View.VISIBLE);
if (data.isExpanded()) {
holder.img.setImageResource(R.drawable.arrow_h);
} else {
holder.img.setImageResource(R.drawable.arrow_v);
}
} else {
holder.img.setVisibility(View.INVISIBLE);
}
//設置圖片偏移位置
RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) holder.img.getLayoutParams();
int ratio = data.getTreeLevel() <= 0? 0 : data.getTreeLevel()-1;
params.setMargins(maginLeft * ratio, 0, 0, 0);
holder.img.setLayoutParams(params);
//顯示文本
holder.title.setText(data.getName());
holder.pageNum.setText(String.valueOf(data.getPageNum()));
//圖片點擊事件
holder.img.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
//控制樹節點展開、摺疊
data.setExpanded(!data.isExpanded());
//刷新數據源
reDataToDiaplayData();
}
});
holder.itemView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
//回調結果
if(delegate!=null){
delegate.onSelectTreeNode(data);
}
}
});
}
@Override
public int getItemCount() {
return displayData.size();
}
/**
* 定義RecyclerView的ViewHolder對象
*/
class TreeNodeViewHolder extends RecyclerView.ViewHolder {
ImageView img;
TextView title;
TextView pageNum;
public TreeNodeViewHolder(View view) {
super(view);
img = view.findViewById(R.id.iv_arrow);
title = view.findViewById(R.id.tv_title);
pageNum = view.findViewById(R.id.tv_pagenum);
}
}
/**
* 介面:Tree事件
*/
public interface TreeEvent{
/**
* 當選擇了某tree節點
* @param data tree節點數據
*/
void onSelectTreeNode(TreeNodeData data);
}
/**
* 設置Tree的事件
* @param treeEvent Tree的事件對象
*/
public void setTreeEvent(TreeEvent treeEvent){
this.delegate = treeEvent;
}
}
PDF目錄樹頁面:PDFCatelogueActivity
/**
* UI頁面:PDF目錄
* <p>
* 1、用於顯示Pdf目錄信息
* 2、點擊tree item,帶回Pdf頁碼到前一個頁面
* <p>
* 作者:齊行超
* 日期:2019.08.07
*/
public class PDFCatelogueActivity extends AppCompatActivity implements TreeAdapter.TreeEvent {
RecyclerView recyclerView;
Button btn_back;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
UIUtils.initWindowStyle(getWindow(), getSupportActionBar());
setContentView(R.layout.activity_catelogue);
initView();//初始化控制項
setEvent();//設置事件
loadData();//載入數據
}
/**
* 初始化控制項
*/
private void initView() {
btn_back = findViewById(R.id.btn_back);
recyclerView = findViewById(R.id.rv_tree);
}
/**
* 設置事件
*/
private void setEvent() {
btn_back.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
PDFCatelogueActivity.this.finish();
}
});
}
/**
* 載入數據
*/
private void loadData() {
//從intent中獲得傳遞的數據
Intent intent = getIntent();
List<TreeNodeData> catelogues = (List<TreeNodeData>) intent.getSerializableExtra("catelogues");
//使用RecyclerView載入數據
LinearLayoutManager llm = new LinearLayoutManager(this);
llm.setOrientation(LinearLayoutManager.VERTICAL);
recyclerView.setLayoutManager(llm);
TreeAdapter adapter = new TreeAdapter(this, catelogues);
adapter.setTreeEvent(this);
recyclerView.setAdapter(adapter);
}
/**
* 點擊tree item,帶回Pdf頁碼到前一個頁面
*
* @param data tree節點數據
*/
@Override
public void onSelectTreeNode(TreeNodeData data) {
Intent intent = new Intent();
intent.putExtra("pageNum", data.getPageNum());
setResult(Activity.RESULT_OK, intent);
finish();
}
}
PDF目錄樹的佈局文件:activity_catelogue.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<RelativeLayout
android:id="@+id/rl_top"
android:layout_width="match_parent"
android:layout_height="70dp"
android:layout_alignParentTop="true"
android:background="#03a9f5">
<Button
android:id="@+id/btn_back"
android:layout_width="60dp"
android:layout_height="30dp"
android:layout_alignParentBottom="true"
android:layout_marginLeft="10dp"
android:layout_marginBottom="10dp"
android:background="@drawable/shape_button"
android:text="返回"
android:textColor="#ffffff"
android:textSize="18sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginBottom="15dp"
android:text="目錄列表"
android:textColor="#ffffff"
android:textSize="18sp" />
</RelativeLayout>
<android.support.v7.widget.RecyclerView
android:id="@+id/rv_tree"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@+id/rl_top" />
</RelativeLayout>
8、PDF預覽縮略圖
這個功能算是本Demo中最為複雜的一個了:
如何將PDF某頁面的內容轉成圖片?(預設是無法從pdfview中獲得頁面圖片的)
如何減少圖片記憶體的占用?(用戶可能快速滑動列表,實時讀取、顯示多張圖片)
如何優化PDF預覽縮略圖列表的滑動體驗?(圖片的獲取需要一定時間)
如何合理的及時釋放記憶體占用?
8.1、PDF預覽縮略圖列表的效果圖
8.2、功能分析
1、如何將PDF某頁面的內容轉成圖片?
查看android-pdfview的源碼,無法通過PDFView控制項獲得某頁面的圖片,所以只能分析pdfium sdk的API了,如下圖:
pdfium的renderPageBitmap方法可以將頁面渲染成圖片,不過需要傳遞一系列參數,而且要小心OutOfMemoryError。
那麼,我們需要在代碼中獲取或者創建PdfiumCore對象,調用該方法,傳遞PdfDocument等參數,當bitmap使用完後,應及時釋放掉。
2、如何減少記憶體的占用?
記憶體主要包括:
1、pdfium sdk載入pdf文件產生的記憶體(我們無法優化)
2、android-pdfview產生的記憶體(如果有需要,可改其源碼)
3、我們將pdf頁面轉為縮略圖,而產生的記憶體(必須優化,否則,容易oom)
3.1、當PdfiumCore、PdfDocument不再使用時,應及時關閉;
3.2、當縮略圖不再使用時,應及時釋放;
3.3、可使用LruCache臨時緩存縮略圖,防止重覆調用renderPageBitmap獲取圖片;
3.4、LruCache應合理管控,當預覽頁面關閉時,必須清空緩存,以釋放記憶體;
3.5、創建圖片時,應使用RGB_565,能節約記憶體開銷(一個像素點,占2位元組)
3.6、創建圖片時,應儘可能小的指定圖片的寬高,能看清就行(圖片占用的記憶體 = 寬 * 高 * 一個像素點占的位元組數)
3、如何優化PDF預覽縮略圖列表的滑動體驗?
查看pdfium源碼,調用renderPageBitmap方法之前,還必須確保對應的頁面已被打開,即調用了openPage方法。然而,這兩個方法都需要一定時間才能執行完成的。
那麼,如果我們直接在主線程中讓每個RecylerVew的item分別調用renderPageBitmap方法,滑動列表時,會感覺特別卡,所以該方法只能放在子線程中調用了。
那麼問題又來了,那麼多子線程應該如何管控?
1、考慮CPU的占用,應使用線程池控制子線程併發、阻塞;
2、考慮到用戶滑動速度,有可能某線程正執行或者阻塞著呢,頁面已經滑過去了,那麼,即使該線程載入出來了圖片,也無法顯示到列表中。所以對於RecyclerView已不可見的Item項對應的線程,應及時取消,防止做無用功,也節省了記憶體和cpu開銷。
8.3、功能實現
預覽縮略圖工具類:PreviewUtils
/**
* 預覽縮略圖工具類
*
* 1、pdf頁面轉為縮略圖
* 2、圖片緩存管理(僅保存到記憶體,可使用LruCache,註意空間大小控制)
* 3、多線程管理(線程併發、阻塞、Future任務取消)
*
* 作者:齊行超
* 日期:2019.08.08
*/
public class PreviewUtils {
//圖片緩存管理
private ImageCache imageCache;
//單例
private static PreviewUtils instance;
//線程池
ExecutorService executorService;
//線程任務集合(可用於取消任務)
HashMap<String, Future> tasks;
/**
* 單例(僅主線程調用,無需做成線程安全的)
*
* @return PreviewUtils實例對象
*/
public static PreviewUtils getInstance() {
if (instance == null) {
instance = new PreviewUtils();
}
return instance;
}
/**
* 預設構造函數
*/
private PreviewUtils() {
//初始化圖片緩存管理對象
imageCache = new ImageCache();
//創建併發線程池(建議最大併發數大於1屏grid item的數量)
executorService = Executors.newFixedThreadPool(20);
//創建線程任務集合,用於取消線程執行
tasks = new HashMap<>();
}
/**
* 從pdf文件中載入圖片
*
* @param context 上下文
* @param imageView 圖片控制項
* @param pdfiumCore pdf核心對象
* @param pdfDocument pdf文檔對象
* @param pdfName pdf文件名稱
* @param pageNum pdf頁碼
*/
public void loadBitmapFromPdf(final Context context,
final ImageView imageView,
final PdfiumCore pdfiumCore,
final PdfDocument pdfDocument,
final String pdfName,
final int pageNum) {
//判斷參數合法性
if (imageView == null || pdfiumCore == null || pdfDocument == null || pageNum < 0) {
return;
}
try {
//緩存key
final String keyPage = pdfName + pageNum;
//為圖片控制項設置標記
imageView.setTag(keyPage);
Log.i("PreViewUtils", "載入pdf縮略圖:" + keyPage);
//獲得imageview的尺寸(註意:如果使用正常控制項尺寸,太占記憶體了)
/*int w = imageView.getMeasuredWidth();
int h = imageView.getMeasuredHeight();
final int reqWidth = w == 0 ? UIUtils.dip2px(context,100) : w;
final int reqHeight = h == 0 ? UIUtils.dip2px(context,150) : h;*/
//記憶體大小= 圖片寬度 * 圖片高度 * 一個像素占的位元組數(RGB_565 所占位元組:2)
//註意:如果使用正常控制項尺寸,太占記憶體了,所以此處指定四縮略圖看著會模糊一點
final int reqWidth = 100;
final int reqHeight = 150;
//從緩存中取圖片
Bitmap bitmap = imageCache.getBitmapFromLruCache(keyPage);
if (bitmap != null) {
imageView.setImageBitmap(bitmap);
return;
}
//使用線程池管理子線程
Future future = executorService.submit(new Runnable() {
@Override
public void run() {
//打開頁面(調用renderPageBitmap方法之前,必須確保頁面已open,重要)
pdfiumCore.openPage(pdfDocument, pageNum);
//調用native方法,將Pdf頁面渲染成圖片
final Bitmap bm = Bitmap.createBitmap(reqWidth, reqHeight, Bitmap.Config.RGB_565);
pdfiumCore.renderPageBitmap(pdfDocument, bm, pageNum, 0, 0, reqWidth, reqHeight);
//切回主線程,設置圖片
if (bm != null) {
//將圖片加入緩存
imageCache.addBitmapToLruCache(keyPage, bm);
//切回主線程載入圖片
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
if (imageView.getTag().toString().equals(keyPage)) {
imageView.setImageBitmap(bm);
Log.i("PreViewUtils", "載入pdf縮略圖:" + keyPage + "......已設置!!");
}
}
});
}
}
});
//將任務添加到集合
tasks.put(keyPage, future);
} catch (Exception ex) {
ex.printStackTrace();
}
}
/**
* 取消從pdf文件中載入圖片的任務
*
* @param keyPage 頁碼
*/
public void cancelLoadBitmapFromPdf(String keyPage) {
if (keyPage == null || !tasks.containsKey(keyPage)) {
return;
}
try {
Log.i("PreViewUtils", "取消載入pdf縮略圖:" + keyPage);
Future future = tasks.get(keyPage);
if (future != null) {
future.cancel(true);
Log.i("PreViewUtils", "取消載入pdf縮略圖:" + keyPage + "......已取消!!");
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
/**
* 獲得圖片緩存對象
* @return 圖片緩存
*/
public ImageCache getImageCache(){
return imageCache;
}
/**
* 圖片緩存管理
*/
public class ImageCache {
//圖片緩存
private LruCache<String, Bitmap> lruCache;
//構造函數
public ImageCache() {
//初始化 lruCache
//int maxMemory = (int) Runtime.getRuntime().maxMemory();
//int cacheSize = maxMemory/8;
int cacheSize = 1024 * 1024 * 30;//暫時設定30M
lruCache = new LruCache<String, Bitmap>(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap value) {
return value.getRowBytes() * value.getHeight();
}
};
}
/**
* 從緩存中取圖片
* @param key 鍵
* @return 圖片
*/
public synchronized Bitmap getBitmapFromLruCache(String key) {
if(lruCache!= null) {
return lruCache.get(key);
}
return null;
}
/**
* 向緩存中加圖片
* @param key 鍵
* @param bitmap 圖片
*/
public synchronized void addBitmapToLruCache(String key, Bitmap bitmap) {
if (getBitmapFromLruCache(key) == null) {
if (lruCache!= null && bitmap != null)
lruCache.put(key, bitmap);
}
}
/**
* 清空緩存
*/
public void clearCache(){
if(lruCache!= null){
lruCache.evictAll();
}
}
}
}
grid列表適配器: GridAdapter
/**
* grid列表適配器
* 作者:齊行超
* 日期:2019.08.08
*/
public class GridAdapter extends RecyclerView.Adapter<GridAdapter.GridViewHolder> {
Context context;
PdfiumCore pdfiumCore;
PdfDocument pdfDocument;
String pdfName;
int totalPageNum;
public GridAdapter(Context context, PdfiumCore pdfiumCore, PdfDocument pdfDocument, String pdfName, int totalPageNum) {
this.context = context;
this.pdfiumCore = pdfiumCore;
this.pdfDocument = pdfDocument;
this.pdfName = pdfName;
this.totalPageNum = totalPageNum;
}
@Override
public GridViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view = LayoutInflater.from(context).inflate(R.layout.grid_item, null);
return new GridViewHolder(view);
}
@Override
public void onBindViewHolder(GridViewHolder holder, int position) {
//設置PDF圖片
final int pageNum = position;
PreviewUtils.getInstance().loadBitmapFromPdf(context, holder.iv_page, pdfiumCore, pdfDocument, pdfName, pageNum);
//設置PDF頁碼
holder.tv_pagenum.setText(String.valueOf(position));
//設置Grid事件
holder.iv_page.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if(delegate!=null){
delegate.onGridItemClick(pageNum);
}
}
});
return;
}
@Override
public void onViewDetachedFromWindow(GridViewHolder holder) {
super.onViewDetachedFromWindow(holder);
try {
//item不可見時,取消任務
if(holder.iv_page!=null){
PreviewUtils.getInstance().cancelLoadBitmapFromPdf(holder.iv_page.getTag().toString());
}
//item不可見時,釋放bitmap (註意:本Demo使用了LruCache緩存來管理圖片,此處可註釋掉)
/*Drawable drawable = holder.iv_page.getDrawable();
if (drawable != null) {
Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap();
if (bitmap != null && !bitmap.isRecycled()) {
bitmap.recycle();
bitmap = null;
Log.i("PreViewUtils","銷毀pdf縮略圖:"+holder.iv_page.getTag().toString());
}
}*/
}catch (Exception ex){
ex.printStackTrace();
}
}
@Override
public int getItemCount() {
return totalPageNum;
}
class GridViewHolder extends RecyclerView.ViewHolder {
ImageView iv_page;
TextView tv_pagenum;
public GridViewHolder(View itemView) {
super(itemView);
iv_page = itemView.findViewById(R.id.iv_page);
tv_pagenum = itemView.findViewById(R.id.tv_pagenum);
}
}
/**
* 介面:Grid事件
*/
public interface GridEvent{
/**
* 當選擇了某Grid項
* @param position tree節點數據
*/
void onGridItemClick(int position);
}
/**
* 設置Grid事件
* @param event Grid事件對象
*/
public void setGridEvent(GridEvent event){
this.delegate = event;
}
//Grid事件委托
private GridEvent delegate;
}
PDF預覽縮略圖頁面:PDFPreviewActivity
/**
* UI頁面:PDF預覽縮略圖(註意:此頁面,需多關註記憶體管控)
* <p>
* 1、用於顯示Pdf縮略圖信息
* 2、點擊縮略圖,帶回Pdf頁碼到前一個頁面
* <p>
* 作者:齊行超
* 日期:2019.08.07
*/
public class PDFPreviewActivity extends AppCompatActivity implements GridAdapter.GridEvent {
RecyclerView recyclerView;
Button btn_back;
PdfiumCore pdfiumCore;
PdfDocument pdfDocument;
String assetsFileName;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
UIUtils.initWindowStyle(getWindow(), getSupportActionBar());
setContentView(R.layout.activity_preview);
initView();//初始化控制項
setEvent();
loadData();
}
/**
* 初始化控制項
*/
private void initView() {
btn_back = findViewById(R.id.btn_back);
recyclerView = findViewById(R.id.rv_grid);
}
/**
* 設置事件
*/
private void setEvent() {
btn_back.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
//回收記憶體
recycleMemory();
PDFPreviewActivity.this.finish();
}
});
}
/**
* 載入數據
*/
private void loadData() {
//載入pdf文件
loadPdfFile();
//獲得pdf總頁數
int totalCount = pdfiumCore.getPageCount(pdfDocument);
//綁定列表數據
GridAdapter adapter = new GridAdapter(this, pdfiumCore, pdfDocument, assetsFileName, totalCount);
adapter.setGridEvent(this);
recyclerView.setLayoutManager(new GridLayoutManager(this, 3));
recyclerView.setAdapter(adapter);
}
/**
* 載入pdf文件
*/
private void loadPdfFile() {
Intent intent = getIntent();
if (intent != null) {
assetsFileName = intent.getStringExtra("AssetsPdf");
if (assetsFileName != null) {
loadAssetsPdfFile(assetsFileName);
} else {
Uri uri = intent.getData();
if (uri != null) {
loadUriPdfFile(uri);
}
}
}
}
/**
* 載入assets中的pdf文件
*/
void loadAssetsPdfFile(String assetsFileName) {
try {
File f = FileUtils.fileFromAsset(this, assetsFileName);
ParcelFileDescriptor pfd = ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY);
pdfiumCore = new PdfiumCore(this);
pdfDocument = pdfiumCore.newDocument(pfd);
} catch (Exception ex) {
ex.printStackTrace();
}
}
/**
* 基於uri載入pdf文件
*/
void loadUriPdfFile(Uri uri) {
try {
ParcelFileDescriptor pfd = getContentResolver().openFileDescriptor(uri, "r");
pdfiumCore = new PdfiumCore(this);
pdfDocument = pdfiumCore.newDocument(pfd);
}catch (Exception ex){
ex.printStackTrace();
}
}
/**
* 點擊縮略圖,帶回Pdf頁碼到前一個頁面
*
* @param position 頁碼
*/
@Override
public void onGridItemClick(int position) {
//回收記憶體
recycleMemory();
//返回前一個頁碼
Intent intent = new Intent();
intent.putExtra("pageNum", position);
setResult(Activity.RESULT_OK, intent);
finish();
}
/**
* 回收記憶體
*/
private void recycleMemory(){
//關閉pdf對象
if (pdfiumCore != null && pdfDocument != null) {
pdfiumCore.closeDocument(pdfDocument);
pdfiumCore = null;
}
//清空圖片緩存,釋放記憶體空間
PreviewUtils.getInstance().getImageCache().clearCache();
}
}
PDF預覽縮略圖頁面的佈局文件:activity_preview.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<RelativeLayout
android:id="@+id/rl_top"
android:layout_width="match_parent"
android:layout_height="70dp"
android:layout_alignParentTop="true"
android:background="#03a9f5">
<Button
android:id="@+id/btn_back"
android:layout_width="60dp"
android:layout_height="30dp"
android:layout_alignParentBottom="true"
android:layout_marginLeft="10dp"
android:layout_marginBottom="10dp"
android:background="@drawable/shape_button"
android:text="返回"
android:textColor="#ffffff"
android:textSize="18sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginBottom="15dp"
android:text="預覽縮略圖列表"
android:textColor="#ffffff"
android:textSize="18sp" />
</RelativeLayout>
<android.support.v7.widget.RecyclerView
android:id="@+id/rv_grid"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@+id/rl_top" />
</RelativeLayout>
總結
文檔中涉及的功能點較多,難點也較多,尤其是記憶體管理、多線程管理,有不明白的建議下載Demo,多看下源碼。也歡迎留言咨詢,就是不一定有時間解答,哈哈。。。。
如果希望把該demo用到項目中,建議多測試一下,因為時間關係,我這邊僅做了基本測試。
Demo下載地址(github + 百度網盤):
https://github.com/qxcwanxss/AndroidPdfViewerDemo
https://pan.baidu.com/s/1_Py36avgQqcJ5C87BaS5Iw