ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android ListView 的 Adapter、BaseAdapter 与 RecycleBin 复用机制拆解:从 getView 到 convertView 的完整链路

Android ListView 的 Adapter、BaseAdapter 与 RecycleBin 复用机制拆解:从 getView 到 convertView 的完整链路 1. 为什么你的 ListView 越滑越卡从一次真实卡顿说起Android ListView 的 Adapter、BaseAdapter 与 RecycleBin 复用机制是很多中高级开发者面试和实战都绕不开的硬骨头。它决定了列表滑动是否流畅、内存是否稳定、会不会在长列表里突然 OOM。简单说Adapter 是数据与视图之间的翻译官BaseAdapter 是它最常用的抽象底座而 RecycleBin 则是 AbsListView 内部那个默默回收 View 的仓库管理员。适合已经会写getView、但说不清convertView到底从哪来、为什么有时复用失效的开发者。我见过不少项目ListView 只有几十条数据就开始掉帧一查getView里每次都在inflateconvertView永远是 null。表面看是“没写 ViewHolder”根因其实是没理解 RecycleBin 的两级缓存ActiveViews 和 ScrapViews。前者保存布局开始时屏幕上的 View后者保存已经滚出屏幕、等待被复用的 View。只有把这条链路走通你才能解释为什么getView的convertView有时有值、有时为 null以及getItemViewType为什么能决定复用是否安全。这篇会从职责边界讲到可复制代码再给出用日志和 Layout Inspector 验证复用次数的具体步骤。你不需要死记源码只要跟着把 BaseAdapter 骨架、ViewHolder 配置和验证方法跑一遍就能把“复用”从概念变成可观测的行为。2. 前置准备TaoToken 与开发环境怎么配在开始拆 ListView 之前先把模型对话和编码辅助的环境准备好。我平时排查这类源码问题会一边看 AOSP 片段一边让模型帮我解释RecycleBin的方法调用顺序效率比纯翻文档高不少。TaoToken 在这里的角色是提供一个统一的模型调用入口你可以在官网了解它的能力边界再决定用哪种方式接入。官网地址https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content如果你只是想快速问几个getView复用的问题直接用模型对话就行https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite如果你打算长期做 Android 源码阅读和 Agent 辅助编码可以看 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite需要自己管理调用凭证时进控制台创建https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewriteAPI Key 在 API Keys 页面生成https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite接入文档在这里包含请求格式和参数说明https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite如果你用 Claude Code 做 Android 工程辅助Anthropic 兼容入口是https://taotoken.net/ClaudeCodeAnthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecodeutm_campaignrewriteAPI 基础地址统一用https://taotoken.net/api注意上面这些链接只是环境准备真正决定 ListView 性能的还是你自己的 Adapter 实现。模型可以帮你解释源码但不会替你写对getViewTypeCount。本地环境需要 Android Studio 任意较新版本SDK 里带上 Layout Inspector。建一个空 Activity 项目即可不需要额外依赖。下面所有代码都放在一个MainActivity和一个SimpleBaseAdapter里方便你直接复制运行。3. Adapter、BaseAdapter 与 RecycleBin 的职责边界先把三者的关系理清楚不然后面看代码会混。Adapter是接口定义了getCount、getItem、getItemId、getView这些必须实现的方法还定义了getItemViewType、getViewTypeCount、registerDataSetObserver等可选能力。ListAdapter和SpinnerAdapter都继承自Adapter分别给列表和下拉框用。BaseAdapter是抽象类实现了ListAdapter和SpinnerAdapter把getItemViewType默认返回 0、getViewTypeCount默认返回 1、isEmpty默认判断getCount() 0这样你继承它时只需要补上数据相关的方法。RecycleBin 不是 Adapter 的一部分它藏在AbsListView内部ListView 和 GridView 都通过它来缓存 View。它的核心成员有两块mActiveViews保存布局开始时屏幕上的 ViewmScrapViews是一个按 viewType 分组的 ArrayList 数组保存已经滚出屏幕的 View。mCurrentScrap在viewTypeCount 1时直接指向mScrapViews[0]省去一次数组寻址。关键方法调用链是这样的布局开始时如果数据没变layoutChildren调用fillActiveViews把屏幕上的 child 放进mActiveViews然后obtainView里通过getActiveView(position)或getScrapView(position)取复用 View布局结束时scrapActiveViews把mActiveViews里没被用到的 View 移到mScrapViews并调用pruneScrapViews防止缓存无限增长。addScrapView则在 View 滚出屏幕时把它加入 scrap 堆并记录scrappedFromPosition。retrieveFromScrap的查找策略值得单独说它先遍历 scrap 列表找scrappedFromPosition position的 View找到就直接返回找不到就返回列表最后一个列表为空则返回 null。这解释了三种典型场景首次显示时缓存为空convertView为 null向上滚动产生新 item 时从缓存尾部取一个向下滚回已回收的位置时能精确命中之前那个 View。shouldRecycleViewType判断viewType 0才回收ITEM_VIEW_TYPE_IGNORE -1和ITEM_VIEW_TYPE_HEADER_OR_FOOTER -2都不会进 scrap 堆。如果你自定义了不该复用的类型却返回了非负值就会浪费内存甚至 OOM。4. 可复制配置BaseAdapter 骨架与 ViewHolder下面这份SimpleBaseAdapter可以直接放进项目。它包含数据列表、ViewHolder 缓存、getView复用逻辑以及getItemViewType和getViewTypeCount的默认实现。注意getView里只在convertView null时才inflate其余情况直接复用并更新数据。public class SimpleBaseAdapter extends BaseAdapter { private final ListString data; private final LayoutInflater inflater; public SimpleBaseAdapter(Context context, ListString data) { this.data data; this.inflater LayoutInflater.from(context); } Override public int getCount() { return data null ? 0 : data.size(); } Override public Object getItem(int position) { return data.get(position); } Override public long getItemId(int position) { return position; } Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView null) { convertView inflater.inflate(R.layout.item_simple, parent, false); holder new ViewHolder(); holder.title convertView.findViewById(R.id.tv_title); holder.index convertView.findViewById(R.id.tv_index); convertView.setTag(holder); Log.d(RecycleBin, new view at position position); } else { holder (ViewHolder) convertView.getTag(); Log.d(RecycleBin, reuse view at position position , scrappedFrom position); } holder.title.setText(data.get(position)); holder.index.setText(String.valueOf(position)); return convertView; } static class ViewHolder { TextView title; TextView index; } }对应的item_simple.xml只需要两个 TextView一个显示内容一个显示位置方便你在屏幕上直接看到复用是否把数据更新对了。?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal android:padding16dp TextView android:idid/tv_index android:layout_width48dp android:layout_heightwrap_content android:textSize16sp / TextView android:idid/tv_title android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:textSize16sp / /LinearLayoutActivity 里初始化 ListView 并设置 Adapter数据给 200 条方便观察滚动时的复用日志。public class MainActivity extends AppCompatActivity { Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); ListView listView findViewById(R.id.list_view); ListString data new ArrayList(); for (int i 0; i 200; i) { data.add(Item i); } listView.setAdapter(new SimpleBaseAdapter(this, data)); } }如果你要做分组列表比如联系人按字母分组就需要重写getItemViewType和getViewTypeCount。分组标签返回 0联系人返回 1getViewTypeCount返回 2。这样 RecycleBin 会维护两个 scrap 列表复用不会串类型。Override public int getItemViewType(int position) { return isGroup(position) ? 0 : 1; } Override public int getViewTypeCount() { return 2; }注意getViewTypeCount返回 2 之后getView里必须根据getItemViewType返回的类型去 inflate 对应布局否则会出现类型错乱表现为分组标签里显示了联系人数据。5. 验证请求与成功结果日志与 Layout Inspector 怎么看光写代码不够得能证明复用真的发生了。第一种方式看 Logcat。把上面的代码跑起来过滤 tagRecycleBin你会看到前十几条是new view at position0到new view at positionNN 大约等于屏幕能显示的 item 数加少量缓冲。之后继续向上滑动日志变成reuse view at position...说明convertView不再为 nullRecycleBin 开始工作。如果你看到滑动过程中仍然不断出现new view说明复用没生效。常见原因是getView里每次都在inflate或者getItemViewType返回了超出getViewTypeCount范围的值导致getScrapView里whichScrap mScrapViews.length直接返回 null。第二种方式用 Layout Inspector。在 Android Studio 里运行 App打开 Layout Inspector选中 ListView展开它的 child。你会看到屏幕上只有有限个 View 节点而不是 200 个。滚动列表后重新抓取节点数量基本不变只是内容变了。这直观说明 View 被复用了而不是每次新建。第三种方式在getView里加计数器统计new和reuse的次数滚动到底部后打印比例。正常情况下new的次数应该接近屏幕可见数量reuse次数接近总滚动次数。如果new次数接近数据总量说明 RecycleBin 的 scrap 堆没被用上。private int newCount 0; private int reuseCount 0; // 在 getView 里 if (convertView null) { newCount; } else { reuseCount; } // 滚动结束后 Log.d(RecycleBin, new newCount , reuse reuseCount);实测下来200 条数据在 1080P 屏幕上new大约 12 到 15 次reuse会到 180 次以上。这个比例可以作为你判断复用是否正常的参考。6. 本篇常见错排查convertView 为 null、复用错乱与 OOM第一个高频问题convertView一直为 null。除了没写 ViewHolder更隐蔽的原因是getViewTypeCount返回了 1但getItemViewType返回了 1 或更大值。getScrapView里会判断whichScrap mScrapViews.length不满足就返回 null于是每次都新建。解决方法是让getItemViewType的返回值落在[0, getViewTypeCount()-1]区间内。第二个问题复用后数据错乱比如第 3 条显示第 10 条的内容。这通常是 ViewHolder 没更新全部字段或者getItemViewType和布局不匹配。检查getView里是否对所有需要变化的 TextView 都调用了setText以及分组类型和布局是否一一对应。第三个问题IllegalStateException: The content of the adapter has changed but ListView did not receive a notification。这是数据在非 UI 线程被修改或者修改后没调用notifyDataSetChanged。RecycleBin 依赖 DataSetObserver 来感知数据变化数据变了不通知布局和缓存就会不一致。第四个问题OOM。pruneScrapViews会把超过mActiveViews.length的缓存释放掉但如果你的 Adapter 每次getView都新建 View 且不回收scrap 堆会不断增长。另一个原因是getItemViewType返回了ITEM_VIEW_TYPE_IGNORE之外的非负值但实际布局不该复用导致缓存了不该缓存的 View。检查shouldRecycleViewType的语义不该复用的类型返回 -1。第五个问题HeaderView 或 FooterView 被错误回收。addScrapView里对ITEM_VIEW_TYPE_HEADER_OR_FOOTER会直接removeDetachedView而不加入 scrap 堆。如果你自己给 header 设置了普通 viewType就可能被回收后复用成普通 item导致界面异常。不要手动改 header 的 viewType。提示排查复用问题时先把getItemViewType和getViewTypeCount临时改成默认的 0 和 1确认基础复用正常再逐步加分组逻辑能快速定位是类型系统的问题还是 ViewHolder 的问题。7. 继续深入把复用机制用到自己的控件里理解 RecycleBin 之后你再看AbsListView.obtainView就不会觉得神秘。它先尝试getActiveView再尝试getScrapView都没有才调用mAdapter.getView(position, null, this)。布局结束时scrapActiveViews把没用的 ActiveView 降级到 ScrapViewspruneScrapViews控制缓存上限。这条链路清晰之后你自己写横向列表、瀑布流或者自定义容器时也可以照搬两级缓存加按类型分组的思路。如果你在接入模型辅助阅读 AOSP 时遇到请求格式或鉴权问题可以回到 API Keys 和接入文档核对参数https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 和 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite需要长期做 Android 源码分析和 Agent 辅助编码的Coding Plan 更适合https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite只想快速验证某个getView行为或让模型解释retrieveFromScrap的直接用模型对话https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewriteAPI 基础地址https://taotoken.net/api最后留一个可操作的练习把SimpleBaseAdapter的getItemViewType改成根据 position 奇偶返回 0 和 1getViewTypeCount返回 2两个类型用不同背景色。滚动列表观察 Logcat 里new的次数是否变成两组再用 Layout Inspector 确认两种类型的 View 各自被复用。这个练习能帮你把 RecycleBin 按类型分组的机制真正跑通。
返回列表