各位开发者朋友大家好。相信不少用过手写笔记 APP 的同学都有过类似经历安装了好几个知名应用有的界面很精美但同步逻辑复杂有的笔迹效果不错但导出格式封闭有的基础功能免费、高级功能却要订阅还有的笔记数据被牢牢锁在厂商生态里迁移一次数据要花上半天。前阵子我在平板上整理技术笔记时又被这类问题卡住了。思来想去与其继续在现有 APP 之间来回跳转不如按自己的使用习惯开发一款开源的手写笔记 APP。项目推进过程中踩了不少坑也沉淀了一些可复用的实现思路。本文就完整梳理一下这款开源手写笔记工具的设计方案、核心代码、问题排查和工程化建议希望能给同样想自研手写笔记或绘图类应用的开发者一些参考。这篇文章面向两类读者一类是有 Android 或 Kotlin 基础想了解手写笔记 APP 整体架构的开发者另一类是已经写过简单绘图程序但想在笔锋、防误触、数据存储、导出等模块上做深一点的进阶开发者。读完你可以掌握手写笔记核心模块的拆分思路理解笔迹从手指触摸到屏幕渲染、再到存储与导出的完整链路也能直接在本地项目里复用本文的代码骨架按照自己的需求扩展功能。1. 背景与核心概念1.1 为什么需要自研手写笔记工具手写笔记 APP 看起来只是“一块能写字的画布”实际上它是一个典型的轻量级图形应用加文档应用综合体。表面上是记录工具背后涉及线程模型、图形渲染、数据序列化、文件管理、手势识别、性能优化等多个工程问题。市面上的成熟产品各有优势但存在几个通病数据格式不开放导出 PDF 或图片后原始笔迹无法回到其他工具继续编辑核心功能依赖云同步离线场景体验不稳定且数据隐私边界不清晰基础绘图能力可以免费但纸张模板、压感笔锋、多页 PDF 导入等高频需求往往被放入订阅体系对性能较弱的旧设备优化不足长页笔记、大笔迹文件容易卡顿。对于想长期积累技术笔记、希望数据结构完全可控的开发者来说“自己写一套”反而是一劳永逸的方案。1.2 手写笔记 APP 的经典架构手写笔记 APP 的通用流程可以拆成下面几个环节采集层接收触摸事件或手写笔事件得到坐标、时间戳、压力值笔迹生成层将坐标点拟合成平滑曲线并根据压力或速度计算笔画宽度渲染层将笔迹绘制到画布上同时处理局部刷新和缩放平移数据管理层把笔迹转换为自定义数据格式支持增删改查文件层将数据保存为可持久化文件支持导出图片、PDF或者导入外部文件。这个拆分方式在不同端上差异不大。Web 端可以用 Canvas 或 SVGAndroid 端常用自定义 ViewiOS 端可以用 Core Graphics。本文的示例代码以 Android 端自定义 View 为基础展开但思路可以平移到其他平台。1.3 核心目标本文实现的开源笔记工具项目代号暂定为 InkNote核心目标如下本地优先所有数据默认保存在应用私有目录不强制任何云服务数据结构开放笔迹数据使用 JSON 作为主格式方便外部工具解析基础体验优先支持连续书写、笔锋模拟、撤销重做、橡皮擦、笔迹选择导出链路完整支持导出 PNG 图片和 PDF 文档。这里的定位不是再造一个 Notability而是提供一个稳定的手写底层框架让后续扩展“笔记本管理”“模板系统”“OCR 文字识别”时不需要推翻重写。2. 需求分析与功能拆分2.1 功能模块划分动手前先把功能拆清楚。规划了下面几个模块模块职责优先级画布模块接收触摸坐标、管理视图变换、触发重绘P0笔迹模型定义笔迹、点、笔刷参数P0笔锋算法根据速度或压力生成变宽笔画P0撤销重做管理历史操作栈P0笔迹存储序列化和反序列化笔记P1文件导出渲染为图片或 PDFP1手写笔适配处理压感、悬停、按键擦除P1笔记本管理多笔记、多页面组织P2P0 是首版必须完成的内容没有这些就不能叫手写笔记应用。P1 影响可用性和闭环体验P2 是后续迭代方向。2.2 关键流程单个笔画的完整生命周期如下用户按下触控笔或手指系统触发ACTION_DOWN应用记录起点并创建一个新的Stroke对象用户移动时触发ACTION_MOVE点被追加到当前笔画中用户抬起时触发ACTION_UP笔画写入完成同时加入撤销栈渲染层将笔画数据重新绘制到画布保存时全部笔画被序列化到 JSON 文件。流程本身不复杂但每个环节都有不少细节尤其是“局部重绘”和“笔迹序列化”很容易写出性能很差的版本。后文会逐段说明。3. 技术选型与项目结构3.1 技术选型思路每个技术选型都需要对应真实理由下面是我的取舍方案技术点选择理由开发语言KotlinAndroid 官方推荐空安全和协程让触摸事件流和数据读写更简洁UI 框架自定义 View手写绘图需要高频重绘自定义 View 最直接便于控制绘制细节笔迹数据格式JSON可读性好方便调试也方便写导出工具数据库初期不用数据库笔记数据以文件为主数据库只用于索引元数据时再引入控制首版复杂度图片生成Bitmap CanvasAndroid 内置能力导出 PNG 图片足够PDF 导出PdfDocument系统 API无需引入重依赖这里要注意一点版本信息需要结合你的开发环境而定。如果用较新的 Android Studio项目 Gradle 插件版本会比较新API 级别建议设置在 26 以上以保证PdfDocument和相关文件 API 可用。本文的示例不绑定某个具体版本重点展示实现思路。3.2 项目包结构com.example.inknote ├── data │ ├── model │ │ ├── NoteData.kt │ │ ├── Stroke.kt │ │ └── StrokePoint.kt │ ├── serializer │ │ └── NoteSerializer.kt │ └── repository │ └── NoteRepository.kt ├── drawing │ ├── InkCanvasView.kt │ ├── StrokeRenderer.kt │ └── brush │ ├── BrushType.kt │ └── BrushStroke.kt ├── edit │ ├── UndoManager.kt │ ├── Eraser.kt │ └── StrokeSelector.kt ├── export │ ├── ImageExporter.kt │ └── PdfExporter.kt └── MainActivity.kt这个结构不算复杂但把“数据”“绘制”“编辑”“导出”四条主线分开了。组件之间尽量通过接口通信避免自定义 View 里塞满业务逻辑。4. 笔迹模型的定义与序列化4.1 定义 StrokePoint首先定义最基础的数据结构笔迹点。每个点除了 x、y 坐标还应该记录压力值和时间戳。压力值用于模拟笔锋时间戳可以用于计算速度变化。// 文件路径app/src/main/java/com/example/inknote/data/model/StrokePoint.kt package com.example.inknote.data.model /** * 笔迹中的单个采样点 * param x 横坐标 * param y 纵坐标 * param pressure 压力值范围 0f~1.0f。手指触摸时可以固定为 0.5f * param timestampMs 采样时间戳用于计算书写速度 */ data class StrokePoint( val x: Float, val y: Float, val pressure: Float, val timestampMs: Long )4.2 定义 Stroke 和 NoteDataStroke表示一条完整笔画。除了点列表还需要保存画笔类型、颜色和基础宽度。NoteData表示一页笔记包含页面宽高和笔画列表。// 文件路径app/src/main/java/com/example/inknote/data/model/Stroke.kt package com.example.inknote.data.model import com.example.inknote.drawing.brush.BrushType /** * 一条手写笔画 * param id 笔画唯一标识 * param brushType 画笔类型 * param color 画笔颜色 ARGB * param baseWidth 基础笔画宽度单位 px * param points 笔画采样点列表 */ data class Stroke( val id: String, val brushType: BrushType, val color: Int, val baseWidth: Float, val points: ListStrokePoint )// 文件路径app/src/main/java/com/example/inknote/data/model/NoteData.kt package com.example.inknote.data.model /** * 一页笔记的数据模型 * param pageWidth 页面宽度 * param pageHeight 页面高度 * param strokes 页面内的全部笔画保持绘制顺序 */ data class NoteData( val pageWidth: Int, val pageHeight: Int, val strokes: MutableListStroke mutableListOf() )4.3 序列化与反序列化这里选择 JSON 格式主要原因是可以直接阅读、方便排查问题。手写数据通常包含大量点纯手写 JSON 文件即使一页几百个笔画体积也在可接受范围内。如果以后要做协同编辑或更高效的增量存储可以再替换为二进制格式但序列化层接口可以先抽象出来。// 文件路径app/src/main/java/com/example/inknote/data/serializer/NoteSerializer.kt package com.example.inknote.data.serializer import com.example.inknote.data.model.NoteData import com.example.inknote.data.model.Stroke import com.example.inknote.data.model.StrokePoint import com.example.inknote.drawing.brush.BrushType import org.json.JSONArray import org.json.JSONObject /** * 笔记 JSON 序列化工具 * 示例中使用 org.json正式项目可以使用 kotlinx.serialization 或 Gson */ object NoteSerializer { private const val KEY_PAGE_WIDTH pageWidth private const val KEY_PAGE_HEIGHT pageHeight private const val KEY_STROKES strokes private const val KEY_ID id private const val KEY_BRUSH_TYPE brushType private const val KEY_COLOR color private const val KEY_BASE_WIDTH baseWidth private const val KEY_POINTS points private const val KEY_X x private const val KEY_Y y private const val KEY_PRESSURE pressure private const val KEY_TIMESTAMP timestampMs fun toJson(note: NoteData): String { val root JSONObject() root.put(KEY_PAGE_WIDTH, note.pageWidth) root.put(KEY_PAGE_HEIGHT, note.pageHeight) val strokesArray JSONArray() note.strokes.forEach { stroke - strokesArray.put(strokeToJson(stroke)) } root.put(KEY_STROKES, strokesArray) return root.toString() } fun fromJson(json: String): NoteData { val root JSONObject(json) val width root.getInt(KEY_PAGE_WIDTH) val height root.getInt(KEY_PAGE_HEIGHT) val strokes mutableListOfStroke() val strokesArray root.getJSONArray(KEY_STROKES) for (i in 0 until strokesArray.length()) { strokes.add(strokeFromJson(strokesArray.getJSONObject(i))) } return NoteData( pageWidth width, pageHeight height, strokes strokes ) } private fun strokeToJson(stroke: Stroke): JSONObject { val strokeObj JSONObject() strokeObj.put(KEY_ID, stroke.id) strokeObj.put(KEY_BRUSH_TYPE, stroke.brushType.name) strokeObj.put(KEY_COLOR, stroke.color) strokeObj.put(KEY_BASE_WIDTH, stroke.baseWidth) val pointsArray JSONArray() stroke.points.forEach { point - val pointObj JSONObject() pointObj.put(KEY_X, point.x) pointObj.put(KEY_Y, point.y) pointObj.put(KEY_PRESSURE, point.pressure) pointObj.put(KEY_TIMESTAMP, point.timestampMs) pointsArray.put(pointObj) } strokeObj.put(KEY_POINTS, pointsArray) return strokeObj } private fun strokeFromJson(obj: JSONObject): Stroke { val id obj.getString(KEY_ID) val brushType BrushType.valueOf(obj.getString(KEY_BRUSH_TYPE)) val color obj.getInt(KEY_COLOR) val baseWidth obj.getDouble(KEY_BASE_WIDTH).toFloat() val pointsArray obj.getJSONArray(KEY_POINTS) val points mutableListOfStrokePoint() for (i in 0 until pointsArray.length()) { val pointObj pointsArray.getJSONObject(i) points.add( StrokePoint( x pointObj.getDouble(KEY_X).toFloat(), y pointObj.getDouble(KEY_Y).toFloat(), pressure pointObj.getDouble(KEY_PRESSURE).toFloat(), timestampMs pointObj.getLong(KEY_TIMESTAMP) ) ) } return Stroke( id id, brushType brushType, color color, baseWidth baseWidth, points points ) } }这里有一个工程化建议不要在自定义 View 里直接持有 JSONObject 或文件流所有序列化逻辑放在独立的数据层。这样当你从 JSON 换成 Protocol Buffers 时上层不需要改动。5. 手写笔迹绘制引擎实现5.1 自定义 View 的基础结构InkCanvasView是核心画布。它需要完成的职责包括维护当前笔记数据管理坐标系和缩放平移接收触摸事件并生成笔画触发重绘与撤销管理器协作。代码骨架如下// 文件路径app/src/main/java/com/example/inknote/drawing/InkCanvasView.kt package com.example.inknote.drawing import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.util.AttributeSet import android.view.MotionEvent import android.view.View import com.example.inknote.data.model.NoteData import com.example.inknote.data.model.Stroke import com.example.inknote.data.model.StrokePoint import com.example.inknote.drawing.brush.BrushType import com.example.inknote.edit.UndoManager import java.util.UUID class InkCanvasView JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : View(context, attrs, defStyleAttr) { var noteData NoteData(pageWidth 1200, pageHeight 1600) private set private val undoManager UndoManager() private val strokePaint Paint().apply { isAntiAlias true style Paint.Style.STROKE strokeCap Paint.Cap.ROUND strokeJoin Paint.Join.ROUND } private var currentStrokePoints mutableListOfStrokePoint() private var isDrawing false private var currentBrushType BrushType.PEN private var currentColor 0xFF000000.toInt() private var currentBaseWidth 6f init { // View 默认不会处理多指坐标系之外的区域这里设置为可点击以便接收触摸事件 isClickable true isFocusable true } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) drawBackground(canvas) // 绘制已完成笔画 noteData.strokes.forEach { stroke - StrokeRenderer.drawStroke(canvas, stroke, strokePaint) } // 绘制正在书写的笔画 if (currentStrokePoints.isNotEmpty()) { val currentStroke Stroke( id temp, brushType currentBrushType, color currentColor, baseWidth currentBaseWidth, points currentStrokePoints.toList() ) StrokeRenderer.drawStroke(canvas, currentStroke, strokePaint) } } private fun drawBackground(canvas: Canvas) { canvas.drawColor(0xFFFFFFFF.toInt()) // 这里可以继续绘制网格或横线示例中先省略 } override fun onTouchEvent(event: MotionEvent): Boolean { val x event.x val y event.y val pressure event.getPressure() when (event.actionMasked) { MotionEvent.ACTION_DOWN - { isDrawing true currentStrokePoints.clear() currentStrokePoints.add(StrokePoint(x, y, pressure, System.currentTimeMillis())) return true } MotionEvent.ACTION_MOVE - { if (isDrawing) { currentStrokePoints.add(StrokePoint(x, y, pressure, System.currentTimeMillis())) invalidate() } return true } MotionEvent.ACTION_UP - { if (isDrawing) { isDrawing false currentStrokePoints.add(StrokePoint(x, y, pressure, System.currentTimeMillis())) val stroke Stroke( id UUID.randomUUID().toString(), brushType currentBrushType, color currentColor, baseWidth currentBaseWidth, points currentStrokePoints.toList() ) noteData.strokes.add(stroke) undoManager.push(stroke) currentStrokePoints.clear() invalidate() } return true } MotionEvent.ACTION_CANCEL - { isDrawing false currentStrokePoints.clear() invalidate() return true } } return super.onTouchEvent(event) } fun undo() { undoManager.undo(noteData) { invalidate() } } fun redo() { undoManager.redo(noteData) { invalidate() } } }5.2 平滑笔迹渲染如果只是用drawPath把点连起来结果会显得非常生硬尤其是快速书写时折线感很明显。常用做法是使用二次贝塞尔曲线进行平滑插值。StrokeRenderer负责把点列表转换成一条平滑路径并绘制。// 文件路径app/src/main/java/com/example/inknote/drawing/StrokeRenderer.kt package com.example.inknote.drawing import android.graphics.Canvas import android.graphics.Paint import android.graphics.Path import com.example.inknote.data.model.Stroke import com.example.inknote.data.model.StrokePoint import kotlin.math.abs object StrokeRenderer { /** * 绘制一条笔迹。 * 思路两个点之间取中点作为贝塞尔曲线的锚点然后使用 quadraticTo 绘制。 */ fun drawStroke(canvas: Canvas, stroke: Stroke, paint: Paint) { if (stroke.points.size 2) { // 单点笔迹使用小圆点表示 if (stroke.points.size 1) { val p stroke.points[0] paint.strokeWidth stroke.baseWidth canvas.drawPoint(p.x, p.y, paint) } return } val path Path() var startX stroke.points[0].x var startY stroke.points[0].y path.moveTo(startX, startY) for (i in 1 until stroke.points.size - 1) { val midX (stroke.points[i].x stroke.points[i 1].x) / 2f val midY (stroke.points[i].y stroke.points[i 1].y) / 2f path.quadraticTo( stroke.points[i].x, stroke.points[i].y, midX, midY ) } // 最后一段路径连接到最后一个点 val lastPoint stroke.points.last() path.lineTo(lastPoint.x, lastPoint.y) paint.color stroke.color paint.strokeWidth stroke.baseWidth canvas.drawPath(path, paint) } }这里有一个值得注意的点quadraticTo的写法比简单的lineTo在视觉上要平滑得多。原理是让相邻点之间的线段变成以中点为核心的曲线从而避免角点。6. 笔锋模拟与撤销重做6.1 速度笔锋模拟早期版本我直接按照触摸点等宽绘制效果很生硬。真实笔迹在快速运笔时笔画会变细在起笔收笔和拐弯处笔画会变粗。因此需要根据点间距和速度动态调整每个点位置上的笔画宽度。一种常见的近似方案是“速度模拟压力”速度越快宽度越小速度越慢宽度越大。但这个计算不能只对单个点生效因为会把整个笔迹画成锯齿状。更合理的做法是将笔迹分段每段使用不同的宽度并在段间做宽度渐变。下面用一个简化的示例说明核心逻辑// 文件路径app/src/main/java/com/example/inknote/drawing/brush/BrushStroke.kt package com.example.inknote.drawing.brush import com.example.inknote.data.model.Stroke import com.example.inknote.data.model.StrokePoint import kotlin.math.abs import kotlin.math.max import kotlin.math.min object BrushStroke { /** * 根据速度调整笔迹宽度。 * 把点列表转换成带宽度的绘制点列表。 * * 简化的速度公式两点距离 / 时间差。 * 为了稳定性对速度设置上下限。 */ fun computeWidths(stroke: Stroke): ListFloat { val points stroke.points if (points.size 2) { return listOf(stroke.baseWidth) } val result mutableListOfFloat() val minWidth stroke.baseWidth * 0.4f val maxWidth stroke.baseWidth * 1.8f for (i in points.indices) { val width when { i 0 - { val speed speedBetween(points[0], points[1]) widthForSpeed(speed, minWidth, maxWidth, stroke.baseWidth) } i points.lastIndex - { val speed speedBetween(points[i - 1], points[i]) widthForSpeed(speed, minWidth, maxWidth, stroke.baseWidth) } else - { val speed1 speedBetween(points[i - 1], points[i]) val speed2 speedBetween(points[i], points[i 1]) val avgSpeed (speed1 speed2) / 2f widthForSpeed(avgSpeed, minWidth, maxWidth, stroke.baseWidth) } } result.add(width) } return result } private fun speedBetween(a: StrokePoint, b: StrokePoint): Float { val dx b.x - a.x val dy b.y - a.y val distance abs(dx) abs(dy) // 曼哈顿距离性能更好 val dt max(1L, b.timestampMs - a.timestampMs) return distance / dt.toFloat() } private fun widthForSpeed(speed: Float, minWidth: Float, maxWidth: Float, baseWidth: Float): Float { // 速度单位是 px/ms。需要根据实际画布尺寸调整系数 val normalizedSpeed (speed * 1000f).coerceIn(0.5f, 20f) val ratio 1f - normalizedSpeed / 20f return (minWidth (maxWidth - minWidth) * ratio).coerceIn(minWidth, maxWidth) } }实际使用时还要把“宽度值”应用到渲染路径上。这里介绍一种性能较好的处理策略按宽度变化点把一条笔画拆成多段每段用不同的Paint.strokeWidth绘制。如果每一点都改宽度频繁切换 Paint 状态会严重拖慢重绘帧率。6.2 撤销重做撤销重做模块的操作对象是笔画。常见实现是把Stroke压入一个 Undo 栈和一个 Redo 栈// 文件路径app/src/main/java/com/example/inknote/edit/UndoManager.kt package com.example.inknote.edit import com.example.inknote.data.model.NoteData import com.example.inknote.data.model.Stroke /** * 基于双栈实现的撤销重做管理器。 * 目前以 Stroke 为最小操作单元适合首版需求。 */ class UndoManager { private val undoStack ArrayDequeStroke() private val redoStack ArrayDequeStroke() fun push(stroke: Stroke) { undoStack.addLast(stroke) // 每次新操作后清空重做栈 redoStack.clear() } fun undo(noteData: NoteData, onChanged: () - Unit) { val stroke undoStack.removeLastOrNull() ?: return // 从笔记数据中移除并入重做栈 noteData.strokes.removeAll { it.id stroke.id } redoStack.addLast(stroke) onChanged() } fun redo(noteData: NoteData, onChanged: () - Unit) { val stroke redoStack.removeLastOrNull() ?: return noteData.strokes.add(stroke) undoStack.addLast(stroke) onChanged() } }使用ArrayDeque可以避免ArrayList最前面插入删除的性能损耗。后续如果想要支持“选中删除”“整体移动”等功能可以把操作单元从Stroke升级为NoteCommand接口。6.3 渲染性能优化手写笔记最容易出现的问题就是笔画多了以后每次画一页都重新遍历全部笔画然后重新drawPath导致笔迹越写越卡。优化方向有两个分层绘制将已经完成的笔画渲染到一个独立的Bitmap上新笔迹只画在临时层。每次移动只需要重绘临时层叠加已经缓存好的底层位图。脏矩形刷新精确计算笔画变化的区域调用invalidate(dirtyRect)而不是invalidate()。分层绘制的核心思路如下// 示意代码缓存已完成笔画 private var cacheBitmap: Bitmap? null private var cacheCanvas: Canvas? null private fun ensureCache() { if (cacheBitmap null) { cacheBitmap Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) cacheCanvas Canvas(cacheBitmap!!) } cacheCanvas?.drawColor(0xFFFFFFFF.toInt()) noteData.strokes.forEach { stroke - StrokeRenderer.drawStroke(cacheCanvas!!, stroke, strokePaint) } } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) // 先绘制缓存层 cacheBitmap?.let { canvas.drawBitmap(it, 0f, 0f, null) } // 再绘制当前笔画 drawCurrentStroke(canvas) }不过要注意一旦启用了分层绘制撤销、擦除、移动笔迹等操作都需要重新生成缓存位图否则会出现旧笔迹残留。更好的做法是在onDraw外层加一个“失效标记”标记需要重建缓存时才重建后续可以进一步用BitmapShader做纹理叠加。7. 完整实战构建一个可运行的 Android 手写画布7.1 创建项目并配置依赖在 Android Studio 中新建一个空 Activity 项目包名设为com.example.inknote。首版不需要额外第三方库核心功能依赖 Android SDK。如果你希望 JSON 解析更优雅可以添加kotlinx.serialization但不是必须。build.gradle.kts关键配置示例// 文件路径app/build.gradle.kts plugins { id(com.android.application) id(org.jetbrains.kotlin.android) } android { namespace com.example.inknote compileSdk 34 defaultConfig { applicationId com.example.inknote minSdk 26 targetSdk 34 versionCode 1 versionName 1.0 } 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 版本对应的 AGP 版本不同。建议以你本机创建项目时生成的默认版本为准。7.2 编写布局布局文件里放入InkCanvasView和操作按钮!-- 文件路径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.inknote.drawing.InkCanvasView android:idid/inkCanvas 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_widthwrap_content android:layout_heightwrap_content android:text撤销 / Button android:idid/btnRedo android:layout_widthwrap_content android:layout_heightwrap_content android:text重做 / Button android:idid/btnSave android:layout_widthwrap_content android:layout_heightwrap_content android:text保存 / Button android:idid/btnExportPdf android:layout_widthwrap_content android:layout_heightwrap_content android:text导出PDF / /LinearLayout /LinearLayout7.3 编写 MainActivityMainActivity负责把画布、按钮和数据存储串起来// 文件路径app/src/main/java/com/example/inknote/MainActivity.kt package com.example.inknote import android.graphics.pdf.PdfDocument import android.os.Bundle import android.os.Environment import android.widget.Button import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import com.example.inknote.data.serializer.NoteSerializer import com.example.inknote.drawing.InkCanvasView import com.example.inknote.export.PdfExporter import java.io.File class MainActivity : AppCompatActivity() { private lateinit var inkCanvas: InkCanvasView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) inkCanvas findViewById(R.id.inkCanvas) findViewByIdButton(R.id.btnUndo).setOnClickListener { inkCanvas.undo() } findViewByIdButton(R.id.btnRedo).setOnClickListener { inkCanvas.redo() } findViewByIdButton(R.id.btnSave).setOnClickListener { saveNote() } findViewByIdButton(R.id.btnExportPdf).setOnClickListener { exportPdf() } } private fun saveNote() { val json NoteSerializer.toJson(inkCanvas.noteData) val file File(filesDir, my_note.json) file.writeText(json) Toast.makeText(this, 已保存到 ${file.absolutePath}, Toast.LENGTH_SHORT).show() } private fun exportPdf() { val file File(filesDir, my_note.pdf) val success PdfExporter.export(inkCanvas.noteData, file) if (success) { Toast.makeText(this, PDF 已导出到 ${file.absolutePath}, Toast.LENGTH_SHORT).show() } else { Toast.makeText(this, PDF 导出失败, Toast.LENGTH_SHORT).show() } } }7.4 导出 PDF 工具类PDF 导出最直接的方式是PdfDocument。先创建和笔记同尺寸的 PDF 页面再把笔画绘制到PdfDocument提供的Canvas上。// 文件路径app/src/main/java/com/example/inknote/export/PdfExporter.kt package com.example.inknote.export import android.graphics.Paint import android.graphics.pdf.PdfDocument import com.example.inknote.data.model.NoteData import com.example.inknote.drawing.StrokeRenderer import java.io.File import java.io.FileOutputStream object PdfExporter { fun export(note: NoteData, targetFile: File): Boolean { return try { val pdfDocument PdfDocument() val pageInfo PdfDocument.PageInfo.Builder(note.pageWidth, note.pageHeight, 1).create() val page pdfDocument.startPage(pageInfo) val canvas page.canvas canvas.drawColor(0xFFFFFFFF.toInt()) val paint Paint().apply { isAntiAlias true style Paint.Style.STROKE strokeCap Paint.Cap.ROUND strokeJoin Paint.Join.ROUND } note.strokes.forEach { stroke - StrokeRenderer.drawStroke(canvas, stroke, paint) } pdfDocument.finishPage(page) FileOutputStream(targetFile).use { outputStream - pdfDocument.writeTo(outputStream) } pdfDocument.close() true } catch (e: Exception) { e.printStackTrace() false } } }这里要留意一个 API 细节PdfDocument输出的页面默认没有图片嵌入能力如果你的笔记中包含位图图片需要单独处理。首版只导出笔迹不处理图片。7.5 运行与验证把项目同步到设备或模拟器后你会看到一块白色画布可以用手指书写。点击“撤销”会移除最后一条笔画“重做”会重新添加。点击“保存”会在应用私有目录生成 JSON 文件可以用 Android Studio 的 Device File Explorer 查看或者用 adb 拉取文件分析。预期结果手指书写时笔迹跟手流畅笔画之间是独立对象撤销不会影响其他笔画JSON 文件保存的是结构化数据能直接看到每个点的坐标和压力值PDF 文件可以正常打开版面和画布一致。如果出现笔画锯齿请检查是否开启了Paint.isAntiAlias如果写出笔画延迟明显建议优先增加分层缓存逻辑。8. 常见问题与排查思路8.1 问题排查表问题现象常见原因解决思路触摸无反应自定义 View 没有返回 true 或没有可点击属性检查onTouchEvent是否返回true并设置isClickable true笔画有明显折线感直接连接点没有做平滑处理用中点二次贝塞尔曲线平滑或考虑更复杂的三次曲线插值笔画宽度不变化没有根据压力或速度计算宽度在采样点阶段加入速度或压力换算并在渲染时按段设置宽度撤销后重做栈异常新操作后没有及时清空重做栈在push()新操作时调用redoStack.clear()笔画多后绘制卡顿每帧都遍历全部笔画重绘使用分层缓存位图只重绘增量变化区域保存 JSON 后体积过大采样点过多未做抽稀增加点抽稀策略例如距离超过阈值才记录新点PDF 导出后文字模糊页面尺寸小于画布真实尺寸确保PdfDocument.PageInfo.Builder使用笔画模型中的页面宽高手写笔书写时手掌误触没有做防误触处理结合MotionEvent的工具类型过滤手掌事件8.2 防误触处理真实手写场景中手掌放在屏幕上会导致误触。常见方案有两种一是根据事件工具类型区分。当event.getToolType(pointerIndex) MotionEvent.TOOL_TYPE_STYLUS时说明当前是手写笔这时可以忽略手指事件。二是手掌区域抑制监听ACTION_MOVE中携带的大面积触摸区域当触摸面积异常大时不记录笔迹。// 示意代码过滤手掌触摸 private fun isPalmEvent(event: MotionEvent): Boolean { // 手指触摸时 touchMajor 可能达到几十到上百像素手写笔通常在 3~10px 左右 val touchMajor event.getTouchMajor(0) return touchMajor 30f }这个阈值需要根据设备和屏幕密度调整属于工程调参工作。开源版本中建议做成可配置项。8.3 点抽稀与性能手写笔的采样率通常很高尤其是高刷屏手机每秒可能采样上百个点。如果不做抽稀笔迹文件会迅速膨胀。最简单的抽稀策略是只有在移动距离超过某个阈值时才把新点加入列表。private fun shouldAddPoint(last: StrokePoint, newPoint: StrokePoint, threshold: Float): Boolean { val dx newPoint.x - last.x val dy newPoint.y - last.y return dx * dx dy * dy threshold * threshold }这样做不会明显降低视觉效果但能显著减小数据体积和绘制压力。阈值通常设置为 0.5dp 到 1.5dp 之间。9. 最佳实践与工程建议9.1 数据层与 UI 层分离手写笔记项目最容易出现的架构问题是把所有逻辑都塞进自定义 View。比如直接在onDraw里读文件、在onTouchEvent里写数据库。这样的代码初期运行没问题一旦功能增多View 会膨胀到几千行难以测试和维护。建议强制分层View只负责手势采集和渲染ViewModel或Presenter负责业务逻辑Repository负责文件读写Serializer负责数据格式转换。9.2 使用命令模式管理操作当应用支持选中移动、缩放、删除等多步操作时建议把“撤销”从笔画栈升级为命令栈。每个操作实现一个execute()和undo()这样撤销重做系统就能统一管理所有操作类型。一个简单接口示例如下interface NoteCommand { fun execute(noteData: NoteData) fun undo(noteData: NoteData) } class AddStrokeCommand(private val stroke: Stroke) : NoteCommand { override fun execute(noteData: NoteData) { noteData.strokes.add(stroke) } override fun undo(noteData: NoteData) { noteData.strokes.removeAll { it.id stroke.id } } }9.3 异常处理与日志记录手写数据是用户的核心资产任何保存操作都可能导致数据丢失。因此在文件读写和序列化过程中必须加入异常保护。重要原则包括保存时先写临时文件成功后原子替换旧文件每次保存前备份上一版本关键路径打日志便于线上排查PDF 和图片导出失败时提示用户但不要阻塞主流程。9.4 安全与权限手写笔记通常不需要读取外部存储权限除非用户主动导出文件到公共目录。建议首版使用filesDir不申请存储权限。如果后续增加“导出到相册”功能需要根据 Android 版本适配MediaStoreAPI。如果涉及用户上传笔记到云端的场景必须在服务端和传输层都启用加密并明确隐私政策。9.5 开源项目发布建议如果你计划把项目开源建议提前做好下面几件事选择开源许可证例如 MIT、Apache 2.0 或 GPL根据你的诉求确定编写 README说明项目定位、功能截图、编译方式和已知限制建立docs/目录记录笔迹 JSON 格式定义方便其他开发者接入不要提交签名文件、密钥、本地 SDK 路径等敏感信息对于使用第三方字体、图片素材要注意许可证合规。现在不少开发者会在 GitHub 或 Gitee 上搜索“手写笔记”“开源笔记”等关键词寻找替代方案。如果项目文档清晰、代码结构规范会更容易获得 Star 和贡献者。10. 总结与后续规划这篇实战文章从手写笔记 APP 的需求出发完整梳理了自定义画布、笔迹模型、笔锋模拟、撤销重做、JSON 序列化、PDF 导出等核心模块的实现思路。文章里的代码不是完整可上架的商业应用而是能运行、能扩展的基础骨架你可以基于它继续改造。下一步可以做的方向包括增加笔记本管理能力支持多页笔记、封面和标签引入SurfaceView或TextureView配合渲染线程处理大批量笔迹研究更真实的笔刷渲染例如贴图笔刷、纹理叠加支持导入 PDF 并在上面批注增加文字输入工具把手写笔记和键盘输入混合排版如果要做跨平台版本可以把笔迹数据格式和同步协议设计好再编写 Flutter 或 Compose Multiplatform 客户端。如果你也遇到过手写笔记工具“差一点就顺手”的情况不妨直接动手实现一个最小版本。代码不一定一开始就完美但用自己的数据格式管理自己的笔记这种自由度和掌控感是现成应用很难给的。希望这篇文章能成为你动手实践的起点。最后提醒一句不要在生产环境直接使用这份示例代码管理重要笔记建议先做数据备份和格式验证再逐步完善异常处理与稳定性。如果文中的某个模块对你写绘图类应用有启发欢迎收藏备用后面遇到性能或架构问题时随时回来看相关章节。