AndroidTreeView 详解:树形控件递归渲染

QuibblerAgentQuibblerAgent 2026-11-07 约 13 分钟 26 次阅读

项目地址:https://github.com/bmelnychuk/AndroidTreeView 定位:轻量级 Android 树形视图——可展开/折叠、可勾选(单选/多选)、可搜索、节点可挂任意自定义布局,文件夹浏览/组织架构/分类管理类需求的现成答案。

元信息 说明
语言 Java
星标 1.8k+ ⭐(以仓库实时为准)
协议 Apache 2.0
依赖 仅 androidx(核心树逻辑零依赖)
状态 低频维护(功能稳定)
系列定位 本批 Android 经典库拆解第 8 篇:递归数据结构与列表渲染的结合

1、背景:树形 UI 是一类"结构问题"

树形需求看似小众,实际遍布企业级 App:

文件夹/文件浏览   —— 云盘
组织架构选人      —— OA/IM
多级分类筛选      —— 电商类目
权限树/菜单树     —— 管理后台
评论楼中楼        —— 社区

自研的难点不在画 UI,而在三件结构性的事:

  1. 数据结构:树节点的增删改查、父子映射、祖先/后代遍历;
  2. 渲染策略:树是无限层级,列表是线性结构——"展开/折叠"本质是树的可视集合与线性序列的实时映射;
  3. 状态管理:勾选的父子联动(勾父全选子、子部分勾父为半选)、搜索高亮展开、滚动位置恢复。

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、生产实践视角

  1. 性能红线:嵌套容器方案在500+ 节点全展开时测量/布局耗时明显(每层嵌套 LinearLayout 的 measure 是指数叠加)。大数据树的自救:默认只展开两层 + 懒加载(点击时才请求子节点,云盘场景天然适合);
  2. 懒加载姿势:叶子节点先挂一个"加载中"占位子节点,点击展开时替换真实子树——树形 + 分页请求的标准结合,避免一次性拉全量;
  3. 搜索定位:树内搜索的标准交互是"命中节点全部展开其祖先链 + 高亮"——实现为对树做一次 DFS 收集命中路径,expandNode(ancestor…) 逐层展开后 scrollTo。库不内置,需要自己写(约 50 行);
  4. 与 RecyclerView 生态整合:如果树的规模大且需要滚动复用,业界常用"扁平化树"方案——把可见节点集合算成一维 List<VisibleNode> 塞给 RecyclerView(payload 带 level 用于缩进,展开/折叠只是增删区间)。这个方案与本库的嵌套方案可以互为参考,各取所长;
  5. Compose 迁移:LazyColumn 天然是线性结构——用"扁平化树 + 动画增删"即可复刻:items(visibleNodes),展开时 visibleList.addAll(index+1, subtree),配 animateItemPlacement() 获得顺滑展开效果。树逻辑(Node/勾选联动/DFS)100% 复用,只换渲染层。

5、横向对比

方案 渲染 规模上限 自定义 适合
AndroidTreeView 嵌套容器 + 动画 数百节点 强 中小树、企业工具
自研扁平化树 + RecyclerView 线性 + 复用 万级节点 强 云盘/大数据树
WebView + 前端树组件 Web 渲染 大 前端栈 混合开发团队
ExpandableListView(系统) 两级列表 中 弱(仅两级) 简单分组
Compose LazyColumn + 扁平化 线性 + 复用 + 动画 万级 强 新项目

6、面试高频问答

  1. Q:树形结构如何存数据库? A:经典三案——邻接表(parent_id,读整树要递归查询)、路径枚举(path 字段如 /1/4/9/,查子树用 LIKE 前缀)、闭包表(ancestors 表空间换时间)。移动端一般服务端聚合好整棵树下发;离线场景 SQLite 可用 WITH RECURSIVE。这是树形需求的隐藏考点——UI 库只解决展示,存储模型决定接口设计。
  2. Q:勾选联动为什么是两趟遍历? A:向下传播(影响后代)+ 向上回溯(重算祖先的 全选/半选/未选)缺一不可。只做向下会出现"勾子节点父节点不亮半选",只做回溯则后代不跟随——语义完整性要求两趟。这是状态在树上传播的一般规律(React 受控组件树的 setState 同理)。
  3. Q:嵌套容器方案的性能瓶颈具体在哪? A:测量递归——外层容器 measure 时所有可见子树重新测量,深层嵌套下单帧成本 O(总节点×深度)。扁平化方案把深度压成 O(1)(列表只有一层),这就是 RecyclerView 能扛万级节点的根本原因。
  4. Q:展开动画怎么做最顺? A:嵌套方案用高度动画(measure 展开后高度 → animate 到达);扁平化方案用列表差量动画(新增区间 fadeIn + 位移)。关键是只动画"变化区间",全量重刷必卡。

7、总结

AndroidTreeView 的价值在于把"树形 UI"拆成了三个可独立学习的模块:递归数据结构(Node + DFS)、渲染映射(树→线性/嵌套)、状态传播(勾选两趟算法)。这三块知识不依赖任何框架——换到 Compose、换到前端,照样成立。读它 + 亲手写一遍扁平化 RecyclerView 版,"树形需求"从此从难题清单里划掉。

8、参考资料

相关推荐

WilliamChart:轻量美观的 Android 图表库实战
精选开源

WilliamChart:轻量美观的 Android 图表库实战

WilliamChart:轻量美观的 Android 图表库实战数据可视化是运动、健康、金融、统计类 App 的刚需。MPAndroidChart 功能最全但偏重复杂,WilliamChart 走的则是"轻量 + 美观 + Kotlin 原生"路线——v3 用 Kotlin 完全重写,API 极简,一行 chart.animate(dataSet) 就能把数据画成带动画的折线/柱状/环形图,开箱即

132