ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android ListView数据更新失效?彻底搞懂notifyDataSetChanged与Adapter机制

Android ListView数据更新失效?彻底搞懂notifyDataSetChanged与Adapter机制 最近在Android新人交流群里看到最频繁的一个问题就是“我把List的数据改了也调用了notifyDataSetChanged为什么ListView就是不刷新”这个问题以各种形态反复出现比如列表数据变了但界面纹丝不动、日志报错崩溃、刷新之后数据错乱、滚动几下又变成旧内容等等。其实根子都在ListView的数据更新机制上。这篇内容我打算彻底讲透这件事先还原一个典型翻车现场再讲Adapter底层是怎么把数据和视图绑定起来的接着依次拆解notifyDataSetChanged的用法、线程问题、数据源引用陷阱最后给出一个可以少踩坑的工程化封装。不管你是刚学Android基础的新手还是在维护老项目的开发者这篇都值得读一读。1. 先复现一个典型翻车现场数据改了界面纹丝不动1.1 翻车代码还原很多新人写列表页代码长这样public class MainActivity extends AppCompatActivity { private ListString data new ArrayList(); private ArrayAdapterString adapter; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); ListView listView findViewById(R.id.listView); adapter new ArrayAdapter(this, android.R.layout.simple_list_item_1, data); listView.setAdapter(adapter); loadData(); } private void loadData() { new Thread(() - { // 模拟网络请求返回 ListString result fetchFromNetwork(); data.clear(); data.addAll(result); adapter.notifyDataSetChanged(); }).start(); } }这段代码的逻辑看起来没毛病网络请求回来把数据塞进列表通知适配器刷新。但真跑起来轻则崩溃重则崩溃之前列表一直是空的。遇到这种问题不要把锅甩给ListView它不是不刷新是你踩了它的几个雷区。1.2 排查第一步先确认事实再改代码我见过太多人上来就瞎改布局、换Adapter实现结果折腾一下午。正确的排查链路应该按顺序确认四件事第一数据源本身有没有变化在data.addAll(result)之后打印一下data.size()确认结果不是空列表。第二notifyDataSetChanged到底有没有执行在它前后各打一条日志排除分支没走到的情况。第三代码执行在哪个线程如果日志在子线程打印出来基本已经踩中了Android的UI线程限制。第四ListView界面上绑定的Adapter和你调notify的那个Adapter是不是同一个实例经常有人在Activity重建后new了一个新Adapter旧Adapter还挂在ListView上。把这四件事查完90%的“数据更新失败”问题都能定位。上面示例代码的问题很明显在子线程里直接做了UI操作系统会抛出“Only the original thread that created a view hierarchy can touch its views”的异常。但就算你侥幸没崩溃界面也不会稳定刷新因为notifyDataSetChanged触发的重新布局最终还是和UI线程状态强相关。2. 底层机制是根因Adapter、数据源和getView是怎么配合的2.1 ListView的显示链路一个“目录-仓库-印刷工”的模型要理解数据更新问题先得知道ListView显示数据走的是一条固定链路。ListView本身只是一个“显示器”它不直接读你的数据。它只知道三件事调用adapter.getCount()知道总共要显示多少行。调用adapter.getItem(position)知道某个位置对应的数据对象是什么。调用adapter.getView(position, convertView, parent)拿到一个填充好内容的View铺到自己身上。把这三步用一个生活模型来理解ListView是前台展示架Adapter是印刷工兼客服数据源是仓库。展示架尺寸是固定的它不关心仓库里到底放了什么当有人通知它“仓库库存变了”它才会去问印刷工“现在有几件货、每件长什么样”。如果你改了仓库里的货却没有通知展示架那架子上的东西自然还是旧的。所以notifyDataSetChanged()这个名字虽然叫“更新数据”实质含义是“通知观察者数据变了请重新走一遍getCount、getItem、getView流程”。它不是直接改界面而是触发一条重新渲染的流水线。2.2 convertView复用机制为什么局部刷新需要小心聊到底层就绕不开convertView。ListView滚动的时候划出屏幕的Item View会被丢进一个回收池划入屏幕的新Item会优先从回收池里取出旧View复用而不是每次inflate一个新的。这就是ListView高性能的关键也是很多数据错乱bug的来源。新手最容易犯的错是只在convertView null的时候绑定数据Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView null) { convertView inflater.inflate(R.layout.item, parent, false); holder new ViewHolder(convertView); convertView.setTag(holder); } else { holder (ViewHolder) convertView.getTag(); } // 注意这里每次都必须重新赋值 holder.title.setText(data.get(position).getName()); holder.icon.setImageResource(data.get(position).getIcon()); return convertView; }复用机制决定了convertView带着上一次绑定的旧数据。所以不管convertView是不是null都要重新给控件赋值。很多初学者写了if判断之后就只在首次创建时setText结果列表滚起来后有的行显示的是上一行的内容——这不是数据更新问题是复用机制没搞明白。2.3 BaseAdapter和ArrayAdapter的刷新哲学差异Android里常用的Adapter大概分两类。ArrayAdapterT是系统封装好的通用Adapter构造时传入一个List内部直接持有这个List的引用。它做得比较“傻瓜”只要你在原地修改传入的那个List对象然后调用notifyDataSetChanged就会刷新。很多新人因此以为只要“数据变化notify”就完事但恰恰忽略了一点它持有的是你传入的那个对象引用。你把字段data指向了一个新ArrayListAdapter根本不知道。BaseAdapter则是一个抽象类它要求你自己实现getCount、getItem、getView等方法加载更多自定义、多类型布局、复杂数据模型都是基于它来做。数据更新逻辑没有内置魔法能不能刷新生效全看你的实现。我后面给出的工程化封装也是基于BaseAdapter来做的。这里记住一句话无论是ArrayAdapter还是BaseAdapter界面的刷新都遵循“数据源发生变化 - 通知 - 重新走getView”这个过程。任何一步断了列表都不会动。3. notifyDataSetChanged是主解药但时机和线程都是雷区3.1 它到底做了什么DataSetObservable与观察者很多人没看过notifyDataSetChanged的源码只知道“调了就刷新”。实际上BaseAdapter内部维护了一个DataSetObservable它继承自ObservableDataSetObserver。notifyDataSetChanged做的事就是遍历所有注册的DataSetObserver调用它们的onChanged()方法。ListView在setAdapter的时候会注册一个AdapterDataSetObserver到Adapter上。当onChanged()被触发ListView会调用requestLayout()请求重新布局接着重新测量、绘制列表项。所以整个流程是修改数据源 - notifyDataSetChanged - ListView收到onChanged - requestLayout - 重新执行getCount/getItem/getView - 界面更新注意这个链路的起点是“修改数据源”。你必须在数据已经真正变化之后去notify否则ListVew重新渲染的还是一模一样的旧内容。我看到不少人在网络请求返回前就调用了notify结果列表当然不会出现新数据。3.2 必须在主线程调用异常信息与切线程姿势Android规定UI只能在主线程访问。notifyDataSetChanged内部会触发requestLayout而requestLayout会检查当前线程是不是创建View的线程不是就直接抛CalledFromWrongThreadException。经典报错长这样E/AndroidRuntime: FATAL EXCEPTION: Thread-3 Process: com.example.listviewdemo, PID: 12345 android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.所以子线程拿到数据之后第一件事就是把数据和刷新操作切回主线程。常用的姿势有三种// 方式一runOnUiThread runOnUiThread(() - { data.clear(); data.addAll(result); adapter.notifyDataSetChanged(); });// 方式二Handler.post new Handler(Looper.getMainLooper()).post(() - { data.clear(); data.addAll(result); adapter.notifyDataSetChanged(); });// 方式三如果手里有任意一个View直接用view.post binding.listView.post(() - { data.clear(); data.addAll(result); adapter.notifyDataSetChanged(); });如果你在用Kotlin协程更自然的方式是在主线程做刷新lifecycleScope.launch(Dispatchers.Main) { val result withContext(Dispatchers.IO) { fetchFromNetwork() } data.clear() data.addAll(result) adapter.notifyDataSetChanged() }拿到数据后切回主线程再统一更新这是ListView数据更新的标准动作。不要抱有“我运气好不崩溃”的侥幸心理一旦数据量大或者刷新频率高子线程刷新带来的问题会从“偶发不更新”升级成“闪退”。3.3 “调用之后还是不动”的几个意外原因排查过很多次之后我总结出几个特别容易忽略的情况notify虽然调了但界面就是不动。第一个你没有改Adapter持有的那个数据源。这是引用问题下一章专门展开说。第二个notify调用时机太早。比如你在onCreate里ListView还没完成第一次布局就notify了之后网络返回又忘了再notify。建议所有数据变更逻辑统一封装到一个方法里比如refreshData(ListT newData)避免散落各处导致时机错乱。第三个你调的是另一个Adapter实例的notify。Activity重建后重新setAdapter是常见场景之后你拿着旧adapter引用去刷新新列表当然不动。可以在自定义Adapter里加一个isAttached标记刷新前确认当前Adapter确实是ListView正在用的那个。第四个Item布局高度居然是wrap_content加嵌套复杂布局即使数据刷新了ListView在高度测量阶段就把内容“折叠”了。这种问题比较隐蔽页面看起来就像没刷新实际上已经渲染了只是全部挤在一个高度为0的区域里。遇到“刷新无效”时值得顺手check一下Item根布局的height设置。4. 更高频的隐形坑数据源引用、生命周期和异步回调4.1 数据源引用被换掉Adapter手里还是旧列表我单独拎出来讲是因为这个问题占“数据更新失败”里至少三成。看这段代码ListString data new ArrayList(); adapter new ArrayAdapter(this, layout, data); // 某处刷新 ListString newData new ArrayList(); newData.add(A); newData.add(B); data newData; // 关键是这句 adapter.notifyDataSetChanged();data newData只是把Activity里的局部变量指向了新列表但ArrayAdapter内部的成员变量还指着最开始那个ArrayList。你notify之后ListView问Adapter要数据Adapter还是从老列表里取。这样的代码无论你notify多少次界面都是旧的。正确做法是不要更换引用原地清空再填充data.clear(); data.addAll(newData); adapter.notifyDataSetChanged();如果你想支持整体替换数据源就不要在外部操作原list而是在自定义Adapter里提供setData方法让它自己更新内部引用顺便notify。这种封装后面会给出示例。Kotlin里还有一个隐蔽版本调用了list.toMutableList()得到的是一个新ArrayList然后赋给var字段又踩进同一个坑。Kotlin写起来有多顺手这个坑就有多容易踩。4.2 异步回调里的顺序问题被覆盖的数据更新网络请求存在竞态问题。最常见的画面是用户下拉刷新触发请求A又上滑加载更多触发请求BA和B都发起网络请求B先返回列表先刷新成B的数据。随后A返回又把列表覆盖成A的旧数据。这不是ListView的错是数据更新的顺序没管好。处理思路有两个方向一是给每次请求带一个自增序号回调时比对序号只接受最新一次请求的结果二是用协程/Flow或者RxJava的组件保证请求是按顺序更新的。初学者用一个简单的AtomicInteger就能解决private final AtomicInteger requestId new AtomicInteger(0); private void fetchData() { int id requestId.incrementAndGet(); // 发起请求在回调里判断 if (id ! requestId.get()) { return; // 过期响应直接丢弃 } runOnUiThread(() - { data.clear(); data.addAll(result); adapter.notifyDataSetChanged(); }); }不要小看这个细节一套稳定的更新逻辑必须考虑“先发出的后返回”这种反直觉情况否则线上列表时不时就跳回旧状态。4.3 Activity重建与数据状态丢失旋转屏幕、从后台返回导致Activity重建时onCreate会重新执行一遍ListView重新创建Adapter也被重新new出来。如果你的数据还停留在某个异步回调里回调触发时Activity已经销毁此时更新界面轻则无效重则抛异常。处理办法如果数据是页面独立的用ViewModel保存数据配合LiveData在onCreate里重新订阅如果只是临时页面用完即弃那在回调里至少要判断Activity的状态if (isFinishing() || isDestroyed()) { return; }这一行判断能挡住大量因生命周期错位导致的不刷新和崩溃。4.4 多个Adapter共享同一数据源时的互相踩踏还有一种比较隐蔽的情况同一个页面有两个ListView或者一个Activity里多个Fragment各自持有Adapter但它们的Adapter构造时都传了同一个List对象。某个页面执行了data.clear()再addAll另一个页面虽然没调用notify但下一次滚动触发getView时读到的数据已经变了于是出现“我没刷新这个列表但它内容却变了”的灵异现象。这说明数据源引用被多处共享本身就是一种耦合。工程实践上每个Adapter最好拥有自己的数据容器通过方法传值进来而不是所有组件共用一个全局List。尤其在进入页面时深拷贝一份专属数据能避免很多意想不到的联动问题。5. 从“能刷新”到“刷得好”局部更新、差量刷新与性能优化5.1 整体刷新不是万能药闪烁与卡顿的根源notifyDataSetChanged的代价是整体重建所有可见Item。ListView滚动状态会被打断所有可见行重新执行getView图片重新加载文本重新设置。数据一多、布局一复杂肉眼可见的卡顿和闪烁就来了。所以“能刷新”只是第一步“刷得好”才是工程能力的体现。如果你只是更新了某一个Item的文本或状态正确姿势不是notify全表而是精确更新那一行。5.2 局部刷新的标准做法ListView没有自带类似于notifyItemChanged(position)的方法那是RecyclerView的但可以用一个通用技巧手动更新可见区域。思路是拿到当前可见Item的首末position遍历可见View找到目标position对应的View只更新它的内容。private void updateItem(int position) { int first listView.getFirstVisiblePosition(); int last listView.getLastVisiblePosition(); if (position first || position last) { // 不可见等滚动到可见时getView自然会用新数据 return; } View targetView listView.getChildAt(position - first); if (targetView ! null) { // 根据position从数据源取数据更新targetView上的控件 } }注意一个前提这个方法只更新界面不负责数据变更。数据源里该改的值一定要在调用前改好否则滚动后重新getView时又会用旧数据渲染。如果你需要更新一个连续区间或者整个列表但不想整体重建也可以循环遍历可见项手动挨个更新。比起notify全表这种方式少了重新测量布局的开销对滚动状态也更友好。5.3 其他性能优化ViewHolder、stableId、二分查找定位当列表数据量变大刷新性能问题就不只是“闪烁”了而是主线程卡顿。处理好几个点能显著改善在getView里维护ViewHolder避免反复findViewById。不要在getView里做耗时操作比如decode Bitmap、执行数据库查询。如果Item有多个类型重写getItemViewType和getViewTypeCount避免类型错乱导致的重复创建。如果列表支持增删且你用了自定义Adapter可以配合hasStableIds()返回true这样ListView在Item移动和状态保存时会稳定一些。这个开关不是银弹但能让整体体验更顺滑。对于“差量刷新”这个更进阶的话题我多说一句ListView时代大家基本都是靠整体notify硬扛数据量大时体验确实一般。如果项目允许新页面优先考虑RecyclerView ListAdapter DiffUtil它能把差量计算的活交给DiffUtil只更新真正变化的部分。但ListView这套数据更新机制并没有白学RecyclerView的Adapter思想一脉相承懂了ListView的getView、数据源引用和notify链路学RecyclerView会非常快。6. 高频问题速查与我的工程实践建议6.1 症状对照表我把这些年在ListView数据更新上见过的问题汇总成一张表排查时可以对照着找思路。症状最可能的原因处理方式数据变了但界面完全没反应数据源引用被替换或者根本没有调用notify原地修改List或走Adapter的setData统一刷新子线程更新后直接闪退在子线程执行notify用runOnUiThread或Handler切主线程刷新列表偶尔不刷新重启手机又正常异步返回顺序错乱引入请求序号丢弃过期回包滚动后部分Item显示成旧内容convertView复用后没有重新绑定getView里每次都对所有控件重新赋值刷新时列表跳到顶部notify全表刷新位置丢失记录firstVisiblePosition刷新后scrollToPosition恢复刷新后layout大量重排滚动卡顿Item布局复杂且每次notify整体重建用局部刷新优化或迁移到RecyclerView6.2 一个可复用的Adapter封装骨架基于前面的经验我通常会把自用的BaseAdapter封装成这样一个基类核心思想只有一个数据容器的引用由Adapter自己管理外部只能通过方法更新数据不能直接替换引用。public abstract class SimpleAdapterT extends BaseAdapter { private final ListT items new ArrayList(); protected final LayoutInflater inflater; public SimpleAdapter(Context context) { this.inflater LayoutInflater.from(context); } // 整体替换数据内部完成刷新 public void setItems(ListT newItems) { items.clear(); if (newItems ! null) { items.addAll(newItems); } notifyDataSetChanged(); } // 增量追加数据 public void addItems(ListT extra) { if (extra ! null !extra.isEmpty()) { items.addAll(extra); notifyDataSetChanged(); } } protected ListT getItems() { return items; } Override public int getCount() { return items.size(); } Override public T getItem(int position) { return items.get(position); } Override public long getItemId(int position) { return position; } }子类只需要实现getView然后调用方统一使用setItems和addItems。这样数据更新入口被收敛了不会出现外部换引用导致的不刷新。这个骨架看起来很朴素却是解决ListView数据更新问题最省心的方式。6.3 个人经验理解数据更新比记住API更重要最后说点我自己的体会。每次看到新手卡在ListView刷新问题上我都建议他不要急着找“刷新函数”而是先在纸上画出“数据 - Adapter - ListView”这条链标出数据是在哪一步变的、notify是在哪一步发的、getView用的数据是哪份。把这条链理清楚刷新问题基本解决一半。如果非要说一个最值得记住的建议那就是永远保证Adapter手里的数据源是同一个引用所有数据的增删改都通过一个入口方法完成然后在这个入口方法的最后调用notifyDataSetChanged。在维护老项目、改造二手代码时这个原则能帮你避开绝大多数“列表不刷新”的坑。等你把这套逻辑吃透再去写RecyclerView的DiffUtil回头看这些入门问题还真有种“一览众山小”的感觉。
返回列表