一、项目介绍
在许多阅读器、文档批注、教育培训或企业内部审批场景中,在 PDF 文档上进行涂鸦、手写批注 是一个常见需求。与只读的 PDF 浏览不同,涂鸦功能要求:
-
能在 PDF 页面上自由绘制线条、矩形、箭头等标记
-
支持撤销/重做、橡皮擦、清空
-
支持多页切换时保持各页涂鸦内容
-
能将涂鸦结果导出为新的 PDF 或图片(可选)
本教程将从零到一,带你实现一个基于 PdfRenderer + 自定义涂鸦层的 PDF 注释功能,并扩展到使用 AndroidPdfViewer 库的方案。最终封装为可复用组件 PdfGraffitiView
,支持触摸涂鸦、多页管理、保存导出等功能。
二、相关知识
在正式编码之前,需要掌握以下技术点:
-
Android PdfRenderer(API21+)
-
PdfRenderer
可以打开ParcelFileDescriptor
指向的 PDF 文件,渲染为Bitmap
-
每页调用
renderer.openPage(pageIndex)
生成Bitmap
并绘制到Canvas
-
-
第三方 PDF 查看库
-
AndroidPdfViewer:基于
PdfiumAndroid
,支持缩放、滑动、快速渲染 -
可通过
PDFView
加载 PDF,支持获取当前页数与缩放/滑动回调
-
-
自定义涂鸦(Graffiti)层
-
继承
View
,在其onDraw(Canvas)
上维护一组Path
; -
在触摸事件中记录
ACTION_DOWN
/MOVE
/UP
,往当前路径中添加点; -
支持撤销:用
Stack<Path>
保存已完成路径;
-
-
多层叠加
-
PDF 渲染层与涂鸦层分离,可放在同一个父布局中,PDF 在下,涂鸦层在上;
-
切页时 PDF 重绘底层,涂鸦层需根据当前页切换相应
Path
集合;
-
-
状态保存与恢复
-
在
onSaveInstanceState
保存当前页的涂鸦数据(可序列化)、当前页码; -
应用后台切回或横竖屏切换时恢复;
-
-
导出
-
将 PDF 页面 Bitmap 与对应涂鸦 Bitmap 合成,按页写入新的 PDF(借助 iText 或 PdfBox-Android);
-
或将每页导出为图片保存。
-
三、实现思路
方案一:原生 PdfRenderer
+ 自定义 GraffitiView
-
加载 PDF
-
将
assets
或File
中的 PDF 拷贝到可读路径; -
用
ParcelFileDescriptor.open(file, MODE_READ_ONLY)
构造PdfRenderer
;
-
-
渲染页面
-
在自定义
PdfPageView
中持有PdfRenderer.Page
,在onDraw()
中调用page.render(bitmap, null, null, RENDER_MODE_FOR_DISPLAY)
,再绘制到canvas
;
-
-
涂鸦层
-
自定义
GraffitiView
继承View
,持有currentPath: Path
、paths: MutableList<Path>
; -
在
onTouchEvent
处理落笔、划线、收笔; -
在
onDraw
遍历paths
用Paint
绘制,每条路径都按上次滑动记录;
-
-
页面管理
-
使用
ViewPager2
将每页 PDF + 对应GraffitiView
封装为一个子页面; -
在
Adapter
中为每页创建一对PdfPageView + GraffitiView
; -
外层
PdfGraffitiView
作为控件整合切页与操作接口;
-
-
撤销/重做/橡皮擦
-
撤销:
paths.removeLast()
; -
重做:需维护
redoStack: MutableList<Path>
,撤销时 push 到重做栈,重做时 pop; -
橡皮擦:切换
Paint.setXfermode(PorterDuffXfermode(Mode.CLEAR))
,或检测触摸点在哪条路径中删除;
-
-
保存与导出
-
遍历每页,将 PDF 渲染和涂鸦合并到一个
Bitmap
; -
用
PdfDocument
写入新 PDF 页面; -
或
bitmap.compress(...)
导出图片。
-
方案二:AndroidPdfViewer + 覆盖 GraffitiView
-
集成库
-
implementation 'com.github.barteksc:android-pdf-viewer:2.8.2'
-
-
加载 PDF
-
在布局中使用
<com.github.barteksc.pdfviewer.PDFView>
-
pdfView.fromFile(file).enableSwipe(true).enableAnnotationRendering(true).load()
-
-
涂鸦层叠加
-
将
PDFView
放在FrameLayout
,上面添加GraffitiView
,拦截触摸并根据pdfView.currentPage
记录路径到对应页面集合;
-
-
坐标转换
-
PDFView 缩放/平移后,GraffitiView 的触摸坐标需通过
pdfView.getCurrentXOffset()
,pdfView.getZoom()
转换到 PDF 真实坐标;
-
四、环境与依赖
// app/build.gradle
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 34
defaultConfig {
applicationId "com.example.pdfgraffiti"
minSdkVersion 21
targetSdkVersion 34
}
buildFeatures { viewBinding true }
kotlinOptions { jvmTarget = "1.8" }
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'androidx.viewpager2:viewpager2:1.2.0'
implementation 'com.github.barteksc:android-pdf-viewer:2.8.2' // 方案二
implementation 'com.itextpdf:itextg:5.5.13.3' // 导出PDF(可选)
}
五、整合代码
// =======================================================
// 文件: AndroidManifest.xml
// 描述: 注册 MainActivity 无需特殊权限
// =======================================================
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.pdfgraffiti">
<application
android:allowBackup="true"
android:label="PDF涂鸦Demo"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<activity android:name=".MainActivity"
android:exported="true"/>
</application>
</manifest>
// =======================================================
// 文件: res/layout/activity_main.xml
// 描述: 包含 ViewPager2 和操作按钮
// =======================================================
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- 操作栏 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp"
android:gravity="center">
<Button android:id="@+id/btn_undo" android:text="撤销"/>
<Button android:id="@+id/btn_redo" android:text="重做" android:layout_marginStart="8dp"/>
<Button android:id="@+id/btn_eraser" android:text="橡皮擦" android:layout_marginStart="8dp"/>
<Button android:id="@+id/btn_clear" android:text="清空" android:layout_marginStart="8dp"/>
<Button android:id="@+id/btn_export" android:text="导出" android:layout_marginStart="8dp"/>
</LinearLayout>
<!-- PDF 页面容器 -->
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
</LinearLayout>
// =======================================================
// 文件: PdfGraffitiAdapter.kt
// 描述: ViewPager2 Adapter:每页 PDF + 涂鸦层
// =======================================================
package com.example.pdfgraffiti
import android.content.Context
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.example.pdfgraffiti.databinding.ItemPdfPageBinding
import java.io.File
class PdfGraffitiAdapter(
private val ctx: Context,
private val pdfFile: File,
private val graffitiData: GraffitiData
) : RecyclerView.Adapter<PdfGraffitiAdapter.VH>() {
private val renderer: PdfRenderer
private val fd: ParcelFileDescriptor
init {
fd = ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY)
renderer = PdfRenderer(fd)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val b = ItemPdfPageBinding.inflate(LayoutInflater.from(ctx), parent, false)
return VH(b)
}
override fun getItemCount(): Int = renderer.pageCount
override fun onBindViewHolder(holder: VH, position: Int) {
// 渲染 PDF 页面
holder.binding.pdfPageView.setPage(renderer, position)
// 关联涂鸦层
holder.binding.graffitiView.setPageIndex(position)
holder.binding.graffitiView.setData(graffitiData)
holder.binding.graffitiView.invalidate()
}
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
super.onDetachedFromRecyclerView(recyclerView)
renderer.close()
fd.close()
}
inner class VH(val binding: ItemPdfPageBinding) : RecyclerView.ViewHolder(binding.root)
}
// =======================================================
// 文件: GraffitiData.kt
// 描述: 存储每页的涂鸦路径集合,用于撤销/重做/保存
// =======================================================
package com.example.pdfgraffiti
import android.graphics.Path
class GraffitiData {
// 每页的历史路径栈和重做栈
val pathsMap = mutableMapOf<Int, MutableList<Path>>()
val redoMap = mutableMapOf<Int, MutableList<Path>>()
fun getPaths(page: Int): MutableList<Path> =
pathsMap.getOrPut(page) { mutableListOf() }
fun getRedo(page: Int): MutableList<Path> =
redoMap.getOrPut(page) { mutableListOf() }
fun clear(page: Int) {
pathsMap[page]?.clear()
redoMap[page]?.clear()
}
}
// =======================================================
// 文件: PdfPageView.kt
// 描述: 自定义 View,用 PdfRenderer 渲染单页 PDF
// =======================================================
package com.example.pdfgraffiti
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import android.util.AttributeSet
import android.view.View
import java.io.File
class PdfPageView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
private var pageBitmap: Bitmap? = null
/** 渲染指定页到 Bitmap 并调用 invalidate() */
fun setPage(renderer: PdfRenderer, index: Int) {
val page = renderer.openPage(index)
val w = page.width
val h = page.height
pageBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
page.render(pageBitmap!!, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
page.close()
requestLayout()
invalidate()
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
pageBitmap?.let {
setMeasuredDimension(it.width, it.height)
} ?: super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
pageBitmap?.let { canvas.drawBitmap(it, 0f, 0f, null) }
}
}
// =======================================================
// 文件: GraffitiView.kt
// 描述: 自定义涂鸦层,支持撤销/重做/橡皮擦
// =======================================================
package com.example.pdfgraffiti
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
class GraffitiView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED; strokeWidth = 5f; style = Paint.Style.STROKE
}
private var eraserMode = false
private var currentPath: Path? = null
private var pageIndex = 0
private lateinit var data: GraffitiData
fun setPageIndex(index: Int) { pageIndex = index }
fun setData(g: GraffitiData) { data = g }
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 绘制已有路径
for (p in data.getPaths(pageIndex)) {
canvas.drawPath(p, paint)
}
// 绘制当前路径
currentPath?.let { canvas.drawPath(it, paint) }
}
override fun onTouchEvent(ev: MotionEvent): Boolean {
val x = ev.x; val y = ev.y
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
currentPath = Path().apply { moveTo(x, y) }
}
MotionEvent.ACTION_MOVE -> {
currentPath?.lineTo(x, y); invalidate()
}
MotionEvent.ACTION_UP -> {
currentPath?.let {
data.getPaths(pageIndex).add(it)
data.getRedo(pageIndex).clear()
}
currentPath = null; invalidate()
}
}
return true
}
fun undo() {
val list = data.getPaths(pageIndex)
if (list.isNotEmpty()) {
val last = list.removeAt(list.lastIndex)
data.getRedo(pageIndex).add(last)
invalidate()
}
}
fun redo() {
val redoList = data.getRedo(pageIndex)
if (redoList.isNotEmpty()) {
val p = redoList.removeAt(redoList.lastIndex)
data.getPaths(pageIndex).add(p)
invalidate()
}
}
fun eraseMode(on: Boolean) {
eraserMode = on
paint.xfermode = if (on) PorterDuffXfermode(PorterDuff.Mode.CLEAR) else null
}
fun clearPage() {
data.clear(pageIndex)
invalidate()
}
}
// =======================================================
// 文件: MainActivity.kt
// 描述: 初始化 ViewPager2、按钮绑定
// =======================================================
package com.example.pdfgraffiti
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.viewpager2.widget.ViewPager2
import com.example.pdfgraffiti.databinding.ActivityMainBinding
import java.io.File
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var adapter: PdfGraffitiAdapter
private val graffitiData = GraffitiData()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// 1. 准备 PDF 文件
val pdfFile = File(filesDir, "sample.pdf")
if (!pdfFile.exists()) assets.open("sample.pdf").use { ins ->
pdfFile.outputStream().use { out -> ins.copyTo(out) }
}
// 2. 初始化适配器
adapter = PdfGraffitiAdapter(this, pdfFile, graffitiData)
binding.viewPager.adapter = adapter
binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL
// 3. 按钮事件
binding.btnUndo.setOnClickListener { graffitiCurrent { undo() } }
binding.btnRedo.setOnClickListener { graffitiCurrent { redo() } }
binding.btnEraser.setOnClickListener { graffitiCurrent { eraseMode(true) } }
binding.btnClear.setOnClickListener { graffitiCurrent { clearPage() } }
binding.btnExport.setOnClickListener { exportPdfWithAnnotations(pdfFile) }
}
private fun graffitiCurrent(action: GraffitiView.() -> Unit) {
val idx = binding.viewPager.currentItem
val holder = binding.viewPager.findViewHolderForAdapterPosition(idx)
as? PdfGraffitiAdapter.VH ?: return
action(holder.binding.graffitiView)
}
private fun exportPdfWithAnnotations(src: File) {
// 略:循环合成每页 Bitmap,再通过 iText 写入新 PDF。
}
}
六、代码解读
-
MainActivity
-
拷贝
assets/sample.pdf
到filesDir
; -
初始化
PdfGraffitiAdapter
并设置到ViewPager2
; -
操作按钮通过
graffitiCurrent { … }
执行对当前页GraffitiView
的方法;
-
-
PdfGraffitiAdapter
-
打开
PdfRenderer
并为每页创建PdfPageView
(渲染页面)与GraffitiView
(涂鸦层); -
通过
binding.pdfPageView.setPage(renderer, position)
渲染 PDF; -
binding.graffitiView.setPageIndex(position)
关联涂鸦数据页码;
-
-
PdfPageView
-
使用
PdfRenderer
渲染单页到Bitmap
并在onDraw()
绘制;
-
-
GraffitiView
-
维护每页的
paths
和redo
栈,支持撤销/重做; -
触摸事件生成
currentPath
,渲染时先绘制历史路径再绘制当前路径; -
eraseMode(true)
切换到橡皮擦模式(PorterDuff.Mode.CLEAR
);
-
-
数据持久化
-
GraffitiData
在内存中维护各页涂鸦数据,若需持久化可序列化Path
(点列表)到 JSON 或二进制文件。
-
七、性能与优化
-
Bitmap 缓存
-
PDF 页面渲染耗时,可在
Adapter
中缓存每页Bitmap
,避免重复渲染;
-
-
涂鸦数据压缩
-
Path
对象较重,可将点列表(List<PointF>)
序列化存储,减少内存;
-
-
硬件加速
-
若遇到
clear
模式在硬件加速失效,可在GraffitiView
中setLayerType(LAYER_TYPE_SOFTWARE, null)
;
-
-
导出优化
-
批量合成页面 Bitmap 时可按需暂停涂鸦层交互,避免 UI 干扰;
-
八、项目总结与拓展
通过本文,你已学会:
-
使用
PdfRenderer
与自定义View
实现 PDF 渲染与涂鸦; -
管理多页涂鸦数据、撤销重做、橡皮擦等功能;
-
将底层逻辑封装为
PdfGraffitiAdapter
与GraffitiView
组件,复用性强;
拓展方向
-
导出 PDF
-
使用 iText 或 PdfBox-Android 将涂鸦合并到原 PDF 并生成新文档;
-
-
跨进程共享
-
将涂鸦数据上传服务器,多端同步注释;
-
-
更多标注类型
-
增加文字标注、图章、图形(矩形、圆形)等功能;
-
-
Jetpack Compose 重构
-
在 Compose 中使用
Canvas
与Path
实现同样涂鸦效果。
-
九、FAQ
Q1:Path
如何序列化存储?
A1:遍历 Path
中的 PathMeasure
获取点列表,存入 JSON 数组;恢复时重新 moveTo
/lineTo
。
Q2:橡皮擦模式在硬件加速下不生效怎么办?
A2:调用 view.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
禁用硬件加速,或手动检测并清除路径。
Q3:如何导出为新的 PDF?
A3:使用 iText 的 PdfStamper
,将涂鸦页面 Bitmap
转为 Image
对象压入对应页。
Q4:页面渲染卡顿怎么办?
A4:在后台线程预渲染所有页面 Bitmap
,或使用 AndroidPdfViewer 提供的异步渲染。
Q5:多点触控和缩放怎么支持?
A5:在 GraffitiView
中结合 ScaleGestureDetector
和 GestureDetector
,动态缩放和绘制路径。