文章目录

  • 效果展示
  • Android 平台已有的解决方案
  • 自定义 View 做 PDF 查看器的优势
  • 开始自定义 PDF 查看器
    • 总体规划、步骤分解
    • PdfRenderer 相关 API 介绍
      • PdfRenderer
      • PdfRenderer.Page
    • Step1.绘制占位空白页
    • Step2.实现滑动
    • Step3.绘制 PDF 内容页
    • Step4.实现缩放
    • Step5.缩放后加载高清 PDF
    • Step6.绘制水印
  • 源代码
  • Q&A

效果展示


Android 平台已有的解决方案

目前 android 平台上查看 pdf 的方案还是有很多可选的:

  1. 编写本地 html,引入 pdf.js,利用 WebView 去渲染 pdf
  2. 集成 AndroidPdfViewer 或其他类似的 library,使用第三方 so 库去处理 pdf 并渲染
  3. 自定义 View 结合系统原生的 pdf 处理类 PdfRenderer 进行 pdf 渲染

以上几种方式,各自都有优缺点。
使用 WebView 方式去渲染是最简单直接的,兼容性也好,但是当渲染比较大的 pdf 文档的时候会 oom;
第三方的 so 库渲染,通常兼容性会比较好,稳定性也比较好,但是由于使用到 so 库,会显著增加 apk 的包体积;
使用系统的 PdfRenderer + 自定义 View 来渲染 pdf,由于 pdf 渲染是由系统原生处理的,不需要额外的 so 包,所以不会显著增加 apk 的体积,也比较稳定,但是这个类是 API 21 后加入的,所以它只支持 android 5.0+。

自定义 View 做 PDF 查看器的优势

上边已经说明了,其实各种方式都各有优缺点,选择哪种方案完全取决于你对项目的评估和所作的决策。
我对 pdf 查看器的选择标准:

  1. 内存优化必须要好,拒绝 OOM
  2. 流畅、稳定、高可定制化
  3. 由于是简单查看 pdf,不希望因为它使 apk 的体积增加太多

以上边的标准,WebView 方案第一个排除掉,WebView 方式可控性太低了,打开一个 WebView 页面也比较慢;第三方的 so 库渲染,缺点就是 apk 包体积会增大,其他各方面都很优秀,使用起来也很方便,如果项目主要任务就是 pdf 相关的,那增大包体积也可以接受。
自定义 View + PdfRenderer 来实现 pdf 查看器的优势在于,系统原生渲染 pdf,安全可靠稳定,不会额外增加安装包体积;自定义 View 处理 pdf 的显示和交互逻辑,自由程度比较大;从零写一个 pdf 查看器,对自己也会有很大的收获。

开始自定义 PDF 查看器

写一个复杂的自定义 View,看起来比较复杂比较难,所有要实现功能交织在一起极其复杂,通常都会一头雾水没有思路,不知道该从哪里下手。其实如果换个角度去写,它实现起来也没那么难。先想想都要实现哪些功能,然后分步骤分阶段一步一步去做,等所有步骤都实现了,自定义 View 也就成了。

总体规划、步骤分解

我们需要这样一个 pdf 查看器,支持滑动、支持多点触控时缩放、支持放大后查看 pdf 高清页,支持添加水印。
这里先来头脑风暴一下,考虑滑动流畅内存优化。pdf 页的渲染需要在滑动停止时进行,同时加载的 pdf 页的个数也需要限制(当前显示的页+预加载的页);pdf 页转换 Bitmap 的过程是个耗时操作,需要在后台线程处理;滑动状态和停止滑动状态会频繁的切换,会频繁的去渲染 pdf 页,因此需要线程池去管理 pdf 转换 Bitmap 的后台线程任务;可以把转换过的 pdf 页对应的 Bitmap 做一个三级缓存,这样再次加载时就不用再从 pdf 渲染了;考虑到运行内存是有限的,所以三级缓存中的内存缓存需要限制个数,就跟预加载 pdf 页的个数保持一致好了;放大后渲染高清 pdf,只渲染当前在屏幕上可见的部分就行。
我们来总结一下上边提到的技术点:

  1. 支持设置 pdf 页的预加载个数
  2. 滑动状态为空闲时,开启 pdf 转 Bitmap 的渲染任务渲染当前页和预加载页
  3. 要有个线程池处理耗时任务
  4. pdf 页的 Bitmap 资源需要做个三级缓存
  5. 放大查看高清 pdf 时,只渲染屏幕上显示的内容的高清 Bitmap

大致的需求和功能点都有了,接下来分步骤来完成:

  1. 绘制占位空白页
  2. 实现滑动
  3. 绘制 PDF 内容页
  4. 实现缩放
  5. 缩放后加载高清 PDF
  6. 绘制水印

PdfRenderer 相关 API 介绍

在开始之前,还需要了解一下需要用到的 PdfRenderer 的系统 api,看看它都能做些什么。

PdfRenderer

来看看官方文档对它的介绍:


This class enables rendering a PDF document. This class is not thread safe.

If you want to render a PDF, you create a renderer and for every page you want to render, you open the page, render it, and close the page. After you are done with rendering, you close the renderer. After the renderer is closed it should not be used anymore. Note that the pages are rendered one by one, i.e. you can have only a single page opened at any given time.


翻译一下就是:


这个类能渲染 pdf 文档,它不是线程安全的。

如果想要渲染 pdf 文档,你需要创建一个渲染器,对每一个将要渲染的 pdf 页,你都要打开它,渲染它,然后关闭它。pdf 文档渲染完成后,还需要关闭渲染器。渲染器关闭后就不能再次使用这个渲染器了。有一点需要注意,所有的 pdf 页都是一个一个被渲染的,即同一时间只能打开一个 pdf 页


构造函数:

public PdfRenderer(@NonNull ParcelFileDescriptor input) throws IOException {...
}

只有一个构造函数,传入 pdf 文件的文件描述符,获取 pdf 渲染器实例。

方法名 简介
PdfRenderer#close() 关闭当前 pdf 渲染器实例
PdfRenderer#getPageCount() 获取 pdf 页的总数
PdfRenderer#openPage(index: Int) 打开目标 pdf 页

PdfRenderer.Page

方法名 简介
Page#close() 关闭当前 pdf 页
Page#getHeight() 获取当前 pdf 页的高度
Page#getWidth() 获取当前 pdf 页的宽度
Page#getIndex() 获取当前 pdf 页在 pdf 文档中的索引
Page#render(destination: Bitmap, destClip: Rect?, transform: Matrix?, renderMode: Int) 把 pdf 页渲染到 destination 这个 Bitmap 中

Page#render(destination: Bitmap, destClip: Rect?, transform: Matrix?, renderMode: Int)
这个方法很关键,它有4个参数:

  • destination:目标 Bitmap,pdf 页的内容会渲染到它里边
  • destClip:目标裁剪,是一个矩形,它作用在 destination 上,如果设置了这个参数,destination 只会显示 destClip 矩形区域内的 pdf 内容
  • transform:变换,可以在渲染到 Bitmap 前,对 pdf 源矩阵数据设置缩放和平移,使得到的 Bitmap 符合显示预期
  • renderMode:渲染模式,显示模式-RENDER_MODE_FOR_DISPLAY、打印模式-RENDER_MODE_FOR_PRINT

参数还有需要注意的点:
destination 目标 Bitmap 的格式必须是 Config#ARGB_8888
transform 变换矩阵只能设置缩放、旋转、平移,不能设置透视变换
详细的官方文档介绍看这里

了解上边的渲染方法后,其实 PDF 文件的显示原理就是:首先把每一页 PDF 都转换成对应的 Bitmap;然后就是把 Bitmap 绘制到画布上,就完成了 PDF 文档的查看。

到这里 PDF 显示的问题,已经转化成了 Bitmap 显示的问题了,那么问题来了,OOM它迎面走来了~。一个 PDF 文档在内存中相当于一个 Bitmap 对象的列表,一个几十上百页的 PDF 文档就相当于一个存了几十上百个 Bitmap 的列表,如果一股脑的把这个列表放到内存里,想象一下,有画面了吧。。

所以我们需要优化这个过程,绝对不能一次性把全部的 PDF 页都渲染出来(直接崩了也渲染不出来)。

Step1.绘制占位空白页

一个 PDF 文档,我们可以获取到它的页数量,每页的宽高信息,一次性渲染所有 PDF 页到内存不可行,那就先一次性把所有 PDF 页将要渲染在画布上的位置宽高信息的列表创建出来。
占位位置数据的创建,使用后台线程去处理,处理完后 Handler 发通知转到 UI 线程

  1. 定义创建pdf页框架数据的任务
    占位空白页的 Rect 数据,可以通过循环从 PDF 渲染器中取出的 Page 对象来创建。
    PDF 页的原始宽高不会刚好跟屏幕宽度一样的,所以还需要缩放到屏幕宽度。
    在循环保存 Page 宽高信息的时候,直接就把 Rect 在画布的位置规定好(通过累加的pdfTotalHeight
    每页保存宽高信息前,需要预留 Page 页分隔线的高度信息

    /*** 线程任务* 初始化pdf页框架数据*/
    private class InitPdfFramesTask(pdfView: PDFView) : Runnable {private val mWeakReference = WeakReference(pdfView)override fun run() {val pdfView = mWeakReference.get() ?: returnval pdfRenderer =pdfView.mPdfRenderer ?: throw NullPointerException("pdfRenderer is null!")val tempPagePlaceHolders = arrayListOf<PageRect>()var pdfTotalHeight = 0fval left = pdfView.paddingLeft.toFloat() + pdfView.mDividerHeightval right =pdfView.measuredWidth.toFloat() - pdfView.paddingRight.toFloat() - pdfView.mDividerHeightvar fillWidthScale: Floatvar scaledHeight: Floatfor (index in 0 until pdfRenderer.pageCount) {val page = pdfRenderer.openPage(index)fillWidthScale = (right - left) / page.width.toFloat()scaledHeight = page.height * fillWidthScale//预留分割线的高度if (index != 0)pdfTotalHeight += pdfView.mDividerHeightval rect = RectF(left, pdfTotalHeight, right, pdfTotalHeight + scaledHeight)pdfTotalHeight = rect.bottomtempPagePlaceHolders.add(PageRect(fillWidthScale,rect))page.close()}val message = Message()message.what =PDFHandler.MESSAGE_INIT_PDF_PLACE_HOLDERmessage.data.putParcelableArrayList("list", tempPagePlaceHolders)pdfView.mPDFHandler.sendMessage(message)}
    }
    
  2. 线程池执行创建pdf页框架数据的任务
    在 View 测量完成后去开启任务,这时 View 的宽高已经确定了,可以处理后续的数据

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {...//初始化pdf页框架if (mInitPageFramesFuture == null)mInitPageFramesFuture = EXECUTOR_SERVICE.submit(InitPdfFramesTask(this))
    }
    
  3. 画 pdf 页的占位框架
    override fun onDraw(canvas: Canvas) {super.onDraw(canvas)...//画占位图和分隔线drawPlaceHolderAndDivider(canvas)...
    }/*** 画page占位图和横向分隔线*/
    private fun drawPlaceHolderAndDivider(canvas: Canvas) {mPagePlaceHolders.forEachIndexed { index, pageRect ->val fillWidthRect = pageRect.fillWidthRect//画占位页canvas.drawRect(fillWidthRect, mPDFPaint)//画页分隔if (index < mPagePlaceHolders.lastIndex)canvas.drawRect(paddingLeft.toFloat(),fillWidthRect.bottom,measuredWidth - paddingRight.toFloat(),fillWidthRect.bottom + mDividerHeight,mDividerPaint)}
    }
    

Step2.实现滑动

完成第一步后,现在画布上已经有 PDF 页的框架了,先画框架是为了把画布给撑起来,虽然没有具体的 PDF 内容,但是有了占坑的宽高和坐标信息,可以接着往下做滑动了。

滑动通常有两种:手指触摸滑动松开手指后的飞速滑动。要做到人性化的滑动体验,这两种滑动都要处理;还要注意滑动边界的处理。

  1. 滑动边界设置
    滑动是画布平移来实现的,即可平移的范围

    /*** 获取x轴可平移的间距*/
    private fun getCanTranslateXRange(): Range<Float> {return Range(min(-(mCanvasScale * mPdfTotalWidth - width), 0f), 0f)
    }/*** 获取y轴可平移的间距*/
    private fun getCanTranslateYRange(): Range<Float> {return Range(min(-(mCanvasScale * mPdfTotalHeight - height), 0f), 0f)
    }
    
  2. 创建 GestureDetector 处理滑动
    GestureDetector 已经为我们识别了滑动状态 onScroll 和飞速滑动 onFling
    onScroll 在触摸滑动时会一直回调,计算 transation 触发重绘就可以滑动
    onFling 在手指离开屏幕后,如果此时滑动速度到达飞速滑动的标准,就会回调一次,根据速度计算目标滑动位置,然后创建值动画,动画更新时计算 transation 触发重绘实现滑动

    //处理飞速滑动的手势识别器
    private val mGestureDetector by lazy {GestureDetector(context,OnPDFGestureListener(this))
    }/*** 处理触摸滑动和飞速滑动* P.S. 只处理单点触摸的操作*/
    private class OnPDFGestureListener(pdfView: PDFView) :GestureDetector.SimpleOnGestureListener() {private val mWeakReference = WeakReference(pdfView)override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {mWeakReference.get()?.performClick()return true}//处理触摸滑动override fun onScroll(e1: MotionEvent?,e2: MotionEvent?,distanceX: Float,distanceY: Float): Boolean {val pdfView = mWeakReference.get() ?: return false//判断滑动边界,重新设置滑动值val canTranslateXRange = pdfView.getCanTranslateXRange()val canTranslateYRange = pdfView.getCanTranslateYRange()val tempTranslateX = pdfView.mCanvasTranslate.x - distanceXval tempTranslateY = pdfView.mCanvasTranslate.y - distanceYval nextTranslateX = when {tempTranslateX in canTranslateXRange -> tempTranslateXtempTranslateX > canTranslateXRange.upper -> canTranslateXRange.upperelse -> canTranslateXRange.lower}val nextTranslateY = when {tempTranslateY in canTranslateYRange -> tempTranslateYtempTranslateY > canTranslateYRange.upper -> canTranslateYRange.upperelse -> canTranslateYRange.lower}//3.开始滑动,重绘pdfView.mCanvasTranslate.set(nextTranslateX, nextTranslateY)pdfView.invalidate()//4.重新计算当前页索引pdfView.calculateCurrentPageIndex()pdfView.debug("onScroll-distanceX:${distanceX}-distanceY:${distanceY}")//5. 滑动结束监听回调,创建page位图数据(需要再 onTouchEvent 中判断滑动结束,所以这里返回 false)return false}//处理松开手指飞速滑动override fun onFling(e1: MotionEvent?,e2: MotionEvent?,velocityX: Float,velocityY: Float): Boolean {val pdfView = mWeakReference.get() ?: return falsemWeakReference.get()?.debug("onFling-velocityX:${velocityX}-velocityY:${velocityY}")if (e1 != null && e2 != null&& (abs(e1.x - e2.x) > 100 || abs(e1.y - e2.y) > 100)&& (abs(velocityX) > 500 || abs(velocityY) > 500)) {val canTranslateXRange = pdfView.getCanTranslateXRange()val canTranslateYRange = pdfView.getCanTranslateYRange()val tempTranslateX = pdfView.mCanvasTranslate.x + velocityX * 0.75fval tempTranslateY = pdfView.mCanvasTranslate.y + velocityY * 0.75fval endTranslateX = when {tempTranslateX in canTranslateXRange -> tempTranslateXtempTranslateX > canTranslateXRange.upper -> canTranslateXRange.upperelse -> canTranslateXRange.lower}val endTranslateY = when {tempTranslateY in canTranslateYRange -> tempTranslateYtempTranslateY > canTranslateYRange.upper -> canTranslateYRange.upperelse -> canTranslateYRange.lower}val distanceX = endTranslateX - pdfView.mCanvasTranslate.xval distanceY = endTranslateY - pdfView.mCanvasTranslate.ypdfView.startFlingAnim(distanceX, distanceY)return true}return super.onFling(e1, e2, velocityX, velocityY)}
    }
    
  3. onTouchEvent 中合适的时机调用手势识别器

    override fun onTouchEvent(event: MotionEvent): Boolean {...var handled = falsewhen (event.actionMasked) {MotionEvent.ACTION_DOWN -> {...mTouchState = TouchState.SINGLE_POINTERmGestureDetector.onTouchEvent(event)handled = true}...MotionEvent.ACTION_MOVE -> {...handled = when (mTouchState) {TouchState.SINGLE_POINTER -> mGestureDetector.onTouchEvent(event)...else -> false}}MotionEvent.ACTION_UP -> {...handled = when (mTouchState) {TouchState.SINGLE_POINTER -> {val isFling = mGestureDetector.onTouchEvent(event)...true}...else -> false}mTouchState = TouchState.IDLE}}return handled || super.onTouchEvent(event)
    }
    
  4. 飞速滑动时的滑动动画
    在手势识别器中,识别到飞速滑动后,计算目标滑动位置,然后使用值动画平滑动滑动到目标位置

    /*** 开始飞速滑动的动画*/
    private fun startFlingAnim(distanceX: Float, distanceY: Float) {//根据每毫秒20像素来计算动画需要的时间var animDuration = (max(abs(distanceX), abs(distanceY)) / 20).toLong()//时间最短不能小于100毫秒when (animDuration) {in 0 until 100 -> animDuration = 400in 100 until 600 -> animDuration = 600}debug("startFlingAnim--distanceX-$distanceX--distanceY-$distanceY--animDuration-$animDuration")mFlingAnim = ValueAnimator().apply {setFloatValues(0f, 1f)duration = animDurationinterpolator = DecelerateInterpolator()addUpdateListener(PDFFlingAnimUpdateListener(this@PDFView,distanceX,distanceY))...start()}
    }/*** 飞速滑动动画更新回调*/
    private class PDFFlingAnimUpdateListener(pdfView: PDFView,private val distanceX: Float,private val distanceY: Float
    ) : ValueAnimator.AnimatorUpdateListener {private val mWeakReference = WeakReference(pdfView)private val lastCanvasTranslate = PointF(pdfView.mCanvasTranslate.x,pdfView.mCanvasTranslate.y)override fun onAnimationUpdate(animation: ValueAnimator) {val pdfView = mWeakReference.get() ?: return//飞速滑动时,不渲染缩放的 bitmappdfView.clearScalingPages()val percent = animation.animatedValue as FloatpdfView.mCanvasTranslate.x = lastCanvasTranslate.x + distanceX * percentpdfView.mCanvasTranslate.y = lastCanvasTranslate.y + distanceY * percentpdfView.invalidate()//重新计算当前页索引pdfView.calculateCurrentPageIndex()}
    }
    
  5. 绘制画布平移实现滑动

    override fun onDraw(canvas: Canvas) {super.onDraw(canvas)...//平移缩放preDraw(canvas)...
    }/*** 处理画布的平移缩放*/
    private fun preDraw(canvas: Canvas) {canvas.translate(mCanvasTranslate.x, mCanvasTranslate.y)...
    }
    

至此,我们已经处理完滑动了,下一步就要真正的绘制 PDF 页的内容了,为了使 View 使用时流畅,需要在滑动和飞速滑动结束后,再去渲染要显示的 PDF 页。

Step3.绘制 PDF 内容页

当滑动停止时,需要创建后台任务去把要显示的 Page 渲染到 Bitmap。这个过程是个耗时操作,除了放在线程池里,还需要把转换的 Bitmap 缓存下来,内存缓存 LruCache 和磁盘缓存 DiskLruCache
关于内存缓存,由于 Bitmap 比较占内存,内存缓存需要限制数量,即预加载的个数。
预加载的逻辑按照 ViewPager 的预加载逻辑来做,即(当前显示页+当前页前预加载个数+当前页后预加载个数)

  1. 创建渲染 PDF 的任务

    /*** 线程任务* 创建要显示的pdf页的bitmap集合* 有本地缓存的话用本地缓存,没有缓存的话从pdf生成bitmap后缓存*/
    private class PdfPageToBitmapTask(pdfView: PDFView) : Runnable {private val mWeakReference = WeakReference(pdfView)override fun run() {...for (index in startLoadingIndex..endLoadingIndex) {val pageRect = pagePlaceHolders[index]val fillWidthRect = pageRect.fillWidthRectvar bitmap = pdfView.getLoadingPagesBitmapFromCache(index)if (bitmap == null) {//3.本地缓存没拿到,从pdf渲染器创建bitmapval page = pdfRenderer.openPage(index)bitmap = Bitmap.createBitmap((fillWidthRect.width() / pageRect.fillWidthScale).toInt(),(fillWidthRect.height() / pageRect.fillWidthScale).toInt(),Bitmap.Config.ARGB_8888)page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)page.close()//新创建的bitmap,存到内存缓存和本地缓存pdfView.putLoadingPagesBitmapToCache(index, bitmap)}tempLoadingPages.add(DrawingPage(pageRect,bitmap,index))}val message = Message()...pdfView.mPDFHandler.sendMessage(message)}
    }
    
  2. 开启渲染 PDF 的任务

    override fun onTouchEvent(event: MotionEvent): Boolean {...var handled = falsewhen (event.actionMasked) {...MotionEvent.ACTION_UP -> {...handled = when (mTouchState) {TouchState.SINGLE_POINTER -> {val isFling = mGestureDetector.onTouchEvent(event)if (!isFling) {//单指滑动结束,处理滑动结束(无飞速滑动的情况)submitCreateLoadingPagesTask()}true}...else -> false}mTouchState = TouchState.IDLE}}return handled || super.onTouchEvent(event)
    }/*** 提交创建pdf页的任务到线程池* 如果线程池里有未执行或正在执行的任务,取消那个任务*/
    private fun submitCreateLoadingPagesTask() {if (mCreateLoadingPagesFuture?.isDone != true)mCreateLoadingPagesFuture?.cancel(true)mCreateLoadingPagesFuture =EXECUTOR_SERVICE.submit(PdfPageToBitmapTask(this))
    }
    
  3. 绘制 PDF 内容页

    override fun onDraw(canvas: Canvas) {super.onDraw(canvas)...//画将要显示的完整pagedrawLoadingPages(canvas)...
    }/*** 画完整显示的pdf页面*/
    private fun drawLoadingPages(canvas: Canvas) {mLoadingPages.filter { page ->//即将缩放显示的页面,不绘制它的全页bitmapval isScaling = page.pageIndex in mScalingPages.map { it.pageIndex }page.pageRect?.fillWidthRect != null&& page.bitmap != null&& !isScaling}.forEach {val fillWidthScale = it.pageRect!!.fillWidthScaleval fillWidthRect = it.pageRect.fillWidthRectcanvas.save()canvas.translate(fillWidthRect.left, fillWidthRect.top)canvas.scale(fillWidthScale, fillWidthScale)canvas.drawBitmap(it.bitmap!!, 0f, 0f, mPDFPaint)canvas.restore()}
    }
    

此时一个 PDF 查看器已经基本完成了,支持滑动、三级缓存、滑动与Page渲染优化。但显然只是能用还是不够的,接下来继续添加缩放、水印的支持。

Step4.实现缩放

onTouchEvent 中,之前我们用的 GestureDetector 来处理的滑动事件,滑动事件已经从 onTouchEvent 中剥离;缩放是多点触控,还需要把多点触控的触摸事件从 onTouchEvent 中剥离出来。
缩放的实现方式,是通过画布的平移+缩放来实现,根据双指的移动距离,计算出一个缩放倍数和缩放前需要平移的距离,然后在 onDraw 中去完成缩放。

  1. onTouchEvent 剥离多点触控触摸事件

    override fun onTouchEvent(event: MotionEvent): Boolean {...var handled = falsewhen (event.actionMasked) {MotionEvent.ACTION_POINTER_DOWN -> {debug("onTouchEvent-ACTION_POINTER_DOWN")mTouchState =TouchState.MULTI_POINTER//如果有正在执行的 fling 动画,就重置动画stopFlingAnimIfNeeded()handled = onZoomTouchEvent(event)}MotionEvent.ACTION_MOVE -> {debug("onTouchEvent-ACTION_MOVE")handled = when (mTouchState) {...TouchState.MULTI_POINTER -> onZoomTouchEvent(event)else -> false}}MotionEvent.ACTION_UP -> {debug("onTouchEvent-ACTION_UP")handled = when (mTouchState) {...TouchState.MULTI_POINTER -> onZoomTouchEvent(event)else -> false}mTouchState = TouchState.IDLE}}return handled || super.onTouchEvent(event)
    }
    
  2. 处理多点触控事件,计算平移和缩放倍数

    /*** 多指触摸,处理缩放*/
    private fun onZoomTouchEvent(event: MotionEvent): Boolean {//如果没开启缩放,就不处理多点触控if (!mCanZoom) return falsewhen (event.actionMasked) {MotionEvent.ACTION_POINTER_DOWN -> {debug("onZoomTouchEvent-ACTION_POINTER_DOWN")//记录多点触控按下时的初始手指间距mMultiFingerDistanceStart =distance(event.getX(0),event.getX(1),event.getY(0),event.getY(1))//记录按下时的缩放倍数mScaleStart = mCanvasScale//记录按下时的画布中心点mMultiFingerCenterPointStart.set((event.getX(0) + event.getX(1)) / 2,(event.getY(0) + event.getY(1)) / 2)mZoomTranslateStart.set(mCanvasTranslate)return mCanZoom}MotionEvent.ACTION_MOVE -> {debug("onZoomTouchEvent-ACTION_MOVE")if (event.pointerCount < 2) return falseval multiFingerDistanceEnd =distance(event.getX(0),event.getX(1),event.getY(0),event.getY(1))val tempScale = (multiFingerDistanceEnd / mMultiFingerDistanceStart) * mScaleStartmCanvasScale = when (tempScale) {in 0f..mMinScale -> mMinScalein mMinScale..mMaxScale -> tempScaleelse -> mMaxScale}val centerPointEndX = (event.getX(0) + event.getX(1)) / 2val centerPointEndY = (event.getY(0) + event.getY(1)) / 2val vLeftStart: Float = mMultiFingerCenterPointStart.x - mZoomTranslateStart.xval vTopStart: Float = mMultiFingerCenterPointStart.y - mZoomTranslateStart.yval vLeftNow: Float = vLeftStart * (mCanvasScale / mScaleStart)val vTopNow: Float = vTopStart * (mCanvasScale / mScaleStart)//判断滑动边界,重新设置滑动值val canTranslateXRange = getCanTranslateXRange()val canTranslateYRange = getCanTranslateYRange()val tempTranslateX = centerPointEndX - vLeftNowval tempTranslateY = centerPointEndY - vTopNowval nextTranslateX = when {tempTranslateX in canTranslateXRange -> tempTranslateXtempTranslateX > canTranslateXRange.upper -> canTranslateXRange.upperelse -> canTranslateXRange.lower}val nextTranslateY = when {tempTranslateY in canTranslateYRange -> tempTranslateYtempTranslateY > canTranslateYRange.upper -> canTranslateYRange.upperelse -> canTranslateYRange.lower}mCanvasTranslate.set(nextTranslateX, nextTranslateY)invalidate()//重新计算当前页索引calculateCurrentPageIndex()return true}MotionEvent.ACTION_UP -> {debug("onZoomTouchEvent-ACTION_UP")submitCreateLoadingPagesTask()return true}}return false
    }
  3. 绘制缩放

    override fun onDraw(canvas: Canvas) {super.onDraw(canvas)...//平移缩放preDraw(canvas)...
    }/*** 处理画布的平移缩放*/
    private fun preDraw(canvas: Canvas) {canvas.translate(mCanvasTranslate.x, mCanvasTranslate.y)canvas.scale(mCanvasScale, mCanvasScale)
    }
    

缩放的逻辑中,在 ACTION_MOVE 时计算平移量和缩放倍数最为复杂,不好描述,需要自己参透。。

到这里 PDFView已经支持缩放了,但是现在的缩放只是缩放了画布,查看的还是画布之前已有的内容缩放后的效果,这就不可避免的会很模糊。好在渲染 PDF 页面的方法可以设置缩放、平移的参数,这样就可以从 PDF 中重新渲染高清的 Bitmap 了。

Step5.缩放后加载高清 PDF

PDF 页的渲染是一个很耗时的操作,缩放后的 PDF 页的渲染,如果要渲染整页内容,会更加耗时,而且 Bitmap 也会很大,所以渲染高清 PDF 内容这个过程,也需要考虑内存优化。

手机屏幕可显示的区域是固定的,其他不在显示区域内的 PDF 内容页,我们是看不见的,并且我们只需要在缩放结束后去加载高清 PDF 内容页,而在缩放或滑动过程中,继续显示之前画布放大的模糊内容。

那么方案就来了:
缩放结束后,根据此时画布 x/y 轴的平移量和屏幕的宽高,可以知道屏幕窗口相对于 PDF 内容的位置信息;
根据当前显示的 PDF 页的位置信息和屏幕窗口的位置信息,可以知道屏幕窗口中各 PDF 页需要放大和平移部分的 Rect
然后使用 PDF 渲染器提供的 API 去渲染出放大的 PDF 页矩形区域;
把拿到的放大过得 PDF 矩形区域的 Bitmap 绘制到画布上,因为 Bitmap 已经是放大过得了,所以绘制前需要把画布重置为原始状态,清除画布的平移缩放后再画。

  1. 创建渲染高清 PDF 页的任务

    /*** 线程任务* 创建正在缩放的显示的pdf页的bitmap*/
    private class CreateScalingPageBitmapTask(pdfView: PDFView) : Runnable {private val mWeakReference = WeakReference(pdfView)override fun run() {...for (index in startIndex..endIndex) {val placeHolderPageRect = pagePlaceHolders[index]val fillWidthRect = placeHolderPageRect.fillWidthRect//创建缩放bitmap的位置信息val scalingRectTop =max(fillWidthRect.top * pdfView.mCanvasScale - abs(currentTranslateY), 0f)val scalingRectBottom =min(fillWidthRect.bottom * pdfView.mCanvasScale - abs(currentTranslateY),pdfView.measuredHeight - pdfView.paddingTop - pdfView.paddingBottom.toFloat())//处理滑动到分隔线停止的情况if (scalingRectBottom <= scalingRectTop) continueval scalingRect = RectF(0f,scalingRectTop,pdfView.measuredWidth - pdfView.paddingLeft - pdfView.paddingRight.toFloat(),scalingRectBottom)val bitmap = Bitmap.createBitmap(scalingRect.width().toInt(),scalingRect.height().toInt(),Bitmap.Config.ARGB_8888)val matrix = Matrix()//page页真实的缩放倍数=原始页缩放到屏幕宽度的缩放倍数*画布的缩放倍数val scale = placeHolderPageRect.fillWidthScale * pdfView.mCanvasScalematrix.postScale(scale, scale)//平移,因为取的是已经缩放过的page页,所以平移量跟缩放后的画布平移量保持一致matrix.postTranslate(pdfView.mCanvasTranslate.x + (pdfView.paddingLeft + pdfView.mDividerHeight) * pdfView.mCanvasScale,min(fillWidthRect.top * pdfView.mCanvasScale + currentTranslateY, 0f))val page = pdfRenderer.openPage(index)page.render(bitmap, null, matrix, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)page.close()tempScalingPages.add(DrawingPage(PageRect(fillWidthRect = scalingRect),bitmap,index))}val message = Message()message.what =PDFHandler.MESSAGE_CREATE_SCALED_BITMAPmessage.data.putInt("index", currentPageIndex)message.data.putParcelableArrayList("list", tempScalingPages)pdfView.mPDFHandler.sendMessage(message)}
    }
    
  2. 开启渲染高清 PDF 的任务
    应该在缩放结束,手指离开屏幕后去开启加载高清的任务。这个思路没毛病,但是再回顾一下前边的步骤,绘制 PDF 内容页的时候,是先渲染的原始 PDF 页,然后在绘制时放大到屏幕的宽度的,这样在没有缩放的状态下,看到的 PDF 页内容可能是模糊的,体验会不那么完美。
    这里修改一下显示逻辑:滑动或缩放操作结束后,开启渲染 PDF 的任务去渲染预加载个数的 PDF 页;任务完成的同时再开启渲染高清 PDF 页 Rect 的任务。

    /*** 异步处理 pdf_page to bitmap*/
    private class PDFHandler(pdfView: PDFView) : Handler() {...private val mWeakReference = WeakReference(pdfView)override fun handleMessage(msg: Message) {super.handleMessage(msg)val pdfView = mWeakReference.get() ?: returnwhen (msg.what) {...MESSAGE_CREATE_LOADING_PDF_BITMAP -> {pdfView.debug("handleMessage-MESSAGE_CREATE_LOADING_PDF_BITMAP-currentPageIndex:${pdfView.mCurrentPageIndex}")val calculatedPageIndex = msg.data.getInt("index")val tempLoadingPages = msg.data.getParcelableArrayList<DrawingPage>("list")if (pdfView.mCurrentPageIndex != calculatedPageIndex) {return}if (!tempLoadingPages.isNullOrEmpty()) {pdfView.mLoadingPages.clear()pdfView.mLoadingPages.addAll(tempLoadingPages)pdfView.invalidate()//渲染模糊页面成功后,再开始渲染屏幕上显示的pdf块的高清 bitmappdfView.submitCreateScalingPagesTask()}}...}}}/*** 提交创建pdf页的任务到线程池* 如果线程池里有未执行或正在执行的任务,取消那个任务*/
    private fun submitCreateScalingPagesTask() {//只有在滑动状态为空闲的时候,才去创建缩放的 pdf 页的 bitmapif (mTouchState != TouchState.IDLE) returnif (mCreateScalingPagesFuture?.isDone != true)mCreateScalingPagesFuture?.cancel(true)mCreateScalingPagesFuture =EXECUTOR_SERVICE.submit(CreateScalingPageBitmapTask(this))
    }
    

Step6.绘制水印

这里的加水印,是在 View 层面上去加,并不是添加到 PDF 文档里边了。
由于是绘制在 View 层面,所以必须得绘制在 PDF 内容的顶层。因为如果 PDF 中有图片的话,绘制在底层就会被图片盖住(图片背景不透明)

  1. 初始化水印 Bitmap

    /*** 设置水印(暴露到外部的公开方法)*/
    fun setWatermark(@DrawableRes waterMark: Int) {mWaterMark = BitmapFactory.decodeResource(resources, waterMark)mWaterMarkSrcRect.set(0, 0, mWaterMark!!.width, mWaterMark!!.height)
    }/*** 异步处理 pdf_page to bitmap*/
    private class PDFHandler(pdfView: PDFView) : Handler() {...private val mWeakReference = WeakReference(pdfView)override fun handleMessage(msg: Message) {super.handleMessage(msg)val pdfView = mWeakReference.get() ?: returnwhen (msg.what) {MESSAGE_INIT_PDF_PLACE_HOLDER -> {pdfView.debug("handleMessage-MESSAGE_INIT_PDF_PLACE_HOLDER")val tempPagePlaceHolders = msg.data.getParcelableArrayList<PageRect>("list")if (!tempPagePlaceHolders.isNullOrEmpty()) {...//初始化水印的目标绘制宽高pdfView.initWatermarkDestRect()}}}}
    }private fun initWatermarkDestRect() {mWaterMark ?: returnval destWidth = mPdfTotalWidth / 2fval scale = destWidth / mWaterMarkSrcRect.width()val destHeight = mWaterMarkSrcRect.height() * scalemWaterMarkDestRect.set(0f,0f,destWidth,destHeight)
    }
    
  2. 绘制水印
    绘制水印放在最后绘制,因为绘制高清 PDF 时,把平移缩放重置了,所以水印绘制前需要重新平移缩放到重置前的状态

    override fun onDraw(canvas: Canvas) {super.onDraw(canvas)...//平移缩放preDraw(canvas)//画将要显示的完整page的水印drawLoadingWaterMarks(canvas)
    }/*** 画完整显示的pdf页面的水印*/
    private fun drawLoadingWaterMarks(canvas: Canvas) {mWaterMark ?: returnval left = (mPdfTotalWidth - mWaterMarkDestRect.width()) / 2mLoadingPages.filter { page ->page.pageRect?.fillWidthRect != null&& page.bitmap != null}.forEach {val fillWidthRect = it.pageRect!!.fillWidthRectmWaterMarkDestRect.offsetTo(left,fillWidthRect.centerY() - mWaterMarkDestRect.height() / 2)canvas.drawBitmap(mWaterMark!!, mWaterMarkSrcRect, mWaterMarkDestRect, mPDFPaint)}
    }
    

源代码

到这里,整个自定义 PDFView 的开发流程已经结束了。上边贴的代码只是为了配合编程思路和步骤介绍,并不是完整的代码。
完整代码已开源,Github 传送门

Q&A

  1. 用画布平移实现滑动而不是用View.scrollX/Y()

    最开始做的时候,我确实是用 View.scrollX/Y() 来实现滑动的,它来实现滑动很方便。但是到做缩放的时候,我觉得操作 View 好像不太合理。
    首先要了解一下 View.scrollX/Y()Canvas.translate() 分别操作的是什么。
    View.scrollX/Y() 是让 View 的内容去滚动,View 的内容都在画布上,即它是让画布的窗口去移动的;
    Canvas.translate() 是让画布窗口下层的画布去移动,从而使画布窗口上显示的内容发生移动的,在这个过程中画布窗口是固定不动的。
    再来说缩放后的画布处理,这里有两种思路:

    1. 滑动使用 View.scrollX/Y(),缩放使用 View.setScaleX/Y()
    2. 滑动使用 Canvas.translate(),缩放使用 Canvas.scale()

    有一点很重要,要么滑动和缩放都作用在 View 上,要么就都作用在 Canvas 上
    通常自定义 View 如果内容要移动或缩放的,都不会去更改 View 的宽高属性,而是在 onDraw 中以绘制来实现。也没见 ViewPager 的宽高根据数量不同而变化啊~
    所以在做到缩放时,我又回头重新用 Canvas.translate() 去现实滑动了。

  2. 缩放后绘制高清 PDF,为什么不把当前屏幕显示的 PDF 绘制到一个 Bitmap上?

    这个我本来是想只创建一个跟屏幕宽高一致的 Bitmap,然后循环渲染,使用 destClip 这个参数指定要渲染在 Bitmap 的位置,把缩放后显示在屏幕上的 PDF 页的矩形块绘制在同一个 Bitmap 上的。
    但是,这个参数其实并不是我当时理解的那个意思,它并不是作用在 PDF 上的,并不能指定要渲染 PDF 的哪一块内容。首先 PDF 的内容会先绘制在 Bitmap 上,然后 destClip 这个参数可以控制已经渲染过的 Bitmap 只显示哪一块内容。 destClip 是在渲染后控制显示的,这样的话循环完一遍后只会显示最后一个 PDF 块的内容,完成不了需求。。

【自定义View】从零开始写一个PDF查看器相关推荐

  1. Texmaker中PDF查看器的设置经验

    这个问题很简单,不过有时候记不清,所以特意总结一下. Texmaker是一个不错的LaTeX编辑器,在我的推荐下现在实验室的小伙伴们都在用.但是我注意到很多人用的时候有个问题,Texmaker的PDF ...

  2. Aurelia历险记:创建自定义PDF查看器

    本文由Vildan Softic进行同行评审. 感谢所有SitePoint的同行评审人员使SitePoint内容达到最佳状态! 在Web应用程序中处理PDF文件一直很麻烦. 如果幸运的话,您的用户只需 ...

  3. 如何搭建python框架_从零开始:写一个简单的Python框架

    原标题:从零开始:写一个简单的Python框架 Python部落(python.freelycode.com)组织翻译,禁止转载,欢迎转发. 你为什么想搭建一个Web框架?我想有下面几个原因: 有一个 ...

  4. 从零开始写一个武侠冒险游戏-6-用GPU提升性能(1)

    从零开始写一个武侠冒险游戏-6-用GPU提升性能(1) ----把帧动画的实现放在GPU上 作者:FreeBlues 修订记录 2016.06.19 初稿完成. 2016.08.05 增加对 XCod ...

  5. 从零开始写一个抖音App——Apt代码生成技术、gradle插件开发与protocol协议

    1.讨论--总结前两周评论中有意义的讨论并给予我的解答 2.mvps代码生成原理--将上周的 mvps 架构的代码生成原理进行解析 3.开发一款gradle插件--从 mvps 的代码引出 gradl ...

  6. HTML PDF 查看器--RAD PDF 3.33 FOR ASP.NET

    RAD PDF 的主要特点 基于 HTML 的 PDF 阅读器 客户端 PDF 编辑器 功能丰富的 PDF 表单填写器 交互式 PDF 表单设计器 保护 PDF 内容 签署和认证 PDF 文件 广泛的 ...

  7. WordPress的最佳PDF查看器比较

    PDF文件是共享和显示文档的有效且经过时间考验的方式,但是当您的网站没有PDF查看器时,存在一些限制. 首先,您可能会失去访问者的风险:浏览器可以加载PDF文档时,文件会加载到新的标签页或窗口中,这意 ...

  8. 在线PDF查看器和PDF编辑器:GrapeCity Documents PDF (GcPdf)

    跨平台 JavaScript PDF 查看器 使用我们的 JavaScript PDF 查看器在网络上阅读和编辑 PDF.跨浏览器和框架打开和打印.GrapeCity Documents PDF (G ...

  9. mysql c测试程序_Linux平台下从零开始写一个C语言访问MySQL的测试程序

    Linux 平台下从零开始写一个 C 语言访问 MySQL 的测试程序 2010-8-20 Hu Dennis Chengdu 前置条件: (1) Linux 已经安装好 mysql 数据库: (2) ...

  10. 从零开始写一个武侠冒险游戏-8-用GPU提升性能(3)

    从零开始写一个武侠冒险游戏-8-用GPU提升性能(3) ----解决因绘制雷达图导致的帧速下降问题 作者:FreeBlues 修订记录 2016.06.23 初稿完成. 2016.08.07 增加对 ...

最新文章

  1. PHP学习笔记 第八讲 Mysql.简介和创建新的数据库
  2. 制造业智能化的下一站——人与机器的协作
  3. python编程if语法-二、python 语法之变量赋值与if(if else)
  4. 北京低利用率数据中心将有序关闭腾退
  5. centos运行java图形化界面_Linux/CentOS关闭图形界面(X-window)和启用图形界面命令
  6. Python + Selenium + Chrome 使用代理 auth 的用户名密码授权
  7. 第二十一天 认识一维数组part3
  8. 提出问题之后,对于回答问题内容的仔细确认!!!(一个字一个字确认!!)
  9. 由WPS 2005想到的
  10. 【MATLAB】求偏导数
  11. 仙境传说 RO手游 自动技能 定时加状态脚本
  12. Springsecurity+cas整合后无法单点登出
  13. 程序大咖的博客集锦_更新Unity3d
  14. nginx 使用详细解
  15. 计算机快捷键40个,如何快速记住计算机快捷键
  16. 【C++】RAll,裸指针,弃用auto_ptr原因
  17. 总结 : 毕设采访原文呈现
  18. 如何选择最适合自己的地图软件
  19. 汇编中各寄存器的作用(16位CPU14个,32位CPU16个)和 x86汇编指令集大全(带注释)...
  20. Jquery-pagination.js分页处理

热门文章

  1. 如何解决Flash “此Flash Player 与您的地区不相容,请重新安装Flash”的提示?
  2. android多图拼接长图并合理显示
  3. 走过软件定义网络“来时的路”
  4. Android中调用文件管理器进行选择文件(记录)
  5. 百度地图坐标拾取工具
  6. python修改pdf文字_以编程方式更改PDF中文本的字体颜色
  7. JAVA基础,输入/输出(I/O)流
  8. 联想小新锁屏壁纸怎么换_联想_ThinkPad|ThinkCentre|ThinkStation服务与驱动下载_常见问题...
  9. Flow Prediction in Spatio-Temporal Networks Based on Multitask Deep Learning 学习笔记
  10. soap xml 转 json