这些年做 Android 开发我碰到最多的需求之一就是带分组的列表。通讯录按首字母分组的联系人、电商 App 里分类下的商品清单、设置页里分组排列的选项都属于这种两级结构。用普通 ListView 去怼要么代码写得绕要么直接把布局文件堆到卡顿。ExpandableListView 就是专为一级分组 二级条目这种场景设计的控件。这篇文章会站在零基础的角度把 ExpandableListView 的基本使用讲透包括布局怎么写、Adapter 怎么继承、数据怎么组织、有哪些坑绕不过去。适合刚学完 ListView 基础、准备接触第二个列表控件的初学者也适合那些平时用 RecyclerView、但需要快速接手老项目的开发者。1. 为什么要用 ExpandableListView两级列表的场景痛点1.1 单层 ListView 解决不了的分组需求很多新手在拿到分组列表需求时第一反应是我用 ListView 写两遍不就行了逻辑上确实能想通——外层 ListView 放分组名内层再放一个 ListView 放条目。但真做起来会发现两个致命问题。第一个是滑动冲突。内层 ListView 和外层 ListView 的垂直滑动互相抢事件内层列表滚到一半手指一动外层也跟着滚整个界面手感非常怪。要解决就得重写触摸事件分发逻辑对新手来说这比需求本身还难。第二个是性能问题。每个分组下面挂几十个条目外层 ListView 每个 item 都嵌套一个 ListView等于同时创建了大量的 View。数据量一大滑动直接掉帧内存占用也高得离谱。这就好比每层楼都单独盖一部电梯楼还没盖高成本先爆了。ExpandableListView 就是为这种场景设计的。它本身就是一个列表控件内部通过 Group 和 Child 两级数据来呈现结构Group 是分组项Child 是分组下的子项。点击 Group 时展开或收起其下的 Child滑动时整个列表作为一个整体滚动不存在嵌套滑动的问题。1.2 ExpandableListView 的本质还是 ListView 那一套从继承关系上说ExpandableListView 直接继承自 ListView所以 ListView 的大部分特性它都有Adapter 驱动数据、View 复用机制、setOnItemClickListener 之类的监听。它的特殊之处在于数据不再是简单的 List而是被组织成分组 子项的两级结构对应的 Adapter 也从 ListAdapter 换成了 ExpandableListAdapter。理解这一点很重要。因为你以前学 ListView 时积累的经验——比如 convertView 复用、BaseAdapter 的 getCount 和 getView 分工——在这里基本都能平移过来只是方法名变成了 getGroupView 和 getChildView 而已。另外要注意ExpandableListView 默认是不显示指示器右边的那个小箭头的属性 groupIndicator 默认给了一个展开/收起的图标但实际效果因主题而异。后面布局部分我会专门说这个。现在先记住这个控件的核心就是 Adapter 里那十几个回调方法。2. 从零搭建一个可折叠列表布局与最小可用代码2.1 布局文件的写法与几个容易被忽略的属性先把布局写出来。我习惯在项目里用一个单独的 Fragment 或者 Activity 来承载列表但为了演示简单这里直接用 Activity 的布局。新建一个activity_expandable_demo.xml?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical ExpandableListView android:idid/expandable_list android:layout_widthmatch_parent android:layout_heightmatch_parent android:groupIndicatornull android:childDivider#F0F0F0 android:divider#E0E0E0 android:dividerHeight1dp/ /LinearLayout三个属性我特意写上都是有坑的地方android:groupIndicatornull去掉分组项左边的系统默认箭头。默认箭头在不同主题下样式很丑还和文字挤在一起。去掉之后我们可以自己在 group 的布局文件里加一个 ImageView 来控制箭头方向灵活度高得多。android:childDivider子项之间的分隔线。ExpandableListView 默认会让子项分隔线紧贴文字视觉上很怪实际项目里我一般给个浅色值或者干脆和 divider 同色。android:divider和android:dividerHeight这是继承自 ListView 的属性控制的是分组项之间的分隔线。不设置的话分组项之间没有任何视觉分隔展开状态下会分不清组与组之间的边界。这里的核心逻辑是ExpandableListView 实际上有两条分隔线体系分组间一条、子项间一条分别用 divider 和 childDivider 控制初学者最容易忽略这一点导致展开后的列表显得乱糟糟。2.2 最小可用代码二十行把列表跑起来布局搞定后接下来是数据。ExpandableListView 的数据源通常是这样组织的外层是一个 List代表所有分组每个分组的值又是一个 List代表该分组下的子项。对应到 Java 代码就是ListString嵌套ListString或者更常见的是ListListBean。先写一个最简单的最小 Demo。Activity 里这样写public class ExpandableDemoActivity extends AppCompatActivity { private ExpandableListView expandableListView; private ListString groupList new ArrayList(); private ListListString childList new ArrayList(); Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_expandable_demo); expandableListView findViewById(R.id.expandable_list); // 准备测试数据 groupList.add(水果); groupList.add(饮料); childList.add(Arrays.asList(苹果, 香蕉, 橙子)); childList.add(Arrays.asList(可乐, 雪碧, 果汁)); BaseExpandableListAdapter adapter new BaseExpandableListAdapter() { Override public int getGroupCount() { return groupList.size(); } Override public int getChildrenCount(int groupPosition) { return childList.get(groupPosition).size(); } Override public Object getGroup(int groupPosition) { return groupList.get(groupPosition); } Override public Object getChild(int groupPosition, int childPosition) { return childList.get(groupPosition).get(childPosition); } Override public long getGroupId(int groupPosition) { return groupPosition; } Override public long getChildId(int groupPosition, int childPosition) { return childPosition; } Override public boolean hasStableIds() { return true; } Override public View getGroupView(int groupPosition, boolean isExpanded, View convertView, ViewGroup parent) { TextView textView new TextView(ExpandableDemoActivity.this); textView.setText(groupList.get(groupPosition)); textView.setTextSize(18); textView.setPadding(40, 30, 20, 30); return textView; } Override public View getChildView(int groupPosition, int childPosition, boolean isLastChild, View convertView, ViewGroup parent) { TextView textView new TextView(ExpandableDemoActivity.this); textView.setText(childList.get(groupPosition).get(childPosition)); textView.setTextSize(15); textView.setPadding(80, 25, 20, 25); return textView; } Override public boolean isChildSelectable(int groupPosition, int childPosition) { return true; } }; expandableListView.setAdapter(adapter); } }这段代码把接口里所有抽象方法都覆盖了。getGroupCount返回分组数量getChildrenCount返回某个分组下子项的数量这两个决定了数据的长相。getGroupView和getChildView分别返回每一项的 View。跑起来之后点击水果下面的苹果、香蕉、橙子就会展开再点一次就收回去。这里有个很多人第一次写会卡住的点isChildSelectable必须返回 true否则子项点击监听会失效。这个方法的含义是子项是否可以被选中返回 false 时 child 点击事件压根不会触发新手排查半天以为是监听写错了其实问题在这。3. BaseExpandableListAdapter 核心方法逐个拆解3.1 组方法与子项方法的分工对照BaseExpandableListAdapter 是个抽象类实现它需要覆写十几个方法看起来多但拆开看其实就两组一组管 Group一组管 Child。我把它们放在一张表里对照着看方法作用对应组/子项getGroupCount()分组的总数量GroupgetChildrenCount(int groupPosition)指定分组下子项的数量ChildgetGroup(int groupPosition)返回指定分组的数据对象GroupgetChild(int groupPosition, int childPosition)返回指定子项的数据对象ChildgetGroupId(int groupPosition)返回分组的 IDGroupgetChildId(int groupPosition, int childPosition)返回子项的 IDChildhasStableIds()ID 是否稳定常用于数据变化时维护选中状态通用getGroupView(...)创建/复用分组项的 ViewGroupgetChildView(...)创建/复用子项的 ViewChildisChildSelectable(int, int)子项是否可点击选中Child看一眼这张表你会发现它和 BaseAdapter 的方法几乎一一对应只是多了getChildrenCount、getChild、getChildView、isChildSelectable这几个专属于子层级的方法。理解了这个对称性就理解了 ExpandableListAdapter 的设计逻辑它不是一个扁平的列表而是在 BaseAdapter 的每个 item 上又挂了一层子列表。有个细节值得注意getGroupId和getChildId的返回值。官方推荐让它们尽量稳定这样当数据源变化、界面刷新时系统能准确判断哪些 item 需要重新绑定。简单场景下直接返回 position 就行但如果你在项目里做了删除、插入操作position 是会变的此时最好根据业务 ID 来返回。3.2 ViewHolder 复用可折叠列表同样会卡前面 Demo 里我直接 new 了 TextView那是最偷懒的写法实际项目千万别这么干。ExpandableListView 也是基于 Adapter 的同样有 convertView 复用机制。如果像 Demo 一样每次都 new View数据量一旦上百滑动就会明显掉帧。这跟我前面说嵌套 ListView 卡顿是同一个道理只是这次很容易被忽略因为 ExpandableListView 的流畅度本来就比嵌套好前期小数据量根本试不出来。正确的写法是给 Group 和 Child 都做 ViewHolder。以子项为例Override public View getChildView(int groupPosition, int childPosition, boolean isLastChild, View convertView, ViewGroup parent) { ViewHolderChild holder; if (convertView null) { convertView LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_child, parent, false); holder new ViewHolderChild(); holder.tvName convertView.findViewById(R.id.tv_name); convertView.setTag(holder); } else { holder (ViewHolderChild) convertView.getTag(); } holder.tvName.setText(childList.get(groupPosition).get(childPosition)); return convertView; } static class ViewHolderChild { TextView tvName; }分组项的 getGroupView 同理多一个参数isExpanded。这个参数非常关键它告诉你当前分组是不是展开状态通常在绘制分组布局的箭头图标时要用到。展开时箭头朝下收起时箭头朝右就是靠它判断的。实际项目里我通常把箭头做成分组布局里的一个 ImageView然后根据 isExpanded 切换资源而不是依赖系统 groupIndicator。这样样式可控也避免了主题差异带来的显示问题。4. 数据源设计Group 和 Child 的选型与组织4.1 最简单的 List 嵌套 List 方案如果业务简单用ListStringListListString就够了。比如设置页的分组标题、城市列表按省份分组。结构可以用一张图理解外层 List 是行内层 List 是每个分组行下面的展开内容。Adapter 通过getChildrenCount(groupPosition)取内层 List 的长度通过getChild(groupPosition, childPosition)取具体子项。这种方案的优点是写起来快适合 Demo 和原型验证。缺点是数据对象不是实体 Bean一旦子项需要绑定多个字段比如商品有名称、价格、库存String 就不够用了。这时候就要升级数据结构。大多数实际项目子项都是一个实体类。比如商品分类下是商品那我一般这么设计public class Product { private String name; private double price; private int stock; // getter / setter 省略 } public class ProductCategory { private String categoryName; private ListProduct productList; // getter / setter 省略 }Activity 里维护一个ListProductCategoryAdapter 里这样取数Override public int getGroupCount() { return categoryList.size(); } Override public int getChildrenCount(int groupPosition) { return categoryList.get(groupPosition).getProductList().size(); } Override public Object getChild(int groupPosition, int childPosition) { return categoryList.get(groupPosition).getProductList().get(childPosition); }注意一点这个 List 必须保证增删时同步更新否则 getChildrenCount 返回的数量和实际数据对不上会直接抛 IndexOutOfBoundsException。我调试时最常遇到的就是空数据源问题某个分组下子项是空 ListgetChildrenCount 返回 0这没问题但如果你忘了判空直接去 get(0)必崩。4.2 业务数据复杂时的适配思路有些场景业务接口返回的数据并不是规整的两层结构而是平铺的一层。比如接口一次性返回所有城市每个城市带一个 provinceId 字段。这时候把平铺数据转换成两级结构就是 Adapter 之前必须做的一件事。我通常会在网络请求回调里做一次归组操作MapString, ListCity map new HashMap(); for (City city : allCities) { if (!map.containsKey(city.getProvinceId())) { map.put(city.getProvinceId(), new ArrayListCity()); } map.get(city.getProvinceId()).add(city); }然后用 map 的 keySet 构建 groupList每组 id 作为关联。这样分层数据结构就稳定了Adapter 不需要知道网络结构只需要操作归组后的 List。这条思路放之四海皆准——不管接口长什么样在进 Adapter 之前先把数据整理成你想要的层级。另外提一句如果你的分组数量很大比如上千个分组ExpandableListView 的 Adapter 会频繁调用 getGroupView此时务必保证数据查询是 O(1) 级别的不要在 getGroupView 里做遍历。我就见过有人在 getGroupView 里循环查找某条数据数据量大时界面直接卡死。5. 交互细节默认展开、单选展开、点击事件与常见坑5.1 控制默认展开状态ExpandableListView 有一个同步方法expandGroup(int groupPosition)和collapseGroup(int groupPosition)。如果你想让某个分组默认展开在 setAdapter 后调用即可expandableListView.setAdapter(adapter); expandableListView.expandGroup(0); // 默认展开第一个分组有个坑要注意必须在 setAdapter 之后调用才生效。在 setAdapter 之前调用会得到一个不稳定的状态因为 Adapter 还没绑定分组位置和内部状态都没有初始化。你应该把 expandGroup 放在 setAdapter 之后的同一循环队列里或者用post确保布局完成后再展开。另外如果你需要多个分组默认展开循环调用 expandGroup 就行了。如果你希望某个分组永远不能折叠可以在 OnGroupClickListener 里返回 true 拦截默认行为这个下面会细说。5.2 只允许同时展开一个分组展开多个分组虽然系统不限制但实际产品里很多需求要求同一时间只能展开一个——比如手风琴效果。实现思路很简单在 OnGroupClickListener 里判断点击的分组是否已展开。如果展开则收起它并且收起之前展开的那一个。expandableListView.setOnGroupClickListener(new ExpandableListView.OnGroupClickListener() { Override public boolean onGroupClick(ExpandableListView parent, View v, int groupPosition, long id) { // 如果当前分组已展开那么允许系统执行收起 if (expandableListView.isGroupExpanded(groupPosition)) { expandableListView.collapseGroup(groupPosition); return true; // 消费事件避免走默认的展开逻辑 } // 否则先收起所有再展开当前分组 // 一次性收起全部 for (int i 0; i adapter.getGroupCount(); i) { if (expandableListView.isGroupExpanded(i)) { expandableListView.collapseGroup(i); } } expandableListView.expandGroup(groupPosition); return true; } });这里的return true是关键返回 true 表示点击事件被消费系统不会执行默认的展开/收起逻辑。所以展开、收起的动作都得自己控制。返回 false 时系统会在你的逻辑执行完之后再执行一遍默认逻辑导致状态错乱。这个规律在 ExpandableListView 的几个监听器里都适用。5.3 子项点击与组点击的边界子项点击用setOnChildClickListener这个和 ListView 的 OnItemClickListener 类似expandableListView.setOnChildClickListener(new ExpandableListView.OnChildClickListener() { Override public boolean onChildClick(ExpandableListView parent, View v, int groupPosition, int childPosition, long id) { // 拿到子项数据和业务 id跳转或弹窗 Object childData adapter.getChild(groupPosition, childPosition); Toast.makeText(MainActivity.this, childData.toString(), Toast.LENGTH_SHORT).show(); return true; } });这里有一个常见的误解很多新手以为 click 事件只要写了监听就会触发。对于子项前面提过的isChildSelectable必须返回 true这个监听才会回调。对于分组组的点击事件会优先于子项——你点在组标题上触发 OnGroupClickListener点在展开后的子项上触发 OnChildClickListener。两者边界很清晰不需要额外处理。还有一种情况组点击和展开图标点击是同一个区域。如果布局里组标题占满整行用户点击任何位置都会展开或收起。如果你期望只有点箭头图标才展开就得在 getGroupView 里给图标单独设点击监听。我遇到过的真实需求是点击组标题只是进入分组详情点箭头才展开收起。实现方式是在 getGroupView 里把箭头 ImageView 设为可点击并在点击时手动 expandGroup/collapseGroup而组的 OnGroupClickListener 返回 true 拦截默认行为。这样就把两个动作彻底分开了。6. 踩坑与优化经验总结6.1 数据更新后界面不刷新用 ExpandableListView 时我踩过的第一个坑是数据更新后界面一动不动。后来想明白了ExpandableListView 本身不会监听你的数据 List 变化你必须手动通知 Adapter。这点和 ListView 完全一样只是很多人第一次接触两层结构时更容易忽略。正确做法是在数据源变化后调用 adapter.notifyDataSetChanged()。但如果你用的是匿名内部类写的 BaseExpandableListAdapter注意把 adapter 声明成成员变量否则 notify 不到。另外更新数据时尽量替换整个 List 的引用或者在原 List 上 clear 再 addAll不要在 getGroupCount 已经变化后还留着旧 List 的长度信息。还有一个细节如果你调用了 notifyDataSetChanged界面上所有分组会恢复到默认状态全部收起。如果用户本来展开到第三个分组刷新后会被打回原形。要保状态的话需要在刷新前记录展开的分组位置刷新后逐一 expandGroup 回去。这个需求在下拉刷新后保持展开状态的产品里很常见。6.2 子和组 RecyclerView 时代怎么选现在新项目基本都用 RecyclerView 实现分组折叠效果了因为 ExpandableListView 毕竟是老控件很多新特性比如 item 动画、span 网格布局都不支持。但老项目的维护和新手学基础ExpandableListView 依然有它的价值代码量小、结构清晰、不用引入第三方库学习成本比 RecyclerView 多 ViewType 低得多。我的建议是如果你只是做一个简单的两级展开列表而且 App 的最低版本不太新ExpandableListView 完全够用。如果需求里有复杂的 item 布局、滑动删除、拖拽排序、动画那就直接用 RecyclerView配合自定义的 Adapter 实现展开收起或者引入现成的第三方折叠列表库。这里不展开讲 RecyclerView 方案了想说的是选型要根据真实需求来。6.3 几个最容易在真机上翻车的细节最后整理几条我在实际项目中踩过的具体问题每条都对应一个明确的解法问题现象解法子项分割线消失展开后子项之间没有分割线检查是否设置了android:childDivider如果为透明值会看不见子项可点击但无点击效果点击子项没有波纹或高亮给子项布局设置背景选择器selector分组默认展开失效expandGroup 无效确认是在 setAdapter之后调用展开状态切换后箭头不对箭头不动或方向反在 getGroupView 里用 isExpanded 参数切换图片资源数据量大滑动卡顿快速滑动时掉帧确认 getGroupView/getChildView 里没有 new View且数据操作为 O(1)拿第一条来说我以前做个项目子项分割线设置了#F0F0F0结果真机上就是看不到线排查半天发现是因为某个样式文件里把 childDivider 覆写成了透明。这种问题不出现在模拟器上因为模拟器的主题和真机皮肤不一样。所以遇到分割线、背景色这类视觉问题第一反应应该是去查主题和样式覆盖而不是怀疑代码逻辑。还有一条很重要的经验使用 convertView 时一定要判断数据的 position 有效性。ExpandableListView 复用的 View 对象可能来自同一个位置也可能来自不同分组。如果你在 getChildView 里用旧 position 的引用去取数据数据错乱是必然的。所有数据一定要从当前回调参数里取不要缓存 position。写在最后这篇文章里所有代码我都在本地反复跑过虽然 Demo 看着简单但里面每一个注意点都是我在真实项目里踩过的。ExpandableListView 如今不算新潮但它帮你理解列表控件的本质Adapter 驱动、View 复用、事件分发。把这一套吃透后面学 RecyclerView 会轻松非常多。最后分享一个小技巧当你调试展开收起的逻辑时多利用onGroupExpand和onGroupCollapse这两个监听器打点日志它们能告诉你系统实际执行了哪些动作比自己瞎猜快得多。