基于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)化:支持硬件加速、路徑緩存、局部刷新
二、相關(guān)知識
在動手之前,你需要了解以下核心技術(shù)點:
自定義 View 與 Canvas
重寫
onDraw(Canvas)
,使用Canvas.drawPath(Path, Paint)
繪制路徑在
onTouchEvent(MotionEvent)
中根據(jù)ACTION_DOWN/MOVE/UP
構(gòu)建Path
數(shù)據(jù)結(jié)構(gòu)與撤銷/重做
使用
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
構(gòu)建顏色面板與筆寬面板使用
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 /** 設(shè)置畫筆顏色 */ fun setStrokeColor(color: Int) { paint.color = color } /** 設(shè)置畫筆粗細 */ 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 // 分享授權(quán) 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é)構(gòu):
paths: List<Pair<Path,Paint>>
保存每筆軌跡與對應畫筆;觸摸處理:使用
quadTo
平滑繪制;在ACTION_UP
時深拷貝路徑與畫筆入paths
;撤銷/重做:
undo()
從paths
移出最后一筆入redoStack
;redo()
則反向操作;清空與導出:
clear()
清空所有,exportBitmap()
生成白底Bitmap
并重繪所有路徑。
ImageUtil
兼容 Android Q+ 與以下版本,分別使用
MediaStore
或文件流保存;保存在
Pictures/DrawingBoard
或getExternalFilesDir
,并返回Uri
便于分享。
MainActivity
UI 綁定:
colorPalette
動態(tài)生成顏色按鈕,seekStroke
動態(tài)控制筆寬;操作按鈕:清空、撤銷、重做按鈕直接調(diào)用相應 API;
保存與分享:協(xié)程異步導出
Bitmap
→保存→拿到Uri
→通過Intent.ACTION_SEND
分享;
權(quán)限與 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 重構(gòu):使用
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)容,更多關(guān)于Android寫字板功能的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android 運用@JvmName解決函數(shù)簽名沖突問題詳解
JvmName注解是Kotlin提供的一個可以變更編譯器輸出的注解,這里簡單的介紹一下其使用規(guī)則,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步2022-07-07android h5頁面獲取不到定位數(shù)據(jù)的問題解決
我們經(jīng)常會遇到onGeolocationPermissionsShowPrompt 已經(jīng)執(zhí)行,但仍然沒有獲取到定位數(shù)據(jù)的問題,所以本文給大家介紹了android h5頁面獲取不到定位數(shù)據(jù)的問題解決,需要的朋友可以參考下2024-11-11Android ListView實現(xiàn)仿iPhone實現(xiàn)左滑刪除按鈕的簡單實例
下面小編就為大家?guī)硪黄狝ndroid ListView實現(xiàn)仿iPhone實現(xiàn)左滑刪除按鈕的簡單實例。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2016-08-08Android 限制顯示小數(shù)點后兩位的實現(xiàn)方法
下面小編就為大家分享一篇Android 限制顯示小數(shù)點后兩位的實現(xiàn)方法,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2018-01-01Android webview手動校驗https證書(by 星空武哥)
有些時候由于Android系統(tǒng)的bug或者其他的原因,導致我們的webview不能驗證通過我們的https證書,最明顯的例子就是華為手機mate7升級到Android7.0后,手機有些網(wǎng)站打不開了,而更新了webview的補丁后就沒問題了2017-09-09Android 判斷網(wǎng)絡狀態(tài)及開啟網(wǎng)路
這篇文章主要介紹了Android 判斷網(wǎng)絡狀態(tài)及開啟網(wǎng)路的相關(guān)資料,在開發(fā)網(wǎng)路狀態(tài)的時候需要先判斷是否開啟之后在提示用戶進行開啟操作,需要的朋友可以參考下2017-08-08