
简介这是一份面向中级Android开发者的思维导图实现完整工程核心利用递归算法与堆栈数据结构解决树形节点绘制、回溯及清除难题。项目中包含自定义View重写onDraw、onTouchEvent处理交互、树/图结构设计、布局与动画优化等关键知识点适合学习复杂自定义控件与算法落地实践。资源包共43个文件以Java源码、XML布局与配置、PNG/JPG图片素材及Gradle构建脚本为主压缩包仅279KB轻量易用。源码结构清晰包含app模块、gradle配置、README说明等可直接导入Android Studio运行调试。目前已有257人学习对想掌握递归绘图、堆栈回溯及Android绘图机制的同学而言是一份很实用的参考工程。 在 Android 里做思维导图绕不开的核心问题就两个树要怎么遍历节点要怎么画。我在项目里先后试了几种方案最后定下来用递归算法解决节点绘制时的深度优先遍历用堆栈解决节点清除时的批量回收和状态回溯整套逻辑跑下来很稳。这篇就记录一下我是怎么设计数据模型、怎么计算坐标、怎么处理绘制和清除的希望对想自己撸一个思维导图、又不想引大框架的人有点帮助。先说清楚这个项目能解决的问题输入任意层次的思维导图数据它能把每个节点算好坐标画成带连线的树状图你折叠某个分支或者删除某个节点时能快速把对应的绘制内容和内存引用一并清掉。适合正在学自定义 View、想了解树形结构算法在 Android 里怎么落地的同学参考也适合已经做过类似东西、想对比一下设计思路的人。1. 项目定位思维导图在 Android 端的核心难点1.1 思维导图本质上是一棵不规则树一个思维导图不管界面做得多炫数据模型一定是树每个节点有且仅有一个父节点根节点除外下面挂着若干子节点。这一点决定了处理它的算法一定围绕“树遍历”展开。树的遍历无非两种思路递归或者显式地用一个堆栈来模拟递归。递归的好处是代码和人脑的思考方式一致拿到根节点处理它再对每个子节点调用同样的方法。这个方式在处理绘制时特别好用因为绘制本身就是先处理父节点、再从左往右处理子节点的顺序深度优先遍历天然契合。坏处呢就是怕树太深。Java/Kotlin 的调用栈是有限的递归层数太多直接抛 StackOverflowError这个问题我在写节点清除的时候真的踩到过。1.2 为什么绘制用递归、清除用堆栈这是整个项目设计中我认为最有价值的一个取舍。绘制阶段关键要拿到每个节点的坐标。坐标计算依赖整体布局一个节点下面所有子树占据的空间必须递归地去统计子节点的高度然后回溯累加。这种“统计完子树再往回带结果”的模型用递归写最简单几乎没有歧义。你硬要用自定义栈去模拟代码会一次性增加几十行而且出错概率高不划算。清除阶段就不一样了。清除一个节点意味着要把它下面的整棵子树都处理掉。这里有两个需求一是先清理叶子再清理父节点防止子节点还在被引用二是操作量大要尽量少的函数调用开销。如果我用递归去做后序遍历代码是干净但每一层调用都会给 JVM 增加一个栈帧。当树足够深、节点足够多时在低端 Android 机器上很容易爆栈。所以我在清除这块改成了显式堆栈遍历把待处理的节点压栈循环里弹出处理再把子节点压进去。同一个逻辑安全性和可控性比递归好很多。2. 核心细节数据模型与坐标计算的递归实现2.1 Node 数据结构怎么设计先看基础的数据结构。我用 Kotlin 写的核心字段如下class MindNode( val id: Int, var content: String , var parent: MindNode? null ) { val children mutableListOfMindNode() var x: Float 0f var y: Float 0f var width: Float 120f var height: Float 48f var subtreeHeight: Float 0f var isExpanded: Boolean true }解释一下为什么要在 Node 里存这些布局信息而不是单独用一个 Layout 类。因为思维导图在交互过程中节点本身经常会被修改新增子节点、展开折叠把布局信息直接挂在节点上修改后重新计算坐标很简单不需要额外维护一套映射表。x、y 存的是节点左上角的坐标subtreeHeight 是这个节点包括所有子节点在内占用的垂直高度。width 和 height 是节点的宽高后面绘制矩形和连线都要用。这里要提醒一句content 尽量不要直接存长文本思维导图每个节点一般就是几个字到十几个字如果塞一大段长文本进去绘制时的换行逻辑和节点宽度计算会很麻烦性能也扛不住。如果需要展示详情单独做一个详情页或者弹层比在树节点里硬塞要合适得多。2.2 两遍递归完成节点布局坐标计算我采用了经典的“两层递归”策略这也是整个绘制算法里最核心的部分。第一遍递归算每个节点的 subtreeHeightfun calcSubtreeHeight(node: MindNode): Float { if (!node.isExpanded || node.children.isEmpty()) { node.subtreeHeight node.height return node.subtreeHeight } node.subtreeHeight node.children.sumOf { calcSubtreeHeight(it) } return node.subtreeHeight }这段代码的意思很好理解如果一个节点是叶子节点或者说它被折叠了不展示子节点那么它占的垂直高度就是它自身的高度否则它占的高度就是所有子节点占的高度之和。第二遍递归根据 subtreeHeight 推算出每个节点的坐标fun layoutNode(node: MindNode, depth: Int, offsetY: Float, layerWidth: Float) { node.x startX depth * layerWidth node.y offsetY - node.subtreeHeight / 2f var childOffsetY offsetY node.children.forEach { child - layoutNode(child, depth 1, childOffsetY, layerWidth) childOffsetY child.subtreeHeight } }核心逻辑是一个父节点要垂直居中地放在它所有子节点的正中间所以它的 y 坐标是“当前这块区域顶部的 offsetY 减去子树高度的一半”。子节点则从上往下依次摆放每摆一个childOffsetY 就累加这个子树的实际高度。这样一路递归下去整个树的布局不会出现重叠。我在第一个版本里经常出现兄弟节点重叠就是因为把父节点直接放到了第一个子节点的上方没有用中间对齐的算法。需要注意depth 每层加一用它乘以 layerWidth 就能得到 x 坐标。layerWidth 建议不小于 200px不然文字稍微长一点上下层的节点就会挨得太近观感很差。3. 实操过程节点绘制与清除的完整实现3.1 在 onDraw 里递归绘制节点和连线坐标算好了绘制就简单很多。我自定义了一个 MindMapView继承自 View在 onDraw 里调用递归方法把整棵树画出来override fun onDraw(canvas: Canvas) { super.onDraw(canvas) root?.let { drawNode(canvas, it) } } private fun drawNode(canvas: Canvas, node: MindNode) { // 1. 画这个节点自己 canvas.drawRoundRect( node.x, node.y, node.x node.width, node.y node.height, 8f, 8f, nodePaint ) canvas.drawText(node.content, node.x 8f, node.y node.height / 2f - 8f, textPaint) // 2. 画到子节点的连线 node.children.forEach { child - canvas.drawLine( node.x node.width, node.y node.height / 2f, child.x, child.y child.height / 2f, linePaint ) drawNode(canvas, child) } }画的时候有两个性能点值得一说。第一nodePaint、textPaint、linePaint 这些对象要在初始化的时候建好千万别在 onDraw 里 new否则每帧绘制都会产生大量临时对象触发 GC 掉帧。第二如果节点数量很大比如超过几百个建议判断一下节点是否在可视区域里面Canvas 本身虽然做了裁剪但这种 Node 级别的裁剪能提前避免大量的 drawRoundRect 和 drawText 调用实测对滚动和缩放的流畅度提升很明显。3.2 用递归加显式堆栈完成节点清除清除分两种场景删除单个节点和折叠某个节点后批量移除子树。这两个我都会讲。先看删除单个节点。删除节点时要把它从父节点的 children 列表里移除同时还要清掉这个节点下面整棵子树的所有关联用递归做后序遍历最合适fun removeNode(target: MindNode) { target.parent?.let { parent - parent.children.remove(target) } clearSubTree(target) invalidate() } private fun clearSubTree(node: MindNode) { node.children.forEach { child - clearSubTree(child) } node.children.clear() node.parent null }这种写法的核心在于“后序”先递归处理子节点最后再来清理父节点自己。这样能保证一个节点被置空 parent 的时候它的子节点都已经被处理过了不会出现子节点还引用着父节点而父节点已经被回收的隐患。如果担心树特别深或者节点特别多递归清理可能爆栈我还有一个用显式堆栈实现的版本。这个版本在折叠大批量节点时我会优先使用private fun clearSubTreeByStack(rootNode: MindNode) { val stack ArrayDequeMindNode() stack.push(rootNode) while (stack.isNotEmpty()) { val node stack.pop() node.children.forEach { child - stack.push(child) } node.children.clear() node.parent null } }这个写法看着和递归很像但本质上区别很大递归靠 JVM 调用栈来记录“接下来要处理谁”这个版本靠我们自己维护的 ArrayDeque。树的深度再大这个循环也不会有栈溢出的风险因为它的栈大小只受内存限制。注意这里我用的是 ArrayDeque 而不是 java.util.Stack。ArrayDeque 在 Android 上的遍历和 push/pop 性能都比 Stack 好Stack 的线程同步在单线程 UI 操作里属于纯浪费。3.3 绘制与清除如何正确联动在 View 体系里清除数据和界面重绘是两件事。你 clear 了节点以后必须调用 invalidate() 告诉系统重绘否则用户会在屏幕上看到“幽灵节点”也就是数据已经被删了但画面还残留。更进一步如果思维导图比较大有些节点明明没被影响却整棵树重新布局、重新绘制就很浪费。我在项目里的做法是只有当结构变化增删节点、修改父节点时才重新走两遍递归去做全量布局如果只是折叠或者展开那么只需要针对被操作的节点局部重排。操作完成后尽量用 invalidate() 而不是 requestLayout()。提示invalidate() 只触发 onDraw不触发 onMeasure适合内容没变位置变了的情况requestLayout() 会触发整个 measure / layout / draw 流程成本比前者高很多。思维导图这种对流畅度要求高的自定义 View能省一次 layout 就省一次。4. 常见问题与排查实战4.1 递归太深真的会崩处理方案要提前想好这是我最想提醒的一件事。我在开发初期清除节点用的纯粹是递归测试几十个节点的时候完全没问题。后来拿了一个演示数据树深度压到了 80 多层App 直接崩了Logcat 里清清楚楚一个 StackOverflowError。排查思路其实不复杂先确认是哪个操作触发的崩溃然后看栈顶帧数如果符号堆叠 50 层以上基本可以断定是递归调用太深。解决问题不是把递归整个去掉而是选择性使用普通树的绘制和布局递归没有任何问题但要处理那些可能被打得很深的批量清除操作就换成显式堆栈。实测下来深度 200 层左右的树显式栈版本顺手就跑完了差距非常明显。4.2 坐标算完偏移了多半是递归里混入了可变状态我遇到过一种很隐蔽的 bug第一遍 subtreeHeight 算出来的值是好的但第二次 layout 出来的坐标总是上下错位。最后定位到原因是因为我在 layoutNode 里使用了一个成员变量 currentY 来累计子节点的 offsetY而不是通过参数传入。递归是深度优先的一个分支递归调用回来后成员变量 currentY 已经被子节点改掉了父节点再拿它去算下一个子节点的位置自然就乱了。这个问题的标准解法只有一个递归方法里不要依赖成员变量来保存“当前进度”所有中间状态都通过参数传递计算完立刻返回结果。画树跟写业务逻辑不一样业务逻辑里我们习惯用字段存状态但递归遍历时字段是全局可见的一个分支的修改会污染另一个分支。4.3 折叠后再展开节点跑位了怎么办折叠一个节点后它的子节点区域会被整个清除掉。问题出在布局计算折叠操作让父节点的 subtreeHeight 变了如果你不清空它的子树数据展开回来时 layoutNode 会把原来那批子节点再次计算坐标但它们的 parent 状态在清除时被我置空了于是坐标全乱。我是这样处理的折叠和展开虽然是两个动作但底层统一走“重新布局”的入口即先 calcSubtreeHeight再 layoutNode最后 invalidate。同时折叠的时候只做绘制层面的隐藏不真正删掉 children 数据只有删除节点这个动作才真正走 clearSubTree 把关联清掉。把“折叠”和“删除”两个语义分开bug 的数量一下子少了很多。4.4 屏幕上的残影和闪烁怎么消除还有一个小问题画完清除后屏幕上偶尔会有残影或者快速操作时画面闪烁。这个通常不是算法问题而是 Canvas 绘制时没有把上一次的内容盖掉。自定义 View 的 onDraw 会把上一次画的内容当底来叠加如果你只画了新内容而背景没刷叠在一起就会产生残影。我的经验是onDraw 开头先用背景色 drawColor 清一遍画布再开始画节点树canvas.drawColor(backgroundColor)如果还觉得闪烁检查一下是否开启了硬件加速通常硬件加速的情况下这种闪屏会好很多。同时背景色和节点颜色的对比度也很重要别用带透明度的颜色做底色透明度越低越容易出现这种叠加问题。最后再说一点我做这个项目的实际体会。递归和堆栈不是互斥的两种方案它们本质上是同一个遍历逻辑的两种表达方式递归强调直观堆栈强调可控。一个可靠的思维导图绘制框架往往需要在同一个项目里把它们组合着用绘制路径清晰、深度可控的部分放心交给递归批量清除、深度不可控的部分果断换成显式堆栈。这样写出来的代码不仅不容易崩后面再加手势缩放、拖拽平移、节点搜索这些功能的时候也不会被初始的设计卡住手脚。本文还有配套的精品资源点击获取