简介:这是一份面向Android中高级开发者的思维导图自定义控件实战项目,聚焦树形结构可视化难题,解决节点动态绘制、交互响应与状态清除等核心痛点。项目通过递归算法遍历多层节点、堆栈结构保障绘制/清除顺序一致性,并基于自定义View实现高效绘图与触摸事件处理,适用于知识管理类App、学习笔记工具等场景。压缩包共43个文件(279KB),含5个核心Java类(实现递归绘制逻辑与节点管理)、11个XML布局与资源文件、12个PNG图标素材及2个JPG示例图,辅以Gradle构建配置与README说明文档,目录结构清晰,模块职责分明。目前已有259人学习下载,读者可直接复用其递归+堆栈的节点渲染范式、触摸交互状态机设计、轻量级树形数据模型及性能优化思路,快速构建可扩展的移动端思维导图功能。
1. 一个在 Android 上“手绘感”十足的思维导图控件:不用 WebView、不依赖第三方 SDK,纯 Canvas + 递归 + 堆栈搞定动态折叠/展开与节点重绘
你有没有试过在 Android 原生项目里嵌一个真正可交互、能拖拽、支持无限层级折叠、且缩放后文字依然清晰的思维导图?不是用 WebView 加载 JS 库(性能卡顿、手势冲突、调试黑匣子),也不是调用商业 SDK(授权贵、定制难、源码不可控)。这个项目就是答案:它用纯 Java/Kotlin 实现了一个自定义View,核心逻辑仅靠递归遍历树结构 + 双堆栈管理绘制状态 + Canvas 层级偏移控制,就把节点定位、连线路径、文字居中、折叠动画、清除逻辑全拿下了。它不追求炫酷动效,但每一步绘制都可 debug、每一处坐标都可推演——适合需要深度定制导图行为(比如和本地知识库联动、做考试错题分支标记、嵌入教育类 App 的笔记模块)的中高级 Android 开发者。如果你正被「导图太重」「缩放糊字」「折叠后连线错位」这类问题反复折磨,这个项目就是你该立刻 clone 下来跑通的最小可行原型。
2. 架构设计与核心模块拆解:从 MindMapNode 到 Canvas 绘制链路的完整闭环
2.1 数据模型:MindMapNode 是树形结构的唯一载体,无 ORM、无 JSON 封装开销
整个项目的数据层极度轻量,只定义了一个MindMapNode类,它既是数据容器,也是绘制单元。没有引入 Gson 或 Jackson,所有节点通过parent和children字段构成双向树,避免了序列化/反序列化的性能损耗和兼容性风险。每个节点携带最简必要字段:text(显示文本)、level(层级深度,用于计算缩进和颜色梯度)、isExpanded(折叠状态)、x/y(当前屏幕坐标,非绝对位置,而是相对父节点的偏移量)。特别注意:x/y不是最终绘制坐标,而是递归过程中由父节点传递下来的布局参考值,真正落画布前会经calculatePosition()动态修正。
public class MindMapNode { public String text; public MindMapNode parent; public List<MindMapNode> children = new ArrayList<>(); public int level = 0; public boolean isExpanded = true; public float x, y; // relative to parent, NOT absolute canvas coords public MindMapNode(String text) { this.text = text; } // 递归计算当前节点及其子树所有节点的最终屏幕坐标 public void calculatePosition(float parentX, float parentY, float horizontalGap, float verticalGap) { this.x = parentX; this.y = parentY + verticalGap; float childXOffset = horizontalGap * (float) Math.pow(1.2, level); // 指数级拉开子节点横向间距 int childIndex = 0; for (MindMapNode child : children) { if (child.isExpanded) { float childX = this.x + childXOffset * (childIndex - (children.size() - 1) / 2f); child.calculatePosition(childX, this.y, horizontalGap, verticalGap); childIndex++; } } } }提示:
horizontalGap和verticalGap是外部传入的布局参数,不是写死常量。这意味着你可以根据屏幕密度(density)或用户设置的“紧凑模式”动态调整,而无需修改节点逻辑。
2.2 绘制引擎:双堆栈驱动 Canvas 状态管理,解决“折叠后子节点残留”这一经典翻车点
很多自定义导图控件崩溃在折叠操作后——子节点明明isExpanded = false,但 Canvas 上还留着旧的连线和文字。本项目用两个独立堆栈彻底规避:drawStack存储待绘制节点(按 DFS 顺序压入),positionStack存储对应节点的x/y坐标(与drawStack同步 push/pop)。关键在于:onDraw()中只遍历drawStack,而drawStack的构建发生在updateDrawStack()方法里,该方法严格遵循isExpanded状态进行 DFS 遍历,未展开的子树节点根本不会进入drawStack。这就从源头上杜绝了“残留绘制”。
// 在自定义 View 中 private Stack<MindMapNode> drawStack = new Stack<>(); private Stack<PointF> positionStack = new Stack<>(); private void updateDrawStack(MindMapNode root) { drawStack.clear(); positionStack.clear(); dfsBuildDrawStack(root, new PointF(0, 0)); } private void dfsBuildDrawStack(MindMapNode node, PointF pos) { drawStack.push(node); positionStack.push(new PointF(pos.x, pos.y)); if (node.isExpanded && !node.children.isEmpty()) { float centerY = pos.y; float startX = pos.x + 120; // 从父节点右侧开始布局子节点 float gapX = 80f; int childCount = node.children.size(); float offsetX = startX - (childCount - 1) * gapX / 2f; for (int i = 0; i < childCount; i++) { MindMapNode child = node.children.get(i); if (child.isExpanded) { PointF childPos = new PointF(offsetX + i * gapX, centerY); dfsBuildDrawStack(child, childPos); } } } } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (rootNode == null) return; // 清空画布(仅清空内容区,保留背景) canvas.save(); canvas.clipRect(getPaddingLeft(), getPaddingTop(), getWidth() - getPaddingRight(), getHeight() - getPaddingBottom()); canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR); canvas.restore(); // 逐个绘制 drawStack 中的节点 while (!drawStack.isEmpty()) { MindMapNode node = drawStack.pop(); PointF pos = positionStack.pop(); drawNode(canvas, node, pos.x, pos.y); if (!node.children.isEmpty() && node.isExpanded) { drawConnections(canvas, node, pos.x, pos.y); } } }注意:
drawConnections()绘制的是从父节点到每个已展开子节点的贝塞尔曲线,其控制点坐标由pos.x/y和子节点坐标动态计算,确保折叠/展开时连线自动伸缩,无需手动重置路径。
2.3 交互逻辑:触摸事件的三层拦截——区分点击、长按、拖拽,且支持多点缩放
交互不是简单setOnClickListener,而是重写onTouchEvent(),用MotionEvent.getActionMasked()精确识别动作类型:
- 单击:
ACTION_UP时计算触摸点是否落在某个节点文字矩形内(用Paint.getTextBounds()获取文字宽高,再结合node.x/y计算屏幕区域),命中则触发onNodeClick(node)回调; - 长按:
ACTION_DOWN后启动Handler.postDelayed(),500ms 未移动则判定为长按,弹出折叠/展开菜单; - 拖拽:
ACTION_MOVE时记录deltaX/deltaY,平移整个rootNode的x/y,并调用invalidate()触发重绘; - 双指缩放:用
ScaleGestureDetector监听,缩放因子作用于mScaleFactor,并在onDraw()中对canvas.scale(mScaleFactor, mScaleFactor, pivotX, pivotY)。
所有交互均不改变节点树结构,只影响isExpanded状态或视图变换矩阵,保证数据与 UI 严格分离。
3. 运行与调试:从零配置到真机验证的完整流程
3.1 环境准备:最低 API 21,无需额外依赖,Gradle 配置极简
项目基于 AndroidX 构建,无任何第三方 UI 依赖(如 Material Components 也未强制使用),因此兼容性极强。build.gradle(Module: app)只需确认以下基础配置:
android { compileSdk 34 defaultConfig { applicationId "com.example.mindmapdemo" minSdk 21 // 关键:API 21 即可运行,因未使用 ViewBinding 或 Compose targetSdk 34 versionCode 1 versionName "1.0" } } dependencies { implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.appcompat:appcompat:1.6.1' // ⚠️ 注意:无其他 implementation!连 constraintlayout 都非必需 }提示:
minSdk 21是硬性要求,因为Canvas.clipRect()的Region.Op参数在 API 21 才稳定支持,而本项目用它精确裁剪重绘区域,避免闪烁。
3.2 快速集成:三步将 MindMapView 嵌入任意 Activity
- 复制核心文件:将
MindMapView.java(或.kt)和MindMapNode.java复制到你的src/main/java/com/yourpackage/下; - XML 布局声明:在
activity_main.xml中添加自定义 View,并预留足够空间(导图需自由缩放,建议用ConstraintLayout包裹):
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <com.example.mindmapdemo.MindMapView android:id="@+id/mindMapView" android:layout_width="0dp" android:layout_height="0dp" android:padding="24dp" <!-- 关键:padding 为根节点提供安全边距 --> app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>- Activity 中初始化数据:在
MainActivity.java的onCreate()里构建树并设置:
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); MindMapView mindMapView = findViewById(R.id.mindMapView); // 构建测试树:中心主题 → 3 个一级分支 → 每个分支下 2 个二级节点 MindMapNode root = new MindMapNode("核心概念"); root.level = 0; MindMapNode branch1 = new MindMapNode("定义与特征"); branch1.level = 1; root.children.add(branch1); MindMapNode sub1 = new MindMapNode("抽象性"); sub1.level = 2; branch1.children.add(sub1); MindMapNode sub2 = new MindMapNode("结构性"); sub2.level = 2; branch1.children.add(sub2); // ... 添加 branch2, branch3 及其子节点(代码略) // 设置根节点并触发首次绘制 mindMapView.setRootNode(root); }注意:
setRootNode()内部会调用root.calculatePosition()计算初始坐标,再调用updateDrawStack()构建绘制栈,最后invalidate()。整个过程无异步,确保 UI 立即响应。
3.3 调试技巧:用 Log + 坐标快照定位“节点飘移”和“连线断裂”
当发现节点位置偏移或连线指向错误时,不要盲目改gap参数。先开启调试日志,在MindMapView.java的drawNode()和drawConnections()开头加入:
Log.d("MindMap", String.format("DRAW NODE: %s, x=%.1f, y=%.1f, level=%d", node.text, pos.x, pos.y, node.level)); Log.d("MindMap", String.format("CONNECTION FROM (%.1f, %.1f) TO (%.1f, %.1f)", pos.x, pos.y, child.x, child.y));然后在真机上操作(折叠/展开/缩放),观察 Logcat 输出。你会发现:
- 若
pos.x在折叠后仍输出子节点坐标,说明updateDrawStack()未生效 → 检查isExpanded是否被误设; - 若
CONNECTION日志中child.x为0.0,说明子节点calculatePosition()未执行 → 检查isExpanded为true时是否遗漏了dfsBuildDrawStack()调用; - 若
pos.x数值极大(如12000.0),说明horizontalGap未随缩放缩放 → 检查onDraw()中canvas.scale()后是否忘了对pos.x/y做反向缩放(本项目未做,因pos是布局参考值,绘制时已用canvas.translate()处理)。
这种“日志即坐标”的调试法,比断点单步更高效,是我处理复杂 Canvas 布局的血泪经验。
4. 常见问题排查:5 条真实踩坑记录,覆盖从编译到交互的全链路
4.1 现象:App 启动后导图空白,Logcat 无报错
原因:MindMapView的onMeasure()未重写,导致wrap_content时getMeasuredWidth()/Height()返回0,onDraw()中canvas无有效绘制区域。
解决:在MindMapView.java中添加:
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); int width; int height; if (widthMode == MeasureSpec.EXACTLY) { width = widthSize; } else { width = getSuggestedMinimumWidth(); // 至少显示 padding 宽度 width = Math.max(width, widthSize); } if (heightMode == MeasureSpec.EXACTLY) { height = heightSize; } else { height = getSuggestedMinimumHeight(); height = Math.max(height, heightSize); } setMeasuredDimension(width, height); }4.2 现象:双指缩放时,导图中心点剧烈跳动,无法稳定聚焦
原因:ScaleGestureDetector的pivotX/pivotY使用了event.getX()/getY(),但该坐标是相对于View左上角,而canvas.scale()的锚点需是相对于Canvas坐标系(即getScrollX()/getScrollY()未参与计算)。
解决:在onScale()回调中,将触摸点转换为Canvas坐标:
@Override public boolean onScale(ScaleGestureDetector detector) { float focusX = detector.getFocusX() - getScrollX(); float focusY = detector.getFocusY() - getScrollY(); mScaleFactor *= detector.getScaleFactor(); mScaleFactor = Math.max(0.5f, Math.min(mScaleFactor, 3.0f)); // 限制缩放范围 invalidate(); return true; }并在onDraw()开头添加:
canvas.save(); canvas.translate(getScrollX(), getScrollY()); // 先平移,再缩放 canvas.scale(mScaleFactor, mScaleFactor, focusX, focusY); // ... 后续绘制 canvas.restore();4.3 现象:折叠某个节点后,其子节点的文字仍可见,但连线消失
原因:drawNode()中未检查node.isExpanded,导致即使节点被折叠,只要它在drawStack中就会被绘制;而drawConnections()有检查,所以连线消失造成视觉割裂。
解决:在drawNode()开头添加显式判断:
private void drawNode(Canvas canvas, MindMapNode node, float x, float y) { if (!node.isExpanded && node != rootNode) { // 折叠的非根节点不绘制文字,但根节点永远显示 return; } // ... 后续绘制逻辑 }4.4 现象:快速连续点击多个节点,onNodeClick()回调丢失或重复触发
原因:onTouchEvent()中未做点击防抖,ACTION_UP事件可能被上一次ACTION_DOWN的延迟任务干扰。
解决:添加简易防抖机制,在MindMapView中:
private long lastClickTime = 0; private static final long CLICK_INTERVAL = 300; // 300ms 内只认第一次点击 @Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_UP) { if (System.currentTimeMillis() - lastClickTime < CLICK_INTERVAL) { return true; // 忽略抖动 } lastClickTime = System.currentTimeMillis(); // ... 执行点击检测逻辑 } return super.onTouchEvent(event); }4.5 现象:在RecyclerView的item中嵌入MindMapView,滑动时内存飙升甚至 OOM
原因:MindMapView每次onDraw()都新建Paint对象(如mTextPaint = new Paint()),而RecyclerView复用View时未回收这些对象。
解决:将Paint声明为成员变量,在init()中一次性创建:
private Paint mTextPaint; private Paint mLinePaint; private void init() { mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mTextPaint.setTextSize(14 * getResources().getDisplayMetrics().scaledDensity); mTextPaint.setColor(Color.BLACK); mLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG); mLinePaint.setStrokeWidth(2f); mLinePaint.setColor(Color.parseColor("#666666")); }5. 二次开发实战:如何接入本地 SQLite 知识库,并实现“点击节点 → 跳转详情页”的闭环
5.1 数据层扩展:为 MindMapNode 绑定业务 ID,打通与数据库的映射
MindMapNode当前是纯展示模型,要关联真实业务数据(如某知识点的knowledge_id),需扩展字段。不要修改原MindMapNode类(破坏封装),而是采用组合模式:新建KnowledgeNode继承MindMapNode,或更推荐——用SparseArray建立node.hashCode()到业务实体的映射。我选后者,因其零侵入、易维护:
// 在 Activity 或 ViewModel 中 private SparseArray<KnowledgeEntity> nodeToKnowledge = new SparseArray<>(); // 构建树时,为每个节点绑定知识实体 KnowledgeEntity entity = knowledgeDao.getById(1001L); MindMapNode node = new MindMapNode(entity.title); nodeToKnowledge.put(node.hashCode(), entity); // 在 onNodeClick() 回调中 mindMapView.setOnNodeClickListener(node -> { KnowledgeEntity entity = nodeToKnowledge.get(node.hashCode()); if (entity != null) { Intent intent = new Intent(this, KnowledgeDetailActivity.class); intent.putExtra("knowledge_id", entity.id); startActivity(intent); } });注意:
node.hashCode()是安全的,因MindMapNode未重写hashCode(),默认使用对象内存地址,同一节点实例生命周期内不变。
5.2 动态加载:实现“滚动到底部 → 自动加载下一级子节点”的懒加载策略
原项目一次性加载全部节点,大数据量时内存吃紧。我们改造updateDrawStack(),使其支持分页加载。核心思路:为每个节点增加isLoaded标志,isExpanded = true且!isLoaded时,触发网络/数据库请求,成功后再addChild()并invalidate()。
// 修改 MindMapNode public boolean isLoaded = false; // 在 MindMapView 中新增方法 public void loadChildrenForNode(MindMapNode node) { if (node.isLoaded || node.children.isEmpty()) return; // 模拟异步加载(实际应替换为 Retrofit 或 Room 查询) new Thread(() -> { List<KnowledgeEntity> children = knowledgeDao.getChildren(node.knowledgeId); for (KnowledgeEntity childEntity : children) { MindMapNode childNode = new MindMapNode(childEntity.title); childNode.knowledgeId = childEntity.id; node.children.add(childNode); } node.isLoaded = true; // 切回主线程刷新 post(() -> { updateDrawStack(rootNode); invalidate(); }); }).start(); } // 在 onNodeClick() 中,若点击的是未加载的展开节点,则触发加载 if (node.isExpanded && !node.isLoaded) { loadChildrenForNode(node); }5.3 样式定制表:5 个关键 Paint 参数及其业务含义,改一处即全局生效
| 参数名 | 默认值 | 业务含义 | 修改建议 |
|---|---|---|---|
mTextPaint.setTextSize() | 14sp | 节点文字大小 | 教育类 App 可设为16sp提升可读性;儿童类 App 可设为18sp |
mTextPaint.setColor() | #000000 | 文字主色 | 暗色主题下改为#FFFFFF,并同步改mLinePaint.setColor() |
horizontalGap(传入calculatePosition) | 80f | 同级节点横向间距 | 知识点密集时调小至50f;强调层级时调大至100f |
verticalGap(传入calculatePosition) | 60f | 父子节点纵向间距 | 流程图场景可增大至80f避免连线交叉;笔记场景可减小至40f省空间 |
mScaleFactor初始值 | 1.0f | 默认缩放级别 | 新用户引导时设为0.8f,让整棵树完整可见 |
提示:所有参数都应通过
MindMapView的setConfig()方法集中管理,而非散落在各处。例如:public void setConfig(Config config) { this.config = config; invalidate(); // 强制重绘以应用新样式 } public static class Config { public float textSize = 14f; public int textColor = Color.BLACK; public float horizontalGap = 80f; // ... 其他参数 }
从那以后我每次接手新需求,只要涉及“树形结构可视化”,第一反应就是翻出这个思维导图项目,把MindMapNode和双堆栈逻辑抄过来,再根据业务字段微调calculatePosition()和drawNode()。它不花哨,但像一把瑞士军刀——削铅笔、拧螺丝、开罐头,样样都能干,而且刀刃永远锃亮。希望帮到你。
本文还有配套的精品资源,点击获取