基于Android實現(xiàn)寫字板功能的代碼詳解
一、項目介紹
1. 背景與應用場景
在很多應用場景中,我們需要讓用戶進行自由繪畫或手寫輸入,如:
簽字確認:電子合同、快遞簽收
繪圖涂鴉:社交 App 分享手繪內(nèi)容
涂抹擦除:兒童教育繪畫
標注批注:地圖/圖片標記、文檔批注
本項目將實現(xiàn)一個高度可定制的寫字板,滿足:
自由繪制:支持多筆觸、多顏色、多粗細
撤銷重做:可撤銷/重做操作
清屏保存:一鍵清空、一鍵保存為圖片
手勢優(yōu)化:平滑曲線、壓感模擬(粗細模擬)
UI 可定制:顏色面板、筆寬控制、清空/撤銷/保存按鈕
組件化:封裝
DrawingBoardView,易于在任意布局中使用
2. 功能列表
繪制路徑:用戶觸摸屏幕實時繪制連續(xù)曲線
多顏色切換:提供調(diào)色板,支持任意顏色
可調(diào)筆寬:支持至少 3 種筆觸粗細
撤銷/重做:可對每一條路徑進行撤銷和重做
清空畫布:一鍵清空所有繪制內(nèi)容
保存圖片:將畫布內(nèi)容保存到本地相冊或應用私有目錄
導出分享:可直接分享繪制的圖片
性能優(yōu)化:支持硬件加速、路徑緩存、局部刷新
二、相關知識
在動手之前,你需要了解以下核心技術點:
自定義 View 與 Canvas
重寫
onDraw(Canvas),使用Canvas.drawPath(Path, Paint)繪制路徑在
onTouchEvent(MotionEvent)中根據(jù)ACTION_DOWN/MOVE/UP構建Path
數(shù)據(jù)結(jié)構與撤銷/重做
使用
List<Path>保存已完成路徑,用Stack<Path>保存被撤銷的路徑以支持重做每次完成一筆后將
currentPath加入paths,清空redoStack
性能優(yōu)化
緩存
Path和Paint對象,避免頻繁分配在
invalidate(Rect)中局部刷新觸摸區(qū)域,減少全屏重繪
觸摸平滑
使用二次貝塞爾曲線平滑軌跡:
path.quadTo(prevX, prevY, (x+prevX)/2, (y+prevY)/2)
文件保存與分享
將
Bitmap導出:在DrawingBoardView中生成Bitmap并Canvas一次性繪制底圖與所有路徑使用
MediaStore(Android Q+)或FileOutputStream保存到相冊使用
FileProvider和Intent.ACTION_SEND分享圖片
UI 組件
使用
RecyclerView或LinearLayout構建顏色面板與筆寬面板使用
MaterialButton、FloatingActionButton等承載撤銷、重做、清除、保存操作
三、實現(xiàn)思路
封裝
DrawingBoardView公共屬性:
setStrokeColor(int),setStrokeWidth(float),undo(),redo(),clear(),exportBitmap()事件處理:
onTouchEvent采集并平滑記錄觸摸軌跡;
主界面布局
頂部按鈕區(qū)域:撤銷、重做、清空、保存
中部
DrawingBoardView占滿屏幕底部工具欄:顏色選擇、筆寬滑動條
文件存儲與分享
在
MainActivity中調(diào)用drawingBoard.exportBitmap()獲取Bitmap,再保存或分享使用協(xié)程或后臺線程處理 I/O,顯示進度提示
狀態(tài)保存與恢復
在
onSaveInstanceState保存paths和redoStack的序列化數(shù)據(jù)在
onRestoreInstanceState恢復路徑,避免屏幕旋轉(zhuǎn)丟失畫圖
模塊化與復用
將所有繪制邏輯封裝在
DrawingBoardView.kt將保存與分享功能封裝在
ImageUtil.kt
四、環(huán)境與依賴
// 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 監(jiān)聽,省略回調(diào)實現(xiàn)
// =======================================================
package com.example.drawingboard
import android.widget.SeekBar
abstract class SimpleSeekListener: SeekBar.OnSeekBarChangeListener {
override fun onStartTrackingTouch(p0: SeekBar?) {}
override fun onStopTrackingTouch(p0: SeekBar?) {}
}六、代碼解讀
DrawingBoardView數(shù)據(jù)結(jié)構:
paths: List<Pair<Path,Paint>>保存每筆軌跡與對應畫筆;觸摸處理:使用
quadTo平滑繪制;在ACTION_UP時深拷貝路徑與畫筆入paths;撤銷/重做:
undo()從paths移出最后一筆入redoStack;redo()則反向操作;清空與導出:
clear()清空所有,exportBitmap()生成白底Bitmap并重繪所有路徑。
ImageUtil兼容 Android Q+ 與以下版本,分別使用
MediaStore或文件流保存;保存在
Pictures/DrawingBoard或getExternalFilesDir,并返回Uri便于分享。
MainActivityUI 綁定:
colorPalette動態(tài)生成顏色按鈕,seekStroke動態(tài)控制筆寬;操作按鈕:清空、撤銷、重做按鈕直接調(diào)用相應 API;
保存與分享:協(xié)程異步導出
Bitmap→保存→拿到Uri→通過Intent.ACTION_SEND分享;
權限與 URI
使用
FileProvider適配 Android 7.0+ 文件訪問限制;在
AndroidManifest.xml與provider_paths.xml中正確配置;
七、性能與優(yōu)化
局部刷新
可在
onTouchEvent中記錄變化區(qū)域,用invalidate(left, top, right, bottom)替代全局刷新;
對象復用
避免在每次觸摸時創(chuàng)建新
Paint或Path對象,可維護池化策略;
內(nèi)存管理
對于大畫布或長時間繪制,注意 Bitmap 內(nèi)存,必要時使用
inBitmap重用;
多點觸控
擴展至支持多指同時繪制,每根手指一條
Path;
八、項目總結(jié)與拓展
本文完整實現(xiàn)了一個功能完備的寫字板組件,涵蓋自由繪制、撤銷重做、清空、保存與分享的全流程。
通過組件化封裝,業(yè)務層僅需在布局中引用
DrawingBoardView并綁定按鈕,即可快速集成。
拓展方向
筆壓感應:結(jié)合手寫筆壓力,動態(tài)調(diào)整筆寬或透明度;
圖形標注:支持直線、矩形、圓形、文字等多種標注模式;
云端同步:將繪制數(shù)據(jù)以矢量格式上傳服務器,實現(xiàn)跨端同步;
動畫回放:記錄繪制時間戳,支持繪制過程回放;
Jetpack Compose 重構:使用
Canvas與Modifier.pointerInput實現(xiàn) Compose 版寫字板。
九、FAQ
Q:如何保存多頁畫布?
A:可在paths加入頁面索引,導出時分別按照頁碼生成多張Bitmap并打包。Q:Bitmap 導出后圖片太大怎么辦?
A:在保存時對Bitmap進行壓縮,或先縮放至合適尺寸。Q:如何讓撤銷支持部分筆跡?
A:目前按整筆撤銷,若需精細撤銷可將每段quadTo拆分為更小路徑并記錄。Q:如何在旋轉(zhuǎn)屏幕后保持繪制?
A:在onSaveInstanceState序列化paths數(shù)據(jù),旋轉(zhuǎn)后在onRestoreInstanceState中恢復。Q:如何支持涂鴉橡皮擦功能?
A:可在涂鴉模式下切換paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)來擦除軌跡。
以上就是基于Android實現(xiàn)寫字板功能的代碼詳解的詳細內(nèi)容,更多關于Android寫字板功能的資料請關注腳本之家其它相關文章!
相關文章
Android中分析Jetpack?Compose動畫內(nèi)部的實現(xiàn)原理
這篇文章主要介紹了Android中分析Jetpack?Compose動畫內(nèi)部的實現(xiàn)原理,文章圍繞主題展開詳細的內(nèi)容介紹,具有一定的參考價值,感興趣的小伙伴可以參考一下2022-09-09
詳解Android studio ndk配置cmake開發(fā)native C
這篇文章主要介紹了詳解Android studio ndk配置cmake開發(fā)native C,非常具有實用價值,需要的朋友可以參考下2017-09-09
Android開發(fā)之ClipboardManager剪貼板功能示例
這篇文章主要介紹了Android開發(fā)之ClipboardManager剪貼板功能,結(jié)合簡單實例形式分析了Android使用ClipboardManager實現(xiàn)剪貼板功能的相關操作技巧,需要的朋友可以參考下2017-03-03
Android自定義TextView仿微信朋友圈文字展開全文功能
這篇文章主要為大家詳細介紹了Android自定義TextView仿微信朋友圈文字展開全文功能,具有一定的參考價值,感興趣的小伙伴們可以參考一下2018-06-06
android ScrollView實現(xiàn)水平滑動回彈
這篇文章主要為大家詳細介紹了android ScrollView實現(xiàn)水平滑動回彈,文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2022-04-04
Android HorizontalScrollView左右滑動效果
這篇文章主要為大家詳細介紹了Android HorizontalScrollView左右滑動效果的相關資料,具有一定的參考價值,感興趣的小伙伴們可以參考一下2017-02-02
Android實現(xiàn)界面內(nèi)嵌多種卡片視圖(ViewPager、RadioGroup)
這篇文章主要為大家詳細介紹了Android實現(xiàn)界面內(nèi)嵌多種卡片視圖,文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2017-09-09

