Android实现写字板(附带源码)

一、项目介绍

1. 背景与应用场景

在很多应用场景中,我们需要让用户进行自由绘画或手写输入,如:

  • 签字确认:电子合同、快递签收

  • 绘图涂鸦:社交 App 分享手绘内容

  • 涂抹擦除:儿童教育绘画

  • 标注批注:地图/图片标记、文档批注

本项目将实现一个高度可定制的写字板,满足:

  • 自由绘制:支持多笔触、多颜色、多粗细

  • 撤销重做:可撤销/重做操作

  • 清屏保存:一键清空、一键保存为图片

  • 手势优化:平滑曲线、压感模拟(粗细模拟)

  • UI 可定制:颜色面板、笔宽控制、清空/撤销/保存按钮

  • 组件化:封装 DrawingBoardView,易于在任意布局中使用

2. 功能列表

  1. 绘制路径:用户触摸屏幕实时绘制连续曲线

  2. 多颜色切换:提供调色板,支持任意颜色

  3. 可调笔宽:支持至少 3 种笔触粗细

  4. 撤销/重做:可对每一条路径进行撤销和重做

  5. 清空画布:一键清空所有绘制内容

  6. 保存图片:将画布内容保存到本地相册或应用私有目录

  7. 导出分享:可直接分享绘制的图片

  8. 性能优化:支持硬件加速、路径缓存、局部刷新


二、相关知识

在动手之前,你需要了解以下核心技术点:

  1. 自定义 View 与 Canvas

    • 重写 onDraw(Canvas),使用 Canvas.drawPath(Path, Paint) 绘制路径

    • onTouchEvent(MotionEvent) 中根据 ACTION_DOWN/MOVE/UP 构建 Path

  2. 数据结构与撤销/重做

    • 使用 List 保存已完成路径,用 Stack 保存被撤销的路径以支持重做

    • 每次完成一笔后将 currentPath 加入 paths,清空 redoStack

  3. 性能优化

    • 缓存 PathPaint 对象,避免频繁分配

    • invalidate(Rect) 中局部刷新触摸区域,减少全屏重绘

  4. 触摸平滑

    • 使用二次贝塞尔曲线平滑轨迹:path.quadTo(prevX, prevY, (x+prevX)/2, (y+prevY)/2)

  5. 文件保存与分享

    • Bitmap 导出:在 DrawingBoardView 中生成 BitmapCanvas 一次性绘制底图与所有路径

    • 使用 MediaStore(Android Q+)或 FileOutputStream 保存到相册

    • 使用 FileProviderIntent.ACTION_SEND 分享图片

  6. UI 组件

    • 使用 RecyclerViewLinearLayout 构建颜色面板与笔宽面板

    • 使用 MaterialButtonFloatingActionButton 等承载撤销、重做、清除、保存操作


三、实现思路

  1. 封装 DrawingBoardView

    • 公共属性:setStrokeColor(int), setStrokeWidth(float), undo(), redo(), clear(), exportBitmap()

    • 事件处理:onTouchEvent 采集并平滑记录触摸轨迹;

  2. 主界面布局

    • 顶部按钮区域:撤销、重做、清空、保存

    • 中部 DrawingBoardView 占满屏幕

    • 底部工具栏:颜色选择、笔宽滑动条

  3. 文件存储与分享

    • MainActivity 中调用 drawingBoard.exportBitmap() 获取 Bitmap,再保存或分享

    • 使用协程或后台线程处理 I/O,显示进度提示

  4. 状态保存与恢复

    • onSaveInstanceState 保存 pathsredoStack 的序列化数据

    • onRestoreInstanceState 恢复路径,避免屏幕旋转丢失画图

  5. 模块化与复用

    • 将所有绘制逻辑封装在 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、颜色/笔宽工具
// =======================================================



  
  

  
  

  
  

    
    
      
    

    
    
  

  
  

  

  

  



// =======================================================
// 文件: 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>()
  private val redoStack = Stack>()

  // 触摸上一个点
  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?) {}
}

六、代码解读

  1. DrawingBoardView

    • 数据结构paths: List> 保存每笔轨迹与对应画笔;

    • 触摸处理:使用 quadTo 平滑绘制;在 ACTION_UP 时深拷贝路径与画笔入 paths

    • 撤销/重做undo()paths 移出最后一笔入 redoStackredo() 则反向操作;

    • 清空与导出clear() 清空所有,exportBitmap() 生成白底 Bitmap 并重绘所有路径。

  2. ImageUtil

    • 兼容 Android Q+ 与以下版本,分别使用 MediaStore 或文件流保存;

    • 保存在 Pictures/DrawingBoardgetExternalFilesDir,并返回 Uri 便于分享。

  3. MainActivity

    • UI 绑定colorPalette 动态生成颜色按钮,seekStroke 动态控制笔宽;

    • 操作按钮:清空、撤销、重做按钮直接调用相应 API;

    • 保存与分享:协程异步导出 Bitmap→保存→拿到 Uri→通过 Intent.ACTION_SEND 分享;

  4. 权限与 URI

    • 使用 FileProvider 适配 Android 7.0+ 文件访问限制;

    • AndroidManifest.xmlprovider_paths.xml 中正确配置;


七、性能与优化

  1. 局部刷新

    • 可在 onTouchEvent 中记录变化区域,用 invalidate(left, top, right, bottom) 替代全局刷新;

  2. 对象复用

    • 避免在每次触摸时创建新 PaintPath 对象,可维护池化策略;

  3. 内存管理

    • 对于大画布或长时间绘制,注意 Bitmap 内存,必要时使用 inBitmap 重用;

  4. 多点触控

    • 扩展至支持多指同时绘制,每根手指一条 Path


八、项目总结与拓展

  • 本文完整实现了一个功能完备的写字板组件,涵盖自由绘制、撤销重做、清空、保存与分享的全流程。

  • 通过组件化封装,业务层仅需在布局中引用 DrawingBoardView 并绑定按钮,即可快速集成。

拓展方向

  1. 笔压感应:结合手写笔压力,动态调整笔宽或透明度;

  2. 图形标注:支持直线、矩形、圆形、文字等多种标注模式;

  3. 云端同步:将绘制数据以矢量格式上传服务器,实现跨端同步;

  4. 动画回放:记录绘制时间戳,支持绘制过程回放;

  5. Jetpack Compose 重构:使用 CanvasModifier.pointerInput 实现 Compose 版写字板。


九、FAQ

  1. Q:如何保存多页画布?
    A:可在 paths 加入页面索引,导出时分别按照页码生成多张 Bitmap 并打包。

  2. Q:Bitmap 导出后图片太大怎么办?
    A:在保存时对 Bitmap 进行压缩,或先缩放至合适尺寸。

  3. Q:如何让撤销支持部分笔迹?
    A:目前按整笔撤销,若需精细撤销可将每段 quadTo 拆分为更小路径并记录。

  4. Q:如何在旋转屏幕后保持绘制?
    A:在 onSaveInstanceState 序列化 paths 数据,旋转后在 onRestoreInstanceState 中恢复。

  5. Q:如何支持涂鸦橡皮擦功能?
    A:可在涂鸦模式下切换 paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) 来擦除轨迹。

你可能感兴趣的:(Android实战项目,android)