Bootstrap

【绝对干货】Android原生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页面)

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页面

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
;