资讯动态

Android自定义View实现思维导图:递归布局与堆栈绘制清除实战

发布时间:2026/10/16 3:25:22 来源:尧图企业网站定制
简介一份面向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 ListMindNode 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; } }逻辑说明整个布局从根节点开始根节点 x0也就是画布左边缘。递归函数每次进入先给自己的 x 赋值x 的递推关系是「父节点的 x 父节点宽度 H_GAP」一层层往右展开。垂直方向是这段代码的核心叶子节点直接把自己放在 topY 往下半个高度的位置返回「节点高度 V_GAP」也就是自己占用的垂直空间。非叶子节点先把所有孩子递归摆下去cursor 从 topY 开始不断累加每个孩子返回的 span这样每个孩子都知道自己的 topY 从哪开始兄弟之间天然不重叠。等所有孩子都摆完totalHeight 就是整个子树的垂直跨度父节点直接取这个跨度的中间位置作为自己的 y视觉上就是「父节点垂直居中于所有孩子」。参数说明x 是本层传入的固定起点topY 是子树允许占用的垂直起点span 是每个子树返回的占用高度cursor 是「下一个兄弟该从哪开始」的游标。返回值同时承担两个职责——告诉父节点自己占多高以及通过游标间接告诉父节点下一个兄弟的起始位置。这个设计让一次递归同时拿到两个信息省掉一整个后序遍历。提示这个布局算法假设「父节点永远在子节点左侧」也就是单向向右展开。想改左右分叉导图需要把奇数层/偶数层的展开方向分开处理递归结构不变只改 x 的递推公式。3.2 手动推演一遍返回值、游标与居中对齐光看代码可能还绕拿一棵具体的小树走一遍。根节点 R 有两个孩子 A、BA 下面挂 a1、a2 两个叶子B 下面挂 b1 一个叶子。节点高度 48V_GAP 按 20 算。从 layoutNode(R, 0, 0) 进入R 不是叶子cursor0。先处理 AlayoutNode(A, 200, 0)A 不是叶子继续往下a1 是叶子y 0 24 24返回 482068cursor 累加到 68a2 的 y 68 24 92返回 68。A 的 totalHeight 136A.y 0 136/2 68A 向上返回 136。回到 Rcursor 变成 136处理 BlayoutNode(B, 200, 136)B 下面只有 b1b1.y 136 24 160B.y 136 68/2 170B 返回 68。最后 R 的 totalHeight 204R.y 102。最终坐标一览R(0, 102)、A(200, 68)、B(200, 170)、a1(400, 24)、a2(400, 92)、b1(400, 160)。检查几组关键间距a1 和 a2 相距 68正好是一个 spanA 和 B 相距 102等于 A 子树跨度的一半加 B 子树跨度的一半a2 的 92 和 b1 的 160 之间隔了 68说明不同分支的叶子也不会撞在一起。父节点 102 (68 170)/2稳稳坐在两个孩子的正中间。再往深层走规律是同样的每个父节点只关心「孩子们的总高度」和「我该在哪个 y 上居中」至于孩子内部怎么布局是孩子自己的递归去解决。这个职责下放是递归布局最优雅的地方——不需要知道孙子节点的具体坐标也能保证整个子树占位正确。反过来也说明一个边界如果某层节点只有一个孩子totalHeight 就是孩子的高度父节点垂直方向上和孩子完全重合这是正常行为不是 bug。3.3 布局参数表与调法四个参数决定导图的整体形态改它们比改算法更常用参数默认值作用调整建议H_GAP80f父子节点水平间距决定整图宽度屏幕放不下先调它V_GAP20f兄弟节点垂直间距节点变高时改为 node.height / 2node.width120f节点矩形宽度用 measureText 动态计算更准node.height48f节点矩形高度行高固定一般不动水平方向整图宽度等于最大深度乘以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; StackMindNode 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 顺序一致 ListMindNode 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 里移除并重置节点状态。如果只是 rootnull 再 invalidate表面上看画布清了但那些节点的 parent 和 children 引用还互相牵着GC 要遍历整张引用图才能回收而且一旦引入节点池复用脏引用会直接把下一个使用方带偏。清除也要用堆栈完整走一遍public void clearAll() { if (root null) return; StackMindNode 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 上下带额外 paddingbaseline 会偏得更多追求像素级居中可以 textPaint.setIncludeFontPadding(false)能减少大约 20% 的偏移误差。6. 二次开发从静态导图到可交互控件的改造6.1 触摸命中与节点折叠静态绘制跑通之后第一个值得加的能力是「点节点折叠子树」。核心是两步命中检测加折叠布局。命中检测复用堆栈遍历private MindNode hitTest(MindNode root, float x, float y) { StackMindNode 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 StackMindNode 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); }我后来每次接导图或者树形控件需求都强制走一遍这套流程先画一棵三层小树验证布局再塞一棵两百层深树验证栈深度最后才接真实业务数据。深树那次真在线上崩过一回从那以后这条验证路线再没跳过。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑