ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android自定义View:思维导图递归绘制与堆栈删除

Android自定义View:思维导图递归绘制与堆栈删除

简介:面向Android开发者与需要实现树形绘图交互的读者,这份思维导图示例围绕递归算法和堆栈结构解决节点绘制、遍历与清除问题,适合作为自定义控件、触摸交互与数据结构落地的参考。内容将树形节点的递归绘制、堆栈回溯、自定义View的onDraw与onTouchEvent处理,以及布局管理、展开折叠动画和性能优化串联起来,并涉及节点隐藏、删除和重置等清除逻辑。压缩包共43个文件,以PNG效果图、XML布局与配置、Java源码为主,辅以Gradle构建脚本和说明文档,整体约279KB,体量轻便,便于快速导入Android Studio对照阅读。已有259人学习,适合希望理解递归绘制流程、排查节点刷新问题或积累思维导图类控件实现思路的初中级开发者参考。

1. 从一块能拖拽的画布说起:思维导图节点绘制要先解决什么

做 Android 思维导图控件,最先撞上的不是界面长什么样,而是节点怎么画、怎么删这两件事。父子关系天然是一棵树,用递归算法由上往下绘制最顺手;但一旦用户折叠分支、拖动节点或者删掉整棵子树,递归就不够用了,得靠堆栈把待清理的节点兜住。这两套东西配合不好,最常见的结果就是节点画出来了,线没跟上;或者节点删掉了,内存里还挂着半棵树的引用。

这套方案面向的是正在做 Android 课设或毕设、需要交一套完整自定义控件实现的人。整个控件不依赖任何第三方图表库,核心就三块:一棵带父子指针的节点树、一个入口清晰的递归绘制函数、一个负责删除和撤销的显式栈。代码量控制在可讲清楚的范围内,答辩时每个模块都能说出为什么这么设计,而不是背代码。

2. 节点数据建模:一棵可递归的树需要哪些字段

递归绘制能不能写干净,七成取决于节点类怎么设计。字段少了,画线时找不到父节点;字段多了,每个节点都挂 View 引用,删的时候必然漏资源。

2.1 节点实体与父子关系的字段约定

我一般会把节点拆成“纯数据”和“绘制态”两组字段。纯数据是 id、parentId、title、level;绘制态是逻辑坐标、宽高、是否折叠。节点本身不持有任何 View、Bitmap 或 Context 的强引用,所有绘制都在自定义 View 的 onDraw 里统一完成。这样删除一个节点时,只要把它从树里摘掉,就不需要额外操心回收画笔或者解绑监听器。

public class MindNode { public long id; // 节点唯一标识,用于持久化和命中测试 public long parentId; // 父节点 id,根节点为 0 public String title; // 节点文字 public int level; // 层级,根节点为 0,用于控制缩进和字号 public float logicX; // 逻辑坐标 X,不随画布平移改变 public float logicY; // 逻辑坐标 Y public float width; // 节点宽度,按文字测量结果赋值 public float height; // 节点高度 public boolean collapsed; // 是否折叠子树 public List<MindNode> children = new ArrayList<>(); }

id和parentId看起来冗余,因为 children 列表已经能表达父子关系。但删除节点时,需要根据被删节点的 parentId 快速定位父节点,再把它从父节点的 children 里移除,避免每次删除都全树遍历。两者都保留,本质是用一点内存换掉一次 O(n) 的查找,节点数上百之后这个差别在拖动重绘时能感觉到。

提示:节点类里不要存 View 或者 Drawable。一旦存了,删除节点时就得手动置空,否则自定义 View 被销毁后,节点树还活着,整条引用链会一起泄漏。

2.2 逻辑坐标与画布坐标:平移缩放不重算布局

思维导图要支持拖动和双指缩放,如果每次手指移动都重新跑一遍布局递归,节点一多就会掉帧。做法是把坐标拆成两层:逻辑坐标记录节点在树里的自然位置,只在增删节点、折叠展开、修改文字时重算;画布坐标等于逻辑坐标乘缩放比例再加平移偏移,只在 onDraw 里现算。

public class CanvasTransform { private float offsetX = 0f; // 画布平移量 X,手指拖动时累加 private float offsetY = 0f; // 画布平移量 Y private float scale = 1.0f; // 缩放比例,范围通常限制在 0.5 到 2.0 public float toCanvasX(float logicX) { return logicX * scale + offsetX; } public float toCanvasY(float logicY) { return logicY * scale + offsetY; } // 反向换算,用于点击命中测试 public float toLogicX(float canvasX) { return (canvasX - offsetX) / scale; } }

三个参数的物理含义要讲清楚:offsetX、offsetY是画布原点相对屏幕左上角的位移,拖动时直接累加手指位移量;scale放在乘法位置,保证缩放时节点间距和文字一起等比变化。反向换算toLogicX是命中测试的关键,点击屏幕坐标后先换算回逻辑坐标,再和节点的逻辑矩形做包含判断,这样缩放之后点击位置才不会偏。

2.3 布局递归的入参、返回值与终止条件

布局递归负责给每个节点算出 logicX 和 logicY。常见做法是先算子树总高度,再自上而下分配纵向空间,父节点纵向居中于第一个和最后一个子节点之间。递归函数的终止条件必须写全:节点为空、节点已折叠、节点没有子节点,三种情况都要提前返回,否则折叠状态下还会给不可见的子节点分配坐标,展开时位置全乱。

private static final float NODE_H_SPACING = 48f; // 父子节点横向间距 private static final float NODE_V_SPACING = 24f; // 兄弟节点纵向间距 private static final int MAX_DEPTH = 64; // 递归深度防御上限 // 计算以 node 为根的子树占用高度 private float measureHeight(MindNode node) { if (node == null) return 0f; if (node.collapsed || node.children.isEmpty()) { return node.height; // 终止条件:叶子或折叠节点只算自身高度 } float total = 0f; for (MindNode child : node.children) { total += measureHeight(child) + NODE_V_SPACING; } return Math.max(node.height, total - NODE_V_SPACING); } // 递归布局:left、top 为子树可用区域的左上角 private void layout(MindNode node, float left, float top, int depth) { if (node == null) return; if (depth > MAX_DEPTH) return; // 防御异常数据造成的栈溢出 node.logicX = left; float childTop = top; float firstCenterY = 0f; float lastCenterY = 0f; for (int i = 0; i < node.children.size(); i++) { MindNode child = node.children.get(i); float childHeight = measureHeight(child); layout(child, left + node.width + NODE_H_SPACING, childTop, depth + 1); childTop += childHeight + NODE_V_SPACING; float centerY = child.logicY + child.height / 2f; if (i == 0) firstCenterY = centerY; lastCenterY = centerY; } if (!node.children.isEmpty() && !node.collapsed) { node.logicY = (firstCenterY + lastCenterY) / 2f - node.height / 2f; } else { node.logicY = top; } }

measureHeight和layout是两个独立的递归,前者只读,后者写入逻辑坐标。MAX_DEPTH设成 64 不是因为思维导图真有 64 层,而是防止从本地文件导入异常数据时,一棵退化成链表的树把调用栈打穿。正常场景五六层就到底了,这个上限不会误伤。

3. 递归绘制链路:从根节点到叶子节点的 Canvas 输出

布局算好之后,绘制本身反而简单,递归从根节点往下走,每个节点负责画自己和连到父节点的那条线。容易出问题的是绘制顺序和对象复用,这两点没处理好,画面会脏,帧率也会抖。

3.1 绘制顺序:连线、节点底、文字、选中的四层

同一棵树上,不同元素的叠放顺序必须固定,否则会出现连线穿过节点文字、选中高亮被节点底盖住这类画面。我一般按四层来组织:先画所有连线,再画节点底板,接着画文字,最后画选中态和折叠按钮。递归天然是深度优先,如果每个节点先画自己再画子节点,连线就会和节点底板交错,所以连线的绘制要放在节点底板之前单独走一遍,或者在递归里先处理父到子的线,再画当前节点的底板。

private void drawTree(Canvas canvas, MindNode node, Paint nodePaint, Paint textPaint, Paint linePaint, Paint selectPaint) { if (node == null || node.collapsed) return; // 第一层:先画当前节点到每个子节点的连线 for (MindNode child : node.children) { float startX = transform.toCanvasX(node.logicX + node.width); float startY = transform.toCanvasY(node.logicY + node.height / 2f); float endX = transform.toCanvasX(child.logicX); float endY = transform.toCanvasY(child.logicY + child.height / 2f); canvas.drawLine(startX, startY, endX, endY, linePaint); } // 第二层:当前节点底板 float left = transform.toCanvasX(node.logicX); float top = transform.toCanvasY(node.logicY); float right = transform.toCanvasX(node.logicX + node.width); float bottom = transform.toCanvasY(node.logicY + node.height); canvas.drawRoundRect(left, top, right, bottom, 8f, 8f, nodePaint); // 第三层:文字,垂直居中 float baseline = top + node.height * transform.getScale() / 2f - (textPaint.descent() + textPaint.ascent()) / 2f; canvas.drawText(node.title, left + 12f * transform.getScale(), baseline, textPaint); // 第四层:子节点递归 for (MindNode child : node.children) { drawTree(canvas, child, nodePaint, textPaint, linePaint, selectPaint); } }

这段代码里有几个细节值得说。连线用的是直线而不是曲线,思维导图节点多了之后,贝塞尔曲线每条都要构造 Path,onDraw 里频繁 new 对象会让 GC 压力明显上来;直线用drawLine直接走硬件加速,视觉上也够用。文字的 baseline 计算必须考虑缩放,textPaint的字号在缩放时要同步调整,否则放大后文字和节点框比例会脱节。

3.2 递归绘制函数与 Paint 对象复用

onDraw 每秒可能被调用几十次,Paint、Path、RectF 这些对象如果在递归里现 new,内存抖动会非常明显。正确做法是在自定义 View 的构造函数里一次性初始化,递归只做坐标计算和绘制调用。

public class MindMapView extends View { private final Paint nodePaint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint linePaint = new Paint(Paint.ANTI_ALIAS_FLAG); private final CanvasTransform transform = new CanvasTransform(); public MindMapView(Context context, AttributeSet attrs) { super(context, attrs); nodePaint.setStyle(Paint.Style.FILL); nodePaint.setColor(0xFFFFFFFF); textPaint.setColor(0xFF333333); textPaint.setTextSize(32f); linePaint.setStyle(Paint.Style.STROKE); linePaint.setStrokeWidth(2f); linePaint.setColor(0xFF999999); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (root == null) return; drawTree(canvas, root, nodePaint, textPaint, linePaint, selectPaint); } }

Paint 复用的另一层意义是状态一致。文字大小、颜色在缩放时如果分散在递归里改,容易出现某些节点字号对、某些节点字号不对的情况。统一在onDraw开头根据transform.getScale()设置一次textPaint.setTextSize(BASE_TEXT_SIZE * scale),递归里就只读不写。

3.3 全量重绘和局部刷新的取舍

思维导图节点规模通常在几十到几百之间,全量invalidate()在主流机型上跑满 60 帧没有压力,所以默认走全量重绘,代码简单、不容易出现残影。真正需要局部刷新的是拖动单个节点和输入框跟手这类场景,这时候用invalidate(Rect)限定脏区,能明显降低 GPU 负载。

判断标准可以简化成一条:如果一次操作影响的节点超过总节点数的三分之一,直接全量重绘;少于三分之一再算脏区。算脏区本身也有成本,节点稀疏的时候反而得不偿失。这个阈值不是固定的,节点数量大的项目可以把比例调低,节点少的项目干脆不做局部刷新。

4. 堆栈接管:删除子树与撤销重做的实现

递归负责“往下画”,堆栈负责“往回清”。删除一棵子树时,如果直接在递归里摘节点,很容易在遍历 children 的过程中修改 children,触发并发修改异常;撤销重做又要求删除操作可逆,这两件事放在一起,显式栈是最稳的解法。

4.1 删除子树为什么必须后序

删除一个带子节点的节点,正确顺序是先删叶子、再删中间节点、最后删根。如果先删父节点,子节点就失去了 parentId 指向的对象,变成孤儿;更麻烦的是,父节点的 children 列表被清空之后,再想找子节点回收资源就没有入口了。后序遍历(先子后父)保证每次删除的节点都没有未处理的子节点残留。

递归写法当然也能做后序,但有两个隐患。一是深度大时递归本身有栈溢出风险,虽然思维导图深度有限,但导入异常数据时不能赌;二是删除过程中要修改父节点的 children 列表,递归回调里改列表很容易写成边遍历边删。用显式栈先把整棵子树的节点收集到一个列表里,再按逆序统一删除,逻辑上更干净。

4.2 用显式栈收集待清理节点并回收资源

收集阶段用广度优先或深度优先都可以,目的只有一个:把子树里所有节点平铺到一个 List 里,父节点排在子节点前面。删除阶段从列表尾部往前遍历,天然满足后序。

// 收集以 root 为根的整棵子树,父节点在前、子节点在后 public List<MindNode> collectSubtree(MindNode root) { List<MindNode> result = new ArrayList<>(); if (root == null) return result; Deque<MindNode> stack = new ArrayDeque<>(); stack.push(root); while (!stack.isEmpty()) { MindNode current = stack.pop(); result.add(current); // 子节点压栈,保证弹出时父节点先被访问 for (int i = current.children.size() - 1; i >= 0; i--) { stack.push(current.children.get(i)); } } return result; } // 删除子树:逆序清理,先子后父 public void removeSubtree(MindNode root) { if (root == null) return; List<MindNode> nodes = collectSubtree(root); // 从父节点的 children 中摘除根节点 MindNode parent = findById(root.parentId); if (parent != null) { parent.children.remove(root); } // 逆序删除,保证子节点先于父节点被清理 for (int i = nodes.size() - 1; i >= 0; i--) { MindNode node = nodes.get(i); node.children.clear(); // 断开子节点引用,帮助 GC node.parentId = 0; } nodes.clear(); requestLayout(); invalidate(); }

ArrayDeque比Stack更合适,前者没有同步开销,作为局部变量使用也不需要线程安全。压栈时倒序遍历 children,是为了让弹出顺序和原始顺序一致,方便调试时对照节点顺序。逆序删除阶段把children清空、parentId置零,是主动断开引用链,避免节点列表清空之后还有对象互相持有。

4.3 撤销重做的双栈结构与命令接口

删除、新增、修改文字、拖动位置,这四类操作如果要支持撤销,统一抽象成命令最省事。两个栈:undoStack 存已执行命令,redoStack 存被撤销的命令。执行新命令时清空 redoStack,这是大多数编辑器的通用行为。

public interface EditCommand { void apply(); // 执行 void revert(); // 撤销 } public class CommandManager { private final Deque<EditCommand> undoStack = new ArrayDeque<>(); private final Deque<EditCommand> redoStack = new ArrayDeque<>(); public void execute(EditCommand command) { command.apply(); undoStack.push(command); redoStack.clear(); // 新操作产生后,重做历史失效 } public boolean undo() { if (undoStack.isEmpty()) return false; EditCommand command = undoStack.pop(); command.revert(); redoStack.push(command); return true; } public boolean redo() { if (redoStack.isEmpty()) return false; EditCommand command = redoStack.pop(); command.apply(); undoStack.push(command); return true; } }

以删除子树为例,apply()里调removeSubtree,revert()里把之前保存的节点列表按原顺序重新挂回父节点。这里有个容易忽略的点:revert()恢复的不只是节点对象,还有它们在父节点 children 列表里的原始下标,否则撤销之后节点顺序会乱。命令对象里多存一个originalIndex字段就能解决。

5. 避坑与常见问题:递归绘制里最容易翻车的四个点

5.1 折叠分支后展开,节点位置整体偏移

现象:折叠一个中间分支再展开,该分支的子节点跑到了父节点上方,和兄弟分支重叠。

原因:布局递归里计算父节点logicY时用了子节点中心点求平均,但折叠状态下子节点没有重新布局,坐标还停留在折叠前的位置,展开后直接拿旧坐标参与计算。

解决:折叠和展开时都强制重跑一次整树布局,而不是只改collapsed标志位然后invalidate()。布局入口里对折叠节点提前返回,保证它的子节点坐标在展开时被重新写入。

5.2 删除节点后点击命中仍然命中已删除区域

现象:删掉一个节点,画面上已经看不到它,但点击原位置还会触发选中或者弹出编辑框。

原因:命中测试用的节点列表和绘制用的节点树不是同一份数据。绘制走的是递归树,命中测试走的是一个缓存 List,删除时只更新了树,没有同步缓存。

解决:命中测试不要另建缓存,直接在递归里做。从根节点开始,先判断点击点是否落在当前节点的逻辑矩形内,命中就返回;没命中再递归子节点。这样数据源只有一棵树,不存在同步问题。

5.3 缩放后连线端点对不上节点边缘

现象:双指放大到 1.5 倍以上,连线没有连到节点边框,而是穿进了节点内部或者悬空。

原因:连线的起点用了节点逻辑坐标加节点宽度,但宽度没有乘缩放比例;终点用的却是乘过缩放的画布坐标,两边单位不一致。

解决:所有参与绘制的坐标统一走toCanvasX、toCanvasY换算,包括节点宽度和高度。换算函数里已经包含缩放,调用方不要再手动乘一次。最简单的检查方法是在drawTree里打印起点终点坐标,缩放前后对比数值是否连续。

5.4 节点数量多时拖动明显掉帧

现象:节点超过两百个之后,手指拖动画面出现明显卡顿,低端机上甚至掉到十几帧。

原因:onDraw里每次都在递归中构造RectF和Path,两三百个节点乘以每帧一次,对象分配量直接压垮 GC。另外drawText每次都重新测量文字宽度。

解决:RectF、Path在 View 构造时初始化成成员变量,递归里复用;文字宽度在节点创建和文字修改时测量一次,存进node.width,onDraw里不再调measureText。做完这两步,同样节点数量下帧率通常能回到正常水平。

6. 验收与答辩:怎么证明这套绘制链路是对的

验收这套思维导图控件,不建议只靠“看着没问题”。功能上按四类操作各准备一组用例:新增节点(根节点、中间节点、叶子节点各一)、删除节点(叶子、带一个子节点的中间节点、带整棵子树的根节点各一)、折叠展开(单层折叠、多层嵌套折叠)、拖动缩放(拖到画布边缘、缩放到上下限)。每类操作执行完都检查三件事:画面上的节点数和树里的节点数是否一致、连线端点是否都落在节点边框上、点击命中是否和视觉位置一致。

性能上给两个可量化的指标。节点数两百时,连续拖动十秒,帧率不低于 50 帧;删除一棵五十个节点的子树,主线程阻塞不超过 16 毫秒。这两个数字不需要专业工具,Android Studio 的 Profiler 开 CPU 录制就能看到,答辩时把截图放进 PPT 比口头说“很流畅”有说服力。

验收项操作通过标准
节点一致性新增、删除各十次树内节点数与画面节点数始终相等
连线准确性缩放至 0.5 倍和 2.0 倍连线端点与节点边框偏差不超过 2px
命中测试缩放后点击节点中心与边缘命中结果与视觉位置一致
删除完整性删除带子树的节点后查看内存被删节点无残留引用,无泄漏报告
撤销重做连续撤销十步再重做十步节点顺序和坐标与操作前完全一致

答辩时最容易被问的是“为什么画个树要用栈”。我的回答思路是拆成两句话:递归负责单向的、只读的遍历,比如绘制和布局,它没有副作用,写起来短;栈负责需要修改数据结构的遍历,比如删除和撤销,它能把“收集”和“修改”两个阶段分开,避免边遍历边改容器。这个区分讲清楚,面试官基本不会再追着问。

从那以后我每次做树形结构的删除功能,都强制先把待删节点收集成列表、再逆序清理,不管树有多浅。这条习惯帮我省掉了很多次“删到一半崩了”的排查时间。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进