Android实现手写签名(附带源码)

一、项目介绍

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:在后台线程读写,不阻塞主线程。


三、实现思路

  1. 设计 SignatureView

    • 继承 ViewAppCompatImageView,选择 View 更灵活;

    • 内部维护:currentPath: Path 表示当前正在绘制的路径;paths: MutableList<PathWithPaint> 存储所有完成笔画;undoStack/redoStack 管理撤销重做;

    • Paint 配置画笔样式,支持动态修改;

    • onTouchEvent

      • ACTION_DOWN:创建新 Path,记录起点;

      • ACTION_MOVE:向 Path 添加 quadTo 平滑曲线,并保存触点坐标;

      • ACTION_UP:将 currentPath 与其 Paint 一并存入 paths,清空 redoStack

      • 每次操作后调用 invalidate() 重绘。

  2. 提供 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) 异步保存。

  3. 状态管理

    • 使用 SignatureViewModel 保存 paths 与当前画笔配置,并在 Activity.onCreate 时恢复;

    • 利用 DataBinding 绑定 ViewModel 中的 LiveData 控制按钮状态(如 canUndocanRedo)。

  4. UI 布局

    • 主界面:SignatureView 占屏幕主要区域,底部工具栏包括“撤销”、“重做”、“清除”、“保存”为图标或文字按钮;

    • 保存后可弹出 Toast 或对话框提示保存路径。

  5. 性能优化

    • 避免在 onDraw 中分配对象,所有 PaintPath 复用或预分配;

    • 对大笔画或长轨迹,设置 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/ 子目录。


六、项目总结

  1. 成果回顾

    • 从零实现了功能完备的手写签名控件,支持平滑绘制、撤销重做、清除、颜色和宽度自定义、导出与保存;

    • 详细分析了触摸事件、贝塞尔曲线原理与性能优化;

    • 提供完整示例代码与注释,可直接复制到项目中使用。

  2. 技术收获

    • 深入理解 Android 自定义 View 绘制与触摸处理;

    • 学会使用 Path.quadTo 实现自然平滑的手写轨迹;

    • 掌握撤销重做栈机制与 UI 更新;

    • 掌握 Bitmap 的导出与文件 I/O;

  3. 后续优化

    • 橡皮擦功能:实现部分路径擦除算法或蒙版擦除;

    • 压力感应:兼容手写笔压力,动态改变笔宽;

    • 序列化与云同步:将签名点序列存入 JSON,以便多端同步或回放;

    • 矢量导出:导出为 SVG,以保证在任意分辨率下清晰;

    • 分享与签名嵌入:将签名嵌入到 PDF 或合同模板中;

    • UI 美化:添加笔锋、渐变笔色、签名动画;

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值