开发者

基于Android实现写字板功能的代码详解

开发者 https://www.devze.com 2025-04-30 10:17 出处:网络 作者: Katie。
目录一、项目介绍1. 背景与应用场景2. 功能列表二、相关知识三、实现思路四、环境与依赖五、整合代码六、代码解读七、性能与优化八、项目总结与拓展拓展方向九、FAQ一、项目介绍
目录
  • 一、项目介绍
    • 1. 背景与应用场景
    • 2. 功能列表
  • 二、相关知识
    • 三、实现思路
      • 四、环境与依赖
        • 五、整合代码
          • 六、代码解读
            • 七、性能与优化
              • 八、项目总结与拓展
                • 拓展方向
              • 九、FAQ

                一、项目介绍

                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<Path> 保存已完成路径,用 Stack<Path> 保存被撤销的路径以支持重做

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

                3. 性能优化

                  • 缓存 Path 和 Paint 对象,避免频繁分配

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

                4. 触摸平滑

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

                5. 文件保存与分享

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

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

                  • 使用 FileProvider 和 Intent.ACTION_SEND 分享图片

                6. UI 组件

                  • 使用 RecyclerView 或 LinearLayout 构建颜色面板与笔宽面板

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

                三、实现思路

                1. 封装 DrawingBoardView

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

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

                2. 主界面布局

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

                  • 中部 DrawingBoardView 占满屏幕

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

                3. 文件存储与分享

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

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

                4. 状态保存与恢复

                  • 在 onSaveInstanceState 保存 paths 和 redoStack 的序列化数据

                  • 在 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、颜色/笔宽工具
                // =======================================================
                <?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.graphinoXFUmcs.*
                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 -> {
                        // 完成一笔,将路径及其画笔属性存储
                       js 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")
                      ihttp://www.devze.comf (!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.EXhttp://www.devze.comTRA_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<Pair<Path,Paint>> 保存每笔轨迹与对应画笔;

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

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

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

                2. ImageUtil

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

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

                3. MainActivity

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

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

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

                4. 权限与 URI

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

                  • 在 AndroidManifest.xml 与 provider_paths.xml 中正确配置;

                七、性能与优化

                1. 局部刷新

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

                2. 对象复用

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

                3. 内存管理

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

                4. 多点触控

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

                八、项目总结与拓展

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

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

                拓展方向

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

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

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

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

                5. Jetpack Compose 重构:使用 Canvas 与 Modifier.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 = Porte编程客栈rDuffXfermode(PorterDuff.Mode.CLEAR) 来擦除轨迹。

                以上就是基于Android实现写字板功能的代码详解的详细内容,更多关于Android写字板功能的资料请关注编程客栈(www.devze.com)其它相关文章!

                0

                精彩评论

                暂无评论...
                验证码 换一张
                取 消

                关注公众号