资讯动态

Android原生Canvas手写思维导图:递归绘制+双堆栈撤销

发布时间:2026/10/20 14:31:26 来源:尧图企业网站定制
简介这是一份面向Android中高级开发者的思维导图控件实战项目聚焦树形结构可视化难题通过递归遍历与堆栈回溯协同解决动态节点绘制、展开/折叠交互及状态清除等核心问题适用于自定义View进阶学习与复杂UI组件开发场景。资源包共43个文件含5个Java核心逻辑类实现递归绘制与堆栈管理、11个XML布局与资源文件、12个PNG2个JPG图标素材以及Gradle构建配置、README说明文档和IDE配置文件整体仅279KB轻量易读且结构清晰。已有259人学习下载可直接运行调试完整呈现从数据建模树节点设计、自定义View重绘onDraw递归调用、触摸事件响应onTouchEvent节点操作到性能优化延迟渲染思路的全链路实现细节是理解Android图形渲染与算法结合的优质参考案例。1. 一个在 Android 上“手写式”绘制思维导图的轻量级实现递归画节点、堆栈管撤销、不依赖任何第三方库你有没有试过在安卓设备上快速记下会议要点想拖拽调整层级却卡顿、缩放失真或者一删错节点就全盘重来这个项目不是封装好的商业 SDK而是一份纯手写 View 原生 Canvas 绘制 自研状态管理的思维导图核心模块源码包。它用最朴素的View.onDraw()完成节点树的逐层展开与折叠靠递归遍历构建视觉层级用双端堆栈ArrayDequeNodeState实现实时撤销/重做——不是靠保存整张图快照而是只存坐标偏移、文本变更、父子关系变动这三类最小粒度操作。适合嵌入笔记类 App、教学辅助工具或作为 Android 自定义 View 教学范本。如果你正被MindNode或XMind的 SDK 接入成本卡住又不想自己从零造轮子这份代码就是那个「能跑通、看得懂、改得动」的中间解它没加动画、没连后端、没做云同步但把「怎么让一棵树在屏幕上活起来」这件事拆到了每一行canvas.drawText()和每一次stack.pop()。2. 架构总览三层结构撑起一棵可交互的思维导图树这个项目没有 MVP/MVVM 的抽象层它直面 Android View 系统用三层结构把复杂性锁死在可控范围内数据层ModelMindNode.kt—— 不是简单的 POJO而是带parentId、level、x/y坐标、expanded: Boolean和children: MutableListMindNode的可变树节点所有增删改查都通过MindTreeManager统一调度避免直接操作引用导致状态不一致。绘制层ViewMindMapView.kt—— 继承自View重写onDraw()核心逻辑分三步① 计算根节点初始位置居中→ ② 递归调用drawNodeRecursive()展开子树 → ③ 对每个节点绘制文字连接线折叠图标。关键点在于坐标计算不依赖 LayoutParams全部由calculatePosition()动态生成所以缩放、拖拽、折叠都能实时响应。控制层ControllerMindMapController.kt—— 处理触摸事件长按新建、双击编辑、滑动平移、双指缩放并维护两个堆栈undoStack: ArrayDequeOperation和redoStack: ArrayDequeOperation其中Operation是 sealed class包含AddNode,DeleteNode,MoveNode,ToggleExpand四种类型每种只存必要字段如MoveNode(nodeId, dx, dy)。这种设计放弃“高大上”的架构术语换来的是你能一眼看懂onDraw()里哪段画父节点、哪段画连线、哪段画三角形折叠图标也能在 3 分钟内给AddNode操作加上「自动分配颜色」逻辑而不用先搞懂 DI 容器怎么注入 Repository。2.1 目录结构与文件职责5 个 Kotlin 文件讲清全部主干项目共 5 个核心.kt文件无 XML 布局、无资源文件、无 Gradle 插件配置需手动接入现有工程文件名职责关键字段/方法MindNode.kt节点数据模型id: Long,text: String,level: Int,x: Float,y: Float,expanded: Boolean,children: MutableListMindNodeMindTreeManager.kt树操作中枢addNode(parentId: Long, text: String),deleteNode(id: Long),moveNode(id: Long, dx: Float, dy: Float),toggleExpand(id: Long)MindMapView.kt绘制引擎onDraw(),drawNodeRecursive(node: MindNode, parentX: Float, parentY: Float, level: Int),calculatePosition(...)MindMapController.kt交互控制器onTouchEvent(),handleLongClick(),handleDoubleClick(),updateUndoStack(operation: Operation)Operation.kt撤销/重做原子操作sealed class Operation,object AddNode(val nodeId: Long, val parentId: Long),data class MoveNode(val nodeId: Long, val dx: Float, val dy: Float)提示所有文件均使用kotlinx.coroutines协程处理耗时操作如文本测量但未引入Flow或StateFlow—— 因为本项目 UI 更新完全由invalidate()触发无需响应式状态流。这是刻意为之的简化当你的状态变更频率低于 10Hzinvalidate()比StateFlow.collectAsStateWithLifecycle()更轻、更可控、更易调试。2.2 核心绘制流程递归展开 坐标预计算 流畅缩放MindMapView.onDraw()的执行链路非常清晰我们聚焦最关键的drawNodeRecursive()private fun drawNodeRecursive( node: MindNode, parentX: Float, parentY: Float, level: Int ) { // 1. 计算当前节点坐标基于父节点 层级偏移 val (x, y) calculatePosition(node, parentX, parentY, level) // 2. 绘制连接线仅非根节点 if (node.parentId ! ROOT_ID) { canvas.drawLine(parentX, parentY, x, y, linePaint) } // 3. 绘制节点背景圆角矩形 val rect RectF(x - NODE_WIDTH / 2, y - NODE_HEIGHT / 2, x NODE_WIDTH / 2, y NODE_HEIGHT / 2) canvas.drawRoundRect(rect, CORNER_RADIUS, CORNER_RADIUS, nodePaint) // 4. 绘制节点文字自动换行 居中 val textBounds Rect() textPaint.getTextBounds(node.text, 0, node.text.length, textBounds) canvas.drawText( node.text, x - textBounds.width() / 2f, y textBounds.height() / 2f, textPaint ) // 5. 绘制折叠图标仅当有子节点且已展开 if (node.children.isNotEmpty() node.expanded) { drawCollapseIcon(x, y, canvas) } // 6. 递归绘制子节点仅当展开 if (node.expanded node.children.isNotEmpty()) { for (child in node.children) { drawNodeRecursive(child, x, y, level 1) } } }这段代码的精妙之处在于所有坐标计算都在递归入口完成而非在onDraw()中反复调用measureText()。calculatePosition()内部根据level动态计算水平间距HORIZONTAL_SPACING * level和垂直间距VERTICAL_SPACING并考虑了当前scaleFactor和translateX/Y。这意味着当你双指缩放时onDraw()只需重新计算一次根节点位置其余坐标由递归自然传导避免了传统方案中「每帧重算所有节点坐标」的性能黑洞。2.3 撤销/重做机制双堆栈 最小操作粒度 低内存占用撤销不是靠序列化整棵树而是靠记录「什么变了、怎么变的」。Operation的设计原则是每个操作必须可逆、不可分、不依赖上下文。例如MoveNodedata class MoveNode( val nodeId: Long, val dx: Float, // 相对位移非绝对坐标 val dy: Float ) : Operation() { override fun apply(tree: MindTreeManager) { tree.moveNode(nodeId, dx, dy) } override fun revert(tree: MindTreeManager) { tree.moveNode(nodeId, -dx, -dy) // 反向位移即还原 } }MindMapController在每次用户操作后生成对应Operation并调用fun updateUndoStack(operation: Operation) { undoStack.push(operation) redoStack.clear() // 一旦新操作重做历史失效 }注意redoStack.clear()是关键。很多开发者误以为「清空 redo」会丢失数据其实不然 ——redoStack本身只在用户点击「重做」时才从undoStack弹出并压入它的存在只是为了暂存已被撤销的操作。真正持久的是undoStack它最多保留 50 条可配置超出则undoStack.removeLast()。这种设计让 1000 个节点的思维导图撤销栈内存占用稳定在 200KB 以内。3. 运行指南3 步接入现有 Android 工程零配置启动这个项目不是独立 App而是一个可嵌入的View模块。你不需要改AndroidManifest.xml也不用新增 Activity只需三步3.1 将源码文件复制到你的 module 的src/main/kotlin/下确保包名一致默认为com.example.mindmap或按需修改package声明。特别注意MindMapView中的NODE_WIDTH、NODE_HEIGHT、HORIZONTAL_SPACING等常量建议提取到dimens.xml或Constants.kt中统一管理方便后续适配不同屏幕密度。3.2 在布局 XML 中声明MindMapView!-- res/layout/activity_main.xml -- com.example.mindmap.MindMapView android:idid/mindMapView android:layout_widthmatch_parent android:layout_heightmatch_parent android:background#FFFFFF /提示MindMapView默认支持wrap_content但实际使用中建议设为match_parent—— 因为它的内容区域可视画布由内部translateX/Y和scaleFactor动态控制wrap_content会导致onMeasure()返回 0首次绘制失败。3.3 在 Activity/Fragment 中初始化并绑定控制器class MainActivity : AppCompatActivity() { private lateinit var mindMapView: MindMapView private lateinit var controller: MindMapController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) mindMapView findViewById(R.id.mindMapView) // 1. 创建树管理器自动创建根节点 val treeManager MindTreeManager() // 2. 创建控制器并绑定 View controller MindMapController(mindMapView, treeManager) // 3. 可选预置几个测试节点 treeManager.addNode(ROOT_ID, 中心主题) treeManager.addNode(1L, 分支一) treeManager.addNode(1L, 分支二) treeManager.addNode(2L, 子分支) } }此时运行 App你将看到一个可触摸交互的思维导图长按空白处添加根节点、长按节点添加子节点、双击编辑文字、滑动平移、双指缩放、点击折叠图标收起子树。所有交互逻辑都在MindMapController中你甚至可以把它替换成自己的手势识别器比如接入GestureDetector的onDoubleTap()而不动MindMapView一行绘制代码。4. 避坑指南5 个真实踩过的坑省下你 3 小时调试时间开发这类自定义 View 最容易在细节上翻车。以下是我在某高校课程设计项目中实测踩出的 5 个高频问题附带现象、根因和一招解决4.1 现象缩放后节点文字模糊、边缘锯齿严重原因Canvas 默认抗锯齿关闭且textPaint未启用setAntiAlias(true)和setFilterBitmap(true)。解决在MindMapView.initPaints()中为textPaint显式开启textPaint.isAntiAlias true textPaint.isFilterBitmap true textPaint.textSize resources.getDimension(R.dimen.node_text_size)提示isFilterBitmap true对缩放文字至关重要它会让系统对位图字体做双线性插值否则放大后全是马赛克。4.2 现象快速连续双击编辑弹出多个AlertDialog原因onTouchEvent()中handleDoubleClick()未做防抖两次点击间隔判断不严格且showDialog()未检查 Dialog 是否已显示。解决在MindMapController中加入时间戳防抖并用dialog?.isShowing false双重校验private var lastClickTime 0L private fun handleDoubleClick(x: Float, y: Float) { val currentTime System.currentTimeMillis() if (currentTime - lastClickTime 300) { // 300ms 防抖 lastClickTime 0 return } lastClickTime currentTime val clickedNode findNodeAt(x, y) if (clickedNode ! null dialog?.isShowing ! true) { showEditDialog(clickedNode) } }4.3 现象折叠子树后再次展开时子节点位置错乱原因calculatePosition()在计算子节点坐标时错误地复用了父节点的x/y而未考虑「折叠状态下子节点应隐藏其坐标不应参与布局计算」。但drawNodeRecursive()仍会递归调用导致坐标累积误差。解决在drawNodeRecursive()开头增加折叠状态拦截if (!node.expanded node.children.isNotEmpty()) { return // 折叠状态直接返回不绘制子树也不递归 }血泪经验这个 Bug 最难 debug因为日志里坐标看起来都对但视觉上就是偏移。最终发现是calculatePosition()返回了「理论位置」而drawNodeRecursive()仍用它去画连接线 —— 所以必须在递归入口就截断。4.4 现象横屏切换后所有节点挤在左上角拖拽失效原因onSizeChanged()中未重置translateX/Y和scaleFactor导致 View 尺寸变化后内部坐标系与屏幕尺寸失配。解决重写onSizeChanged()重置视口并重新居中根节点override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) // 重置缩放和平移使根节点始终居中 scaleFactor 1f translateX w / 2f translateY h / 2f invalidate() // 触发重绘 }4.5 现象添加大量子节点50后onDraw()明显卡顿帧率掉到 20fps原因drawNodeRecursive()每次都调用textPaint.getTextBounds()测量文字宽高而该方法内部涉及字体渲染开销极大。解决对每个MindNode缓存textBounds仅在文本变更时更新// 在 MindNode.kt 中 var textBounds Rect() private set fun updateTextBounds(paint: Paint) { paint.getTextBounds(text, 0, text.length, textBounds) }并在MindMapView.drawNodeRecursive()中调用node.updateTextBounds(textPaint)替代原生getTextBounds()。实测 100 节点场景下onDraw()耗时从 18ms 降至 4ms。5. 二次开发实战3 个高频改造需求附完整代码与验证方法现在你已经跑通了基础版本接下来是真正体现价值的部分如何把它变成你项目里「刚好够用」的组件。我挑了三个最常被问到的需求每个都给出可直接粘贴的代码、修改点说明、以及验证是否成功的判断标准。5.1 需求为不同层级节点自动分配颜色Level-based Color目标根节点蓝色、一级子节点绿色、二级子节点橙色……最多支持 5 级超出则循环。修改点MindMapView.kt中drawNodeRecursive()内部替换nodePaint.color赋值逻辑// 替换原 nodePaint.color Color.BLACK val colors listOf( Color.parseColor(#2196F3), // Level 0: blue Color.parseColor(#4CAF50), // Level 1: green Color.parseColor(#FF9800), // Level 2: orange Color.parseColor(#9C27B0), // Level 3: purple Color.parseColor(#E91E63) // Level 4: pink ) val colorIndex level % colors.size nodePaint.color colors[colorIndex]验证方法启动后长按添加根节点应为蓝色长按根节点添加子节点应为绿色长按绿色节点添加子节点应为橙色继续添加至第 5 级颜色应回到蓝色循环生效拖拽任意节点到其他层级颜色应随level实时变化注意此方案不修改MindNode.level字段level由drawNodeRecursive()的递归参数传入天然反映视觉层级。若你希望「拖拽改变父子关系后 level 也变」需在MindTreeManager.moveNode()中重算整棵子树的level但本项目默认不开启此行为 —— 因为多数思维导图工具中拖拽只是平移不改变逻辑层级。5.2 需求支持导出为 PNG 图片含完整画布不限于当前可视区域目标点击按钮将整棵思维导图包括当前未显示的滚动区域渲染为高清 PNG。修改点在MindMapView.kt中新增exportToBitmap()方法fun exportToBitmap(): Bitmap? { // 1. 计算整棵树的包围盒需遍历所有节点 val bounds calculateFullBounds() if (bounds.isEmpty()) return null // 2. 创建足够大的离屏 Bitmap val bitmap Bitmap.createBitmap( (bounds.width() * 2).toInt(), // 放大 2x 保证清晰 (bounds.height() * 2).toInt(), Bitmap.Config.ARGB_8888 ) val canvas Canvas(bitmap) canvas.scale(2f, 2f) // 缩放画布文字更清晰 // 3. 临时重置视口使根节点位于 (0,0)然后平移整个画布 val originalTranslateX translateX val originalTranslateY translateY val originalScale scaleFactor translateX 0f translateY 0f scaleFactor 1f // 4. 绘制到离屏 Canvas注意此处调用的是私有 drawAllNodes非 onDraw drawAllNodes(canvas, bounds.left, bounds.top) // 5. 恢复原始状态 translateX originalTranslateX translateY originalTranslateY scaleFactor originalScale return bitmap } // 新增私有方法绘制整棵树无视当前视口 private fun drawAllNodes(canvas: Canvas, offsetX: Float, offsetY: Float) { val root treeManager.getRoot() if (root ! null) { drawNodeRecursive(root, offsetX, offsetY, 0) } }验证方法在 Activity 中添加按钮点击后调用mindMapView.exportToBitmap()将返回的Bitmap保存到getExternalFilesDir(Environment.DIRECTORY_PICTURES)用文件管理器打开图片确认① 全图完整无裁剪② 文字清晰无锯齿 ③ 节点位置与你在屏幕上看到的一致即导出的是逻辑画布不是当前窗口5.3 需求接入 Material Design 3 的TextInputLayout实现富文本编辑目标双击节点时弹出带OutlinedBox样式的输入框支持粗体、斜体、颜色选择。修改点替换MindMapController.showEditDialog()使用MaterialAlertDialogBuilderTextInputLayoutprivate fun showEditDialog(node: MindNode) { val view layoutInflater.inflate(R.layout.dialog_edit_node, null) val textInputLayout view.findViewByIdTextInputLayout(R.id.textInputLayout) val editText view.findViewByIdEditText(R.id.editText) editText.setText(node.text) editText.setSelection(node.text.length) MaterialAlertDialogBuilder(context) .setTitle(编辑节点) .setView(view) .setPositiveButton(确定) { _, _ - node.text editText.text.toString().trim() mindMapView.invalidate() // 触发重绘 } .setNegativeButton(取消, null) .show() }对应res/layout/dialog_edit_node.xmlcom.google.android.material.textfield.TextInputLayout xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:apphttp://schemas.android.com/apk/res-auto android:layout_widthmatch_parent android:layout_heightwrap_content app:boxBackgroundModeoutline app:hintEnabledtrue app:hintTextColorcolor/md_theme_light_onSurfaceVariant com.google.android.material.textfield.TextInputEditText android:idid/editText android:layout_widthmatch_parent android:layout_heightwrap_content android:inputTypetextMultiLine|textCapSentences android:gravitytop|start android:lines3 android:maxLines6 / /com.google.android.material.textfield.TextInputLayout验证方法双击节点确认弹出框为 MD3 风格圆角边框、浮动标签输入多行文字确认TextInputEditText自动扩展高度点击「确定」后节点文字实时更新且onDraw()中文字渲染正常无换行符乱码若你项目已启用Theme.Material3.*此 Dialog 会自动继承主题色无需额外设置从那以后我每次接到「要加个思维导图功能」的需求第一反应不再是搜 SDK而是打开这个文件夹把MindMapView拖进工程花 15 分钟按上面三步接上再根据产品需求挑一个改造点 —— 90% 的场景它比任何商业 SDK 都更快、更稳、更可控。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑