ExpandableListView源码解析与实战排错指南

发布时间:2026/6/22 10:35:41

ExpandableListView源码解析与实战排错指南 1. 这个控件不是“过时的摆设”而是理解Android视图层级的钥匙你点开Android官方文档看到ExpandableListView被标记为“deprecated”已弃用第一反应可能是这玩意儿还值得学直接上RecyclerViewExpandableAdapter不就完了我最初也这么想——直到在维护一个2016年上线的政务类App时发现它底层菜单结构完全依赖ExpandableListView而替换成本高到需要重写整个导航模块。更关键的是当我在调试RecyclerView嵌套展开逻辑时反复卡在notifyItemChanged()触发时机和ItemAnimator冲突的问题上回头翻ExpandableListView的源码才真正看懂AndroidAdapterView体系里“数据-视图-状态”三者绑定的原始契约。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:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical !-- 关键ExpandableListView不能放在ScrollView里 -- ExpandableListView android:idid/expandable_list android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 android:groupIndicatornull / /LinearLayout提示android:layout_height0dpandroid:layout_weight1是必须的。如果你写成wrap_contentExpandableListView会尝试测量所有子项高度而此时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 ListString groupList; private final MapString, ListString childMap; public MyExpandableAdapter(ListString groups, MapString, ListString 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); ListString 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); ListString 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。如果设为trueExpandableListView在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 childs 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前完成 ListString groups Arrays.asList(系统设置, 网络配置, 安全中心); MapString, ListString 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()返回nullposition就是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 SparseArrayBoolean mExpandingGroups; // 正在展开的group private SparseArrayBoolean 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)返回0ExpandableListView会认为这个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()返回falseExpandableListView会继续执行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:idid/expandable_list android:layout_width0dp android:layout_height0dp app:layout_constraintTop_toTopOfparent app:layout_constraintBottom_toBottomOfparent app:layout_constraintStart_toStartOfparent app:layout_constraintEnd_toEndOfparent /4.2 故障类型B点击父项无反应子项可点击现象点击分组标题无任何反馈但点击子项能正常触发onChildClick()。Logcat特征D/ExpandableListView: onGroupClick: groupPosition0, id0这样的日志完全不出现。根因分析ExpandableListView的onGroupClick()监听器只在mOnGroupClickListener不为null时才会被调用。但更重要的是AbsListView的onTouchEvent()中有一个判断if (mOnGroupClickListener ! null isGroupClick) { // 执行onGroupClick() }而isGroupClick的判定依赖于pointToPosition()返回的有效position。如果getGroupView()返回的View高度为0比如TextView的setText()后没设置minHeightpointToPosition()会返回INVALID_POSITIONisGroupClick恒为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设为trueconvertView会被错误地添加到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维护一个ArrayListView缓存池。当你滚动时getChildView()可能拿到一个之前为其他group生成的convertView。如果getChildView()中没有重置所有TextView的内容旧数据就会残留。修复方案在getChildView()中必须显式重置所有可能残留数据的ViewOverride 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显示renderthreadCPU占用持续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 AsyncTaskVoid, Void, Bitmap() { private final WeakReferenceImageView 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: groupPosition0日志出现但onGlobalLayout()未被触发。根因分析ExpandableListView的collapseGroup()方法只是标记状态真正的高度收缩发生在onLayout()中。如果ExpandableListView的父容器是ScrollViewScrollView会忽略子View的高度变化因为它只关心自己的scrollY。ExpandableListView在onLayout()中调用setMeasuredDimension()但ScrollView的onMeasure()不会重新测量子View。修复方案绝对不要把ExpandableListView放在ScrollView里这是Android开发的黄金法则。如果必须实现“可滚动的展开列表”请改用NestedScrollViewLinearLayout并手动管理View的setVisibility()NestedScrollView android:layout_widthmatch_parent android:layout_heightmatch_parent LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical !-- 手动添加groupView -- LinearLayout android:idid/group_0 android:layout_widthmatch_parent android:layout_heightwrap_content !-- group title -- /LinearLayout !-- 手动添加childViews初始gone -- LinearLayout android:idid/children_0 android:layout_widthmatch_parent android:layout_heightwrap_content android:visibilitygone !-- child items -- /LinearLayout /LinearLayout /NestedScrollView4.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()返回nullTextView退化为默认的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的FrameLayoutpublic class HybridAdapter extends RecyclerView.AdapterHybridAdapter.ViewHolder { private final ListString sectionHeaders; public HybridAdapter(ListString 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.xmlFrameLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightwrap_content ExpandableListView android:idid/section_list android:layout_widthmatch_parent android:layout_heightwrap_content android:groupIndicatornull / /FrameLayout这种方式的优势在于你保留了所有ExpandableListView的业务逻辑只需修改UI容器RecyclerView负责整体滚动和回收ExpandableListView只负责单个section内的展开/收起性能开销可控。5.2 第二阶段渐进替换——用Groupie库实现零学习成本迁移如果你的团队熟悉ExpandableListView的APIGroupie库是最佳选择。它用GroupAdapter抽象出分组概念Item对应子项Group对应父项API设计几乎与BaseExpandableListAdapter一致// Groupie方式 val adapter GroupAdapterGroupieViewHolder() val settingsGroup Group() settingsGroup.add(Item().apply { title 亮度调节 }) settingsGroup.add(Item().apply { title 声音设置 }) adapter.add(settingsGroup) recyclerView.adapter adapterGroupie的Group类内部维护一个ListItemGroupAdapter的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 ListExpandableItem oldList; private final ListExpandableItem newList; public ExpandableDiffCallback(ListExpandableItem oldList, ListExpandableItem 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(); } } // 在数据变更后 ListExpandableItem 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(ListString children) { // 存

相关新闻