一、项目介绍
1.1 背景与意义
在众多行业应用中,手写签名功能发挥着不可或缺的作用。无论是金融系统中的电子合同签署、物流行业的收货签名、医疗场景的知情同意,还是教育领域的纸质作业扫描,用户都需要在移动设备上以自然的笔触完成签字。相比传统的扫描和拍照方式,内置的手写签名控件能提供更加流畅、真实的签名体验,并能直接将签名导出为图片、向服务器上传或嵌入到 PDF 文件中。
本项目旨在从零实现一个通用、可配置、高性能的 SignatureView 签名控件,支持:
-
手指/手写笔绘制:自然顺滑,根据触摸历史绘制贝塞尔曲线路径
-
画笔粗细与颜色配置:支持多种颜色、线宽可调
-
橡皮擦模式:可擦除部分笔迹
-
撤销重做:支持多步撤销与重做操作
-
清除与导出:一键清空画布,将签名内容导出为
Bitmap
或文件 -
样式与背景图:可在控件背景设置模板图,如合同空白款式
-
压力感应支持(可选):兼容部分支持压力的手写笔设备
-
保存与恢复:将笔迹序列化存储,以便下次恢复签名过程
-
MVVM 兼容:与 ViewModel、LiveData 联动,易于状态管理
-
性能优化:避免大量 GC,保证大面积签名流畅无卡顿
通过本文,您将全面掌握 Android 自定义 View 的绘制原理、多点触控处理、贝塞尔曲线插值、手势与轨迹的平滑算法,以及如何构建一个功能齐备、可扩展的签名控件。
二、相关知识
在正式编码前,建议对下列知识有一定了解:
2.1 Android 自定义 View 与 Canvas
-
onDraw(Canvas)
:所有绘制逻辑放在此方法中,并通过invalidate()
或postInvalidate()
触发重绘。 -
Paint
:画笔对象,可配置颜色、抗锯齿、线宽、样式等。 -
Path
:用于记录绘制路径,可绘制直线、贝塞尔曲线、弧线等。 -
双缓冲与硬件加速:避免闪烁,提升性能。
2.2 触摸事件与多点触控
-
onTouchEvent(MotionEvent)
:处理单点/多点触摸,获取动作类型、坐标和压力值。 -
压力值(
event.getPressure()
):部分设备支持压力感应,可用于动态调整笔宽。 -
多触点逻辑:本项目聚焦单笔签名,仅处理第一个触点。
2.3 贝塞尔曲线平滑
-
二阶贝塞尔:通过
path.quadTo(x1, y1, x2, y2)
绘制曲线,平滑两点之间轨迹。 -
算法原理:记录前一触点与当前触点,计算中间控制点,提高轨迹平滑度。
2.4 撤销/重做
-
操作栈:使用
Stack<Path>
存储每一次笔画的 Path,撤销时弹栈,重做时入另一个栈。 -
内存管理:避免无限增长,可设置最大栈深。
2.5 MVVM 与 LiveData
-
ViewModel:持有签名状态、笔迹序列、操作栈等,可在屏幕旋转或配置变更时保持不丢失。
-
LiveData:UI 可观察清空、撤销状态以更新按钮可用性。
2.6 位图导出与文件存储
-
Bitmap
:通过Canvas
绘制签名内容到Bitmap
,并使用compress
保存为 PNG/JPEG。 -
文件 I/O:在后台线程读写,不阻塞主线程。
三、实现思路
-
设计
SignatureView
-
继承
View
或AppCompatImageView
,选择View
更灵活; -
内部维护:
currentPath: Path
表示当前正在绘制的路径;paths: MutableList<PathWithPaint>
存储所有完成笔画;undoStack
/redoStack
管理撤销重做; -
Paint
配置画笔样式,支持动态修改; -
onTouchEvent
:-
ACTION_DOWN
:创建新Path
,记录起点; -
ACTION_MOVE
:向Path
添加quadTo
平滑曲线,并保存触点坐标; -
ACTION_UP
:将currentPath
与其Paint
一并存入paths
,清空redoStack
; -
每次操作后调用
invalidate()
重绘。
-
-
-
提供 API
-
fun clear()
:清空paths
与操作栈; -
fun undo()
/fun redo()
:管理操作栈并invalidate()
; -
fun setStrokeWidth(w: Float)
与setColor(c: Int)
:动态改变Paint
; -
fun getSignatureBitmap(): Bitmap
:生成签名位图; -
fun saveSignature(file: File, format: Bitmap.CompressFormat)
异步保存。
-
-
状态管理
-
使用
SignatureViewModel
保存paths
与当前画笔配置,并在Activity.onCreate
时恢复; -
利用 DataBinding 绑定
ViewModel
中的 LiveData 控制按钮状态(如canUndo
、canRedo
)。
-
-
UI 布局
-
主界面:
SignatureView
占屏幕主要区域,底部工具栏包括“撤销”、“重做”、“清除”、“保存”为图标或文字按钮; -
保存后可弹出
Toast
或对话框提示保存路径。
-
-
性能优化
-
避免在
onDraw
中分配对象,所有Paint
与Path
复用或预分配; -
对大笔画或长轨迹,设置
setLayerType(LAYER_TYPE_HARDWARE)
; -
在保存位图时使用合适的分辨率,避免 OOM。
-
四、整合代码
// ---------------- 文件: SignatureView.kt ----------------
package com.example.signature
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import androidx.core.content.ContextCompat
import kotlin.math.abs
/**
* SignatureView:自定义手写签名控件
*/
class SignatureView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
// 当前正在绘制的路径
private var currentPath: Path? = null
// 当前路径对应的画笔配置
private var currentPaint: Paint? = null
// 完成的所有路径及其画笔
private val paths = mutableListOf<PathWithPaint>()
// 撤销与重做栈
private val undoStack = mutableListOf<PathWithPaint>()
private val redoStack = mutableListOf<PathWithPaint>()
// 默认画笔样式
private var paintColor: Int = Color.BLACK
private var strokeWidth: Float = 8f
// 平滑绘制:记录上次触点
private var lastX = 0f
private var lastY = 0f
private val touchTolerance = 4f
init {
// 可在 attrs.xml 中自定义颜色与宽度,省略此处
isFocusable = true
isFocusableInTouchMode = true
initPaint()
}
/** 初始化画笔 */
private fun initPaint() {
currentPaint = Paint().apply {
color = paintColor
isAntiAlias = true
style = Paint.Style.STROKE
strokeJoin = Paint.Join.ROUND
strokeCap = Paint.Cap.ROUND
strokeWidth = this@SignatureView.strokeWidth
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 绘制已完成笔画
paths.forEach { pp ->
canvas.drawPath(pp.path, pp.paint)
}
// 绘制当前正在绘制的笔画
currentPath?.let { path ->
currentPaint?.let { paint ->
canvas.drawPath(path, paint)
}
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
val x = event.x
val y = event.y
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 开始新笔画
currentPath = Path().apply { moveTo(x, y) }
currentPaint = Paint(currentPaint) // 复制画笔
undoStack.clear() // 新操作清空重做栈
lastX = x; lastY = y
}
MotionEvent.ACTION_MOVE -> {
val dx = abs(x - lastX)
val dy = abs(y - lastY)
if (dx >= touchTolerance || dy >= touchTolerance) {
// 使用二阶贝塞尔平滑
currentPath?.quadTo(
lastX, lastY,
(x + lastX) / 2, (y + lastY) / 2
)
lastX = x; lastY = y
}
}
MotionEvent.ACTION_UP -> {
// 完成笔画并加入列表
currentPath?.lineTo(lastX, lastY)
paths.add(PathWithPaint(currentPath!!, currentPaint!!))
currentPath = null
currentPaint = null
}
else -> return false
}
invalidate()
return true
}
/** 清空所有笔画 */
fun clear() {
paths.clear()
undoStack.clear()
redoStack.clear()
invalidate()
}
/** 撤销最后一笔 */
fun undo() {
if (paths.isNotEmpty()) {
val last = paths.removeAt(paths.size - 1)
undoStack.add(last)
invalidate()
}
}
/** 重做最后一次撤销 */
fun redo() {
if (undoStack.isNotEmpty()) {
val pp = undoStack.removeAt(undoStack.size - 1)
paths.add(pp)
invalidate()
}
}
/** 设置画笔颜色 */
fun setPaintColor(color: Int) {
paintColor = color
initPaint()
}
/** 设置画笔宽度 */
fun setStrokeWidth(width: Float) {
strokeWidth = width
initPaint()
}
/** 导出签名为 Bitmap */
fun getSignatureBitmap(): Bitmap {
// 创建与 View 大小相同的 Bitmap
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
draw(canvas)
return bmp
}
/** 异步保存签名到文件 */
fun saveSignature(file: java.io.File, format: Bitmap.CompressFormat = Bitmap.CompressFormat.PNG) {
Thread {
val bmp = getSignatureBitmap()
file.outputStream().use { out ->
bmp.compress(format, 100, out)
}
}.start()
}
/** 数据类:路径与对应画笔 */
private data class PathWithPaint(val path: Path, val paint: Paint)
}
// ---------------- 文件: activity_main.xml ----------------
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data/>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- 签名画布 -->
<com.example.signature.SignatureView
android:id="@+id/signView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="@android:color/white"/>
<!-- 操作按钮 -->
<LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:padding="16dp">
<Button
android:id="@+id/btnUndo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="撤销"/>
<Button
android:id="@+id/btnRedo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="重做"
android:layout_marginStart="16dp"/>
<Button
android:id="@+id/btnClear"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="清除"
android:layout_marginStart="16dp"/>
<Button
android:id="@+id/btnSave"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="保存"
android:layout_marginStart="16dp"/>
</LinearLayout>
</LinearLayout>
</layout>
// ---------------- 文件: MainActivity.kt ----------------
package com.example.signature
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.example.signature.databinding.ActivityMainBinding
import java.io.File
/**
* MainActivity:演示 SignatureView 功能
*/
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val REQUEST_STORAGE = 123
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// 撤销
binding.btnUndo.setOnClickListener {
binding.signView.undo()
}
// 重做
binding.btnRedo.setOnClickListener {
binding.signView.redo()
}
// 清除
binding.btnClear.setOnClickListener {
binding.signView.clear()
}
// 保存
binding.btnSave.setOnClickListener {
checkPermissionAndSave()
}
}
/** 检查存储权限并保存签名 */
private fun checkPermissionAndSave() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_STORAGE
)
} else {
saveSignature()
}
}
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<out String>, grantResults: IntArray
) {
if (requestCode == REQUEST_STORAGE && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
saveSignature()
} else {
Toast.makeText(this, "存储权限被拒绝,无法保存签名", Toast.LENGTH_SHORT).show()
}
}
/** 实际保存签名图片 */
private fun saveSignature() {
val bmp: Bitmap = binding.signView.getSignatureBitmap()
val dir = File(getExternalFilesDir(null), "signatures")
if (!dir.exists()) dir.mkdirs()
val file = File(dir, "sign_${System.currentTimeMillis()}.png")
binding.signView.saveSignature(file)
Toast.makeText(this, "签名已保存:${file.absolutePath}", Toast.LENGTH_LONG).show()
}
}
五、代码解读
-
SignatureView.onTouchEvent
-
ACTION_DOWN
:创建新Path
与对应Paint
,记录触点; -
ACTION_MOVE
:每当触点移动超过阈值,用quadTo
添加贝塞尔曲线平滑轨迹,并更新上次坐标; -
ACTION_UP
:完成当前路径,加入paths
列表,并清空临时currentPath
。
-
-
撤销/重做逻辑
-
paths
存储已完成笔画; -
每次
undo()
从paths
弹出最后一笔,入undoStack
; -
redo()
则从undoStack
弹回paths
; -
清除操作会同时清空两个栈。
-
-
导出与保存
-
getSignatureBitmap()
通过draw(canvas)
将当前视图内容绘制到新Bitmap
; -
saveSignature()
在后台线程调用compress()
保存为 PNG。
-
-
MainActivity
-
通过 DataBinding 快速获取
SignatureView
与按钮; -
在保存前动态申请存储权限;
-
保存路径为应用私有外部目录下
signatures/
子目录。
-
六、项目总结
-
成果回顾
-
从零实现了功能完备的手写签名控件,支持平滑绘制、撤销重做、清除、颜色和宽度自定义、导出与保存;
-
详细分析了触摸事件、贝塞尔曲线原理与性能优化;
-
提供完整示例代码与注释,可直接复制到项目中使用。
-
-
技术收获
-
深入理解 Android 自定义 View 绘制与触摸处理;
-
学会使用
Path.quadTo
实现自然平滑的手写轨迹; -
掌握撤销重做栈机制与 UI 更新;
-
掌握
Bitmap
的导出与文件 I/O;
-
-
后续优化
-
橡皮擦功能:实现部分路径擦除算法或蒙版擦除;
-
压力感应:兼容手写笔压力,动态改变笔宽;
-
序列化与云同步:将签名点序列存入 JSON,以便多端同步或回放;
-
矢量导出:导出为 SVG,以保证在任意分辨率下清晰;
-
分享与签名嵌入:将签名嵌入到 PDF 或合同模板中;
-
UI 美化:添加笔锋、渐变笔色、签名动画;
-