前言 在 Android 中需要随手写、标注或简单涂鸦时,常用做法是在自定义 View 里用 Path 记录触点轨迹,在 onDraw 里用 Canvas.drawPath 画出来。
内容来自手指或触控笔的 MotionEvent,每一笔从 ACTION_DOWN 到 ACTION_UP 形成一条折线或曲线。
导出图片时,将当前笔迹绘制到与控件同尺寸的 Bitmap 上,再压缩写入文件或编码为 Base64 即可,无需额外绘图库。
本文用 Kotlin 演示手写轨迹的收集、重绘与导出保存流程。
依赖 使用 Canvas、Path、Bitmap 均依赖 Android SDK 自带 API,无需额外三方库。
将文件保存到应用专属目录(例如 getExternalFilesDir)时,一般不需要存储运行时权限。
若你改为写入公共相册或 MediaStore,需按目标系统版本处理分区存储与权限,此处不展开。
实现 手写 View:Path、触摸与导出 用 MutableList<Path> 保存已完成的每一笔,用 currentPath 表示当前正在画的一笔。
在 ACTION_DOWN 里 moveTo,在 ACTION_MOVE 里 lineTo 并 invalidate,在 ACTION_UP 里把当前 Path 加入列表并将 currentPath 置空。
下一笔会新建 Path,已加入列表的对象不再被改写,因此无需再拷贝 Path。
下面将绘制逻辑抽到 drawPaths,供 onDraw 与导出位图共用。
并在类内提供 exportBitmap、savePngToAppPictures 与 exportPngBase64,导出时先铺白底再绘制笔迹,避免透明 PNG 在部分查看器里不易辨认。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 import android.content.Contextimport android.graphics.Bitmapimport android.graphics.Canvasimport android.graphics.Colorimport android.graphics.Paintimport android.graphics.Pathimport android.os.Environmentimport android.util.AttributeSetimport android.util.Base64import android.view.MotionEventimport android.view.Viewimport java.io.ByteArrayOutputStreamimport java.io.Fileimport java.io.FileOutputStreamclass HandwritingDrawView @JvmOverloads constructor ( context: Context, attrs: AttributeSet? = null , defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private val strokes = mutableListOf<Path>() private var currentPath: Path? = null private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#212121" ) style = Paint.Style.STROKE strokeWidth = 8f strokeJoin = Paint.Join.ROUND strokeCap = Paint.Cap.ROUND } override fun onDraw (canvas: Canvas ) { super .onDraw(canvas) drawPaths(canvas) } private fun drawPaths (canvas: Canvas ) { for (p in strokes) canvas.drawPath(p, strokePaint) currentPath?.let { canvas.drawPath(it, strokePaint) } } override fun onTouchEvent (event: MotionEvent ) : Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { currentPath = Path().apply { moveTo(event.x, event.y) } } MotionEvent.ACTION_MOVE -> { currentPath?.lineTo(event.x, event.y) invalidate() } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { currentPath?.let { strokes.add(it) } currentPath = null invalidate() } } return true } fun clear () { strokes.clear() currentPath = null invalidate() } fun exportBitmap () : Bitmap? { val w = width val h = height if (w <= 0 || h <= 0 ) return null val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) canvas.drawColor(Color.WHITE) drawPaths(canvas) return bitmap } fun savePngToAppPictures () : File? { val bitmap = exportBitmap() ?: return null val dir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: return null if (!dir.exists()) dir.mkdirs() val file = File(dir, "handwriting_${System.currentTimeMillis()} .png" ) FileOutputStream(file).use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100 , out ) } bitmap.recycle() return file } fun exportPngBase64 () : String? { val bitmap = exportBitmap() ?: return null val bytes = ByteArrayOutputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100 , out ) out .toByteArray() } bitmap.recycle() return Base64.encodeToString(bytes, Base64.NO_WRAP) } }
若追求更圆滑线条,可将 lineTo 换成 quadTo 等二次贝塞尔,按采样点维护控制点即可。
布局中引用 在布局里为画板指定足够大的区域,并保留底部或顶部给「清空」「导出」等按钮。
下面将手写区铺满剩余空间时,可与 LinearLayout 或 ConstraintLayout 搭配按钮 id 使用。
1 2 3 4 5 6 <com.example.app.HandwritingDrawView android:id ="@+id/handwritingView" android:layout_width ="match_parent" android:layout_height ="0dp" android:layout_weight ="1" android:background ="#FFF5F5F5" />
包名请换成你模块中的实际包名。
Activity 中触发导出 按钮点击时调用 savePngToAppPictures,将返回的 File 路径提示给用户或写入日志。
下面示例假设布局中已有 handwritingView 与 btnExport 的 id。
1 2 3 4 5 6 7 8 9 10 import android.widget.Buttonimport android.widget.Toastval handwritingView = findViewById<HandwritingDrawView>(R.id.handwritingView)findViewById<Button>(R.id.btnExport).setOnClickListener { val file = handwritingView.savePngToAppPictures() val msg = file?.absolutePath ?: "导出失败或未布局完成" Toast.makeText(this , msg, Toast.LENGTH_LONG).show() }
若希望相册立刻可见,可在保存后发送 MediaScanner 扫描该文件路径,或改用 MediaStore 插入,按产品需求选择。
导出为 Base64 接口上传或 JSON 里夹带图片时,常把 PNG 字节再做 Base64 编码。
先复用 exportBitmap 得到与界面一致的位图,再用 Bitmap.compress 写入 ByteArrayOutputStream,最后用 Base64.encodeToString 且使用 NO_WRAP,避免换行符打断一行传输。
下面方法返回的是纯 Base64 文本,不含 data:image/png;base64, 前缀。
若服务端需要 Data URL,可自行拼接该前缀。
1 2 3 val b64 = handwritingView.exportPngBase64()
内容变化与刷新 手写过程中每一帧依赖 ACTION_MOVE 触发 invalidate()。
子线程若参与生成笔迹数据,更新集合后应回到主线程再 invalidate(),或调用 postInvalidate()。
与尺寸相关的注意点 exportBitmap 使用当前 width 与 height。
若需在未完成布局前截图,可监听 ViewTreeObserver 或在 onSizeChanged 之后再导出。
验证
运行应用,在手写区域滑动,确认笔迹连续、松手后笔迹保留。
调用清空后画布应无笔迹。
导出后根据日志或 Toast 中的路径找到 PNG,用图库或其它设备打开,确认与屏幕上的笔迹一致且背景为白色。
调用 exportPngBase64 后,可用在线 Base64 解码工具或单元测试里 Base64.decode 再写临时文件,确认能还原为有效 PNG。
将窗口旋转或分屏,确认布局变化后仍可书写与导出(若需避免配置重建,可为 Activity 配置 configChanges 或保存笔迹状态,按产品要求处理)。
扩展 笔迹很多时可考虑双缓冲或仅重绘脏区,此处从略。
需要撤销上一笔时,可维护 strokes 的栈结构,弹出最后一项后 invalidate()。
更高帧率或相机叠层可评估 SurfaceView 等方案。
总结 步骤:
用 Path 与 MotionEvent 收集手写轨迹,在 onDraw 中 drawPath。
已完成笔迹放入列表,当前笔迹单独绘制,避免列表与当前笔混淆。
导出时用 Bitmap.createBitmap 与 Canvas(bitmap),铺底后复用同一套 drawPaths。
使用 Bitmap.compress 与 FileOutputStream 保存 PNG,路径优先选应用专属目录以降低权限复杂度。
需要字符串传输时对 PNG 字节做 Base64.encodeToString,与文件导出共用同一套 exportBitmap 笔迹数据。
注意:
导出前判断宽高大于 0,避免创建 0 尺寸位图。
大位图用毕可 recycle(),注意不要在界面仍引用该 Bitmap 时回收。
Base64 仅表示编码方式,体积会比原始 PNG 字节大约增加三分之一,长图请注意请求体大小限制。
公共目录与 MediaStore 需按 Android 版本处理权限与 URI,与本文示例目录不同。