Android实现对PDF进行涂鸦操作(附带源码)

一、项目介绍

在许多阅读器、文档批注、教育培训或企业内部审批场景中,在 PDF 文档上进行涂鸦、手写批注 是一个常见需求。与只读的 PDF 浏览不同,涂鸦功能要求:

  1. 能在 PDF 页面上自由绘制线条、矩形、箭头等标记

  2. 支持撤销/重做、橡皮擦、清空

  3. 支持多页切换时保持各页涂鸦内容

  4. 能将涂鸦结果导出为新的 PDF 或图片(可选)

本教程将从零到一,带你实现一个基于 PdfRenderer + 自定义涂鸦层的 PDF 注释功能,并扩展到使用 AndroidPdfViewer 库的方案。最终封装为可复用组件 PdfGraffitiView,支持触摸涂鸦、多页管理、保存导出等功能。


二、相关知识

在正式编码之前,需要掌握以下技术点:

  1. Android PdfRenderer(API21+)

    • PdfRenderer 可以打开 ParcelFileDescriptor 指向的 PDF 文件,渲染为 Bitmap

    • 每页调用 renderer.openPage(pageIndex) 生成 Bitmap 并绘制到 Canvas

  2. 第三方 PDF 查看库

    • AndroidPdfViewer:基于 PdfiumAndroid,支持缩放、滑动、快速渲染

    • 可通过 PDFView 加载 PDF,支持获取当前页数与缩放/滑动回调

  3. 自定义涂鸦(Graffiti)层

    • 继承 View,在其 onDraw(Canvas) 上维护一组 Path

    • 在触摸事件中记录 ACTION_DOWN/MOVE/UP,往当前路径中添加点;

    • 支持撤销:用 Stack<Path> 保存已完成路径;

  4. 多层叠加

    • PDF 渲染层与涂鸦层分离,可放在同一个父布局中,PDF 在下,涂鸦层在上;

    • 切页时 PDF 重绘底层,涂鸦层需根据当前页切换相应 Path 集合;

  5. 状态保存与恢复

    • onSaveInstanceState 保存当前页的涂鸦数据(可序列化)、当前页码;

    • 应用后台切回或横竖屏切换时恢复;

  6. 导出

    • 将 PDF 页面 Bitmap 与对应涂鸦 Bitmap 合成,按页写入新的 PDF(借助 iText 或 PdfBox-Android);

    • 或将每页导出为图片保存。


三、实现思路

方案一:原生 PdfRenderer + 自定义 GraffitiView

  1. 加载 PDF

    • assetsFile 中的 PDF 拷贝到可读路径;

    • ParcelFileDescriptor.open(file, MODE_READ_ONLY) 构造 PdfRenderer

  2. 渲染页面

    • 在自定义 PdfPageView 中持有 PdfRenderer.Page,在 onDraw() 中调用 page.render(bitmap, null, null, RENDER_MODE_FOR_DISPLAY),再绘制到 canvas

  3. 涂鸦层

    • 自定义 GraffitiView 继承 View,持有 currentPath: Pathpaths: MutableList<Path>

    • onTouchEvent 处理落笔、划线、收笔;

    • onDraw 遍历 pathsPaint 绘制,每条路径都按上次滑动记录;

  4. 页面管理

    • 使用 ViewPager2 将每页 PDF + 对应 GraffitiView 封装为一个子页面;

    • Adapter 中为每页创建一对 PdfPageView + GraffitiView

    • 外层 PdfGraffitiView 作为控件整合切页与操作接口;

  5. 撤销/重做/橡皮擦

    • 撤销:paths.removeLast()

    • 重做:需维护 redoStack: MutableList<Path>,撤销时 push 到重做栈,重做时 pop;

    • 橡皮擦:切换 Paint.setXfermode(PorterDuffXfermode(Mode.CLEAR)),或检测触摸点在哪条路径中删除;

  6. 保存与导出

    • 遍历每页,将 PDF 渲染和涂鸦合并到一个 Bitmap

    • PdfDocument 写入新 PDF 页面;

    • bitmap.compress(...) 导出图片。

方案二:AndroidPdfViewer + 覆盖 GraffitiView

  1. 集成库

    • implementation 'com.github.barteksc:android-pdf-viewer:2.8.2'

  2. 加载 PDF

    • 在布局中使用 <com.github.barteksc.pdfviewer.PDFView>

    • pdfView.fromFile(file).enableSwipe(true).enableAnnotationRendering(true).load()

  3. 涂鸦层叠加

    • PDFView 放在 FrameLayout,上面添加 GraffitiView,拦截触摸并根据 pdfView.currentPage 记录路径到对应页面集合;

  4. 坐标转换

    • 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。
  }
}

六、代码解读

  1. MainActivity

    • 拷贝 assets/sample.pdffilesDir

    • 初始化 PdfGraffitiAdapter 并设置到 ViewPager2

    • 操作按钮通过 graffitiCurrent { … } 执行对当前页 GraffitiView 的方法;

  2. PdfGraffitiAdapter

    • 打开 PdfRenderer 并为每页创建 PdfPageView(渲染页面)与 GraffitiView(涂鸦层);

    • 通过 binding.pdfPageView.setPage(renderer, position) 渲染 PDF;

    • binding.graffitiView.setPageIndex(position) 关联涂鸦数据页码;

  3. PdfPageView

    • 使用 PdfRenderer 渲染单页到 Bitmap 并在 onDraw() 绘制;

  4. GraffitiView

    • 维护每页的 pathsredo 栈,支持撤销/重做;

    • 触摸事件生成 currentPath,渲染时先绘制历史路径再绘制当前路径;

    • eraseMode(true) 切换到橡皮擦模式(PorterDuff.Mode.CLEAR);

  5. 数据持久化

    • GraffitiData 在内存中维护各页涂鸦数据,若需持久化可序列化 Path(点列表)到 JSON 或二进制文件。


七、性能与优化

  1. Bitmap 缓存

    • PDF 页面渲染耗时,可在 Adapter 中缓存每页 Bitmap,避免重复渲染;

  2. 涂鸦数据压缩

    • Path 对象较重,可将点列表(List<PointF>) 序列化存储,减少内存;

  3. 硬件加速

    • 若遇到 clear 模式在硬件加速失效,可在 GraffitiViewsetLayerType(LAYER_TYPE_SOFTWARE, null)

  4. 导出优化

    • 批量合成页面 Bitmap 时可按需暂停涂鸦层交互,避免 UI 干扰;


八、项目总结与拓展

通过本文,你已学会:

  • 使用 PdfRenderer 与自定义 View 实现 PDF 渲染与涂鸦;

  • 管理多页涂鸦数据、撤销重做、橡皮擦等功能;

  • 将底层逻辑封装为 PdfGraffitiAdapterGraffitiView 组件,复用性强;

拓展方向

  1. 导出 PDF

    • 使用 iText 或 PdfBox-Android 将涂鸦合并到原 PDF 并生成新文档;

  2. 跨进程共享

    • 将涂鸦数据上传服务器,多端同步注释;

  3. 更多标注类型

    • 增加文字标注、图章、图形(矩形、圆形)等功能;

  4. Jetpack Compose 重构

    • 在 Compose 中使用 CanvasPath 实现同样涂鸦效果。


九、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 中结合 ScaleGestureDetectorGestureDetector,动态缩放和绘制路径。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值