简介:一份面向Android开发者的思维导图节点绘制实现资源,重点演示如何结合递归算法与堆栈数据结构,解决节点分级绘制与清除回溯问题。项目基于自定义View重写onDraw,并处理触摸事件实现交互式展开、折叠与删除,涵盖树形节点设计、布局管理、动画及性能优化要点,对节点清除的回溯流程也给出了清晰实现。压缩包共43个文件,以Java源码、XML布局与配置、PNG与JPG图片为主,辅以Gradle构建脚本和说明文档,整体仅279KB,适合有Android基础、想深入自定义绘图与数据结构应用的开发者学习。目前已有259人学习下载。通过查看完整工程及注释,可理解递归绘制树的调用过程、堆栈压入弹出顺序,以及视图复用和缓存等优化思路,便于迁移到复杂树形或思维导图类应用开发中。
1. 递归 + 堆栈驱动的思维导图:一个自定义 View 解决节点绘制与清除
思维导图在 Android 端的实现,最容易被低估的就是「节点怎么排布」和「画完怎么清掉」这两件事。这个项目用一棵树结构存数据,用一个自定义 View 负责渲染,核心算法就两条:递归算节点坐标,显式堆栈遍历节点做绘制和清除。为什么绘制和清除不用递归?因为节点多了之后调用栈深度不可控,一旦树深过几百层,递归直接 StackOverflowError;显式栈跑在堆内存上,天然扛得住。项目适合两类人:一类想往自己 App 里塞一个导图控件的业务开发,一类在啃自定义 View onDraw 与画布清除逻辑的进阶读者。整份代码集中在一个 Module 里,核心类就三个,拉下来就能跑,改起来也不绕。
2. 工程结构拆解:树模型、绘制管线与三个核心类
2.1 目录结构与类职责
工程是标准 Android 单 Module 结构,没有任何第三方依赖,纯 Android SDK 加一个自定义 View。跑起来很简单:Android Studio 打开工程根目录,等 Gradle 同步完成,把 app 模块 Run 到模拟器或真机上,入口 Activity 会自动构造一棵示例树并交给 MindMapView,屏幕上就能看到向右展开的三层导图。代码分包很常规,但职责切得很干净:
app/src/main/ ├── java/com/example/mindmap/ │ ├── MainActivity.java // 入口:组装树数据,绑定 MindMapView │ ├── model/ │ │ └── MindNode.java // 树节点:文本、尺寸、父子引用、坐标 │ ├── view/ │ │ └── MindMapView.java // 自定义 View:布局调用 + 绘制 + 清除 │ └── util/ │ └── NodeLayoutUtil.java // 递归布局算法,一个静态工具类 ├── res/layout/ │ └── activity_main.xml // 一个布局,里面放 MindMapView └── AndroidManifest.xml三个核心类各管一件事:MindNode 只描述「节点长什么样、挂在哪」,NodeLayoutUtil 只算坐标,MindMapView 只负责把坐标画到 Canvas 上。数据、算法、渲染三层分开,好处是后面想换布局算法或者换渲染方案,互不牵连。
2.2 MindNode 数据模型:树结构怎么表达
节点类是整棵树的根基。字段不多,但每个都有明确用途:
public class MindNode { public String id; public String content; public MindNode parent; public List<MindNode> children = new ArrayList<>(); public float x, y; // 节点中心坐标,布局阶段赋值 public float width, height; // 节点矩形尺寸 public boolean dirty; // 清除阶段的状态标记 public MindNode(String content) { this.id = UUID.randomUUID().toString(); this.content = content; this.width = 120f; this.height = 48f; } public MindNode addChild(MindNode child) { child.parent = this; children.add(child); return this; } public boolean isLeaf() { return children.isEmpty(); } }参数说明:id 用 UUID 是为了后续增删节点时能稳定引用,二次开发里不管做折叠还是做联动定位都用得上;x/y 存的是中心点坐标,不是左上角,因为布局算法计算时以中心点对齐更直观;width/height 这里先给固定值,真实项目应该在 setContent 之后用 textPaint.measureText 量出文字宽度再算矩形宽度,否则长文本会画出框。dirty 字段是给清除阶段用的,用来标识这个节点在本次清除里已经被处理过,避免重复遍历。
树是在 MainActivity 里手动搭的,语义很直白:
MindNode root = new MindNode("中心主题"); root.addChild(new MindNode("分支一")); root.addChild(new MindNode("分支二")); root.children.get(0).addChild(new MindNode("分支一-子节点"));这段逻辑说明:addChild 返回 this,所以理论上可以链式调用;但树结构一旦深了,链式写出来的代码可读性很差,我建议一层一层挂,每层一个变量,调试坐标时也方便打断点。
2.3 绘制管线:布局、绘制、清除三段式分工
MindMapView 对外只暴露两个方法,内部把工作拆成三个阶段:
public void setRoot(MindNode root) { this.root = root; reLayout(); // 阶段一:递归布局,算所有节点坐标 invalidate(); // 阶段二:触发 onDraw,堆栈遍历绘制 } public void clearAll() { clearNodes(); // 阶段三:堆栈遍历,切断引用、重置状态 invalidate(); // 触发重绘,清掉画布上的旧内容 }布局是纯计算,不碰 Canvas;绘制只读坐标,不再做计算;清除负责重置节点状态。这样 onDraw 里永远只做一件事——拿现成的坐标画出来。别小看这个分工,很多导图控件翻车就是因为在 onDraw 里临时算布局,导致每次 invalidate 都要重跑一遍全树递归,节点一多帧率直接崩。
View 的构造函数里有两个必须注意的点:
public MindMapView(Context context, AttributeSet attrs) { super(context, attrs); setWillNotDraw(false); // 告诉系统这个 View 要自己画,否则 onDraw 不被调用 nodePaint = new Paint(Paint.ANTI_ALIAS_FLAG); nodePaint.setColor(0xFF4CAF50); linePaint = new Paint(Paint.ANTI_ALIAS_FLAG); linePaint.setStrokeWidth(2f); linePaint.setColor(0xFF9E9E9E); textPaint = new Paint(Paint.ANTI_ALIAS_FLAG); textPaint.setColor(Color.WHITE); textPaint.setTextSize(18f); }setWillNotDraw(false) 这个坑在后面排查章节还会细讲,这里先记住:自定义 View 如果没这句,invalidate 调了也没用。三支 Paint 分别管节点圆角矩形、连线、文字,颜色和字号都可以直接改。
3. 递归布局算法:用返回值携带子树高度的一次遍历
3.1 布局入口:坐标从根节点开始
布局算法写在 NodeLayoutUtil 里,是纯静态工具类,不持有任何 View 引用。两个常量控制整体疏密,一个入口方法,一个递归函数:
public class NodeLayoutUtil { public static final float H_GAP = 80f; // 父子节点水平间距 public static final float V_GAP = 20f; // 兄弟节点垂直间距 public static void layout(MindNode root) { layoutNode(root, 0f, 0f); } private static float layoutNode(MindNode node, float x, float topY) { node.x = x; if (node.isLeaf()) { node.y = topY + node.height / 2f; return node.height + V_GAP; } float cursor = topY; float totalHeight = 0f; for (MindNode child : node.children) { float span = layoutNode(child, x + node.width + H_GAP, cursor); cursor += span; totalHeight += span; } node.y = topY + totalHeight / 2f; return totalHeight; } }逻辑说明:整个布局从根节点开始,根节点 x=0,也就是画布左边缘。递归函数每次进入先给自己的 x 赋值,x 的递推关系是「父节点的 x + 父节点宽度 + H_GAP」,一层层往右展开。垂直方向是这段代码的核心:叶子节点直接把自己放在 topY 往下半个高度的位置,返回「节点高度 + V_GAP」,也就是自己占用的垂直空间。
非叶子节点先把所有孩子递归摆下去,cursor 从 topY 开始不断累加每个孩子返回的 span,这样每个孩子都知道自己的 topY 从哪开始,兄弟之间天然不重叠。等所有孩子都摆完,totalHeight 就是整个子树的垂直跨度,父节点直接取这个跨度的中间位置作为自己的 y,视觉上就是「父节点垂直居中于所有孩子」。
参数说明:x 是本层传入的固定起点;topY 是子树允许占用的垂直起点;span 是每个子树返回的占用高度;cursor 是「下一个兄弟该从哪开始」的游标。返回值同时承担两个职责——告诉父节点自己占多高,以及通过游标间接告诉父节点下一个兄弟的起始位置。这个设计让一次递归同时拿到两个信息,省掉一整个后序遍历。
提示:这个布局算法假设「父节点永远在子节点左侧」,也就是单向向右展开。想改左右分叉导图,需要把奇数层/偶数层的展开方向分开处理,递归结构不变,只改 x 的递推公式。
3.2 手动推演一遍:返回值、游标与居中对齐
光看代码可能还绕,拿一棵具体的小树走一遍。根节点 R 有两个孩子 A、B,A 下面挂 a1、a2 两个叶子,B 下面挂 b1 一个叶子。节点高度 48,V_GAP 按 20 算。
从 layoutNode(R, 0, 0) 进入,R 不是叶子,cursor=0。先处理 A:layoutNode(A, 200, 0),A 不是叶子,继续往下,a1 是叶子,y = 0 + 24 = 24,返回 48+20=68;cursor 累加到 68,a2 的 y = 68 + 24 = 92,返回 68。A 的 totalHeight = 136,A.y = 0 + 136/2 = 68,A 向上返回 136。回到 R,cursor 变成 136,处理 B:layoutNode(B, 200, 136),B 下面只有 b1,b1.y = 136 + 24 = 160,B.y = 136 + 68/2 = 170,B 返回 68。最后 R 的 totalHeight = 204,R.y = 102。
最终坐标一览:R(0, 102)、A(200, 68)、B(200, 170)、a1(400, 24)、a2(400, 92)、b1(400, 160)。检查几组关键间距:a1 和 a2 相距 68,正好是一个 span;A 和 B 相距 102,等于 A 子树跨度的一半加 B 子树跨度的一半;a2 的 92 和 b1 的 160 之间隔了 68,说明不同分支的叶子也不会撞在一起。父节点 102 = (68 + 170)/2,稳稳坐在两个孩子的正中间。
再往深层走,规律是同样的:每个父节点只关心「孩子们的总高度」和「我该在哪个 y 上居中」,至于孩子内部怎么布局,是孩子自己的递归去解决。这个职责下放是递归布局最优雅的地方——不需要知道孙子节点的具体坐标,也能保证整个子树占位正确。反过来也说明一个边界:如果某层节点只有一个孩子,totalHeight 就是孩子的高度,父节点垂直方向上和孩子完全重合,这是正常行为,不是 bug。
3.3 布局参数表与调法
四个参数决定导图的整体形态,改它们比改算法更常用:
| 参数 | 默认值 | 作用 | 调整建议 |
|---|---|---|---|
| H_GAP | 80f | 父子节点水平间距 | 决定整图宽度,屏幕放不下先调它 |
| V_GAP | 20f | 兄弟节点垂直间距 | 节点变高时改为 node.height / 2 |
| node.width | 120f | 节点矩形宽度 | 用 measureText 动态计算更准 |
| node.height | 48f | 节点矩形高度 | 行高固定,一般不动 |
水平方向整图宽度等于最大深度乘以(node.width + H_GAP),深度上了十层,光默认参数就要小两千像素,手机屏幕肯定放不下。我一般先固定 H_GAP,再根据深度反推缩放比,绘制时对 Canvas 做整体缩放,而不是改每个节点的坐标——改坐标会让文字跟着变形,Canvas 缩放则能保证文字清晰度。
换个角度想,H_GAP 和 V_GAP 不只是间距,还是布局算法的「安全垫」。调优时别两个一起动,先固定一个只调另一个,对比前后两版截图,出了视觉问题能立刻定位是哪个方向导致的。如果业务上要支持从右往左的阅读习惯,把 H_GAP 相关的递推方向反转即可,递归结构一个字都不用改。
4. 堆栈绘制与清除:避免递归深度的工程化选择
4.1 为什么绘制和清除不用递归
画树的直观写法是递归:先画父节点,再对每个孩子递归调用。递归版本写出来其实更短:
private void drawRecursive(Canvas canvas, MindNode node) { drawConnection(canvas, node); drawNodeBody(canvas, node); drawNodeText(canvas, node); for (MindNode child : node.children) { drawRecursive(canvas, child); } }这段代码在树浅的时候完全没问题,但每递归一层就多一帧调用栈。树深 300 层时,这 300 层栈帧叠在主线程上,轻则 GC 压力变大,重则直接 StackOverflowError。思维导图的数据一旦从长文本或者 JSON 解析出来,树深上百很常见,这是我做树形控件踩过的血泪经验,后来所有遍历树的代码我默认用显式 Stack。
改用显式 Stack 后,节点引用压进堆内存,遍历深度不再受调用栈限制,树多深都能压进去。除了安全性,还有代码层面的好处:绘制要访问 Canvas,递归写法得把 Canvas 逐层传参,每个方法签名都拖着 Canvas;用显式栈,Canvas 只活在 onDraw 的局部作用域里,画完即弃,职责干净。
4.2 堆栈绘制:连线、节点、文字的出栈顺序
onDraw 里的主循环长这样:
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (root == null) return; Stack<MindNode> stack = new Stack<>(); stack.push(root); while (!stack.isEmpty()) { MindNode node = stack.pop(); drawConnection(canvas, node); // 1. 连线 drawNodeBody(canvas, node); // 2. 圆角矩形 drawNodeText(canvas, node); // 3. 文字 // 逆序入栈,保证出栈顺序和 children 顺序一致 List<MindNode> children = node.children; for (int i = children.size() - 1; i >= 0; i--) { stack.push(children.get(i)); } } }逻辑说明:每次弹出一个节点,按「连线 → 矩形 → 文字」的顺序画完。孩子逆序入栈是为了让出栈顺序从左到右和 children 保持一致——栈是后进先出,正序压进去就会反着弹出来。这个顺序在节点之间有遮挡关系时很关键,后画的会盖住先画的,我们期望同层兄弟从左到右依次绘制,视觉层次才稳定。
连线部分单独拆出来看:
private void drawConnection(Canvas canvas, MindNode node) { if (node.parent == null) return; float startX = node.parent.x + node.parent.width / 2f; float startY = node.parent.y; float endX = node.x - node.width / 2f; float endY = node.y; canvas.drawLine(startX, startY, endX, endY, linePaint); }参数说明:起点是父节点矩形右侧边的中点,终点是当前节点矩形左侧边的中点,两个点都在布局阶段算好的坐标基础上偏移半个节点宽。画出来就是一条从父节点右边缘到子节点左边缘的直线。真实导图产品一般用贝塞尔曲线让连线更柔和,直线的好处是省计算、逻辑好验证,想改曲线只要把 drawLine 换成 drawPath,控制点用两个节点的中点外扩即可。
4.3 堆栈清除:切断引用、重置状态与整树回收
标题里说的「清除」,指的是把整棵树从 View 里移除并重置节点状态。如果只是 root=null 再 invalidate,表面上看画布清了,但那些节点的 parent 和 children 引用还互相牵着,GC 要遍历整张引用图才能回收,而且一旦引入节点池复用,脏引用会直接把下一个使用方带偏。清除也要用堆栈完整走一遍:
public void clearAll() { if (root == null) return; Stack<MindNode> stack = new Stack<>(); stack.push(root); while (!stack.isEmpty()) { MindNode node = stack.pop(); // 先压孩子,保证整棵子树都会被遍历到 for (MindNode child : node.children) { stack.push(child); } node.parent = null; node.children.clear(); node.dirty = false; // 如果实现了节点池,这里执行 nodePool.recycle(node) } root = null; invalidate(); }逻辑说明:出栈时先把所有孩子压栈,确保深度优先遍历不会漏节点;然后把 parent 置空、children 清空、dirty 重置。dirty 标记是给节点池复用场景准备的——从池里拿出来的节点如果还带着旧标记,画出来状态就是错的。真正的视觉清除靠最后的 invalidate(),Android 的 Canvas 没有「擦除局部」的能力,清除的本质是整块画布重绘。
参数说明:clearAll 是重量级操作,遍历整棵树有开销。如果只是增删一个节点,不要调它,直接重新 setRoot 触发全量重排更划算。什么时候该用 clearAll?退出页面、切换数据源、或者把 View 复用到另一棵树之前,这时候清干净比省一次遍历更重要。
5. 常见问题与排查:节点重叠、栈溢出与绘制残留
这个章节记的是把这套逻辑从单机 Demo 搬到真实项目时踩过的坑,每条都是「现象 → 原因 → 解决」,照着排查能省不少时间。
5.1 兄弟分支视觉重叠
现象:节点一多,两个不同分支的节点在垂直方向上贴在一起,甚至矩形互相压住。
原因:布局的返回值里 V_GAP 只保证同一父节点下的兄弟间距,不同父节点的子树之间只靠各自返回的 span 叠加。如果其中一棵子树的内部高度算得偏小——比如叶子节点返回的 span 是 node.height + V_GAP,而父节点返回 totalHeight 时漏加了自身的占位——整棵子树在父节点眼里就「矮」了,下一个兄弟拿到错误的起点,自然贴上来。
解决:检查每个 return 点,确保叶子返回「height + V_GAP」,非叶子返回「所有孩子 span 之和 + V_GAP」,根节点可以不要尾部间距但不要混用。我的习惯是 V_GAP 别写死,改成和节点高度联动,node.height / 2 作为间距,视觉上比例最稳。还有一种隐蔽情况:节点内容长度不一致导致 width 不一样,但布局用的是中心点对齐,两个宽度不同的兄弟矩形边缘可能很窄,建议布局完加一步校验,遍历兄弟对计算矩形间距,小于阈值就打日志。
5.2 深树布局直接 StackOverflowError
现象:数据从解析器生成的树传进来,树深超过几百层,布局阶段直接崩,Logcat 里是 java.lang.StackOverflowError。
原因:布局算法本身是递归的,坐标计算天然依赖子树结果,这个递归绕不开。绘制和清除用的是显式栈所以没事,崩的通常就是布局这一层。注意 Android 主线程默认栈大小在不同机型上不一致,有的机型 200 层就崩,有的 500 层没事,不能靠开发机测试通过就认为安全。
解决:给递归加深度守卫,在开发期把问题暴露出来:
private static final int MAX_DEPTH = 200; private static float layoutNode(MindNode node, float x, float topY, int depth) { if (depth > MAX_DEPTH) { throw new IllegalStateException("tree too deep: " + depth); } // 原有逻辑不变,递归调用时 depth + 1 ... }配合在数据解析层做树的深度校验,超过阈值就先做展平,把深层节点转成文本列表塞进父节点,而不是硬画。这个深度守卫看着简单,但它把「数据异常」从运行期崩溃变成了构造期的明确报错,省去很多排查时间。
5.3 清除后画布残留残影
现象:调了 clearAll(),也 invalidate() 了,屏幕上还能看到旧节点的轮廓,尤其是文字残留最明显。
原因:自定义 View 默认不主动清背景,onDraw 只在画布上叠加绘制。如果旧的像素没有被新内容完全覆盖,它们就留在缓冲区里。另一个高频原因是构造函数里没写 setWillNotDraw(false),View 认为自己不需要绘制,invalidate 不会走到 onDraw——这个症状和残影一模一样,排查时先看它。
解决:两件事一起做。构造函数里先 setWillNotDraw(false);onDraw 开头按需清一次背景:
@Override protected void onDraw(Canvas canvas) { if (needClear) { canvas.drawColor(Color.WHITE); needClear = false; } ... }needClear 在 clearAll 里置 true,普通重绘不触发,避免每帧都 drawColor 带来的性能损耗。顺带说一句,如果 View 背景是透明的(比如叠在 Fragment 上),drawColor(Color.WHITE) 会盖掉下层内容,这时候应该用 Color.TRANSPARENT 或者干脆只在 root 置空时 invalidate。
5.4 节点文字中心偏移
现象:文字画出来和节点矩形框没垂直居中,偏上或偏下几像素,换字号偏移量还跟着变。
原因:drawText 的 y 参数是文字基线(baseline)的位置,不是文字几何中心。直接把 node.y 传给 drawText,等于让基线落在矩形中心,文字自然整体上移。这个偏移视觉上看不出来,调代码时极易当成「玄学」。
解决:用 Paint.FontMetrics 换算基线偏移:
Paint.FontMetrics fm = textPaint.getFontMetrics(); float baseline = node.y - (fm.ascent + fm.descent) / 2f; canvas.drawText(node.content, node.x, baseline, textPaint);ascent 是负数,descent 是正数,两者取平均再取负,就是让文字中心对准 node.y 的基线偏移量。这个公式适配所有字号,属于自定义 View 绘制的必修课。另一个相关坑是 setIncludeFontPadding,默认 true 时 FontMetrics 上下带额外 padding,baseline 会偏得更多,追求像素级居中可以 textPaint.setIncludeFontPadding(false),能减少大约 20% 的偏移误差。
6. 二次开发:从静态导图到可交互控件的改造
6.1 触摸命中与节点折叠
静态绘制跑通之后,第一个值得加的能力是「点节点折叠子树」。核心是两步:命中检测加折叠布局。命中检测复用堆栈遍历:
private MindNode hitTest(MindNode root, float x, float y) { Stack<MindNode> stack = new Stack<>(); stack.push(root); while (!stack.isEmpty()) { MindNode n = stack.pop(); if (x >= n.x - n.width / 2 && x <= n.x + n.width / 2 && y >= n.y - n.height / 2 && y <= n.y + n.height / 2) { return n; } if (!n.collapsed) { for (int i = n.children.size() - 1; i >= 0; i--) { stack.push(n.children.get(i)); } } } return null; }命中检测只对可见节点生效,折叠节点的孩子不压栈,天然跳过。折叠的布局改造更简单:在布局函数的入口处判断 collapsed,折叠节点按叶子对待,返回自己的高度加间距,子树整体从布局中消失。onTouchEvent 的 ACTION_UP 里拿到命中节点后翻转 collapsed,重新调 reLayout 加 invalidate。
6.2 节点回收池
清除整棵树时最怕 GC 抖动,尤其是几百节点的树反复增删。用显式栈做节点回收池很自然:
private Stack<MindNode> nodePool = new Stack<>(); public MindNode obtainNode() { return nodePool.isEmpty() ? new MindNode() : nodePool.pop(); } public void recycleNode(MindNode node) { node.parent = null; node.children.clear(); node.dirty = false; nodePool.push(node); }配合 clearAll 在清除时把节点回收到池子,之后 setRoot 用 obtainNode 建树,对象复用,内存分配归零。这是把「清除」从纯销毁变成可回收的关键一步,节点池容量建议按树的最大节点数预分配。
6.3 改造后的验证方法
改完一定要验证,我的套路是三步:先跑一棵三层小树,肉眼确认布局不重叠;再喂一棵两百层的深树,确认布局不崩、绘制不掉帧;最后加一个 debug 开关,把每个节点的坐标 dump 出来,检查有没有矩形相交。折叠功能加完之后,再跑一遍折叠前后坐标对比,确认折叠子树的节点真的没参与布局,坐标 dump 可以这样:
for (MindNode n : allNodes) { Log.d("LAYOUT", n.content + " x=" + n.x + " y=" + n.y + " w=" + n.width + " h=" + n.height); }我后来每次接导图或者树形控件需求,都强制走一遍这套流程:先画一棵三层小树验证布局,再塞一棵两百层深树验证栈深度,最后才接真实业务数据。深树那次真在线上崩过一回,从那以后这条验证路线再没跳过。希望帮到你。
本文还有配套的精品资源,点击获取