一、项目介绍
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.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<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 -> {
// 完成一笔,将路径及其画笔属性存储
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<pair<path,paint>>保存每笔轨迹与对应画笔;触摸处理:使用
quadto平滑绘制;在action_up时深拷贝路径与画笔入paths;撤销/重做:
undo()从paths移出最后一笔入redostack;redo()则反向操作;清空与导出:
clear()清空所有,exportbitmap()生成白底bitmap并重绘所有路径。
imageutil兼容 android q+ 与以下版本,分别使用
mediastore或文件流保存;保存在
pictures/drawingboard或getexternalfilesdir,并返回uri便于分享。
mainactivityui 绑定:
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 = porterduffxfermode(porterduff.mode.clear)来擦除轨迹。
以上就是基于android实现写字板功能的代码详解的详细内容,更多关于android写字板功能的资料请关注代码网其它相关文章!
发表评论