文章目录
- 效果展示
- Android 平台已有的解决方案
- 自定义 View 做 PDF 查看器的优势
- 开始自定义 PDF 查看器
- 总体规划、步骤分解
- PdfRenderer 相关 API 介绍
- PdfRenderer
- PdfRenderer.Page
- Step1.绘制占位空白页
- Step2.实现滑动
- Step3.绘制 PDF 内容页
- Step4.实现缩放
- Step5.缩放后加载高清 PDF
- Step6.绘制水印
- 源代码
- Q&A
效果展示
Android 平台已有的解决方案
目前 android 平台上查看 pdf 的方案还是有很多可选的:
- 编写本地 html,引入 pdf.js,利用 WebView 去渲染 pdf
- 集成 AndroidPdfViewer 或其他类似的 library,使用第三方 so 库去处理 pdf 并渲染
- 自定义 View 结合系统原生的 pdf 处理类 PdfRenderer 进行 pdf 渲染
以上几种方式,各自都有优缺点。
使用 WebView 方式去渲染是最简单直接的,兼容性也好,但是当渲染比较大的 pdf 文档的时候会 oom;
第三方的 so 库渲染,通常兼容性会比较好,稳定性也比较好,但是由于使用到 so 库,会显著增加 apk 的包体积;
使用系统的 PdfRenderer
+ 自定义 View 来渲染 pdf,由于 pdf 渲染是由系统原生处理的,不需要额外的 so 包,所以不会显著增加 apk 的体积,也比较稳定,但是这个类是 API 21 后加入的,所以它只支持 android 5.0+。
自定义 View 做 PDF 查看器的优势
上边已经说明了,其实各种方式都各有优缺点,选择哪种方案完全取决于你对项目的评估和所作的决策。
我对 pdf 查看器的选择标准:
- 内存优化必须要好,拒绝 OOM
- 流畅、稳定、高可定制化
- 由于是简单查看 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,只渲染当前在屏幕上可见的部分就行。
我们来总结一下上边提到的技术点:
- 支持设置 pdf 页的预加载个数
- 滑动状态为空闲时,开启 pdf 转 Bitmap 的渲染任务渲染当前页和预加载页
- 要有个线程池处理耗时任务
- pdf 页的 Bitmap 资源需要做个三级缓存
- 放大查看高清 pdf 时,只渲染屏幕上显示的内容的高清 Bitmap
大致的需求和功能点都有了,接下来分步骤来完成:
- 绘制占位空白页
- 实现滑动
- 绘制 PDF 内容页
- 实现缩放
- 缩放后加载高清 PDF
- 绘制水印
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 线程
- 定义创建pdf页框架数据的任务
占位空白页的Rect
数据,可以通过循环从 PDF 渲染器中取出的Page
对象来创建。
PDF 页的原始宽高不会刚好跟屏幕宽度一样的,所以还需要缩放到屏幕宽度。
在循环保存 Page 宽高信息的时候,直接就把Rect
在画布的位置规定好(通过累加的pdfTotalHeight
)
每页保存宽高信息前,需要预留 Page 页分隔线的高度信息private class InitPdfFramesTask(pdfView: PDFView) : Runnable { private val mWeakReference = WeakReference(pdfView) override fun run() { val pdfView = mWeakReference.get() ?: return val pdfRenderer = pdfView.mPdfRenderer ?: throw NullPointerException("pdfRenderer is null!") val tempPagePlaceHolders = arrayListOf<PageRect>() var pdfTotalHeight = 0f val left = pdfView.paddingLeft.toFloat() + pdfView.mDividerHeight val right = pdfView.measuredWidth.toFloat() - pdfView.paddingRight.toFloat() - pdfView.mDividerHeight var fillWidthScale: Float var scaledHeight: Float for (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.mDividerHeight val rect = RectF(left, pdfTotalHeight, right, pdfTotalHeight + scaledHeight) pdfTotalHeight = rect.bottom tempPagePlaceHolders.add( PageRect( fillWidthScale, rect ) ) page.close() } val message = Message() message.what = PDFHandler.MESSAGE_INIT_PDF_PLACE_HOLDER message.data.putParcelableArrayList("list", tempPagePlaceHolders) pdfView.mPDFHandler.sendMessage(message) } }
- 线程池执行创建pdf页框架数据的任务
在 View 测量完成后去开启任务,这时 View 的宽高已经确定了,可以处理后续的数据override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { ... //初始化pdf页框架 if (mInitPageFramesFuture == null) mInitPageFramesFuture = EXECUTOR_SERVICE.submit( InitPdfFramesTask(this) ) }
- 画 pdf 页的占位框架
override fun onDraw(canvas: Canvas) { super.onDraw(canvas) ... //画占位图和分隔线 drawPlaceHolderAndDivider(canvas) ... } 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 内容,但是有了占坑的宽高和坐标信息,可以接着往下做滑动了。
滑动通常有两种:手指触摸滑动
和松开手指后的飞速滑动
。要做到人性化的滑动体验,这两种滑动都要处理;还要注意滑动边界的处理。
-
滑动边界设置
滑动是画布平移来实现的,即可平移的范围private fun getCanTranslateXRange(): Range<Float> { return Range(min(-(mCanvasScale * mPdfTotalWidth - width), 0f), 0f) } private fun getCanTranslateYRange(): Range<Float> { return Range(min(-(mCanvasScale * mPdfTotalHeight - height), 0f), 0f) }
-
创建
GestureDetector
处理滑动
GestureDetector 已经为我们识别了滑动状态onScroll
和飞速滑动onFling
onScroll
在触摸滑动时会一直回调,计算 transation 触发重绘就可以滑动
onFling
在手指离开屏幕后,如果此时滑动速度到达飞速滑动的标准,就会回调一次,根据速度计算目标滑动位置,然后创建值动画,动画更新时计算 transation 触发重绘实现滑动//处理飞速滑动的手势识别器 private val mGestureDetector by lazy { GestureDetector( context, OnPDFGestureListener(this) ) } 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 - distanceX val tempTranslateY = pdfView.mCanvasTranslate.y - distanceY val nextTranslateX = when { tempTranslateX in canTranslateXRange -> tempTranslateX tempTranslateX > canTranslateXRange.upper -> canTranslateXRange.upper else -> canTranslateXRange.lower } val nextTranslateY = when { tempTranslateY in canTranslateYRange -> tempTranslateY tempTranslateY > canTranslateYRange.upper -> canTranslateYRange.upper else -> 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 false mWeakReference.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.75f val tempTranslateY = pdfView.mCanvasTranslate.y + velocityY * 0.75f val endTranslateX = when { tempTranslateX in canTranslateXRange -> tempTranslateX tempTranslateX > canTranslateXRange.upper -> canTranslateXRange.upper else -> canTranslateXRange.lower } val endTranslateY = when { tempTranslateY in canTranslateYRange -> tempTranslateY tempTranslateY > canTranslateYRange.upper -> canTranslateYRange.upper else -> canTranslateYRange.lower } val distanceX = endTranslateX - pdfView.mCanvasTranslate.x val distanceY = endTranslateY - pdfView.mCanvasTranslate.y pdfView.startFlingAnim(distanceX, distanceY) return true } return super.onFling(e1, e2, velocityX, velocityY) } }
-
在
onTouchEvent
中合适的时机调用手势识别器override fun onTouchEvent(event: MotionEvent): Boolean { ... var handled = false when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { ... mTouchState = TouchState.SINGLE_POINTER mGestureDetector.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) }
-
飞速滑动时的滑动动画
在手势识别器中,识别到飞速滑动后,计算目标滑动位置,然后使用值动画平滑动滑动到目标位置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 = 400 in 100 until 600 -> animDuration = 600 } debug("startFlingAnim--distanceX-$distanceX--distanceY-$distanceY--animDuration-$animDuration") mFlingAnim = ValueAnimator().apply { setFloatValues(0f, 1f) duration = animDuration interpolator = 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 //飞速滑动时,不渲染缩放的 bitmap pdfView.clearScalingPages() val percent = animation.animatedValue as Float pdfView.mCanvasTranslate.x = lastCanvasTranslate.x + distanceX * percent pdfView.mCanvasTranslate.y = lastCanvasTranslate.y + distanceY * percent pdfView.invalidate() //重新计算当前页索引 pdfView.calculateCurrentPageIndex() } }
-
绘制画布平移实现滑动
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 的预加载逻辑来做,即(当前显示页+当前页前预加载个数+当前页后预加载个数)
-
创建渲染 PDF 的任务
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.fillWidthRect var bitmap = pdfView.getLoadingPagesBitmapFromCache(index) if (bitmap == null) { //3.本地缓存没拿到,从pdf渲染器创建bitmap val 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) } }
-
开启渲染 PDF 的任务
override fun onTouchEvent(event: MotionEvent): Boolean { ... var handled = false when (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) } private fun submitCreateLoadingPagesTask() { if (mCreateLoadingPagesFuture?.isDone != true) mCreateLoadingPagesFuture?.cancel(true) mCreateLoadingPagesFuture = EXECUTOR_SERVICE.submit( PdfPageToBitmapTask(this) ) }
-
绘制 PDF 内容页
override fun onDraw(canvas: Canvas) { super.onDraw(canvas) ... //画将要显示的完整page drawLoadingPages(canvas) ... } private fun drawLoadingPages(canvas: Canvas) { mLoadingPages.filter { page -> //即将缩放显示的页面,不绘制它的全页bitmap val isScaling = page.pageIndex in mScalingPages.map { it.pageIndex } page.pageRect?.fillWidthRect != null && page.bitmap != null && !isScaling } .forEach { val fillWidthScale = it.pageRect!!.fillWidthScale val fillWidthRect = it.pageRect.fillWidthRect canvas.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
中去完成缩放。
-
从
onTouchEvent
剥离多点触控触摸事件override fun onTouchEvent(event: MotionEvent): Boolean { ... var handled = false when (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) }
-
处理多点触控事件,计算平移和缩放倍数
private fun onZoomTouchEvent(event: MotionEvent): Boolean { //如果没开启缩放,就不处理多点触控 if (!mCanZoom) return false when (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 false val multiFingerDistanceEnd = distance( event.getX(0), event.getX(1), event.getY(0), event.getY(1) ) val tempScale = (multiFingerDistanceEnd / mMultiFingerDistanceStart) * mScaleStart mCanvasScale = when (tempScale) { in 0f..mMinScale -> mMinScale in mMinScale..mMaxScale -> tempScale else -> mMaxScale } val centerPointEndX = (event.getX(0) + event.getX(1)) / 2 val centerPointEndY = (event.getY(0) + event.getY(1)) / 2 val vLeftStart: Float = mMultiFingerCenterPointStart.x - mZoomTranslateStart.x val vTopStart: Float = mMultiFingerCenterPointStart.y - mZoomTranslateStart.y val vLeftNow: Float = vLeftStart * (mCanvasScale / mScaleStart) val vTopNow: Float = vTopStart * (mCanvasScale / mScaleStart) //判断滑动边界,重新设置滑动值 val canTranslateXRange = getCanTranslateXRange() val canTranslateYRange = getCanTranslateYRange() val tempTranslateX = centerPointEndX - vLeftNow val tempTranslateY = centerPointEndY - vTopNow val nextTranslateX = when { tempTranslateX in canTranslateXRange -> tempTranslateX tempTranslateX > canTranslateXRange.upper -> canTranslateXRange.upper else -> canTranslateXRange.lower } val nextTranslateY = when { tempTranslateY in canTranslateYRange -> tempTranslateY tempTranslateY > canTranslateYRange.upper -> canTranslateYRange.upper else -> canTranslateYRange.lower } mCanvasTranslate.set(nextTranslateX, nextTranslateY) invalidate() //重新计算当前页索引 calculateCurrentPageIndex() return true } MotionEvent.ACTION_UP -> { debug("onZoomTouchEvent-ACTION_UP") submitCreateLoadingPagesTask() return true } } return false }
-
绘制缩放
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 已经是放大过得了,所以绘制前需要把画布重置为原始状态,清除画布的平移缩放后再画。
-
创建渲染高清 PDF 页的任务
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) continue val 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.mCanvasScale matrix.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_BITMAP message.data.putInt("index", currentPageIndex) message.data.putParcelableArrayList("list", tempScalingPages) pdfView.mPDFHandler.sendMessage(message) } }
-
开启渲染高清 PDF 的任务
应该在缩放结束,手指离开屏幕后去开启加载高清的任务。这个思路没毛病,但是再回顾一下前边的步骤,绘制 PDF 内容页的时候,是先渲染的原始 PDF 页,然后在绘制时放大到屏幕的宽度的,这样在没有缩放的状态下,看到的 PDF 页内容可能是模糊的,体验会不那么完美。
这里修改一下显示逻辑:滑动或缩放操作结束后,开启渲染 PDF 的任务去渲染预加载个数的 PDF 页;任务完成的同时再开启渲染高清 PDF 页Rect
的任务。private class PDFHandler(pdfView: PDFView) : Handler() { ... private val mWeakReference = WeakReference(pdfView) override fun handleMessage(msg: Message) { super.handleMessage(msg) val pdfView = mWeakReference.get() ?: return when (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块的高清 bitmap pdfView.submitCreateScalingPagesTask() } } ... } } } private fun submitCreateScalingPagesTask() { //只有在滑动状态为空闲的时候,才去创建缩放的 pdf 页的 bitmap if (mTouchState != TouchState.IDLE) return if (mCreateScalingPagesFuture?.isDone != true) mCreateScalingPagesFuture?.cancel(true) mCreateScalingPagesFuture = EXECUTOR_SERVICE.submit( CreateScalingPageBitmapTask(this) ) }
Step6.绘制水印
这里的加水印,是在 View 层面上去加,并不是添加到 PDF 文档里边了。
由于是绘制在 View 层面,所以必须得绘制在 PDF 内容的顶层。因为如果 PDF 中有图片的话,绘制在底层就会被图片盖住(图片背景不透明)
-
初始化水印 Bitmap
fun setWatermark(@DrawableRes waterMark: Int) { mWaterMark = BitmapFactory.decodeResource(resources, waterMark) mWaterMarkSrcRect.set(0, 0, mWaterMark!!.width, mWaterMark!!.height) } private class PDFHandler(pdfView: PDFView) : Handler() { ... private val mWeakReference = WeakReference(pdfView) override fun handleMessage(msg: Message) { super.handleMessage(msg) val pdfView = mWeakReference.get() ?: return when (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 ?: return val destWidth = mPdfTotalWidth / 2f val scale = destWidth / mWaterMarkSrcRect.width() val destHeight = mWaterMarkSrcRect.height() * scale mWaterMarkDestRect.set( 0f, 0f, destWidth, destHeight ) }
-
绘制水印
绘制水印放在最后绘制,因为绘制高清 PDF 时,把平移缩放重置了,所以水印绘制前需要重新平移缩放到重置前的状态override fun onDraw(canvas: Canvas) { super.onDraw(canvas) ... //平移缩放 preDraw(canvas) //画将要显示的完整page的水印 drawLoadingWaterMarks(canvas) } private fun drawLoadingWaterMarks(canvas: Canvas) { mWaterMark ?: return val left = (mPdfTotalWidth - mWaterMarkDestRect.width()) / 2 mLoadingPages.filter { page -> page.pageRect?.fillWidthRect != null && page.bitmap != null } .forEach { val fillWidthRect = it.pageRect!!.fillWidthRect mWaterMarkDestRect.offsetTo( left, fillWidthRect.centerY() - mWaterMarkDestRect.height() / 2 ) canvas.drawBitmap(mWaterMark!!, mWaterMarkSrcRect, mWaterMarkDestRect, mPDFPaint) } }
源代码
到这里,整个自定义 PDFView 的开发流程已经结束了。上边贴的代码只是为了配合编程思路和步骤介绍,并不是完整的代码。
完整代码已开源,Github 传送门
Q&A
-
用画布平移实现滑动而不是用
View.scrollX/Y()
?最开始做的时候,我确实是用
View.scrollX/Y()
来实现滑动的,它来实现滑动很方便。但是到做缩放的时候,我觉得操作 View 好像不太合理。
首先要了解一下View.scrollX/Y()
和Canvas.translate()
分别操作的是什么。
View.scrollX/Y()
是让 View 的内容去滚动,View 的内容都在画布上,即它是让画布的窗口去移动的;
Canvas.translate()
是让画布窗口下层的画布去移动,从而使画布窗口上显示的内容发生移动的,在这个过程中画布窗口是固定不动的。
再来说缩放后的画布处理,这里有两种思路:- 滑动使用
View.scrollX/Y()
,缩放使用View.setScaleX/Y()
- 滑动使用
Canvas.translate()
,缩放使用Canvas.scale()
有一点很重要,要么滑动和缩放都作用在 View 上,要么就都作用在 Canvas 上
通常自定义 View 如果内容要移动或缩放的,都不会去更改 View 的宽高属性,而是在onDraw
中以绘制来实现。也没见 ViewPager 的宽高根据数量不同而变化啊~
所以在做到缩放时,我又回头重新用Canvas.translate()
去现实滑动了。 - 滑动使用
-
缩放后绘制高清 PDF,为什么不把当前屏幕显示的 PDF 绘制到一个 Bitmap上?
这个我本来是想只创建一个跟屏幕宽高一致的 Bitmap,然后循环渲染,使用
destClip
这个参数指定要渲染在 Bitmap 的位置,把缩放后显示在屏幕上的 PDF 页的矩形块绘制在同一个 Bitmap 上的。
但是,这个参数其实并不是我当时理解的那个意思,它并不是作用在 PDF 上的,并不能指定要渲染 PDF 的哪一块内容。首先 PDF 的内容会先绘制在 Bitmap 上,然后destClip
这个参数可以控制已经渲染过的 Bitmap 只显示哪一块内容。destClip
是在渲染后控制显示的,这样的话循环完一遍后只会显示最后一个 PDF 块的内容,完成不了需求。。