目录
- 一、项目介绍
- 1. 背景与应用场景
- 2. 功能列表
- 二、相关知识
- 三、实现思路
- 四、环境与依赖
- 五、整合代码
- 六、代码解读
- 七、性能与优化
- 八、项目总结与拓展
- 拓展方向
- 九、FAQ
一、项目介绍
1. 背景与应用场景
在很多应用场景中,我们需要让用户进行自由绘画或手写输入,如:
签字确认:电子合同、快递签收
绘图涂鸦:社交 App 分享手绘内容
涂抹擦除:儿童教育绘画
标注批注:地图/图片标记、文档批注
本项目将实现一个高度可定制的写字板,满足:
自由绘制:支持多笔触、多颜色、多粗细
撤销重做:可撤销/重做操作
清屏保存:一键清空、一键保存为图片
手势优化:平滑曲线、压感模拟(粗细模拟)
UI 可定制:颜色面板、笔宽控制、清空/撤销/保存按钮
组件化:封装
DrawingBoardView
,易于在任意布局中使用
2. 功能列表
绘制路径:用户触摸屏幕实时绘制连续曲线
多颜色切换:提供调色板,支持任意颜色
可调笔宽:支持至少 3 种笔触粗细
撤销/重做:可对每一条路径进行撤销和重做
清空画布:一键清空所有绘制内容
保存图片:将画布内容保存到本地相册或应用私有目录
导出分享:可直接分享绘制的图片
性能优化:支持硬件加速、路径缓存、局部刷新
二、相关知识
在动手之前,你需要了解以下核心技术点:
自定义 View 与 Canvas
重写
onDraw(Canvas)
,使用Canvas.drawpath(Path, Paint)
绘制路径在
onTouchEvent(MotionEvent)
中根据ACTION_DOWN/MOVE/UP
构建Path
数据结构与撤销/重做
使用
List<Path>
保存已完成路径,用Stack<Path>
保存被撤销的路径以支持重做每次完成一笔后将
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、颜色/笔宽工具 // ======================================================= <?xml version="1.0" encoding="utf-8"?> <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <!-- 顶部操作栏 --> <com.google.android.material.appbar.MaterialToolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar" app:title="写字板"/> <!-- 绘制面板 --> <com.example.drawingboard.DrawingBoardView android:id="@+id/drawingBoard" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_marginTop="?attr/actionBarSize" android:background="#FFFFFF"/> <!-- 底部工具栏 --> <LinearLayout android:id="@+id/bottomTools" android:orientation="horizontal" android:gravity="center_vertical" android:padding="8dp" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:background="#CCFFFFFF"> <!-- 颜色面板 --> <HorizontalScrollView android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content"> <LinearLayout android:id="@+id/colorPalette" android:orientation="horizontal" android:layout_width="wrap_content" android:layout_height="wrap_content"/> </HorizontalScrollView> <!-- 笔宽滑动条 --> <SeekBar android:id="@+id/seekStroke" android:layout_width="120dp" android:layout_height="wrap_content" android:max="50" android:progress="10" android:layout_marginStart="16dp"/> </LinearLayout> <!-- 悬浮操作按钮 --> <com.google.android.material.floatingactionbutton.FloatingActionButton android:id="@+id/btnClear" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="16dp" android:src="@drawable/ic_baseline_clear_24" app:layout_anchorGravity="bottom|end" app:layout_anchor="@id/drawingBoard"/> <com.google.android.material.floatingactionbutton.FloatingActionButton android:id="@+id/btnUndo" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="16dp" android:src="@drawable/ic_baseline_undo_24" app:layout_anchorGravity="bottom|start" app:layout_anchor="@id/drawingBoard"/> <com.google.android.material.floatingactionbutton.FloatingActionButton android:id="@+id/btnRedo" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="16dp" android:src="@drawable/ic_baseline_redo_24" app:layout_anchorGravity="bottom|start" app:layout_anchor="@id/btnUndo"/> <com.google.android.material.floatingactionbutton.FloatingActionButton android:id="@+id/btnSave" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="16dp" android:src="@drawable/ic_baseline_save_24" app:layout_anchorGravity="bottom|end" app:layout_anchor="@id/btnClear"/> </androidx.coordinatorlayout.widget.CoordinatorLayout> // ======================================================= // 文件: DrawingBoardView.kt // 描述: 自定义绘制板,支持绘制、撤销、重做、清空、导出 // ======================================================= package com.example.drawingboard import android.content.Context import android.graphinoXFUmcs.* 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<Pair<Path, Paint>>() private val redoStack = Stack<Pair<Path, Paint>>() // 触摸上一个点 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 -> { // 完成一笔,将路径及其画笔属性存储 js 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") ihttp://www.devze.comf (!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.EXhttp://www.devze.comTRA_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<Pair<Path,Paint>>
保存每笔轨迹与对应画笔;触摸处理:使用
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 版写字板。
九、FAQ
Q:如何保存多页画布?
A:可在paths
加入页面索引,导出时分别按照页码生成多张Bitmap
并打包。Q:Bitmap 导出后图片太大怎么办?
A:在保存时对Bitmap
进行压缩,或先缩放至合适尺寸。Q:如何让撤销支持部分笔迹?
A:目前按整笔撤销,若需精细撤销可将每段quadTo
拆分为更小路径并记录。Q:如何在旋转屏幕后保持绘制?
A:在onSaveInstanceState
序列化paths
数据,旋转后在onRestoreInstanceState
中恢复。Q:如何支持涂鸦橡皮擦功能?
A:可在涂鸦模式下切换paint.xfermode = Porte编程客栈rDuffXfermode(PorterDuff.Mode.CLEAR)
来擦除轨迹。
以上就是基于Android实现写字板功能的代码详解的详细内容,更多关于Android写字板功能的资料请关注编程客栈(www.devze.com)其它相关文章!
精彩评论