• Android原生PDF功能实现


    PDF Demo 效果

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

  • 相关阅读:
    JSP数据交互(一)
    response.setHeader()用法
    Vue初步认识
    Socket初步了解
    DOM4j的修改删除方式
    多线程
    ArrayList和Vector区别
    集合框架(一)
    深入C#数据类型
    深入.NET框架
  • 原文地址:https://www.cnblogs.com/qixingchao/p/11658226.html
Copyright © 2020-2023  润新知