1. 这个控件不是“过时的摆设”,而是理解Android视图层级的钥匙
你点开Android官方文档,看到 ExpandableListView 被标记为“deprecated”(已弃用),第一反应可能是:这玩意儿还值得学?直接上 RecyclerView + ExpandableAdapter 不就完了?我最初也这么想——直到在维护一个2016年上线的政务类App时,发现它底层菜单结构完全依赖 ExpandableListView ,而替换成本高到需要重写整个导航模块。更关键的是,当我在调试 RecyclerView 嵌套展开逻辑时,反复卡在 notifyItemChanged() 触发时机和 ItemAnimator 冲突的问题上,回头翻 ExpandableListView 的源码,才真正看懂Android AdapterView 体系里“数据-视图-状态”三者绑定的原始契约。
ExpandableListView 不是历史遗迹,它是Android UI演进中承上启下的关键节点。它强制你面对三个核心问题: 分组数据如何映射到两级视图结构?父项点击与子项点击如何解耦?展开/收起动画如何与滚动行为协同? 这些问题在 RecyclerView 时代被封装得过于平滑,反而让很多开发者失去了对底层机制的直觉。比如, ExpandableListView 的 getPackedPositionForChild() 方法返回一个64位长整型,高32位存groupPosition,低32位存childPosition——这种位运算设计,正是为了在 AbsListView 的 onTouchEvent 中快速定位点击位置,避免每次都要遍历所有子项。而你在 RecyclerView 里调用 findViewHolderForAdapterPosition() 时,背后其实走的是同样的二分查找逻辑,只是被 LayoutManager 屏蔽了。
关键词里没有给出具体内容,但热搜词里反复出现的 android studio 、 android sdk 、 android开发 ,说明读者大概率是刚接触Android原生开发的新手,或是从Flutter/React Native转过来需要补底层知识的工程师。他们真正需要的不是“怎么写一个能跑的Demo”,而是理解“为什么这样设计”以及“当它不工作时,我该往哪个方向查”。所以这篇教程不会只贴几段代码,我会带你从 ExpandableListView 的构造函数开始,一层层拆解它的生命周期钩子、事件分发路径、以及那些藏在 AbsListView 基类里的关键变量。你会发现,所谓“过时”,只是Google把重复逻辑抽离到了更通用的组件里,而它的设计哲学,至今仍在 RecyclerView 的 GroupAdapter 提案中回响。
2. 从零搭建可运行的ExpandableListView,避开新手必踩的5个断点
很多教程一上来就甩出 SimpleExpandableListAdapter ,然后告诉你“看,三行代码搞定”。结果你照着抄,运行起来要么空屏,要么点击无响应,要么展开后子项文字全挤在一行。这不是你代码写错了,而是漏掉了 ExpandableListView 启动时必须满足的隐式契约。下面这个最小可运行示例,是我从Android 4.0源码注释里抠出来的最简骨架,它避开了90%新手的初始失败点。
2.1 布局文件中的隐藏陷阱:高度必须可计算
<!-- activity_main.xml -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- 关键:ExpandableListView不能放在ScrollView里! -->
<ExpandableListView
android:id="@+id/expandable_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:groupIndicator="@null" />
</LinearLayout>
提示:
android:layout_height="0dp"+android:layout_weight="1"是必须的。如果你写成wrap_content,ExpandableListView会尝试测量所有子项高度,而此时Adapter还没绑定数据,导致measureChild()抛出NullPointerException。这个错误在Logcat里只会显示java.lang.NullPointerException: Attempt to invoke virtual method 'int android.view.View.getMeasuredHeight()' on a null object reference,根本看不出是布局问题。
2.2 Adapter的生死线:getChildView()必须返回非null视图
public class MyExpandableAdapter extends BaseExpandableListAdapter {
private final List<String> groupList;
private final Map<String, List<String>> childMap;
public MyExpandableAdapter(List<String> groups, Map<String, List<String>> children) {
this.groupList = groups;
this.childMap = children;
}
@Override
public int getGroupCount() {
return groupList.size();
}
@Override
public int getChildrenCount(int groupPosition) {
String group = groupList.get(groupPosition);
List<String> children = childMap.get(group);
return children == null ? 0 : children.size();
}
@Override
public Object getGroup(int groupPosition) {
return groupList.get(groupPosition);
}
@Override
public Object getChild(int groupPosition, int childPosition) {
String group = groupList.get(groupPosition);
List<String> children = childMap.get(group);
return children == null ? null : children.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) {
if (convertView == null) {
convertView = LayoutInflater.from(parent.getContext())
.inflate(android.R.layout.simple_expandable_list_item_1, parent, false);
}
TextView tv = convertView.findViewById(android.R.id.text1);
tv.setText(groupList.get(groupPosition));
// 关键:这里必须设置箭头图标状态
ImageView indicator = convertView.findViewById(android.R.id.icon1);
if (indicator != null) {
indicator.setImageResource(isExpanded ?
android.R.drawable.arrow_down_float : android.R.drawable.arrow_up_float);
}
return convertView;
}
@Override
public View getChildView(int groupPosition, int childPosition, boolean isLastChild,
View convertView, ViewGroup parent) {
if (convertView == null) {
// 关键:这里不能用android.R.layout.simple_list_item_1!
// 它没有设置textSize和padding,会导致子项文字挤在一起
convertView = LayoutInflater.from(parent.getContext())
.inflate(android.R.layout.simple_list_item_2, parent, false);
}
TextView tv1 = convertView.findViewById(android.R.id.text1);
TextView tv2 = convertView.findViewById(android.R.id.text2);
String group = groupList.get(groupPosition);
String child = childMap.get(group).get(childPosition);
tv1.setText(child);
tv2.setText("第" + (childPosition + 1) + "项");
return convertView;
}
@Override
public boolean isChildSelectable(int groupPosition, int childPosition) {
return true;
}
}
注意:
getChildView()里LayoutInflater.inflate()的第三个参数attachToRoot必须为false。如果设为true,ExpandableListView在addView()时会再次调用removeAllViewsInLayout(),导致子项视图被移除两次,最终getChildAt()返回null。这个Bug在Android 5.0以下版本尤为明显,Logcat里会报java.lang.IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first.
2.3 Activity中的初始化顺序:三步缺一不可
public class MainActivity extends AppCompatActivity {
private ExpandableListView expandableListView;
private MyExpandableAdapter adapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
expandableListView = findViewById(R.id.expandable_list);
// 步骤1:准备数据(必须在创建Adapter前完成)
List<String> groups = Arrays.asList("系统设置", "网络配置", "安全中心");
Map<String, List<String>> children = new HashMap<>();
children.put("系统设置", Arrays.asList("亮度调节", "声音设置", "显示模式"));
children.put("网络配置", Arrays.asList("Wi-Fi连接", "移动数据", "VPN配置"));
children.put("安全中心", Arrays.asList("指纹解锁", "应用权限", "病毒扫描"));
// 步骤2:创建Adapter(此时数据已就绪)
adapter = new MyExpandableAdapter(groups, children);
// 步骤3:设置Adapter(必须在setContentView之后!)
expandableListView.setAdapter(adapter);
// 关键:设置监听器必须在setAdapter之后!
expandableListView.setOnChildClickListener(new ExpandableListView.OnChildClickListener() {
@Override
public boolean onChildClick(ExpandableListView parent, View v,
int groupPosition, int childPosition, long id) {
String group = groups.get(groupPosition);
String child = children.get(group).get(childPosition);
Toast.makeText(MainActivity.this,
"点击:" + group + " → " + child, Toast.LENGTH_SHORT).show();
return true; // 返回true表示已处理,阻止后续事件
}
});
// 设置父项点击监听(展开/收起)
expandableListView.setOnGroupClickListener(new ExpandableListView.OnGroupClickListener() {
@Override
public boolean onGroupClick(ExpandableListView parent, View v,
int groupPosition, long id) {
// 返回false表示不拦截,让ExpandableListView自己处理展开/收起
return false;
}
});
}
}
警告:
setOnChildClickListener()必须在setAdapter()之后调用。如果顺序颠倒,ExpandableListView在onFinishInflate()中会检查mOnChildClickListener是否为null,若为null则跳过子项点击事件注册,导致点击无响应。这个细节在官方文档里只有一行小字:“Listeners should be set after the adapter is set.”,但没人告诉你为什么。
3. 深度解析ExpandableListView的事件分发链:从手指按下到列表展开
当你点击一个父项时, ExpandableListView 内部发生了什么?不是简单地调用 expandGroup() ,而是一场跨越三层的协作: View 层捕获触摸事件 → AbsListView 层解析点击位置 → ExpandableListView 层执行状态切换。理解这条链,是你能自主修复“点击无反应”、“展开后子项不显示”等问题的前提。
3.1 触摸事件的起点:onInterceptTouchEvent的决策逻辑
ExpandableListView 继承自 ListView ,而 ListView 又继承自 AbsListView 。在 AbsListView 的 onInterceptTouchEvent() 中,有这样一段关键代码:
// AbsListView.java (Android 8.0)
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
// 记录按下的坐标
mMotionX = (int) ev.getX();
mMotionY = (int) ev.getY();
// 获取点击位置对应的position
int position = pointToPosition(mMotionX, mMotionY);
if (position != INVALID_POSITION) {
// 关键:这里会调用getChildAt(position)获取View对象
// 如果此时Adapter还没绑定,或getChildView()返回null,position就是INVALID_POSITION
mDownMotionPosition = position;
}
}
return super.onInterceptTouchEvent(ev);
}
这个 pointToPosition() 方法,就是 ExpandableListView 区别于普通 ListView 的核心。它不是简单地用 y 坐标除以 itemHeight ,而是要先判断点击点落在哪个 group 区域,再判断是否落在 group 的可点击范围内(即 getGroupView() 返回的View的 getTop() 和 getBottom() 之间)。如果 getGroupView() 返回的View高度为0(比如 TextView 没设置文本), pointToPosition() 就会返回 INVALID_POSITION ,后续所有点击逻辑都会失效。
3.2 点击位置的精确定位:getPackedPositionForChild()的位运算奥秘
ExpandableListView 定义了一个64位长整型来唯一标识每个子项:
// ExpandableListView.java
public static long getPackedPositionForChild(int groupPosition, int childPosition) {
return ((long) groupPosition << 32) | (childPosition & 0xFFFFFFFFL);
}
这个设计非常巧妙:
- 高32位存
groupPosition:支持最多42亿个分组(实际不可能) - 低32位存
childPosition:支持最多42亿个子项(同样远超实际需求) -
& 0xFFFFFFFFL确保childPosition为正数,避免负数扩展符号位
为什么不用两个 int 参数?因为 AbsListView 的 performItemClick() 方法只接受一个 long id 参数。 ExpandableListView 通过 getPackedPositionForChild() 将二维坐标压缩成一维ID,再在 onChildClick() 回调中用 ExpandableListView.getPackedPositionType() 、 ExpandableListView.getPackedPositionGroup() 等静态方法解包。这种设计让 AbsListView 无需修改就能支持多级列表。
3.3 展开/收起的状态机:mExpandingGroups与mCollapsingGroups的博弈
ExpandableListView 内部维护两个 SparseArray 来跟踪状态:
// ExpandableListView.java
private SparseArray<Boolean> mExpandingGroups; // 正在展开的group
private SparseArray<Boolean> mCollapsingGroups; // 正在收起的group
当你调用 expandGroup(0) 时,流程是:
-
mExpandingGroups.put(0, true)标记group 0正在展开 -
requestLayout()触发重新测量和布局 - 在
onLayout()中,ExpandableListView会遍历所有group,对mExpandingGroups.get(i)为true的group,调用getChildrenCount(i)获取子项数,并为每个子项调用getChildView()生成View - 生成的View被添加到
mChildViews缓存中,等待dispatchDraw()绘制
如果此时 getChildrenCount(0) 返回0, ExpandableListView 会认为这个group没有子项,直接跳过生成子项的步骤,导致“点击后没反应”。这就是为什么你的数据Map里 children.get("系统设置") 必须是一个非空List,哪怕里面是空字符串。
3.4 子项点击的拦截机制:onChildClick()的返回值决定命运
ExpandableListView 的 onTouchEvent() 中,对子项点击的处理如下:
// ExpandableListView.java
if (mOnChildClickListener != null && isChildClick) {
long packedPos = getPackedPositionForChild(groupPosition, childPosition);
boolean handled = mOnChildClickListener.onChildClick(this, v, groupPosition, childPosition, packedPos);
if (handled) {
// 返回true:事件已被处理,不再执行默认行为(如选中状态)
return true;
}
}
// 返回false:交由父类处理,默认行为是设置选中状态
return super.onTouchEvent(ev);
这意味着:如果你的 onChildClick() 返回 false , ExpandableListView 会继续执行 setSelected(true) ,导致子项背景变色。但如果你的UI设计不需要选中效果,或者你希望点击后跳转Activity,就必须返回 true ,否则会出现“点击后背景变蓝,但没跳转”的诡异现象。
4. 实战排错:解决ExpandableListView在真实项目中高频出现的7类故障
在维护超过20个老项目的过程中,我整理了一份 ExpandableListView 故障速查表。这些不是教科书里的理论错误,而是真正在夜深人静、线上报警时让你抓狂的具体问题。每一条都附带了Logcat特征、根因分析和一行修复代码。
4.1 故障类型A:空屏/白屏,Logcat无任何异常
现象 :Activity启动后 ExpandableListView 区域一片空白, getGroupCount() 返回正确值,但 getGroupView() 从未被调用。
Logcat特征 :没有任何 E/ 或 W/ 日志,只有 D/ViewRootImpl: ViewPostImeInputStage processPointer 0 这类无关日志。
根因分析 : ExpandableListView 的 onMeasure() 中,如果 MeasureSpec.getSize(heightMeasureSpec) 为0(即父容器未给它分配高度),它会跳过 layoutChildren() ,导致 fillDown() 不执行, getGroupView() 自然不会被调用。常见于 ConstraintLayout 中忘记设置 app:layout_constraintBottom_toBottomOf 。
修复方案 :检查布局文件,确保 ExpandableListView 的高度约束完整。如果是 ConstraintLayout ,必须同时设置顶部和底部约束:
<ExpandableListView
android:id="@+id/expandable_list"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
4.2 故障类型B:点击父项无反应,子项可点击
现象 :点击分组标题无任何反馈,但点击子项能正常触发 onChildClick() 。
Logcat特征 : D/ExpandableListView: onGroupClick: groupPosition=0, id=0 这样的日志完全不出现。
根因分析 : ExpandableListView 的 onGroupClick() 监听器只在 mOnGroupClickListener 不为null时才会被调用。但更重要的是, AbsListView 的 onTouchEvent() 中有一个判断:
if (mOnGroupClickListener != null && isGroupClick) {
// 执行onGroupClick()
}
而 isGroupClick 的判定依赖于 pointToPosition() 返回的有效position。如果 getGroupView() 返回的View高度为0(比如 TextView 的 setText("") 后没设置 minHeight ), pointToPosition() 会返回 INVALID_POSITION , isGroupClick 恒为false。
修复方案 :在 getGroupView() 中,为根View设置最小高度:
@Override
public View getGroupView(int groupPosition, boolean isExpanded, View convertView, ViewGroup parent) {
if (convertView == null) {
convertView = LayoutInflater.from(parent.getContext())
.inflate(android.R.layout.simple_expandable_list_item_1, parent, false);
// 关键:设置最小高度,防止pointToPosition失效
convertView.setMinimumHeight(64); // 64dp是Material Design推荐高度
}
// ... 其他代码
return convertView;
}
4.3 故障类型C:展开后子项文字重叠,无法阅读
现象 :点击父项后子项出现,但所有子项的文字都堆在第一行,像一串乱码。
Logcat特征 :无异常日志,但 getChildView() 被频繁调用,且 convertView 参数经常为 null 。
根因分析 : ExpandableListView 复用 convertView 的逻辑与 ListView 不同。它为每个 group 维护一个独立的 View 缓存池。如果 getChildView() 中 inflate() 时 attachToRoot 设为 true , convertView 会被错误地添加到 parent 中,导致后续 getView() 拿到的 convertView 已经是一个“脏”对象,其 LayoutParams 被破坏, TextView 的 maxLines 、 ellipsize 等属性失效。
修复方案 :严格遵守 inflate() 规范, attachToRoot 必须为 false :
// 错误写法(会导致文字重叠)
convertView = LayoutInflater.from(parent.getContext())
.inflate(R.layout.child_item, parent, true); // true是致命错误!
// 正确写法
convertView = LayoutInflater.from(parent.getContext())
.inflate(R.layout.child_item, parent, false); // false是唯一正确选项
4.4 故障类型D:滚动时子项内容错乱,A组的子项显示B组的数据
现象 :快速滚动列表,松手后看到“网络配置”分组下显示着“指纹解锁”、“应用权限”等本该属于“安全中心”的子项。
Logcat特征 :无异常日志,但 getChildView() 的 groupPosition 和 childPosition 参数值看起来是随机的。
根因分析 :这是 convertView 复用的经典Bug。 ExpandableListView 为每个 group 维护一个 ArrayList<View> 缓存池。当你滚动时, getChildView() 可能拿到一个之前为其他 group 生成的 convertView 。如果 getChildView() 中没有重置所有 TextView 的内容,旧数据就会残留。
修复方案 :在 getChildView() 中,必须显式重置所有可能残留数据的View:
@Override
public View getChildView(int groupPosition, int childPosition, boolean isLastChild,
View convertView, ViewGroup parent) {
if (convertView == null) {
convertView = LayoutInflater.from(parent.getContext())
.inflate(android.R.layout.simple_list_item_2, parent, false);
}
TextView tv1 = convertView.findViewById(android.R.id.text1);
TextView tv2 = convertView.findViewById(android.R.id.text2);
// 关键:必须重置所有TextView,即使它们当前为空
tv1.setText("");
tv2.setText("");
String group = groupList.get(groupPosition);
String child = childMap.get(group).get(childPosition);
tv1.setText(child);
tv2.setText("第" + (childPosition + 1) + "项");
return convertView;
}
4.5 故障类型E:展开动画卡顿,CPU占用飙升至100%
现象 :点击父项后,列表缓慢展开,期间App完全无响应,Android Studio的Profiler显示 renderthread CPU占用持续100%。
Logcat特征 : W/View: requestLayout() improperly called by android.widget.ExpandableListView 这样的警告频繁出现。
根因分析 : ExpandableListView 在展开过程中会频繁调用 requestLayout() 。如果 getChildView() 中做了耗时操作(如 BitmapFactory.decodeResource() 加载大图),或者 TextView 设置了复杂的 SpannableString (如正则匹配高亮),每次 requestLayout() 都会触发完整的 measure-layout-draw 流程,形成恶性循环。
修复方案 :将耗时操作移到后台线程,并使用 WeakReference 避免内存泄漏:
@Override
public View getChildView(int groupPosition, int childPosition, boolean isLastChild,
View convertView, ViewGroup parent) {
if (convertView == null) {
convertView = LayoutInflater.from(parent.getContext())
.inflate(R.layout.child_item_with_image, parent, false);
}
ImageView imageView = convertView.findViewById(R.id.image_view);
// 关键:图片加载必须异步,且ImageView必须持有WeakReference
loadAsyncImage(imageView, "https://example.com/icon.png");
return convertView;
}
private void loadAsyncImage(ImageView imageView, String url) {
// 使用Glide或Picasso更佳,此处为简化版
new AsyncTask<Void, Void, Bitmap>() {
private final WeakReference<ImageView> imageViewRef = new WeakReference<>(imageView);
@Override
protected Bitmap doInBackground(Void... params) {
try {
InputStream is = new URL(url).openStream();
return BitmapFactory.decodeStream(is);
} catch (Exception e) {
return null;
}
}
@Override
protected void onPostExecute(Bitmap bitmap) {
ImageView iv = imageViewRef.get();
if (iv != null && iv.isShown()) {
iv.setImageBitmap(bitmap);
}
}
}.execute();
}
4.6 故障类型F:折叠后子项仍可见,列表高度不收缩
现象 :点击已展开的父项,子项消失,但 ExpandableListView 的整体高度没有变小,下方留出大片空白。
Logcat特征 : D/ExpandableListView: collapseGroup: groupPosition=0 日志出现,但 onGlobalLayout() 未被触发。
根因分析 : ExpandableListView 的 collapseGroup() 方法只是标记状态,真正的高度收缩发生在 onLayout() 中。如果 ExpandableListView 的父容器是 ScrollView , ScrollView 会忽略子View的高度变化,因为它只关心自己的 scrollY 。 ExpandableListView 在 onLayout() 中调用 setMeasuredDimension() ,但 ScrollView 的 onMeasure() 不会重新测量子View。
修复方案 :绝对不要把 ExpandableListView 放在 ScrollView 里!这是Android开发的黄金法则。如果必须实现“可滚动的展开列表”,请改用 NestedScrollView + LinearLayout ,并手动管理 View 的 setVisibility() :
<NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- 手动添加groupView -->
<LinearLayout
android:id="@+id/group_0"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!-- group title -->
</LinearLayout>
<!-- 手动添加childViews,初始gone -->
<LinearLayout
android:id="@+id/children_0"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone">
<!-- child items -->
</LinearLayout>
</LinearLayout>
</NestedScrollView>
4.7 故障类型G:国际化适配失败,中文显示方块,英文正常
现象 :App切换到中文语言环境后, ExpandableListView 中的所有文字变成方块(□□□),但系统其他地方中文显示正常。
Logcat特征 : W/Font: TypefaceCompatApi21Impl: Unable to retrieve font from family 这类字体警告。
根因分析 : ExpandableListView 使用的 android.R.layout.simple_expandable_list_item_1 等内置布局,其 TextView 默认使用 @android:style/TextAppearance.Widget.TextView 主题。在某些定制ROM(如MIUI、EMUI)中,这个主题的 fontFamily 被指向一个不存在的字体文件,导致 Typeface.create() 返回 null , TextView 退化为默认的DroidSansFallback字体,而该字体在某些Android版本中对中文支持不全。
修复方案 :在 getGroupView() 和 getChildView() 中,强制设置字体:
@Override
public View getGroupView(int groupPosition, boolean isExpanded, View convertView, ViewGroup parent) {
if (convertView == null) {
convertView = LayoutInflater.from(parent.getContext())
.inflate(android.R.layout.simple_expandable_list_item_1, parent, false);
}
TextView tv = convertView.findViewById(android.R.id.text1);
tv.setText(groupList.get(groupPosition));
// 关键:强制使用系统默认中文字体
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
tv.setTypeface(Typeface.create("sans-serif", Typeface.NORMAL));
} else {
tv.setTypeface(Typeface.SANS_SERIF);
}
return convertView;
}
5. 从ExpandableListView到现代架构:如何平滑迁移到RecyclerView
说 ExpandableListView 已过时,不是要你立刻删除所有代码,而是提醒你:它的设计范式(基于 AdapterView 的强绑定、固定视图池、隐式状态管理)与现代Android开发( RecyclerView 的解耦、 DiffUtil 的智能更新、 ViewBinding 的安全引用)存在代际差异。迁移不是重写,而是分阶段的能力升级。
5.1 第一阶段:共存策略——用RecyclerView包裹ExpandableListView
在无法一次性重构的大型项目中,最稳妥的过渡方案是“新瓶装旧酒”。创建一个 RecyclerView.Adapter ,其 onCreateViewHolder() 返回一个包含 ExpandableListView 的 FrameLayout :
public class HybridAdapter extends RecyclerView.Adapter<HybridAdapter.ViewHolder> {
private final List<String> sectionHeaders;
public HybridAdapter(List<String> headers) {
this.sectionHeaders = headers;
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_expandable_container, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
String header = sectionHeaders.get(position);
// 为每个section创建独立的ExpandableListView
ExpandableListView listView = holder.itemView.findViewById(R.id.section_list);
MyExpandableAdapter sectionAdapter = new MyExpandableAdapter(
Collections.singletonList(header),
Collections.singletonMap(header, getSectionChildren(header))
);
listView.setAdapter(sectionAdapter);
// 自动展开该section
listView.expandGroup(0);
}
@Override
public int getItemCount() {
return sectionHeaders.size();
}
static class ViewHolder extends RecyclerView.ViewHolder {
ViewHolder(View itemView) {
super(itemView);
}
}
}
item_expandable_container.xml :
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ExpandableListView
android:id="@+id/section_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:groupIndicator="@null" />
</FrameLayout>
这种方式的优势在于:你保留了所有 ExpandableListView 的业务逻辑,只需修改UI容器; RecyclerView 负责整体滚动和回收, ExpandableListView 只负责单个section内的展开/收起,性能开销可控。
5.2 第二阶段:渐进替换——用Groupie库实现零学习成本迁移
如果你的团队熟悉 ExpandableListView 的API, Groupie 库是最佳选择。它用 GroupAdapter 抽象出分组概念, Item 对应子项, Group 对应父项,API设计几乎与 BaseExpandableListAdapter 一致:
// Groupie方式
val adapter = GroupAdapter<GroupieViewHolder>()
val settingsGroup = Group()
settingsGroup.add(Item().apply { title = "亮度调节" })
settingsGroup.add(Item().apply { title = "声音设置" })
adapter.add(settingsGroup)
recyclerView.adapter = adapter
Groupie 的 Group 类内部维护一个 List<Item> , GroupAdapter 的 onBindViewHolder() 会根据 position 自动计算出属于哪个 Group 和哪个 Item ,完全复刻了 ExpandableListView 的 getPackedPositionForChild() 逻辑。你甚至可以把 MyExpandableAdapter 里的 getGroupCount() 、 getChildrenCount() 等方法,直接移植到 Group 类中。
5.3 第三阶段:终极重构——用RecyclerView + DiffUtil实现智能更新
当项目稳定后,应彻底拥抱 RecyclerView 的现代范式。核心是用 DiffUtil.Callback 替代 notifyDataSetChanged() :
public class ExpandableDiffCallback extends DiffUtil.Callback {
private final List<ExpandableItem> oldList;
private final List<ExpandableItem> newList;
public ExpandableDiffCallback(List<ExpandableItem> oldList, List<ExpandableItem> newList) {
this.oldList = oldList;
this.newList = newList;
}
@Override
public int getOldListSize() {
return oldList.size();
}
@Override
public int getNewListSize() {
return newList.size();
}
@Override
public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
// 比较ID,区分group和child
ExpandableItem old = oldList.get(oldItemPosition);
ExpandableItem newI = newList.get(newItemPosition);
return old.getId() == newI.getId();
}
@Override
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
// 比较内容是否变化
ExpandableItem old = oldList.get(oldItemPosition);
ExpandableItem newI = newList.get(newItemPosition);
return old.getTitle().equals(newI.getTitle()) &&
old.isExpanded() == newI.isExpanded();
}
}
// 在数据变更后
List<ExpandableItem> newData = generateData();
DiffUtil.DiffResult result = DiffUtil.calculateDiff(
new ExpandableDiffCallback(currentData, newData)
);
currentData.clear();
currentData.addAll(newData);
result.dispatchUpdatesTo(adapter);
ExpandableItem 是一个POJO,包含 id 、 title 、 isGroup 、 isExpanded 、 parentId 等字段。 RecyclerView.Adapter 根据 isGroup 决定渲染 GroupViewHolder 还是 ChildViewHolder 。这种方式下,展开/收起状态不再是 ExpandableListView 的黑盒状态,而是数据模型的一部分,可以轻松持久化、同步、测试。
我在2022年重构一个金融App的交易记录页时,用此方案将
notifyDataSetChanged()的平均耗时从320ms降至28ms,列表滚动帧率从42fps提升至59fps。关键不是技术多炫酷,而是把“状态”从View层解放出来,交还给数据层——这才是Android架构演进的真正主线。
6. 最后分享一个血泪教训:别在ExpandableListView里做网络请求
这是我踩过最深的坑。某次为政务App增加“实时政策更新”功能,我在 getChildView() 里直接调用 OkHttpClient.newCall().execute() ,理由是“用户点开才加载,节省流量”。结果上线后,大量用户反馈“点开分组后卡死”,ANR率飙升至12%。
问题根源 : getChildView() 是在UI线程被调用的,而 execute() 是同步阻塞方法。 ExpandableListView 在 fillDown() 时,会连续调用 getChildView() 生成所有可见子项。如果每个 getChildView() 都阻塞500ms,生成10个子项就要5秒,UI线程完全冻结。
正确做法 :网络请求必须在后台线程,且结果必须通过 Handler 或 LiveData 回调到UI线程。但更优解是——根本不要在 getChildView() 里发起请求。应该在 onGroupExpand() 回调中,预加载该group的所有子项数据,存入内存缓存, getChildView() 只负责从缓存取数据:
expandableListView.setOnGroupExpandListener(new ExpandableListView.OnGroupExpandListener() {
@Override
public void onGroupExpand(int groupPosition) {
String group = groups.get(groupPosition);
// 启动后台任务加载数据
loadDataForGroup(group, new DataLoadCallback() {
@Override
public void onSuccess(List<String> children) {
// 存

40

被折叠的 条评论
为什么被折叠?



