it编程 > App开发 > Android

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

28人参与 2025-04-29 Android

一、项目介绍

1. 背景与应用场景

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

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

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.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<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 -> {
        // 完成一笔,将路径及其画笔属性存储
        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<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

八、项目总结与拓展

拓展方向

  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 = porterduffxfermode(porterduff.mode.clear) 来擦除轨迹。

以上就是基于android实现写字板功能的代码详解的详细内容,更多关于android写字板功能的资料请关注代码网其它相关文章!

(0)
打赏 微信扫一扫 微信扫一扫

您想发表意见!!点此发布评论

推荐阅读

Android实现定时任务的几种方式汇总(附源码)

05-03

Android开发环境配置避坑指南

05-03

Android实现一键录屏功能(附源码)

05-04

Android实现文字滚动播放效果的示例代码

05-06

Android 实现一个隐私弹窗功能

05-06

基于Android实现文件共享功能

04-27

猜你喜欢

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论