在很多应用场景中,我们需要让用户进行自由绘画或手写输入,如:
签字确认:电子合同、快递签收
绘图涂鸦:社交 App 分享手绘内容
涂抹擦除:儿童教育绘画
标注批注:地图/图片标记、文档批注
本项目将实现一个高度可定制的写字板,满足:
自由绘制:支持多笔触、多颜色、多粗细
撤销重做:可撤销/重做操作
清屏保存:一键清空、一键保存为图片
手势优化:平滑曲线、压感模拟(粗细模拟)
UI 可定制:颜色面板、笔宽控制、清空/撤销/保存按钮
组件化:封装 DrawingBoardView
,易于在任意布局中使用
绘制路径:用户触摸屏幕实时绘制连续曲线
多颜色切换:提供调色板,支持任意颜色
可调笔宽:支持至少 3 种笔触粗细
撤销/重做:可对每一条路径进行撤销和重做
清空画布:一键清空所有绘制内容
保存图片:将画布内容保存到本地相册或应用私有目录
导出分享:可直接分享绘制的图片
性能优化:支持硬件加速、路径缓存、局部刷新
在动手之前,你需要了解以下核心技术点:
自定义 View 与 Canvas
重写 onDraw(Canvas)
,使用 Canvas.drawPath(Path, Paint)
绘制路径
在 onTouchEvent(MotionEvent)
中根据 ACTION_DOWN/MOVE/UP
构建 Path
数据结构与撤销/重做
使用 List
保存已完成路径,用 Stack
保存被撤销的路径以支持重做
每次完成一笔后将 currentPath
加入 paths
,清空 redoStack
性能优化
缓存 Path
和 Paint
对象,避免频繁分配
在 invalidate(Rect)
中局部刷新触摸区域,减少全屏重绘
触摸平滑
使用二次贝塞尔曲线平滑轨迹:path.quadTo(prevX, prevY, (x+prevX)/2, (y+prevY)/2)
文件保存与分享
将 Bitmap
导出:在 DrawingBoardView
中生成 Bitmap
并 Canvas
一次性绘制底图与所有路径
使用 MediaStore
(Android Q+)或 FileOutputStream
保存到相册
使用 FileProvider
和 Intent.ACTION_SEND
分享图片
UI 组件
使用 RecyclerView
或 LinearLayout
构建颜色面板与笔宽面板
使用 MaterialButton
、FloatingActionButton
等承载撤销、重做、清除、保存操作
封装 DrawingBoardView
公共属性:setStrokeColor(int)
, setStrokeWidth(float)
, undo()
, redo()
, clear()
, exportBitmap()
事件处理:onTouchEvent
采集并平滑记录触摸轨迹;
主界面布局
顶部按钮区域:撤销、重做、清空、保存
中部 DrawingBoardView
占满屏幕
底部工具栏:颜色选择、笔宽滑动条
文件存储与分享
在 MainActivity
中调用 drawingBoard.exportBitmap()
获取 Bitmap
,再保存或分享
使用协程或后台线程处理 I/O,显示进度提示
状态保存与恢复
在 onSaveInstanceState
保存 paths
和 redoStack
的序列化数据
在 onRestoreInstanceState
恢复路径,避免屏幕旋转丢失画图
模块化与复用
将所有绘制逻辑封装在 DrawingBoardView.kt
将保存与分享功能封装在 ImageUtil.kt
// app/build.gradle
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 34
defaultConfig {
applicationId "com.example.drawingboard"
minSdkVersion 21
targetSdkVersion 34
}
buildFeatures { viewBinding true }
kotlinOptions { jvmTarget = "1.8" }
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
}
// =======================================================
// 文件: res/layout/activity_main.xml
// 描述: 主界面布局,包含工具栏、DrawingBoardView、颜色/笔宽工具
// =======================================================
// =======================================================
// 文件: DrawingBoardView.kt
// 描述: 自定义绘制板,支持绘制、撤销、重做、清空、导出
// =======================================================
package com.example.drawingboard
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import java.util.*
class DrawingBoardView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
// 画笔与路径集合
private var paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK; strokeWidth = 10f
style = Paint.Style.STROKE; strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
private var currentPath = Path()
private val paths = mutableListOf>()
private val redoStack = Stack>()
// 触摸上一个点
private var prevX = 0f; private var prevY = 0f
/** 设置画笔颜色 */
fun setStrokeColor(color: Int) { paint.color = color }
/** 设置画笔粗细 */
fun setStrokeWidth(width: Float) { paint.strokeWidth = width }
/** 撤销 */
fun undo() {
if (paths.isNotEmpty()) redoStack.push(paths.removeAt(paths.lastIndex))
invalidate()
}
/** 重做 */
fun redo() {
if (redoStack.isNotEmpty()) paths += redoStack.pop()
invalidate()
}
/** 清空 */
fun clear() {
paths.clear(); redoStack.clear()
invalidate()
}
/** 导出 Bitmap */
fun exportBitmap(): Bitmap {
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
canvas.drawColor(Color.WHITE)
for ((p, paint) in paths) canvas.drawPath(p, paint)
return bmp
}
override fun onTouchEvent(e: MotionEvent): Boolean {
val x = e.x; val y = e.y
when (e.action) {
MotionEvent.ACTION_DOWN -> {
currentPath = Path().apply { moveTo(x, y) }
prevX = x; prevY = y
// 新操作清空 redo 栈
redoStack.clear()
}
MotionEvent.ACTION_MOVE -> {
val mx = (x + prevX) / 2
val my = (y + prevY) / 2
currentPath.quadTo(prevX, prevY, mx, my)
prevX = x; prevY = y
}
MotionEvent.ACTION_UP -> {
// 完成一笔,将路径及其画笔属性存储
val p = Path(currentPath)
val paintCopy = Paint(paint)
paths += Pair(p, paintCopy)
}
}
invalidate()
return true
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 依次绘制历史路径
for ((p, paint) in paths) canvas.drawPath(p, paint)
// 绘制当前路径
canvas.drawPath(currentPath, paint)
}
}
// =======================================================
// 文件: ImageUtil.kt
// 描述: 图片保存与分享工具
// =======================================================
package com.example.drawingboard
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import java.io.*
object ImageUtil {
/** 保存到相册并返回 Uri */
fun saveBitmapToGallery(ctx: Context, bmp: Bitmap, name: String = "draw_${System.currentTimeMillis()}"): Uri? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "$name.png")
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/DrawingBoard")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri = ctx.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
uri?.let {
ctx.contentResolver.openOutputStream(it)?.use { os -> bmp.compress(Bitmap.CompressFormat.PNG, 100, os) }
values.clear(); values.put(MediaStore.Images.Media.IS_PENDING, 0)
ctx.contentResolver.update(it, values, null, null)
}
uri
} else {
val dir = File(ctx.getExternalFilesDir(null), "DrawingBoard")
if (!dir.exists()) dir.mkdirs()
val file = File(dir, "$name.png")
FileOutputStream(file).use { fos -> bmp.compress(Bitmap.CompressFormat.PNG, 100, fos) }
Uri.fromFile(file)
}
}
}
// =======================================================
// 文件: MainActivity.kt
// 描述: 主界面逻辑:初始化画板、工具绑定、保存与分享
// =======================================================
package com.example.drawingboard
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.widget.ImageButton
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import com.example.drawingboard.databinding.ActivityMainBinding
import kotlinx.coroutines.*
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val scope = CoroutineScope(Dispatchers.Main + Job())
// 分享后临时 Uri
private var savedImageUri: Uri? = null
// 分享授权
private val shareLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { /* nothing */ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// 初始化颜色面板
initColorPalette()
// 笔宽控制
binding.seekStroke.setOnSeekBarChangeListener(object: SimpleSeekListener(){
override fun onProgressChanged(sb: androidx.appcompat.widget.AppCompatSeekBar, p: Int, u: Boolean) {
binding.drawingBoard.setStrokeWidth(p.toFloat())
}
})
// 顶部按钮绑定
binding.btnClear.setOnClickListener { binding.drawingBoard.clear() }
binding.btnUndo.setOnClickListener { binding.drawingBoard.undo() }
binding.btnRedo.setOnClickListener { binding.drawingBoard.redo() }
binding.btnSave.setOnClickListener { saveDrawing() }
}
private fun initColorPalette() {
val colors = listOf(Color.BLACK, Color.RED, Color.BLUE, Color.GREEN, Color.MAGENTA)
for (c in colors) {
val btn = ImageButton(this).apply {
val size = resources.getDimensionPixelSize(R.dimen.color_btn_size)
layoutParams = androidx.appcompat.widget.LinearLayoutCompat.LayoutParams(size, size).apply {
marginEnd = 16
}
setBackgroundColor(c)
setOnClickListener { binding.drawingBoard.setStrokeColor(c) }
}
binding.colorPalette.addView(btn)
}
}
private fun saveDrawing() {
// 异步保存并分享
scope.launch {
val bmp = withContext(Dispatchers.Default) { binding.drawingBoard.exportBitmap() }
savedImageUri = ImageUtil.saveBitmapToGallery(this@MainActivity, bmp)
if (savedImageUri != null) {
shareImage(savedImageUri!!)
} else {
Toast.makeText(this@MainActivity, "保存失败", Toast.LENGTH_SHORT).show()
}
}
}
private fun shareImage(uri: Uri) {
val contentUri = if (uri.scheme == "file") {
FileProvider.getUriForFile(this, "$packageName.fileprovider", Uri.parse(uri.path!!).toFile())
} else uri
val intent = Intent(Intent.ACTION_SEND).apply {
type = "image/png"
putExtra(Intent.EXTRA_STREAM, contentUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
shareLauncher.launch(Intent.createChooser(intent, "分享绘图"))
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
}
// =======================================================
// 文件: SimpleSeekListener.kt
// 描述: 简易 SeekBar 监听,省略回调实现
// =======================================================
package com.example.drawingboard
import android.widget.SeekBar
abstract class SimpleSeekListener: SeekBar.OnSeekBarChangeListener {
override fun onStartTrackingTouch(p0: SeekBar?) {}
override fun onStopTrackingTouch(p0: SeekBar?) {}
}
DrawingBoardView
数据结构:paths: List
保存每笔轨迹与对应画笔;
触摸处理:使用 quadTo
平滑绘制;在 ACTION_UP
时深拷贝路径与画笔入 paths
;
撤销/重做:undo()
从 paths
移出最后一笔入 redoStack
;redo()
则反向操作;
清空与导出:clear()
清空所有,exportBitmap()
生成白底 Bitmap
并重绘所有路径。
ImageUtil
兼容 Android Q+ 与以下版本,分别使用 MediaStore
或文件流保存;
保存在 Pictures/DrawingBoard
或 getExternalFilesDir
,并返回 Uri
便于分享。
MainActivity
UI 绑定:colorPalette
动态生成颜色按钮,seekStroke
动态控制笔宽;
操作按钮:清空、撤销、重做按钮直接调用相应 API;
保存与分享:协程异步导出 Bitmap
→保存→拿到 Uri
→通过 Intent.ACTION_SEND
分享;
权限与 URI
使用 FileProvider
适配 Android 7.0+ 文件访问限制;
在 AndroidManifest.xml
与 provider_paths.xml
中正确配置;
局部刷新
可在 onTouchEvent
中记录变化区域,用 invalidate(left, top, right, bottom)
替代全局刷新;
对象复用
避免在每次触摸时创建新 Paint
或 Path
对象,可维护池化策略;
内存管理
对于大画布或长时间绘制,注意 Bitmap 内存,必要时使用 inBitmap
重用;
多点触控
扩展至支持多指同时绘制,每根手指一条 Path
;
本文完整实现了一个功能完备的写字板组件,涵盖自由绘制、撤销重做、清空、保存与分享的全流程。
通过组件化封装,业务层仅需在布局中引用 DrawingBoardView
并绑定按钮,即可快速集成。
笔压感应:结合手写笔压力,动态调整笔宽或透明度;
图形标注:支持直线、矩形、圆形、文字等多种标注模式;
云端同步:将绘制数据以矢量格式上传服务器,实现跨端同步;
动画回放:记录绘制时间戳,支持绘制过程回放;
Jetpack Compose 重构:使用 Canvas
与 Modifier.pointerInput
实现 Compose 版写字板。
Q:如何保存多页画布?
A:可在 paths
加入页面索引,导出时分别按照页码生成多张 Bitmap
并打包。
Q:Bitmap 导出后图片太大怎么办?
A:在保存时对 Bitmap
进行压缩,或先缩放至合适尺寸。
Q:如何让撤销支持部分笔迹?
A:目前按整笔撤销,若需精细撤销可将每段 quadTo
拆分为更小路径并记录。
Q:如何在旋转屏幕后保持绘制?
A:在 onSaveInstanceState
序列化 paths
数据,旋转后在 onRestoreInstanceState
中恢复。
Q:如何支持涂鸦橡皮擦功能?
A:可在涂鸦模式下切换 paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
来擦除轨迹。