尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

ListView数据更新不生效?从Adapter原理到排查实战

ListView数据更新不生效?从Adapter原理到排查实战 先从一个大多数人都踩过的场景开始吧列表请求到数据、塞进Adapter、调用notifyDataSetChanged()屏幕却纹丝不动或者干脆直接崩了。这个问题的经典程度在Android入门阶段基本人手一份。我当年第一次遇到时第一反应是“代码是不是写错位置了”后来才明白ListView的数据更新从来不只是“调一下刷新方法”这么简单它背后是Adapter机制、观察者模式、主线程UI模型以及View复用这四件事的配合问题。这篇内容就把ListView数据更新这件事彻底聊透从底层原理到动手案例再到排查思路尽量让你看完之后不仅会写还能知道为什么这么写。适合对象很明确正在学Android基础、BaseAdapter和ListView还没完全吃透、或者被“数据不刷新”折磨过的新手同学。1. 先搞明白ListView为什么“看不到”数据变化1.1 Adapter是ListView和数据的“中间人”ListView本身不存储任何数据它只负责把当前可见区域的Item一个个画出来。数据在哪儿在一个普通的List或数组里。那数据怎么变成Item靠Adapter。Adapter做三件关键事告诉ListView一共有多少条getCount、每条长什么样getView、以及负责在数据变化时发出通知。这个设计其实就是经典的MVC思路ListView是ViewList是ModelAdapter是Controller。好处是解耦坏处是——如果你只改了List的内容却没有通知Adapter“数据变了”ListView根本不会知道。它不是敏感体质不会自动去检查数据源。而notifyDataSetChanged()的本质是通知ListView“我的数据变了请你重新遍历一遍把当前应该显示的Item重新生成/绑定一遍。”这个通知走的是观察者模式Adapter内部有一个DataSetObservable调用notifyDataSetChanged()会触发所有注册的观察者也就是ListView去执行更新逻辑。理解了这一点你就明白了一个很反直觉的现象先更新数据源再notify。很多人习惯先notify再改List结果是ListView立刻执行getView去拿数据拿到的还是旧数据界面自然不更新。1.2 ListView的“复用机制”对更新策略的影响ListView为了流畅展示大量数据引入了View回收复用机制。屏幕上能看到的Item其实就那么几个滑动时滑出屏幕的Item会被回收进缓存池新滑进来的Item会从缓存池里取一个复用然后重新bind数据。这个机制带来的影响是你调用notifyDataSetChanged()时ListView不会把当前可见区域的Item全部销毁重建而是尽可能复用已有的View只重新绑定数据。所以你经常会发现getView方法在刷新时会被频繁调用但很多View实例是同一个。也正因为复用新手容易遇到一个问题列表滚动后某些Item显示的内容错乱了比如图片串位、文字对不上。这个问题的根源不是数据源错而是你的getView里没有把旧数据清掉复用了上一个Item的View后直接set了新数据。这个坑等会儿在常见问题里细说。2. 数据更新失败的三大经典原因2.1 重新new了ListAdapter还在守着旧对象这是我在各种群里被问得最多的一种情况。代码长这样// 错误写法 val newList fetchDataFromServer() myAdapter MyAdapter(newList) // 重新创建Adapter listView.adapter myAdapter // 重新设置Adapter表面上看你确实把新数据放进去了但如果Adapter内部持有的List引用和你后来修改的List不是同一个对象notifyDataSetChanged()刷了个寂寞。推荐做法是Adapter创建之后始终持有同一个List引用。更新数据时操作的是同一个List然后调用notifyval dataList mutableListOfItemModel() // Adapter内部其实也持有这个引用 // 更新数据 dataList.clear() dataList.addAll(newData) myAdapter.notifyDataSetChanged()有个直接带给我的教训如果你用了两个List——一个给Adapter展示一个临时存网络返回的数据——忘记把临时List里addAll进展示List界面上出现的永远是空列表。小习惯写Adapter时把数据列表的引用在构造函数里直接存下来不要为了省事在Adapter内部再copy一份。否则你每次更新数据源都可能在不知不觉中“断开了连接”。2.2 子线程里直接更新UI导致的崩溃网络请求通常在子线程回调里返回数据这是另一个高频崩溃点。你辛辛苦苦把数据拼好然后直接在回调里调用notifyDataSetChanged()结果Logcat弹出一行经典的错误Only the original thread that created a view hierarchy can touch its views.翻译过来就是只有创建视图的那个主线程才能去碰视图。Android的UI不是线程安全的所以主线程之外的任何UI操作都是非法的。正确姿势是用runOnUiThread或者Handler把数据更新动作切回主线程// 子线程回调里 runOnUiThread { dataList.clear() dataList.addAll(newData) myAdapter.notifyDataSetChanged() }Kotlin里也可以用Handler(Looper.getMainLooper()).post { // 更新数据 notify }这里有个容易被忽略的细节如果子线程的访问时机在主线程正在刷新列表的中间notify之后立即执行getView而数据源在子线程里正在写就可能出现IndexOutOfBoundsException。我现在的习惯是子线程里先构建好完整的副本回到主线程再一次性替换避免边改边读。2.3 增删Item时的索引错位假设当前ListView有10条数据你在第5条的位置插入了1条然后notify。理论上ListView应该在第5条位置显示新插入的数据。但如果你的数据源更新逻辑和界面索引没有对齐或者你用了getItemViewType这样的多布局接口很容易出现“明明插入了显示的位置却是隔壁”。还有一个典型问题删除数据时在for循环里边遍历边remove因为索引不断变化导致删除错位。我遇到过的场景是批量删除多条数据时ListView的总数没变但内容却少了——查了半天才发现removeAll用错了集合引用。对于ListView这种“全量刷新”的机制在处理增删时尤其要注意先操作数据源再notify。不要在notify之后再去调整数据源的结构。3. 从零手写一个“能正确刷新”的列表案例3.1 准备阶段搭建环境和布局这里用Android Studio新建一个Empty Activity项目来演示。虽然现在Android Studio已经默认推荐RecyclerView但用ListView来练手能让你把Adapter的机制看得更清楚。先准备一下布局文件activity_main.xml放一个ListView加一个SwipeRefreshLayout实现下拉刷新。SwipeRefreshLayout就是Material库里的“进度条”容器顶部那个转圈就是它提供的android进度条风格androidx.swiperefreshlayout.widget.SwipeRefreshLayout android:idid/swipeRefreshLayout android:layout_widthmatch_parent android:layout_heightmatch_parent ListView android:idid/listView android:layout_widthmatch_parent android:layout_heightmatch_parent / /androidx.swiperefreshlayout.widget.SwipeRefreshLayout再准备一个item布局item_list.xml展示标题和内容两行文字LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical android:padding16dp TextView android:idid/tvTitle android:layout_widthmatch_parent android:layout_heightwrap_content android:textSize16sp android:textStylebold / TextView android:idid/tvContent android:layout_widthmatch_parent android:layout_heightwrap_content android:textSize14sp android:layout_marginTop4dp / /LinearLayout3.2 核心代码Adapter 数据更新完整实现数据模型就按最简单的来data class ItemData( val title: String, val content: String )Adapter用BaseAdapter实现注意ViewHolder模式的写法。很多新手写的getView里每次都findViewById性能差且Item多了会卡。ViewHolder模式本质上就是“把findViewById的结果缓存起来”避免每次getView都去XML里找一次控件。class ItemAdapter( private val context: Context, private val dataList: MutableListItemData ) : BaseAdapter() { override fun getCount(): Int dataList.size override fun getItem(position: Int): ItemData dataList[position] override fun getItemId(position: Int): Long position.toLong() override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val viewHolder: ViewHolder val itemView: View if (convertView null) { itemView LayoutInflater.from(context).inflate(R.layout.item_list, parent, false) viewHolder ViewHolder(itemView) itemView.tag viewHolder } else { itemView convertView viewHolder itemView.tag as ViewHolder } val item getItem(position) viewHolder.tvTitle.text item.title viewHolder.tvContent.text item.content return itemView } class ViewHolder(view: View) { val tvTitle: TextView view.findViewById(R.id.tvTitle) val tvContent: TextView view.findViewById(R.id.tvContent) } }MainActivity里模拟一个从“网络”获取数据的场景用Handler延迟两秒来模拟请求耗时然后更新数据class MainActivity : AppCompatActivity() { private lateinit var adapter: ItemAdapter private val dataList mutableListOfItemData() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) adapter ItemAdapter(this, dataList) listView.adapter adapter // 模拟初始加载 loadData() // 下拉刷新 swipeRefreshLayout.setOnRefreshListener { refreshData() } } private fun loadData() { // 模拟网络耗时操作 MainScope().launch { delay(1500) val newData buildNewData() dataList.clear() dataList.addAll(newData) adapter.notifyDataSetChanged() } } private fun refreshData() { MainScope().launch { delay(2000) val newData buildNewData(prefix 刷新后) dataList.clear() dataList.addAll(newData) adapter.notifyDataSetChanged() swipeRefreshLayout.isRefreshing false } } private fun buildNewData(prefix: String 初始数据): ListItemData { return (1..20).map { index - ItemData($prefix - 第${index}条, 这是第${index}条内容的详细描述) } } }这段代码的核心就是那三行操作顺序clear、addAll、notify。顺序固定不能乱。3.3 为什么顺序必须是“先改数据再通知”notifyDataSetChanged()触发之后ListView马上会去Adapter取getCount和getItem。如果你把notify写在clear前面getCount一瞬间变成了0UI刷新拿到的就是空列表如果把addAll放在notify后面则可能拿到旧的集合内容。这个顺序问题在数据量小的时候不太明显因为UI线程的调度很快但数据量大或者notify和修改数据源之间插入了耗时操作就很容易露出马脚。再补充一个常见的“编辑单条Item”场景。假如你只想改某一条Item的数据不动其他行可以这样dataList[position] updatedItem adapter.notifyDataSetChanged()虽然这会造成全部Item重新绑定但代码简单、不容易出错。如果想要精准更新一行可以自己封装一个方法fun updateItem(position: Int, newItem: ItemData) { dataList[position] newItem // 拿到这个position的View然后单独更新UI val firstVisible listView.firstVisiblePosition val lastVisible listView.lastVisiblePosition if (position in firstVisible..lastVisible) { val view listView.getChildAt(position - firstVisible) // 更新这个view里面的text } }这个做法有一定适用范围如果Item不在可视区域getChildAt拿不到View只更新数据源即可滚动回来时会通过getView绑定新数据。4. 常见问题排查表那些年我踩过的更新坑4.1 症状与原因对照先看几个比较典型的现象你在开发中碰到过哪一种现象最可能的原因解决思路调了notify但界面完全没变化Adapter内部持有的List和修改的List不是同一个对象检查数据源引用统一用一个List程序直接崩溃Only original thread...子线程直接调notify用runOnUiThread或Handler切主线程刷新后Item内容错乱、图片串位getView里没清空旧View的状态每次绑定数据前先重置控件状态列表底部多出来N条“空白Item”getCount返回了错误数量数据源和UI不同步检查addAll / remove是否成功下拉刷新时转圈永远不停没在数据更新后调用isRefreshing false语法忘了刷新回调里关掉动画快速滑动时列表卡顿每次getView都findViewById用ViewHolder缓存控件4.2 我自己的排查顺序遇到ListView数据不刷新我一般不瞎猜而是按这个顺序逐步排查第一步看数据源。在notify之前打印dataList.size()如果这个数字没变说明数据压根没更新到List里界面不刷新是正常的。这一步能排除90%的“假问题”。第二步看Adapter实例。确保notify的那个adapter和setAdapter的是同一个对象。我见过有人在Activity里重新new了一个Adapter然后调notify结果自然是没反应。第三步看线程。确认notify是不是在主线程。最直接的办法是在notify前后加Log如果和网络回调的Log顺序混在一起很可能就是子线程调用。第四步看布局。如果ListView外面包了ScrollView或者父布局高度写成了wrap_contentListView的高度计算会出问题Item数据更新了但显示不全。这种问题比较隐蔽但排查时值得注意。4.3 调试技巧如何快速定位是哪一步出了问题我在调试这个阶段时最常用的不是断点而是Log。在关键位置加打印// MainActivity里 Log.d(TAG, 开始加载数据当前列表大小: ${dataList.size})在Adapter的getView里加一个计数器看看刷新前后getView被调用了多少次。如果notify之后getView完全没被调用说明问题出在通知链路或ListView的布局上如果被调用了但显示没变那就是数据源的值本身没变。另外一个更容易被轻视的技巧把item的背景色在getView里临时设成随机颜色。刷新后去看界面如果颜色在闪说明Item确实重新绑定了如果颜色都不变说明没有触发重新绑定流程。这个土办法有时候比放Log更直观。5. 从ListView到RecyclerView数据更新的升级思路5.1 RecyclerView和ListView在“刷新”上的本质区别如果你已经掌握了ListView的这套更新机制学RecyclerView会非常快。因为它们底层都是同一个思路Adapter 数据源 通知刷新。但RecyclerView把一些原本需要自己操心的事情接了过去强制的ViewHolder模式不用再手写判断convertView是否为null。提供了精确到Item的更新APInotifyItemChanged(position)、notifyItemInserted(position)、notifyItemRemoved(position)带默认动画不像ListView那样只能全量刷新。新增了DiffUtil可以自动计算新旧数据集的变化只刷新变化的Item。不过有一点没变数据源更新后依然需要显式调用通知方法。RecyclerView不会因为数据自己变了就主动重绘。5.2 DiffUtil带来的全新更新方式现在用Kotlin写AndroidRecyclerView ListAdapter DiffUtil基本上成了“标准答案”。ListAdapter内部会自动执行异步diff计算把数据传进去剩下的交给框架。简单示例class ItemDiffCallback : DiffUtil.ItemCallbackItemData() { override fun areItemsTheSame(oldItem: ItemData, newItem: ItemData): Boolean { return oldItem.id newItem.id } override fun areContentsTheSame(oldItem: ItemData, newItem: ItemData): Boolean { return oldItem newItem } } class ItemListAdapter : ListAdapterItemData, ItemViewHolder(ItemDiffCallback()) { // ... }更新数据时只需要itemListAdapter.submitList(newDataList)submitList内部自动对比然后精准通知哪些Item刷新、哪些插入、哪些移除。这种方式的处理效果完全不是ListView那种“整体闪一下”能比的。5.3 学习路径上的建议有一件事我经常在带新人时强调不要因为RecyclerView更强大就跳过ListView。ListView虽然老但它把Adapter机制暴露得足够“原始”你把它的getView、convertView、ViewHolder、notifyDataSetChanged都亲手写过一遍背后那些“数据源-适配器-界面”的关联逻辑才算真正刻进脑子里。我的个人经验是把ListView的阶段当作“原理课”把RecyclerView当作“实践课”。原理课上踩过的坑——引用不一致、线程问题、索引错位——到RecyclerView阶段你会发现它们依然存在只是换了身皮。6. 写在最后的几个实际体会如果你把上面这些代码原样抄下来跑一遍再故意把顺序改反、把引用换掉、把线程切到子线程各触发一次会比看多少篇文章都记得牢。我第一次搞明白这三者的关系就是在反复“故意写错”的过程中突然开窍的。另外有两个很久以后才真正改掉的习惯分享给你一是每次写Adapter更新逻辑时先问自己“数据源变了没有”再问“通知方式对不对”最后才检查“是不是在主线程”。这个顺序帮我省掉了大量调试时间。二是尽量用小步快跑的方式更新UI不要动不动就全量notify。尤其是列表数量过百时全量刷新不仅浪费性能还容易在视觉上产生闪烁。能用remove、add局部操作解决的就别偷懒用notifyDataSetChanged一把梭。ListView的数据更新问题说到底是理解“数据与界面之间那道桥”的问题。桥搭对了数据怎么变都能准确反映到屏幕上桥搭错了不管你的网络层写得多么漂亮界面都只会给你一张“冷漠”的脸。把这篇文章里的几个原理和步骤吃透你在Android入门阶段关于列表这块的坑基本就填完一大半了。
返回列表