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 bookmar