28人参与 • 2025-04-29 • Android
在很多应用场景中,我们需要让用户进行自由绘画或手写输入,如:
签字确认:电子合同、快递签收
绘图涂鸦:社交 app 分享手绘内容
涂抹擦除:儿童教育绘画
标注批注:地图/图片标记、文档批注
本项目将实现一个高度可定制的写字板,满足:
自由绘制:支持多笔触、多颜色、多粗细
撤销重做:可撤销/重做操作
清屏保存:一键清空、一键保存为图片
手势优化:平滑曲线、压感模拟(粗细模拟)
ui 可定制:颜色面板、笔宽控制、清空/撤销/保存按钮
组件化:封装 drawingboardview
,易于在任意布局中使用
绘制路径:用户触摸屏幕实时绘制连续曲线
多颜色切换:提供调色板,支持任意颜色
可调笔宽:支持至少 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
便于分享。
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)
来擦除轨迹。
以上就是基于android实现写字板功能的代码详解的详细内容,更多关于android写字板功能的资料请关注代码网其它相关文章!
您想发表意见!!点此发布评论
版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。
发表评论