简介:面向Android开发者的思维导图完整项目,巧妙结合递归算法与堆栈数据结构,解决多层级节点绘制、顺序遍历与清除回溯问题。工程从自定义视图重写绘制方法切入,涉及节点模型设计、触摸事件处理、展开折叠与增删节点等交互逻辑,覆盖布局管理、动画效果与性能优化等完整实现路径,适合想深入理解Android绘图机制和数据结构应用的开发者。压缩包共43个文件,以Java核心源码、XML界面布局、Gradle构建配置、PNG/JPG图片素材及项目说明文档为主,整体仅279KB,轻量紧凑。已有259人学习下载。项目代码结构清晰,可帮助读者快速掌握利用递归与堆栈处理树形界面的思路,并可直接参考其节点展开折叠、增删改动等交互设计,用于自己的项目改造或学习演练。同时,资源中附带的README和目录组织也为调试排错提供了便利。
1. 一个原生绘制的思维导图控件:递归布局加上堆栈清除
做笔记类App最头疼的事,是在“Android开发-其它控件”里挑一个不吃性能又有自由度的思维导图控件。列表能用RecyclerView,但多级节点的坐标换算、父子连线、子树删除,基础控件拼不出来。这个项目没引图表库,只用自定义View加一棵Node树:节点绘制走递归算法,节点清除改用堆栈做非递归遍历。同一棵树,画用递归、删用栈,恰好把“画得对”和“删得净”都解决了。
项目适合两类人:想在手写自定义View里看数据结构落地的开发者,以及做笔记、知识库工具、需要多层级自由布局又不想上重型依赖的你。工程拆开只有模型、测量、绘制、清除几条线,拿来即用,改编辑器也不难。
整体印象:根节点在最左,子节点逐级向右展开,贝塞尔曲线连接;间距、颜色、文本样式都走XML属性;删一个节点会连根拔掉整棵子树,对象池负责复用防内存抖动。
2. 架构与运行准备:从数据模型到首帧画面
2.1 工程结构与数据模型
加载完整工程后,核心代码集中在四个文件里:
app/src/main/java/... ├── model/Node.java // 树节点模型,保存坐标与测量缓存 ├── view/MindMapView.java // 自定义View,负责测量、布局、绘制与清除 ├── helper/LayoutCalculator.java // 测量与定位的两个递归入口 └── MainActivity.java // 接入示例,从JSON构建树并设置根节点我的习惯是把测量和定位从View里抽出去,单独做成LayoutCalculator。这样后序遍历、前序遍历的逻辑可以单独做单元测试,不依赖Android的Looper和View系统。Node模型是整个控件的基石,字段不多但各司其职:
public class Node { public String id; // 节点唯一标识,用于命中检测 public String text; // 节点显示文本 public Node parent; // 父节点引用,根节点的 parent 为 null public List<Node> children = new ArrayList<>(); // 子节点列表 public int x, y; // 节点矩形左上角在画布坐标系中的位置 public int width, height; // 节点矩形尺寸,文本+内边距计算得出 public int level; // 层级深度,根为 0,用于颜色渐变 public int subWidth, subHeight; // 以该节点为根的子树横/纵向跨度,测量阶段缓存 public boolean recycled; // 标记已被回收,绘制时跳过 }x、y是画布坐标系下的绝对坐标,不是相对父节点的偏移。因为onDraw阶段还需要用这两个值画连线的起止点,如果存的是相对偏移,画之前要先做累加换算,容易乱。subWidth和subHeight是测量阶段的输出,也是布局阶段的输入:先通过后序遍历算出整棵子树的占位尺寸,再在前序遍历时把每个节点摆到具体坐标上。recycled字段配合第4章的对象池使用,避免已经删除的节点被onDraw意外画出来。
2.2 一帧画面背后的三个步骤
从setRootNode到屏幕上出现第一帧,整个过程是固定的三步。
第一步,用户在Activity里把数据组装成Node树,调用mindMap.setRootNode(root)。第二步,MindMapView内部先调measure(root)做后序遍历,算出每个节点的文本宽度、节点高度,以及以它为根的子树占用的总宽总高。第三步,调layout(root, x, y)做前序遍历,从上到下、从左到右地为每个节点分配坐标,最后invalidate()触发系统在下一帧调用onDraw。
这三个步骤在代码里的入口长这样:
public void setRootNode(Node root) { this.root = root; if (root != null) { calculator.measure(root); calculator.layout(root, getPaddingLeft(), getPaddingTop()); requestLayout(); invalidate(); } }requestLayout()触发布局系统重新计算View本身的位置,invalidate()触发重绘。如果你只在自定义View内部操作树结构,这两个调用缺一不可,只调invalidate()的话,控件尺寸变化时可能不会触发重新绘制。
整棵树的测量、布局、绘制完全由Java层递归完成,不依赖RecyclerView的Adapter,也没有Item布局和复用机制。这样做的好处是节点数量在几百个之内时绘制性能非常好,坏处是没有现成的滚动与回收机制,节点超多时需要自己套ScrollView或用平移手势。控件的定位就是轻量、可定制,适合做中大型知识结构展示,不适合做无限画布级的巨型导图。
2.3 接入配置与首次运行
把工程导入Android Studio后,先用模拟器或真机跑一遍,确认看到的初始demo是一棵三层的主题导图。控件在布局文件里这样声明:
<com.example.mindmap.view.MindMapView android:id="@+id/mindMap" android:layout_width="match_parent" android:layout_height="match_parent" app:nodeTextColor="#333333" app:nodeBgColor="#FFF3E0" app:levelSpace="50dp" app:nodePadding="10dp" app:lineColor="#CCBFA8" />自定义属性在res/values/attrs.xml里定义,MindMapView构造时通过obtainStyledAttributes读取。nodeBgColor是节点圆角矩形的填充色,lineColor是贝塞尔连线的颜色,levelSpace控制相邻两级之间的水平距离,数值越大树越“宽”。这些属性都是运行时可变,后面二次开发做主题切换可以直接复用同一套解析逻辑。
MainActivity中的接入代码只需要三步:构建树、交给控件、让控件自己画。
MindMapView mindMap = findViewById(R.id.mindMap); Node root = buildTreeFromJson(jsonArray); // 解析实现见第6章 mindMap.setRootNode(root);整个工程基于AndroidX,没有引用任何第三方图表库,依赖面极小。buildTreeFromJson是JSON解析节点树的入口,用系统自带的org.json就能跑通,不需要Gson。嫌JSON麻烦,也可以像demo里那样手写几个new Node()拼成树结构,数据量小时反而更直观。
这份资源里的完整工程包含上面列出的几个核心类、attrs.xml配置和一份可运行的MainActivity示例。拿到手后先跑通默认demo,再对照着改节点数据、调颜色和间距,很快就能看出每个属性对应的画面变化。
3. 核心绘制拆解:递归算法完成测量、定位与连线
3.1 后序遍历测量子树尺寸
画思维导图最忌讳边遍历边画。如果不知道子节点最终占多大空间,同级节点很可能会重叠,父节点也无法居中。所以第一步永远是用后序遍历把整棵树的尺寸算出来,自底向上汇总。LayoutCalculator构造时拿到textPaint和levelSpace,measure方法里直接用:
public void measure(Node node) { if (node == null || node.recycled) { return; } node.width = (int) textPaint.measureText(node.text) + node.padding * 2; node.height = NODE_HEIGHT; if (node.children.isEmpty()) { node.subWidth = node.width; node.subHeight = node.height; return; } int maxChildSubWidth = 0; int totalChildSubHeight = 0; for (Node child : node.children) { measure(child); // 先递归,保证孩子信息先算完 maxChildSubWidth = Math.max(maxChildSubWidth, child.subWidth); totalChildSubHeight += child.subHeight; } node.subWidth = Math.max(node.width, maxChildSubWidth + levelSpace); node.subHeight = Math.max(node.height, totalChildSubHeight); }measure先递归处理所有子节点,再回头算父节点,这是后序思想。node.subWidth取“自身宽度”和“最宽的那条子分支横向跨度加一段水平间距”中的较大值。node.subHeight是子节点们纵向占用的总和。参数levelSpace对应布局文件里的levelSpace属性,它既要保证父子水平方向不重叠,也直接参与subWidth的计算,所以改这个参数不只是改间距,而是改整棵树的横向密度。
这里有个容易想当然的地方:子节点并不一定是同一高度。某个子节点如果带着三层孙节点,它的subHeight可能等于好几个叶子节点的高度之和。累加每个子节点的subHeight,才能保证第一棵子树和第二棵子树在垂直方向不会互相压住。如果写成children.size() * NODE_HEIGHT,层级一深就会重叠。
提示:若发现整棵树明显偏向右侧,优先检查根节点传入的起始坐标是否用了getPaddingLeft和getPaddingTop。
3.2 前序遍历分配坐标
测量完成后,每个节点的x、y还是脏数据,需要从根开始一层层往里填坐标。这一步用前序遍历,先给当前节点定位,再处理它的所有子节点:
public void layout(Node node, int x, int y) { node.x = x; node.y = y; if (node.children.isEmpty()) { return; } int childX = x + node.width + levelSpace; int childrenTotalHeight = 0; for (Node child : node.children) { childrenTotalHeight += child.subHeight; } // 让整棵子树垂直居中:第一个子节点上边缘的计算是关键 int cursorY = y + node.height / 2 - childrenTotalHeight / 2; for (Node child : node.children) { layout(child, childX, cursorY); cursorY += child.subHeight; } }childX用当前节点矩形右边缘加上levelSpace得到,所有同级子节点共用这个x,形成清晰的纵向列。cursorY的初始值是这段代码里最容易翻车的地方:它不能直接等于y,而要用父节点高度的一半减去全部子节点总高度的一半,得到“让子节点整体在父节点周围垂直居中”的起始点。
layout函数的参数x、y是绝对画布坐标,不是偏移量。传入的第一个节点通常就是根,坐标来自setRootNode里的getPaddingLeft()和getPaddingTop(),让树从控件的左上内边距开始生长。之后每层递归都在父节点坐标基础上累加levelSpace和subHeight,整体结构始终以根为锚点向右侧展开。
3.3 递归绘制节点与贝塞尔连线
坐标算完,onDraw里做的事情就纯粹了:画线、画矩形、画文字,然后递归画子节点。
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (root == null) { return; } canvas.save(); canvas.translate(getScrollX(), getScrollY()); drawNode(canvas, root); canvas.restore(); } private void drawNode(Canvas canvas, Node node) { if (node == null || node.recycled) { return; } drawLineToParent(canvas, node); drawNodeShape(canvas, node); drawNodeText(canvas, node); for (Node child : node.children) { drawNode(canvas, child); } }canvas先做一次translate,把画布原点平移到View的滚动偏移处,这样后面所有节点坐标都直接使用layout阶段算出的绝对坐标,逻辑统一。drawNode是典型的递归前序遍历,每进入一个节点,先画它通往父节点的连线,再画自身矩形和文字,最后遍历子节点。先画线再画矩形的好处是,连线终点会正好压在矩形边缘内,视觉上没有缝隙。
贝塞尔连线是思维导图最出效果的部分,代码也不复杂:
private void drawLineToParent(Canvas canvas, Node node) { if (node.parent == null) { return; } linePath.reset(); int startX = node.parent.x + node.parent.width; int startY = node.parent.y + node.parent.height / 2; int endX = node.x; int endY = node.y + node.height / 2; int ctrlX = (startX + endX) / 2; linePath.moveTo(startX, startY); linePath.cubicTo(ctrlX, startY, ctrlX, endY, endX, endY); canvas.drawPath(linePath, linePaint); }startX取父节点矩形的右边缘,startY取父节点中心;endX取子节点左边缘,endY取子节点中心。控制点的x取起止点中点,画出来是一条平滑的S形曲线,符合多数思维导图软件的视觉习惯。linePath作为成员变量复用,每次绘制前reset,避免onDraw里频繁new对象。
4. 核心清除拆解:堆栈遍历实现节点删除与资源回收
4.1 递归删除为什么会翻车
绘制能用递归,是因为树的结构和调用栈天然同构。但删除子树时,很多人第一反应也是写递归remove,实际用起来会遇到几个头疼的问题。最典型的是深层级删除时直接StackOverflowError,尤其是用户把某棵子树拖到很深层级后再整体删除,调用栈跟着节点深度一路压下去,线程默认栈空间不够就直接崩。
另一个隐蔽问题是删除过程中容易触发业务回调的连锁反应。如果每个Node删掉时都发一个监听通知,递归删除一棵50个节点的树,onNodeRemoved会被调用50次,上层如果每次都刷新界面或写数据库,就是一场灾难。第三是资源回收时机不统一,递归删除只做了children.clear(),节点里可能持有的缓存对象没有被统一标记,下次新建节点时又new一批,内存水位一步步抬高。
所以清除这条链路上我没有抄绘制那套递归写法,而是压成一个显式的栈,做非递归遍历。绘制时用递归是享受语言天然栈,清除时用堆栈是主动控制遍历节奏,回收和通知的时机都握在自己手里。
4.2 用显式栈做非递归删除
clearSubTree方法负责把某个节点连同整棵子树从树中摘除,并完成内存回收:
public boolean clearSubTree(Node target) { if (target == null || target.recycled) { return false; } Node parent = target.parent; if (parent != null) { parent.children.remove(target); } target.parent = null; ArrayDeque<Node> stack = new ArrayDeque<>(); stack.push(target); while (!stack.isEmpty()) { Node current = stack.pop(); for (Node child : current.children) { stack.push(child); // 子节点全部入栈,下一轮继续处理 } current.children.clear(); // 清空引用,避免内存泄漏 current.parent = null; current.recycled = true; nodePool.offer(current); // 回收到对象池 } if (parent != null) { recalculateFrom(parent); // 只重排受影响的分支 } invalidate(); return true; }这里栈的入栈顺序无所谓,因为不需要区分绘制顺序,只负责把每个节点都访问到。循环内先遍历children把子节点压栈,再清空children,顺序上不会产生漏删:子节点对象已经在栈里持有了引用,即使list被清空也不影响后续处理。
把target的parent直接置null,并从父节点children里remove掉,是从逻辑树上“摘除”的动作。如果不先摘除,后续recalculateFrom会把标记回收的节点重新测量进布局,屏幕上会出现鬼影节点。收回对象池用offer到ArrayDeque尾部,下一次创建节点时从头部poll,避免不断new Node造成GC压力。
这里用ArrayDeque而不是java.util.Stack。Stack继承Vector,方法加了同步锁,在UI线程的遍历删除中没必要;ArrayDeque是标准双端队列,push/pop语义完全够用。Android lint对Stack也有提醒,早换早省心。
4.3 被删节点去哪了:对象池与局部重排
删除不等于销毁。节点对象回收进nodePool后,创建新节点优先从池里取:
private Node obtainNode() { Node node = nodePool.poll(); return node != null ? node.reset() : new Node(); }reset()方法在Node内部实现,把id、text、children、坐标、recycled标志全部重置。大多数思维导图交互场景里,节点创建和删除是高频操作,比如用户反复拖拽、复制、删除分支,没有对象池的话,GC在低端机上容易出现掉帧。节点对象本身很轻,但数量一多,每次创建都走内存分配,长时间操作会让堆水位居高不下。
recalculateFrom是局部重排的入口:
private void recalculateFrom(Node node) { calculator.measure(node); calculator.layout(node, node.x, node.y); }删除的是某个父节点下的一棵子树,理论上只有这条水平分支的尺寸会变化,其余分支不受影响。如果删一次就整棵树重新measure和layout,节点较多时会有明显顿挫感。局部重排只处理被删子树的父节点及其兄弟分支,代价小很多。注意recalculateFrom里layout的起点用的是node.x和node.y,不能是0,否则被删节点的兄弟分支会全部跑回画布左上角。
4.4 清除动画:让删除不是瞬间消失
直接调clearSubTree,节点会瞬间消失,交互上很生硬。我习惯拆成两个动作:先detach再物理回收。detach把节点从逻辑树上摘掉,但对象还活着;视图层拿这个残留对象做一段淡出动画;动画结束后再走真正的clearSubTree做栈回收。
private void startRemoveAnimation(Node target, int duration) { detachNode(target); // 先摘逻辑引用 removeAnim = new RemoveAnim(target, SystemClock.uptimeMillis(), duration); postInvalidateOnAnimation(); }onDraw里检查removeAnim,如果动画进行中就按透明度画出残留节点,动画结束时置空并调用clearSubTree。这样用户看到的是节点先淡出,随后内存里才真正回收。动画采用postInvalidateOnAnimation而不是invalidate,可以让动画帧跟随系统vsync信号,不会和屏幕刷新错位。
5. 避坑指南:节点重叠、栈溢出与坐标换算问题
5.1 同级节点垂直重叠
现象:跑出来的思维导图,第二层和第三层的节点在垂直方向上重叠,间距时大时小,看着像排版出错。
原因:测量阶段计算总高度时误用了children.size() * NODE_HEIGHT这个公式。每个子节点可能自带子树,实际占用的subHeight可能远高于单个NODE_HEIGHT。布局阶段又直接用父节点的y顺序累加,没有考虑让子树垂直居中。
解决:测量阶段统一改成后序遍历累加每个子节点的subHeight,并在布局阶段用node.height / 2 - childrenTotalHeight / 2算出子节点起排位置。改完验证一个三层树,观察叶层多而深的节点排布是否均匀。
5.2 树层级过深导致StackOverflowError
现象:用户在深层次结构上快速展开,或者从数据里解析出一棵几百层的树,绘制时崩溃,日志里能看到java.lang.StackOverflowError,栈顶落在drawNode或measure上。
原因:递归绘制和递归测量都使用JVM线程调用栈,每层递归占用栈帧。Java默认线程栈大小通常只容纳几千到上万层浅递归,树形结构一深,栈帧随层级线性增长,很容易触顶。
解决:做两级防护。第一级在数据入口做层级校验,超过预设最大深度直接抛异常,提示业务方调整数据结构。第二级如果确实要支持更深层级,把绘制链路的递归改成显式栈遍历,逻辑和clearSubTree的做法同构。
5.3 onDraw里频繁new对象导致掉帧
现象:节点数量200以上时滑动不跟手,用Profiler能看到内存抖动曲线呈锯齿状,GC频繁触发。
原因:最常见的写法是在onDraw或drawNode里写new Paint()、new Path()、new RectF()。这些对象每帧都会创建,每个节点又创建一遍,节点一多,分配速度远大于回收速度。
解决:把Paint、Path、RectF全部提升为View的成员变量,在初始化时配好,绘制前reset或复用。linePath按3.3的方式复用,RectF用set方法更新参数而不是重建。从那以后我写任何自定义View都强制走一遍这个检查:onDraw里不允许出现new关键字。
5.4 删除节点后界面不刷新
现象:clearSubTree执行完,返回true,但界面上被删的分支还在,点它也没有命中反应。
原因:一是删除后只改了Node树的数据结构,没有触发invalidate,画面自然不会重绘。二是删除过程中某个节点仍被父节点children引用,recycled标志又没及时置位,onDraw遍历时把它画了出来。
解决:clearSubTree方法末尾统一调invalidate,保证任何调用路径都以重绘收尾。删除循环里先置recycled再清children,遍历时用recycled做短路判断,双重保险。注意所有对Node树结构的改动都要发生在UI线程,不要在子线程里直接操作children,否则抛ConcurrentModificationException。
5.5 触摸坐标与画布坐标偏移
现象:把MindMapView放进ScrollView后,点击节点命中位置总是偏上一段距离,离根部越远偏移越大。
原因:onTouchEvent里直接用event.getX(),而onDraw里用canvas.translate(getScrollX(), getScrollY())做了坐标平移。两者坐标系不一致,触摸点比实际绘制起点少了scrollY这段偏移。
解决:在onTouchEvent入口统一做坐标映射,eventX = event.getX() + getScrollX(),eventY = event.getY() + getScrollY(),后续所有命中检测都用映射后的值。规则很简单:绘制和触摸必须使用同一套坐标系,要么都不translate,要么触摸做同样的反向平移。
6. 二次开发:把静态导图改造成可交互的编辑器
拿到这份资源后,最值得先做的改造是把“只展示”变成“可编辑”。我给三个优先级最高的方向。
第一个是展开收起。在Node上加一个boolean expanded字段,默认true。measure和layout遍历children前先判断expanded,false就跳过子节点,子树占位缩成当前节点自身;onDraw同样在expanded为false时不再递归子节点。这样用户点击节点时切换标志位再invalidate,整棵树的折叠与展开就完成了,改动只集中在measure和drawNode两个方法。
第二个是拖拽换父。手指按下时做一次矩形命中检测,命中后进入拖拽状态,滑到某个节点上方松手,就把当前节点从旧父节点children中移除,加入新父节点children并更新parent引用。注意新父节点不能是当前节点的后代,否则会成环,需要在拖拽结束时做一次向上回溯校验。
第三个是数据持久化,把Node树序列化成JSON存本地,下次启动再解析回来。解析用栈做非递归最容易写对:根节点先压栈,循环里pop一个节点时把它的children和属性读出来,再按顺序压栈,逐级扩展。这样深层次的树不会被递归解析撑爆,逻辑也和clearSubTree里那套遍历思想一致。
改造后的验证方法我是这样做的:LayoutCalculator是纯Java类,测量和布局方法不依赖View,可以直接写JUnit用例。构造一棵三层树,手动断言每个节点的x、y和subHeight,跑一次测试就知道布局有没有回归。UI部分再配合真机拖拽验证命中是否准确。前一阵我做知识库的目录视图时也踩了5.5那个坐标偏移的坑,后来任何自定义View都强制先统一画布坐标再写手势,再没翻过车。希望帮到你。
本文还有配套的精品资源,点击获取