项目地址:https://github.com/bmelnychuk/AndroidTreeView 定位:轻量级 Android 树形视图——可展开/折叠、可勾选(单选/多选)、可搜索、节点可挂任意自定义布局,文件夹浏览/组织架构/分类管理类需求的现成答案。
| 元信息 | 说明 |
|---|---|
| 语言 | Java |
| 星标 | 1.8k+ ⭐(以仓库实时为准) |
| 协议 | Apache 2.0 |
| 依赖 | 仅 androidx(核心树逻辑零依赖) |
| 状态 | 低频维护(功能稳定) |
| 系列定位 | 本批 Android 经典库拆解第 8 篇:递归数据结构与列表渲染的结合 |
1、背景:树形 UI 是一类"结构问题"
树形需求看似小众,实际遍布企业级 App:
文件夹/文件浏览 —— 云盘
组织架构选人 —— OA/IM
多级分类筛选 —— 电商类目
权限树/菜单树 —— 管理后台
评论楼中楼 —— 社区
自研的难点不在画 UI,而在三件结构性的事:
- 数据结构:树节点的增删改查、父子映射、祖先/后代遍历;
- 渲染策略:树是无限层级,列表是线性结构——"展开/折叠"本质是树的可视集合与线性序列的实时映射;
- 状态管理:勾选的父子联动(勾父全选子、子部分勾父为半选)、搜索高亮展开、滚动位置恢复。
AndroidTreeView 把这三件事各给了一个干净的解。
2、原理剖析
2.1、数据模型:Node 与 ViewHolder 的分离
// 数据侧:泛型节点,payload 与结构分离
TreeNode node = new TreeNode("我的云盘"); // value 任意对象
TreeNode child = new TreeNode(new FileItem("文档.pdf")); // 自定义 payload
node.addChildren(child, child2);
// 渲染侧:每个节点类型绑定一个 ViewHolder 工厂
node.setViewHolder(new FileHolder(context)); // 决定该节点长什么样
TreeView tv = new TreeView(root, context);
container.addView(tv.getView());
设计洞察:payload(数据)与 ViewHolder(皮肤)正交——同一棵树可以换不同皮肤渲染(文件树/权限树共用核心)。这与 RecyclerView 的 Adapter/ViewHolder 分工一脉相承,再加上"树形结构"这一维。
2.2、渲染策略:DFS 展开集合 → 线性列表
折叠/展开的实现核心是一个"当前可见节点集合"的计算:
render(root) = DFS 遍历,只进入"已展开"的节点:
visit(node)
├─ 绘制 node(含缩进层级 padding = level * indent)
└─ if (node.isExpanded())
for (child : node.getChildren()) visit(child)
库的实现版本是嵌套容器渲染(每个非叶子节点是一个可折叠的容器 View,子节点往里塞)——简单直接,配合展开动画(高度动画)体验顺滑。但注意它不是 RecyclerView 方案:几千节点的树会有明显的 View 数量与测量开销(见第 4 节性能部分)。
2.3、勾选联动:树上行的半选传播
多选树的经典算法(设置树、权限树的标准行为):
勾选节点 n:
向下传播 —— n 及全部后代置为选中
向上回溯 —— n 的每个祖先:
若所有孩子选中 → 祖先置为"全选"
若部分孩子选中 → 祖先置为"半选"(indeterminate)
若无孩子选中 → 祖父取消选中
实现是 node.setSelected() 里的递归下降 + 父链回溯。这个算法值得背下来——前端组件库(Ant Design 的 Tree)、桌面 Qt 的 TreeWidget 全是同一套。本库通过 DefaultTreeHolder 内建支持,自定义 ViewHolder 时用 TreeView.selectNode() API 走统一路径。
2.4、保存与恢复:树状态的序列化
库提供 saveInstanceState / restoreState(展开状态、勾选状态打包成可存 Bundle 的结构)。树形 UI 的状态恢复是易漏项:旋转屏幕/进程重建后"展开到哪层、勾了哪些"丢失,用户重选一遍的体验极差——任何树形 UI 的验收清单第一项就该是状态恢复。
3、快速上手
// jitpack
implementation 'com.github.bmelnychuk:AndroidTreeView:0.2.5'
// 1) 造树
val root = TreeNode.root()
val docs = TreeNode(NodeData("我的文档")).let {
it.setViewHolder(TreeHolder(activity)); it
}
docs.addChild(TreeNode(FileData("报表.xlsx")).apply { setViewHolder(FileHolder(activity)) })
root.addChildren(docs, TreeNode(NodeData("图片")).apply { setViewHolder(TreeHolder(activity)) })
// 2) 渲染 + 基础 API
val treeView = TreeView(root, activity).apply {
setDefaultAnimation(true) // 展开折叠动画
setDefaultContainerStyle(R.style.TreeNodeStyleCustom)
setDefaultNodeClickListener { node, value -> … }
setDefaultNodeLongClickListener { node, value -> … }
}
container.addView(treeView.view)
treeView.expandNode(docs) // 展开
treeView.collapseNode(docs) // 折叠
treeView.expandAll() // 全展开(大树慎用)
val checked = treeView.checked // 勾选集合(多选模式)
自定义节点样式:继承 TreeNode.BaseNodeViewHolder<F>,createNodeView() 里 inflate 任意布局——树形 UI 的"皮肤"完全开放。
4、生产实践视角
- 性能红线:嵌套容器方案在500+ 节点全展开时测量/布局耗时明显(每层嵌套 LinearLayout 的 measure 是指数叠加)。大数据树的自救:默认只展开两层 + 懒加载(点击时才请求子节点,云盘场景天然适合);
- 懒加载姿势:叶子节点先挂一个"加载中"占位子节点,点击展开时替换真实子树——树形 + 分页请求的标准结合,避免一次性拉全量;
- 搜索定位:树内搜索的标准交互是"命中节点全部展开其祖先链 + 高亮"——实现为对树做一次 DFS 收集命中路径,
expandNode(ancestor…)逐层展开后scrollTo。库不内置,需要自己写(约 50 行); - 与 RecyclerView 生态整合:如果树的规模大且需要滚动复用,业界常用"扁平化树"方案——把可见节点集合算成一维
List<VisibleNode>塞给 RecyclerView(payload 带 level 用于缩进,展开/折叠只是增删区间)。这个方案与本库的嵌套方案可以互为参考,各取所长; - Compose 迁移:
LazyColumn天然是线性结构——用"扁平化树 + 动画增删"即可复刻:items(visibleNodes),展开时visibleList.addAll(index+1, subtree),配animateItemPlacement()获得顺滑展开效果。树逻辑(Node/勾选联动/DFS)100% 复用,只换渲染层。
5、横向对比
| 方案 | 渲染 | 规模上限 | 自定义 | 适合 |
|---|---|---|---|---|
| AndroidTreeView | 嵌套容器 + 动画 | 数百节点 | 强 | 中小树、企业工具 |
| 自研扁平化树 + RecyclerView | 线性 + 复用 | 万级节点 | 强 | 云盘/大数据树 |
| WebView + 前端树组件 | Web 渲染 | 大 | 前端栈 | 混合开发团队 |
| ExpandableListView(系统) | 两级列表 | 中 | 弱(仅两级) | 简单分组 |
| Compose LazyColumn + 扁平化 | 线性 + 复用 + 动画 | 万级 | 强 | 新项目 |
6、面试高频问答
- Q:树形结构如何存数据库? A:经典三案——邻接表(parent_id,读整树要递归查询)、路径枚举(path 字段如
/1/4/9/,查子树用 LIKE 前缀)、闭包表(ancestors 表空间换时间)。移动端一般服务端聚合好整棵树下发;离线场景 SQLite 可用WITH RECURSIVE。这是树形需求的隐藏考点——UI 库只解决展示,存储模型决定接口设计。 - Q:勾选联动为什么是两趟遍历? A:向下传播(影响后代)+ 向上回溯(重算祖先的 全选/半选/未选)缺一不可。只做向下会出现"勾子节点父节点不亮半选",只做回溯则后代不跟随——语义完整性要求两趟。这是状态在树上传播的一般规律(React 受控组件树的 setState 同理)。
- Q:嵌套容器方案的性能瓶颈具体在哪? A:测量递归——外层容器 measure 时所有可见子树重新测量,深层嵌套下单帧成本 O(总节点×深度)。扁平化方案把深度压成 O(1)(列表只有一层),这就是 RecyclerView 能扛万级节点的根本原因。
- Q:展开动画怎么做最顺? A:嵌套方案用高度动画(measure 展开后高度 → animate 到达);扁平化方案用列表差量动画(新增区间 fadeIn + 位移)。关键是只动画"变化区间",全量重刷必卡。
7、总结
AndroidTreeView 的价值在于把"树形 UI"拆成了三个可独立学习的模块:递归数据结构(Node + DFS)、渲染映射(树→线性/嵌套)、状态传播(勾选两趟算法)。这三块知识不依赖任何框架——换到 Compose、换到前端,照样成立。读它 + 亲手写一遍扁平化 RecyclerView 版,"树形需求"从此从难题清单里划掉。
8、参考资料
- 仓库:https://github.com/bmelnychuk/AndroidTreeView
- 树形数据建模(闭包表等):https://stackoverflow.com/questions/4048151/what-are-the-options-for-storing-hierarchical-data-in-a-relational-database
- LazyColumn 动画:https://developer.android.com/develop/ui/compose/lists?hl=zh-cn#animate-items
- RecyclerView:https://developer.android.com/develop/ui/views/layout/recyclerview


