简介:这是一份面向Android开发者的思维导图实现源码工程,重点演示如何用递归算法与堆栈数据结构解决节点绘制、遍历与清除问题,适合对自定义View、树形结构和交互绘制感兴趣的初中级开发者。压缩包共43个文件,以Java源码、XML布局、Gradle配置和图片资源为主,其中5个Java文件为核心逻辑,11个XML负责界面与资源配置,另含PNG/JPG预览图及README说明,整体约279KB,结构清晰便于直接导入学习。已有257人学习下载。通过该工程可以掌握自定义View中重写onDraw执行递归绘制、利用堆栈回溯清除节点、触摸事件处理及性能优化等关键思路,获得一套完整可运行的思维导图Demo,便于在此基础上扩展动画效果或节点编辑功能。 接到在Android端实现思维导图的需求时,我第一反应是它比普通列表复杂得多。思维导图的本质是树形数据结构,每个节点都有子节点,且节点绘制时还要考虑父子层级关系、位置计算、缩进折叠等问题。而标题里提到的递归算法和堆栈,恰好是解决"节点绘制"和"节点清除"这两大痛点的关键工具。
这篇文章我会从一个真实项目的角度,把我在Android上实现思维导图时遇到的核心问题、算法选型、以及最终跑通的方案完整记录下来。适合准备上手自定义View做树形结构的开发者参考,也适合对递归和栈这两种数据结构在图形领域的实战应用感兴趣的读者。
1. 为什么递归和堆栈是绘制思维导图的地基
1.1 思维导图的数据本质是"树"而非"列表"
做任何图形化控件之前,第一步永远是先把数据模型想清楚。思维导图从外观上看是中心主题向四周发散分支,分支再发散子分支,这个结构抽象成数据结构就是一棵多叉树:根节点存放中心主题,每个节点可以有任意数量的子节点,叶子节点没有子节点。
中心主题(根节点) ├── 分支A │ ├── 子节点A-1 │ └── 子节点A-2 ├── 分支B │ └── 子节点B-1 └── 分支C树这种结构有一个天然特性:访问某个节点后,必须递归地访问它的所有子节点,才能完整遍历整棵树。这和数组、链表的线性遍历完全不一样。如果你用for循环和下标去遍历一棵树,代码会异常别扭,因为你不知道当前节点有多少个孩子、孩子的孩子又有多少层。而递归恰好匹配这个场景——函数在处理当前节点时,只需要把"处理子节点"这件事交给自身的函数调用去完成,层数再多也能一层层展开。
1.2 绘制节点的核心矛盾:位置依赖和层级交错
真正上手后会发现,绘制思维导图的难点不在于"画出几个圆角矩形和文字",而在于每个节点的位置是在动态计算中确定的。
- 子节点的水平X坐标取决于父节点的X坐标加上偏移量;
- 子节点的垂直Y坐标取决于前面的兄弟节点已经占了多少高度;
- 整棵子树的宽高又取决于子树内部所有节点的累加结果。
这意味着你在绘制前必须先完成一轮"自底向上"的测量:先计算叶子节点占据的空间,再逐级累加给父节点。完成测量后,再开始"自顶向下"的布局:从根节点开始,依次确认每个子节点落在这个画布的哪个位置。
这一测量一布局,恰好就是两轮遍历。第一轮用递归顺手就能算出每棵子树的尺寸;第二轮布局时,依然用递归把坐标逐层下发。如果只用普通的循环变量,每处理一层就要手动维护一个"当前层节点列表",代码的可读性和维护成本会急剧上升。
1.3 堆栈在这个场景里扮演什么角色
递归和堆栈本质上是一对双胞胎。函数递归调用时,系统底层就是靠栈来保存每一层调用的局部变量和返回地址的。但在实际工程中,我会刻意引入显式堆栈(Stack或Deque)来做两件事:
- 用显式栈替代部分递归:当思维导图的层级非常深(比如用户疯狂回车创建了上百层子节点),递归调用过深会逼近Android主线程的调用栈上限,导致StackOverflowError。用显式栈压入节点对象,循环处理,就不会有这个风险。
- 用栈来辅助节点清除:删除一个节点时,除了要移除它本身,还要把它下面挂载的所有子孙节点全部从内存中解引用。栈的后进先出特性,天然适合深度优先的遍历回收。
所以标题里同时提到递归算法和堆栈,不是堆砌名词,而是各司其职:递归负责直观地遍历和布局,堆栈负责处理深度风险和控制清除顺序。
2. 搭建思维导图节点的基础数据结构
2.1 TreeNode类的设计
我采用的是典型的三段式结构:数据层、布局层、绘制层分离。其中数据层的TreeNode是这个项目的基石,它记录节点的业务数据和拓扑关系。
public class MindNode { // 节点ID,用于删除、查找、动画追踪 public String id; // 节点内容 public String content; // 父节点引用,清除和回溯时用 public MindNode parent; // 子节点列表,保持插入顺序 public List<MindNode> children = new ArrayList<>(); // 以下是布局阶段计算出的位置和尺寸缓存 public float x; public float y; public float width; public float height; // 本子树总高度,测量阶段使用 public float subTreeHeight; public MindNode(String id, String content) { this.id = id; this.content = content; } public boolean isLeaf() { return children.isEmpty(); } }这个类看上去简单,其实有几个字段我是一步步补出来的。
parent(父节点引用)是清除阶段的关键。很多新手写的树结构只有child引用没有parent引用,这样删除一个节点时,你只能从根节点重新遍历整棵树,才能找到"谁挂着这个待删除节点",再把它的children列表里的对应项移除。有了parent引用,删除操作就变成了O(1)的操作:node.parent.children.remove(node),效率高得多。
subTreeHeight(子树总高度)是布局算法里的关键缓存。思维导图通常是水平方向展开,父节点在左侧,子节点在右侧。同一层级的多个兄弟节点纵向排列,它们的Y坐标必须考虑"这个节点下面的整棵子树还要占用多少高度"。如果在测量阶段就把每个节点的subTreeHeight算好缓存起来,后续计算Y坐标时就是纯加减法,不需要重复遍历子树。
2.2 准备一份示例数据
为了验证算法,我通常会在代码里写死一份树形数据,先用小数据跑通绘制流程,再接入真实业务数据。
private MindNode buildTestData() { MindNode root = new MindNode("0", "Android 知识体系"); MindNode javaNode = new MindNode("1", "Java 基础"); javaNode.children.add(new MindNode("1-1", "集合框架")); javaNode.children.add(new MindNode("1-2", "并发编程")); MindNode androidNode = new MindNode("2", "Android 进阶"); androidNode.children.add(new MindNode("2-1", "自定义 View")); androidNode.children.add(new MindNode("2-2", "性能优化")); MindNode algoNode = new MindNode("3", "算法与数据结构"); root.children.add(javaNode); root.children.add(androidNode); root.children.add(algoNode); // 为每个子节点设置 parent for (MindNode child : root.children) { child.parent = root; for (MindNode grandChild : child.children) { grandChild.parent = child; } } return root; }注意这段代码里我手动给每个节点补了parent引用,这一步在实际业务中很能体现数据接入的规范性。如果是从接口返回的JSON解析出来的树,一定要在解析时同步维护parent,否则后面删除节点会非常痛苦。
3. 核心绘制算法:用递归计算位置,用栈防止溢出
3.1 整体流程:测量与布局两轮遍历
在Android的自定义View中,绘制一个思维导图通常这样组织:
onMeasure阶段:计算每个节点的尺寸(宽高),这个可以从文字测量得出。measureSubtree阶段:自底向上计算每棵子树的总高度。layoutSubtree阶段:自顶向下计算每个节点的具体坐标。onDraw阶段:画连线、画节点背景、画文字。
其中第2步和第3步是算法核心,都体现了递归的典型应用。
3.2 递归版:measureSubtree与layoutSubtree
先看测量子树的递归方法。这里的思路是:先递归测量所有子节点,累加出children的总高度,然后再设置当前节点的subTreeHeight。
private float measureSubtree(MindNode node) { if (node.isLeaf()) { node.subTreeHeight = node.height; return node.subTreeHeight; } float totalChildHeight = 0; float verticalGap = 16f; // 兄弟节点之间的垂直间距 for (MindNode child : node.children) { totalChildHeight += measureSubtree(child); totalChildHeight += verticalGap; } totalChildHeight -= verticalGap; // 最后一个孩子的尾部不需要再加间距 node.subTreeHeight = Math.max(node.height, totalChildHeight); return node.subTreeHeight; }这段代码有一个重要设计:父节点的subTreeHeight不一定等于子节点的总和。如果父节点本身就很高(比如文字很多,节点被撑得很高),那么整棵子树高度要取最大值,避免父节点和子节点重叠。这个细节我在第一次实现时忽略了,结果出现了"父节点文字和第一个子节点重叠"的界面问题。
再看布局方法。布局的思想是:把子节点们垂直居中地排列在父节点右侧。
private void layoutSubtree(MindNode node, float x, float y) { node.x = x; node.y = y; if (node.isLeaf()) { return; } float childX = x + node.width + horizontalMargin; float childrenTotalHeight = 0; for (MindNode child : node.children) { childrenTotalHeight += child.subTreeHeight; childrenTotalHeight += verticalGap; } childrenTotalHeight -= verticalGap; // 子节点整体区域的起始Y坐标,让子节点区域中心和父节点中心对齐 float startY = y + (node.height - childrenTotalHeight) / 2f; float currentY = startY; for (MindNode child : node.children) { // 子节点自身垂直居中于自己占用的子树高度区域 float childY = currentY + (child.subTreeHeight - child.height) / 2f; layoutSubtree(child, childX, childY); currentY += child.subTreeHeight + verticalGap; } }这里有个值得说道的细节:子节点的Y坐标不是简单地从startY依次往下排,而是要在每个子节点分配的subTreeHeight区域内居中。因为子节点本身的height可能比它的整棵子树高度小,如果直接靠上排列,层级不对称,视觉上会很怪。这个"区域内居中"的手法,是思维导图布局里最常用的小技巧,也是绘制出来的图看上去"端正"的关键。
3.3 栈版:用显式栈做深度优先遍历
递归版本虽然简洁,但我在压测时曾构造了一棵深度超过1000层的树,结果直接在真机上崩了,Logcat报出明显的栈溢出错误。这是因为Android主线程默认的栈大小有限(通常只有8KB到1MB级别的限制,实际因机型而异),每一层递归都会压入局部变量、返回地址,层级太深时系统栈就扛不住了。
于是我在绘制路径上增加了一个显式栈的版本,用循环来模拟递归。遍历一棵树是标准的"深度优先遍历",用栈伪代码如下:
public void drawByStack(Canvas canvas, MindNode root) { Deque<MindNode> stack = new ArrayDeque<>(); stack.push(root); while (!stack.isEmpty()) { MindNode node = stack.pop(); drawNode(canvas, node); // 注意:先压右子节点再压左子节点,弹出时才会先处理左边 for (int i = node.children.size() - 1; i >= 0; i--) { stack.push(node.children.get(i)); } } }为什么要用Deque而不是Stack?因为Java的Stack继承自Vector,所有方法都加了synchronized,在单线程UI绘制场景下会白白损失性能。ArrayDeque是纯数组实现,没有同步锁,性能更高。这个选择在绘制大量节点时能省下不少时间。
另外一个细节是压栈顺序:从children末尾往前压,这样弹出来的顺序就是从左到右,和递归版保持一致。如果直接用for循环从头到尾压栈,最终绘制顺序会反过来,虽然在思维导图里节点之间不重叠的话绘制顺序影响不大,但保持一致的遍历顺序对动画、点击命中测试都有好处。
3.4 两种方案如何取舍
| 维度 | 递归版 | 显式栈版 |
|---|---|---|
| 代码可读性 | 很高,直接对应树形结构 | 中等,需要理解栈的状态维护 |
| 层级深度限制 | 受限于系统调用栈,深度过大易崩溃 | 只受限于堆内存,深度可达数十万 |
| 性能 | 函数调用有开销,但通常可忽略 | 循环+压栈,略快 |
| 适合场景 | 常规思维导图(层级<100层) | 深度未知或需要挂起/恢复遍历的场景 |
我最终的方案是布局计算保留递归版,绘制遍历保留栈版。因为布局阶段通常只需要在onMeasure和onLayout时执行一次,即便层数很少也不会出问题,而onDraw阶段的绘制方法在每帧都会被调用,用栈版更稳定。
4. 节点清除的完整方案:防止内存泄漏的必经之路
4.1 清除的两种语义:删除节点与清空画布
标题里提到的"清除"在思维导图项目里其实有两种含义,很多文章把它们混为一谈,但代码实现完全不同:
- 删除单个节点:用户长按某个分支,选择删除,该节点和它的子孙节点全部移除。
- 清空整棵导图:用户新建导图,需要把根节点下所有内容都清掉。
两种场景都需要正确的遍历+解引用,否则就会出现经典的内存问题:节点对象虽然从界面上消失了,但仍然被某个父节点的children列表引用着,导致Activity退出时内存无法被回收。
4.2 递归删除 vs 堆栈删除
删除单个节点时,我用递归来释放子树:
private void removeSubtree(MindNode node) { if (node == null) return; // 先从父节点的 children 列表中移除自己 if (node.parent != null) { node.parent.children.remove(node); } // 递归释放所有子节点 for (MindNode child : node.children) { removeSubtree(child); } // 清空引用,加速GC回收 node.children.clear(); node.parent = null; }这段代码本质上是"后序遍历":先删除子节点,再删除当前节点。顺序很重要,如果你先把当前节点的children清空了,再遍历它就没有子节点可删了,内存里依然残留着子节点对象。所以必须是先递归到叶子,逐层往上断开引用。
清空整棵导图则有两种思路。一种是从根节点调用removeSubtree,另一种是用栈做迭代清除:
private void clearTreeByStack(MindNode root) { if (root == null) return; Deque<MindNode> stack = new ArrayDeque<>(); stack.push(root); while (!stack.isEmpty()) { MindNode node = stack.pop(); // 把自己从父节点的children中摘除 if (node.parent != null) { node.parent.children.remove(node); } // 子节点全部压栈,后续逐个清理 for (MindNode child : node.children) { stack.push(child); } // 清空当前节点的子列表和父引用 node.children.clear(); node.parent = null; } }这里用栈的优点是:即使树有几千个节点,也不会占据系统的调用栈空间。清除操作在某些业务场景下是在后台线程做的(比如用户连续快速删除),如果递归太深,一样可能栈溢出。
4.3 清除后必须做的事:通知重绘和命中测试同步
清除节点后要记得刷新界面。调用View.invalidate()触发onDraw重新绘制。这一步看似简单,但有个坑:如果删除的是当前选中节点,而你的代码又在点击事件里持有这个节点的引用,再次点击就会出现NullPointerException或绘制出半个节点的情况。我的做法是在删除节点后统一把当前选中引用置空,并把删除节点的位置信息放入一个"待执行淡出动画"的列表中,动画结束后再彻底释放。
另外需要注意,删除节点后必须重新执行measureSubtree和layoutSubtree,因为兄弟节点的Y坐标会因为子树的移除而上移。如果只invalidate不重新布局,屏幕上会出现空白区域。我封了一个方法专门处理这个事:
private void refreshMindMap() { // 重新测量子树高度 measureSubtree(rootNode); // 重新布局,坐标从根节点(0,0)附近开始 layoutSubtree(rootNode, startX, startY); // 重新绘制 invalidate(); }4.4 清空引用时最常见的两个坑
第一个坑是ConcurrentModificationException。如果你在遍历children的时候同时删除子节点,比如用for-each遍历时调用removeSubtree修改同一个children列表,就会触发这个异常。所以要删除一个节点的多个子节点时,先把要删的节点收集到一个临时列表中,遍历结束后再统一删除;或者像我上面那样,在遍历子节点前先把自己从父亲的children里摘掉,从根上避免对同一个列表边读边写。
第二个坑是括号匹配式的引用遗漏。有些人的树形结构里不仅有children列表,还有一张HashMap<String, MindNode>用于根据ID快速查找节点。删除时只处理了children列表,忘了从HashMap里remove掉这条记录,结果内存泄漏了。删除操作必须同步维护所有引用该节点的地方,这一步非常考察思维的缜密程度。
5. 实测中容易翻车的性能瓶颈与优化手段
5.1 onDraw不要做复杂计算
思维导图的节点数量一旦超过100个,onDraw每帧都要绘制所有节点,性能就开始变得敏感。我在第一版里犯的错误是:在onDraw里直接调用文字测量(Paint.measureText)和矩形圆角路径构建,这导致滑动时每一帧都会重新测量文字,帧率掉得厉害。
正确做法是:文字宽度和节点矩形路径在布局阶段就计算好并缓存,onDraw只负责canvas.drawRect和canvas.drawText这种纯绘制操作。文字测量是相对昂贵的操作,尤其是在中英文混排的场景下,一次绘制200个节点就会调用200次measureText,每次都要走一遍字体排版逻辑,这些计算越早做越好。
5.2 画布裁剪和可见区域过滤
另一个优化是对超出屏幕范围的节点直接跳过绘制。思维导图支持平移和缩放后,可视区域通常只占整棵树的很小一部分,如果依然绘制全部节点,就白白浪费了大量GPU渲染指令。
在onDraw里加入一个简单的可见性判断:
protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (rootNode == null) return; // 保存当前画布的平移缩放状态 canvas.save(); canvas.translate(offsetX, offsetY); canvas.scale(scaleFactor, scaleFactor); drawSubtree(canvas, rootNode); canvas.restore(); } private void drawSubtree(Canvas canvas, MindNode node) { // 如果节点在可见区域之外,跳过绘制子树 if (node.x + node.width < 0 || node.y + node.height < 0 || node.x > getWidth() || node.y > getHeight()) { return; } // 绘制当前节点 drawNode(canvas, node); // 递归绘制子节点 for (MindNode child : node.children) { drawSubtree(canvas, child); } }注意这里我保留了递归实现,是因为这段代码只在onDraw里执行,而onDraw对绝大多数思维导图场景来说,层级不会深到栈溢出的程度。如果确实要画上千层节点,可以替换成前面讲的栈版本,逻辑一样。
5.3 布局计算尽量放在后台线程
测量和布局阶段涉及递归遍历整棵树,当节点数量达到几千个时,即使在主线程执行几十毫秒也会造成可感知的卡顿。我的工程做法是:数据变化后先在后台线程做measureSubtree和layoutSubtree,得到所有节点的坐标和尺寸缓存后,通过runOnUiThread回到主线程只做invalidate。这样界面数据更新时,主线程只负责绘制,不负责计算。
有一点要注意:后台线程计算时使用的是节点对象快照,如果后台计算还没结束,用户又修改了树结构,就会产生数据竞争。我在这里用了简单版本号校验,数据每次变更version自增,后台线程计算结束后检查version是否变化,如果变了就舍弃本次结果,重新计算。这个方案简单可靠,比加锁更好维护。
5.4 大量节点时的绘制策略进阶
如果节点超过5000个,单纯靠自定义View逐节点绘制已经不太够用了。此时可以引入缓存策略:
- 把一整棵子树绘制到离屏Bitmap中;
- 当用户平移时,先整体绘制Bitmap,只对发生变化的局部区域重新绘制节点。
这个优化我是在后期加上的。实际效果非常明显:当平移导图时,被缓存区域的绘制成本从几千次drawXxx调用降为一次drawBitmap调用,帧率直接从20FPS提升到满帧。缺点是实现复杂度高,需要维护缓存失效逻辑,比如某个节点内容变更后,其对应子树的Bitmap就需要重新生成。
6. 这套方案后续可以怎么扩展
6.1 从静态绘制走向手势交互
绘制铺平了,导图就成功了一大半,但真正想做成能用的产品,还需要补齐手势交互。当前框架下,最直接扩展的是两点:
第一,点击命中检测。在onTouchEvent里拿到触摸坐标,遍历所有节点判断点是否落在(node.x, node.y, node.x+node.width, node.y+node.height)矩形内。这个检测如果你不想在主线程遍历所有节点,可以在布局阶段构建一个空间索引(比如四叉树),但思维导图的节点数通常不超过1000,线性遍历就够用了。
第二,缩放和平移。只需要在onDraw里对canvas做translate和scale,并记录offsetX、offsetY、scaleFactor,就能实现最基本的平移缩放。缩放时要同步调整文字大小和节点尺寸,处理起来略微繁琐,但核心的布局坐标不需要重新计算,因为逻辑坐标始终是一致的。
6.2 折叠与展开子节点的设计
思维导图产品几乎都有折叠功能:点击某个节点,它的子节点暂时隐藏。当前的数据结构已经天然支持这个功能,只需要给MindNode加一个boolean isExpanded字段,布局和绘制时跳过被折叠节点的子节点即可。
有个细节值得注意:折叠状态下,父节点的subTreeHeight不再是整棵子树的高度,而应该等于它自身的高度。否则被折叠隐藏的子节点依然占据着空间,导图会出现一大片空白。所以measureSubtree阶段要判断isExpanded,折叠时直接返回node.height。
6.3 我对这套算法组合的真实体会
在Android端做思维导图,最大的感受是:递归算法让代码结构和数据结构保持了一致性,堆栈则在关键环节提供了安全兜底。现在回过头看,坚持在布局阶段使用递归、在绘制和清除阶段使用显式栈,是一个经得起测试的工程决策。尤其是清除节点时用栈实现遍历回收,避免了很多深层递归导致的崩溃问题。
如果在项目初期把这些算法选型都想透彻,后面的开发会顺利很多。希望这篇分享能帮到正在做自定义View树形控件、或者准备做思维导图产品的开发者少走几步弯路。
本文还有配套的精品资源,点击获取