
手写笔记这个需求看起来不复杂真正想找一个顺手工具的时候却总是差点意思。不是同步方案太重就是笔迹渲染延迟明显再不就是格式锁死换设备后数据迁移变成大麻烦。折腾几轮之后我决定干脆自己动手写一个开源的 Android 手写笔记 APP把核心链路完全握在自己手里。本篇文章会把这套实现的关键部分拆开讲解包括自定义手写画板、贝塞尔曲线拟合、撤销重做、数据持久化以及开源落地时值得注意的工程细节。代码以 Kotlin 编写适合有一定 Android 基础、又对自定义 View 和笔记类应用感兴趣的开发者阅读零基础的同学也可以按步骤把项目跑起来。1. 为什么我又双叒自己写了一个手写笔记 APP1.1 现有手写笔记工具的痛点市面上的手写笔记 APP 其实不少但用下来总有几个绕不开的问题。第一是笔迹延迟。部分笔记 APP 把核心渲染逻辑放在跨平台框架里复杂页面上快速书写时笔迹明显跟不上手指尤其是连续长笔画时折线和锯齿感很重。对于手写笔记来说延迟和跟手程度直接决定能不能日常使用。第二是数据格式封闭。很多工具把笔记保存在私有数据库或自定义加密格式中导出只有 PDF 或图片原始笔迹数据带不出来。一旦你想做二次处理、批量检索、自动摘要或者换个笔记软件数据迁移成本非常高。第三是功能臃肿。一个纯手写场景非要塞进 AI 助手、模板市场、云盘同步、会员订阅。对于只需要“打开就写、写完能存、离线可用”的用户来说这些功能反而是噪声。第四是离线能力不稳定。依赖云端同步的笔记应用在弱网环境下经常出现同步冲突本地草稿丢失的情况也不少见。手写笔记本质上应该是本地优先的网络只是附加能力。1.2 自己写一个图的是什么自己动手写一个手写笔记 APP核心目标其实只有几个完整控制笔迹渲染链路从触摸采样、贝塞尔拟合到 View 绘制全部自己实现保证低延迟。数据格式完全开放笔迹点阵用标准 JSON 保存任何人都能解析和迁移。功能做减法只保留手写、撤销、重做、保存、加载不搞复杂订阅体系。离线优先所有数据先落本地后续有需要再加同步能力。这其实也符合开源社区里很多手写笔记项目的思路不追求大而全而是把核心体验打磨好然后通过开源协议把能力开放出来让需要的人可以按自己的方式扩展。1.3 开源能给项目带来什么把这类项目开源收益不只是代码托管。开源首先意味着用户可以自己编译、审查、修改不用担心工具停止维护后笔记再也读不出来。其次手写笔迹的渲染和拟合是个很细致的话题开源之后容易吸引到同样在折腾这类需求的人一起优化触摸事件处理、压感适配和渲染性能。所以在项目设计阶段我就尽量把核心逻辑和 UI 层解耦方便其他人直接复用画板 View而不是把整个 APP 绑死在一起。2. 项目整体设计与技术选型2.1 技术栈选择整个项目采用 Kotlin 编写基于 Android 原生框架核心依赖很少。模块选择说明开发语言Kotlin简洁安全适合自定义 View 开发UI 渲染自定义 View Canvas直接控制绘制过程延迟更低数据序列化org.jsonAndroid 内置无需额外依赖本地存储SharedPreferences / File演示阶段够用后续可替换 Room最低系统版本Android 6.0覆盖绝大多数存量设备不需要引入复杂的手写框架因为这次的目标就是验证手写笔记的核心链路能不能自己做。2.2 功能拆解首版功能限定在四个模块手写画板支持触摸书写、笔迹颜色与粗细设置。笔迹拟合使用二次贝塞尔曲线对原始触摸点做平滑避免折线感。操作栈支持撤销、重做保持可预期的手写体验。数据存取将笔迹序列化为 JSON 并保存到本地文件支持加载恢复。橡皮擦在这个版本里先做“按笔画擦除”不做像素级擦除降低实现复杂度。2.3 项目目录结构handnote/ ├── app/ │ ├── src/main/ │ │ ├── java/com/example/handnote/ │ │ │ ├── MainActivity.kt │ │ │ ├── NoteBoardView.kt │ │ │ ├── NoteModels.kt │ │ │ └── NoteBoardSerializer.kt │ │ ├── res/layout/ │ │ │ └── activity_main.xml │ │ └── AndroidManifest.xml │ └── build.gradle.kts └── build.gradle.kts这个结构后续就算加数据库、加导出功能也不会影响画板本身的可复用性。3. 核心实现自定义手写画板3.1 笔迹数据模型设计手写笔迹的核心是点和笔画两个抽象。一个点只需要保存 x 和 y 坐标后续如果要加压感笔可以扩展为pressure字段。一个笔画由颜色、线宽和点列表构成。// 文件路径app/src/main/java/com/example/handnote/NoteModels.kt package com.example.handnote import android.graphics.Path data class NotePoint( val x: Float, val y: Float ) class Stroke( val color: Int, val strokeWidth: Float, val points: MutableListNotePoint mutableListOf() ) { // path 不参与序列化绘制时根据 points 重建 val path: Path Path() fun resetPath() { path.reset() if (points.isEmpty()) return path.moveTo(points[0].x, points[0].y) var lastX points[0].x var lastY points[0].y for (i in 1 until points.size) { val cur points[i] val midX (lastX cur.x) / 2f val midY (lastY cur.y) / 2f path.quadTo(lastX, lastY, midX, midY) lastX cur.x lastY cur.y } path.lineTo(lastX, lastY) } }这里path是内存态对象不参与序列化。序列化时只保存points加载后再调用resetPath()重建绘制路径这是手写笔记存储的关键设计。3.2 触摸事件采集与防抖自定义 View 中手写输入的入口是onTouchEvent。这里需要区分三种状态ACTION_DOWN开始新笔画。ACTION_MOVE持续追加点。ACTION_UP/ACTION_CANCEL结束笔画。在实际书写过程中触摸事件的采样频率很高如果每个点都处理会产生大量无效计算和内存消耗。这里可以结合ViewConfiguration.get(context).scaledTouchSlop做距离过滤只有移动距离超过阈值的点才加入笔画。3.3 贝塞尔曲线拟合原始触摸点直接连线会出现明显的折角尤其是快速书写时。因此需要使用二次贝塞尔曲线对点列做平滑处理。核心思路是对于相邻两个原始点不直接画直线而是以它们的中点作为曲线终点以前一个原始点作为控制点绘制一条quadTo曲线。这样每段曲线之间在几何上是平滑衔接的。对应到resetPath()中的循环逻辑从第二个点开始每遍历到新点就把上一个原始点到当前点中点的贝塞尔曲线追加到 Path 上。最后再lineTo到最后一个点保证笔画的末端不会缺一块。这种拟合方式的优点是计算量小实时性高适合 Android 手写场景。3.4 完整 NoteBoardView 代码下面是手写画板的核心实现代码中同时包含了撤销重做栈、橡皮擦按钮占位逻辑和绘制流程。// 文件路径app/src/main/java/com/example/handnote/NoteBoardView.kt package com.example.handnote import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Path import android.util.AttributeSet import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import java.util.ArrayDeque class NoteBoardView JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : View(context, attrs, defStyleAttr) { private val paint Paint(Paint.ANTI_ALIAS_FLAG).apply { style Paint.Style.STROKE strokeCap Paint.Cap.ROUND strokeJoin Paint.Join.ROUND color Color.BLACK strokeWidth 6f } private val strokes mutableListOfStroke() private var currentStroke: Stroke? null private var currentPath: Path Path() private val undoStack ArrayDequeStroke() private val redoStack ArrayDequeStroke() var currentColor: Int Color.BLACK var currentStrokeWidth: Float 6f var eraserMode: Boolean false private val touchSlop ViewConfiguration.get(context).scaledTouchSlop private var lastX 0f private var lastY 0f fun getStrokes(): ListStroke strokes.toList() fun setStrokes(newStrokes: ListStroke) { strokes.clear() strokes.addAll(newStrokes) undoStack.clear() redoStack.clear() invalidate() } fun undo() { if (strokes.isEmpty()) return val stroke strokes.removeAt(strokes.size - 1) redoStack.push(stroke) invalidate() } fun redo() { if (redoStack.isEmpty()) return val stroke redoStack.pop() strokes.add(stroke) invalidate() } fun clearAll() { strokes.clear() undoStack.clear() redoStack.clear() invalidate() } override fun onTouchEvent(event: MotionEvent): Boolean { val x event.x val y event.y when (event.actionMasked) { MotionEvent.ACTION_DOWN - { if (eraserMode) { findStrokeAt(x, y)?.let { hit - strokes.remove(hit) invalidate() } } else { currentStroke Stroke(currentColor, currentStrokeWidth).also { it.points.add(NotePoint(x, y)) it.resetPath() } currentPath currentStroke!!.path lastX x lastY y invalidate() } return true } MotionEvent.ACTION_MOVE - { if (eraserMode) { findStrokeAt(x, y)?.let { hit - strokes.remove(hit) invalidate() } } else { val stroke currentStroke ?: return true val dx x - lastX val dy y - lastY if (dx * dx dy * dy touchSlop * touchSlop) return true stroke.points.add(NotePoint(x, y)) stroke.resetPath() currentPath stroke.path lastX x lastY y invalidate() } return true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL - { val stroke currentStroke if (stroke ! null stroke.points.isNotEmpty()) { stroke.resetPath() strokes.add(stroke) undoStack.push(stroke) redoStack.clear() } currentStroke null currentPath Path() invalidate() return true } } return super.onTouchEvent(event) } private fun findStrokeAt(x: Float, y: Float): Stroke? { val threshold touchSlop * 3f return strokes.lastOrNull { stroke - stroke.points.any { p - val dx p.x - x val dy p.y - y dx * dx dy * dy threshold * threshold } } } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) canvas.drawColor(Color.WHITE) for (stroke in strokes) { paint.color stroke.color paint.strokeWidth stroke.strokeWidth canvas.drawPath(stroke.path, paint) } currentStroke?.let { stroke - paint.color stroke.color paint.strokeWidth stroke.strokeWidth canvas.drawPath(currentPath, paint) } } }这里有几个设计点值得说明。撤销和重做采用双栈结构undoStack保存已经完成的笔画redoStack保存被撤销的笔画。每次新笔画结束时把笔画加入undoStack同时清空redoStack保证重做只在撤销后有效。橡皮擦方面目前实现的是“按笔画擦除”即根据触摸点与笔画中所有点的最近距离来判断是否命中。这种方案实现简单符合演示需求但做不到像素级擦除。如果要做到精确擦除需要引入 Canvas 离屏缓冲或区域布尔运算这个可以放到后续版本。4. 撤销、重做与橡皮擦4.1 基于双栈的撤销重做撤销重做的核心数据结构是双栈。一个栈保存历史状态另一个栈保存被撤销的状态。在手写场景中撤销的最小单元是“一笔”也就是一个Stroke。当用户点击撤销按钮时从当前笔画列表尾部取出一笔推入 redo 栈点击重做时再从 redo 栈弹出一笔加回笔画列表。这个实现的关键是保证栈的状态和strokes列表的一致性。每次新笔画完成时要清空 redo 栈避免出现“撤销后写新笔画再重做旧笔画”这种不符合直觉的行为。4.2 橡皮擦的两种策略手写笔记的橡皮擦通常有两种实现策略。第一种是“笔画级擦除”点击或滑动时命中某一条笔画直接删除整笔。优点是逻辑简单、撤销语义清晰缺点是擦除粒度太粗用户只能整笔删除。第二种是“像素级擦除”通过PorterDuff.Mode.CLEAR或保存透明通道实现对局部笔迹的擦除。优点是表现自然但实现复杂还需要处理图层合成和撤销快照。本文的示例实现的是第一种。如果你打算做像素级擦除一个可行的思路是维护一个离屏 Bitmap每次绘制笔迹时同时绘制到 Bitmap 和 View 上擦除时对 Bitmap 做drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)再把 Bitmap 区域重新绘制到 View。要注意的是这种方式下撤销栈需要保存位图快照内存占用会明显上升。4.3 在自定义 View 中落地在NoteBoardView中橡皮擦的入口是eraserMode字段。当这个字段为true时触摸事件不会创建新的笔画而是调用findStrokeAt查找命中的笔画并移除。findStrokeAt用的是距离判定遍历所有笔画中的所有点计算与触摸点的欧式距离只要距离小于阈值就认为命中。这种方法在笔画稀疏时效果不错但在笔画密集区域会出现误删真实项目可以将阈值调低并增加最近一次触摸位置记忆来优化。5. 数据持久化与笔记管理5.1 为什么要自己设计序列化格式选择自设计 JSON 格式核心原因是可读和可迁移。相比 SQLite 数据库JSON 文件可以用任意文本编辑器打开也方便脚本处理。后续要把笔迹数据接入云同步、自动化笔记处理或知识库JSON 都是兼容性最好的中间格式。5.2 JSON 序列化与解析下面给出NoteBoardSerializer的完整实现它负责把NoteBoardView中的笔画列表转换成JSONObject以及反向解析。// 文件路径app/src/main/java/com/example/handnote/NoteBoardSerializer.kt package com.example.handnote import org.json.JSONArray import org.json.JSONObject object NoteBoardSerializer { fun encode(view: NoteBoardView): JSONObject { val root JSONObject() root.put(version, 1) root.put(width, view.width) root.put(height, view.height) val strokesArray JSONArray() view.getStrokes().forEach { stroke - val item JSONObject() item.put(color, stroke.color) item.put(strokeWidth, stroke.strokeWidth.toDouble()) val points JSONArray() for (p in stroke.points) { val point JSONObject() point.put(x, p.x.toDouble()) point.put(y, p.y.toDouble()) points.put(point) } item.put(points, points) strokesArray.put(item) } root.put(strokes, strokesArray) return root } fun decode(json: JSONObject): ListStroke { val strokes mutableListOfStroke() val strokesArray json.optJSONArray(strokes) ?: return strokes for (i in 0 until strokesArray.length()) { val item strokesArray.getJSONObject(i) val color item.optInt(color) val strokeWidth item.optDouble(strokeWidth, 6.0).toFloat() val pointsJson item.getJSONArray(points) val points mutableListOfNotePoint() for (j in 0 until pointsJson.length()) { val p pointsJson.getJSONObject(j) points.add( NotePoint( x p.optDouble(x).toFloat(), y p.optDouble(y).toFloat() ) ) } val stroke Stroke(color, strokeWidth, points) stroke.resetPath() strokes.add(stroke) } return strokes } }注意optDouble提供了默认值可以防止字段缺失导致崩溃。在实际项目中建议对 JSON 的 version 字段做校验如果未来格式变化可以根据不同版本做迁移解析。5.3 保存与加载入口保存和加载可以直接在 Activity 中完成。示例中使用filesDir作为存储目录这是应用私有目录不需要额外权限。// 文件路径app/src/main/java/com/example/handnote/MainActivity.kt package com.example.handnote import android.os.Bundle import android.widget.Button import androidx.appcompat.app.AppCompatActivity import org.json.JSONObject import java.io.File class MainActivity : AppCompatActivity() { private lateinit var noteBoardView: NoteBoardView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) noteBoardView findViewById(R.id.noteBoardView) findViewByIdButton(R.id.btnUndo).setOnClickListener { noteBoardView.undo() } findViewByIdButton(R.id.btnRedo).setOnClickListener { noteBoardView.redo() } findViewByIdButton(R.id.btnSave).setOnClickListener { saveNote() } findViewByIdButton(R.id.btnLoad).setOnClickListener { loadNote() } } private fun saveNote() { val json NoteBoardSerializer.encode(noteBoardView) val file File(filesDir, note_demo.json) file.writeText(json.toString()) } private fun loadNote() { val file File(filesDir, note_demo.json) if (!file.exists()) return val json JSONObject(file.readText()) noteBoardView.setStrokes(NoteBoardSerializer.decode(json)) } }这个版本没有处理异步 IO。真实项目中保存应该在协程或子线程中进行避免在笔迹多、文件大时阻塞 UI 线程。6. 构建运行与工程集成6.1 环境准备本文示例基于 Kotlin 和 Android 原生开发。你需要提前准备Android Studio建议使用当前稳定版本。JDK 17 或以上。Android SDK编译版本建议使用本机已安装的最新稳定版本。如果你还没有 Android Studio可以先去官网下载安装完成后用 SDK Manager 安装对应的 Android 平台版本。本文不绑定具体版本号因为 Android 开发工具链更新较快不同版本的 Android Studio 默认模板会有差异。你只需要用 Android Studio 新建的项目作为基础再对照本文的代码结构进行整合即可。6.2 添加依赖这个示例项目的依赖很少只需要引入 AndroidX 基础库。在app/build.gradle.kts中维护依赖// 文件路径app/build.gradle.kts plugins { id(com.android.application) id(org.jetbrains.kotlin.android) } android { namespace com.example.handnote compileSdk 34 defaultConfig { applicationId com.example.handnote minSdk 23 targetSdk 34 versionCode 1 versionName 1.0 } buildTypes { release { isMinifyEnabled false } } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { jvmTarget 17 } } dependencies { implementation(androidx.core:core-ktx:1.12.0) implementation(androidx.appcompat:appcompat:1.6.1) implementation(com.google.android.material:material:1.11.0) }如果上述版本在你的环境中无法解析请优先使用 Android Studio 新建项目时自动生成的版本号然后再集成本文代码。6.3 编写布局与 MainActivity布局文件使用 LinearLayout 划分画板区域和底部操作按钮。!-- 文件路径app/src/main/res/layout/activity_main.xml -- ?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical com.example.handnote.NoteBoardView android:idid/noteBoardView android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 android:background#FFFFFF / LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal android:padding8dp Button android:idid/btnUndo android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:text撤销 / Button android:idid/btnRedo android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:text重做 / Button android:idid/btnSave android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:text保存 / Button android:idid/btnLoad android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:text加载 / /LinearLayout /LinearLayout6.4 编译运行步骤在 Android Studio 中新建一个空项目包名建议设为com.example.handnote。把NoteModels.kt、NoteBoardView.kt、NoteBoardSerializer.kt、MainActivity.kt四个文件复制到对应目录。替换activity_main.xml布局文件。同步 Gradle 后连接模拟器或真机点击 Run。运行后你可以在画板区域用手指书写底部按钮支持撤销、重做、保存和加载。保存的 JSON 文件位于应用私有目录下的note_demo.json可以通过 Device File Explorer 查看。7. 常见问题与排查清单7.1 高频报错与解决方案问题现象常见原因解决思路编译报错找不到org.jetbrains.kotlin.android插件项目未配置 Kotlin 插件在根目录build.gradle.kts中声明插件版本或用 Android Studio 新建 Kotlin 项目compileSdk 34无法解析本机未安装对应 SDK 版本打开 SDK Manager 安装对应 Platform或改成已安装的版本手写笔迹延迟明显每个 MOVE 事件都重建 Path减少无效点采集或对 Path 做增量更新redo后写新笔画之前撤销的内容又出现新笔画创建时未清空 redo 栈确认ACTION_UP后执行redoStack.clear()保存的 JSON 为空getStrokes()返回了空列表确认笔迹已经结束也就是手指抬起后再保存加载后笔画丢失JSON 解析失败检查points和strokeWidth字段类型是否一致橡皮擦误删笔画距离阈值设置过大降低threshold或改为只删除最近一次触摸命中的笔画7.2 体验问题的排查思路如果手写体验不跟手优先查看两点。第一触摸事件是否在 UI 线程被阻塞。如果在onTouchEvent中做了大量 JSON 解析、文件 IO 或复杂计算必然产生卡顿。手写绘制路径上的代码要尽量轻量重活放到子线程。第二点的过滤策略是否合理。如果完全不过滤可能同一段笔迹重复保存了大量距离很近的点如果过滤阈值太大快速书写时笔迹又会变得不完整。一般以scaledTouchSlop作为基础阈值再根据实际设备调整。8. 最佳实践与开源发布建议8.1 自定义 View 的性能边界这个示例在每次ACTION_MOVE都调用resetPath()重建整条 Path。对于单笔几百个点的场景勉强够用但对于大量笔迹的页面这种方式的 CPU 开销会上升很快。在实际项目中可以改成增量更新只在当前 Path 上追加新线段而不是每次从第一个点开始重建。大概思路是保存上一次的lastX、lastY在 MOVE 事件中只对新加入的点计算贝塞尔中点并 append 到 Path。这样单笔书写的复杂度从 O(n²) 降到 O(1)。另一个优化点是局部刷新。invalidate()会导致整个 View 重绘可以改成invalidate(dirtyRect)只刷新发生变化的区域。配合硬件加速可以明显改善大面积笔记的绘制性能。8.2 数据安全与文件管理手写笔记的数据是用户的重要资产。保存文件时应该遵循几个原则写文件时先写临时文件再通过renameTo覆盖旧文件避免写入中断导致数据损坏。保存成功后再更新笔记列表元数据保证文件与索引的一致性。重要笔记加个定期备份机制比如把 JSON 文件复制到系统备份目录。如果未来引入云同步优先使用增量同步避免每次全量上传整个 JSON 文件。在演示代码中文件读写都发生在主线程实际项目中必须改为协程或线程池。可以配合kotlinx.coroutines的Dispatchers.IO来做。8.3 开源仓库整理建议如果你准备把这个项目开源仓库质量会影响别人是否愿意使用和贡献。在发布前建议做好以下几件事。第一选择一个宽松的开源协议。手写笔记这类学习型项目MIT 或 Apache 2.0 协议比较合适使用者在保留版权声明的前提下可以自由修改和商用。第二写好 README。README 中应该包含功能介绍、运行截图、技术栈说明、构建步骤和目录结构。特别是要写清楚当前实现了哪些功能、哪些功能还没做避免使用者产生误解。第三提供最小可复现示例。不要只放一个完整 APP可以单独抽出一个NoteBoardView的 Demo 页面方便其他开发者快速集成到自己的项目里。第四关注开源安全。仓库中不要提交签名密钥、local.properties、keystore文件.gitignore要提前配置好。同时要在 README 中说明最低系统版本和所需权限让使用者在集成前就清楚边界。9. 写在最后手写笔记看似是个小工具真正做起来牵扯的细节非常多。触摸事件滤波、贝塞尔拟合、撤销栈设计、序列化格式每一块单独拿出来都可以继续深挖。本文这套实现更像一个“最小可用内核”把从手指到屏幕、再从内存到文件的核心链路完整打通了。如果你也在折腾手写笔记类工具建议基于这套代码先把基础体验跑通再考虑加入压感笔支持、离线渲染缓存、PDF 导出、甚至接入开源 OCR 做手写识别扩展。数据格式是开放的这些能力都可以逐步加进去。欢迎在评论区分享你的实现思路也可以把你的开源项目链接贴出来一起交流。