ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Android学习之路(17) Android Adapter详解:从BaseAdapter到ViewHolder的ListView实战

2026/10/7 14:38:53 拓冰建站 浏览量
Android学习之路(17) Android Adapter详解:从BaseAdapter到ViewHolder的ListView实战 1. 为什么你的 ListView 滑动会卡从 BaseAdapter 的 getView 调用次数说起如果你正在学 AndroidListView 大概率是你接触的第一个「列表控件」。它看起来简单给个数据源写个 AdaptersetAdapter 一挂就完事。但很多人第一次做长列表时会发现一个现象——滑动明显掉帧快速甩动时白块一闪一闪甚至滚动到后面数据错乱。这些问题九成出在 Adapter 的 getView 方法上。Adapter 本质是数据与界面之间的桥梁它把 List 里的每个对象翻译成一行可显示的 View。ListView 本身不关心你有多少条数据它只负责在屏幕上摆 View真正决定「第 position 行长什么样」的是 BaseAdapter 的 getView。理解这一点你就理解了 ListView 的性能命门getView 会被反复调用屏幕每滚动一行就可能触发一次如果你在 getView 里每次都 inflate 布局、每次都 findViewById那滑动就是在做重复劳动。这篇内容面向刚学完 Activity 和基础控件的初学者我会带你从零写一个 BaseAdapter 子类然后一步步加上 convertView 复用和 ViewHolder 缓存最后给出一个可复用的泛型 Adapter 模板。你不需要提前懂 MVC只要会写 findViewById 和 setText 就能跟上。核心检索词就三个BaseAdapter、ViewHolder、ListView 复用机制。读完你能自己判断「我的 Adapter 到底有没有复用成功」而不是靠感觉。我试过在 200 条数据的列表里对比优化前后的 getView 调用日志差距非常直观不复用 convertView 时inflate 次数等于数据条数加上 ViewHolder 后inflate 次数约等于屏幕可见行数。这个数字变化就是性能提升的来源。2. 前置准备TaoToken 接入与 Android 工程环境在动手写 Adapter 之前先把两件事准备好一个是能跑起来的 Android 工程另一个是方便你在写代码时随时查文档、问模型的 AI 接入环境。后者我用 TaoToken 来做它提供统一的 API 入口兼容 OpenAI 风格的请求格式适合在写 Android 代码时随手查 BaseAdapter 的用法或让模型帮你检查 getView 逻辑。TaoToken 的官网入口是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 。注意 API 地址不带 UTM 参数直接填这个就行。如果你只是想在浏览器里对话验证模型用模型对话页面如果你要长期写 Android 项目、让 Agent 帮你改代码用 Coding Plan 更合适。Android 工程这边你需要 Android Studio 任意较新版本创建一个 Empty Views Activity 项目minSdk 用 21 以上即可。布局里放一个 ListViewid 设为 list_test。数据实体类随便定义一个比如 Animal包含 icon、name、speak 三个字段。这些是后面所有代码的基础。关于 Key 的获取进入控制台后创建 API Key复制出来保存好。这个 Key 在后面的配置里会用到。如果你用的是 Claude Code 这类工具做 Android 代码辅助可以在 settings 里配置 Base URL 为 https://taotoken.net/api Key 填你刚创建的Model ID 按你选的模型填。三件套齐全才能正常请求缺一个都会报 401。注意API Key 不要硬编码进 Android 项目源码里提交到 Git本地调试可以用 BuildConfig 或 gradle.properties 注入。环境准备好后我们进入正题。先写一个「最笨」的 BaseAdapter故意不复用这样你才能看清问题出在哪。3. 可复制配置BaseAdapter 子类 ViewHolder 复用模板先看一个没有做任何优化的 getView 写法这是很多初学者照着教程写出来的版本public class AnimalAdapter extends BaseAdapter { private Context mContext; private ListAnimal mData; public AnimalAdapter(Context context, ListAnimal data) { this.mContext context; this.mData data; } Override public int getCount() { return mData.size(); } Override public Object getItem(int position) { return mData.get(position); } Override public long getItemId(int position) { return position; } Override public View getView(int position, View convertView, ViewGroup parent) { convertView LayoutInflater.from(mContext) .inflate(R.layout.item_list_animal, parent, false); ImageView imgIcon convertView.findViewById(R.id.img_icon); TextView txtName convertView.findViewById(R.id.txt_aName); TextView txtSpeak convertView.findViewById(R.id.txt_aSpeak); Animal animal mData.get(position); imgIcon.setBackgroundResource(animal.getIcon()); txtName.setText(animal.getName()); txtSpeak.setText(animal.getSpeak()); return convertView; } }这段代码能跑但每次 getView 都 inflate 一次 XML等于每次都在解析布局文件。屏幕上有 8 行可见滑一屏就 inflate 8 次以上数据多了就是灾难。优化第一步复用 convertView。convertView 是 ListView 回收给你的「废弃 View」当一行滑出屏幕它的 View 会被缓存起来下次需要新行时通过 convertView 传回来。你只需要判断它是不是 nullOverride public View getView(int position, View convertView, ViewGroup parent) { if (convertView null) { convertView LayoutInflater.from(mContext) .inflate(R.layout.item_list_animal, parent, false); } ImageView imgIcon convertView.findViewById(R.id.img_icon); TextView txtName convertView.findViewById(R.id.txt_aName); TextView txtSpeak convertView.findViewById(R.id.txt_aSpeak); Animal animal mData.get(position); imgIcon.setBackgroundResource(animal.getIcon()); txtName.setText(animal.getName()); txtSpeak.setText(animal.getSpeak()); return convertView; }这样 inflate 次数从「数据条数」降到「屏幕可见行数 少量缓冲」。但 findViewById 还是每次都调用因为控件引用没有被保存。于是引入 ViewHolder把 findViewById 的结果存进一个静态内部类通过 setTag 挂到 convertView 上。Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView null) { convertView LayoutInflater.from(mContext) .inflate(R.layout.item_list_animal, parent, false); holder new ViewHolder(); holder.imgIcon convertView.findViewById(R.id.img_icon); holder.txtName convertView.findViewById(R.id.txt_aName); holder.txtSpeak convertView.findViewById(R.id.txt_aSpeak); convertView.setTag(holder); } else { holder (ViewHolder) convertView.getTag(); } Animal animal mData.get(position); holder.imgIcon.setBackgroundResource(animal.getIcon()); holder.txtName.setText(animal.getName()); holder.txtSpeak.setText(animal.getSpeak()); return convertView; } static class ViewHolder { ImageView imgIcon; TextView txtName; TextView txtSpeak; }到这里inflate 和 findViewById 都只发生在 convertView 为 null 的时候也就是只在创建新行时执行。滑动时复用的是已经解析好的 View 和已经缓存好的控件引用性能提升非常明显。如果你想让这个 Adapter 更通用可以把它升级成泛型 抽象 bindView 的形式。下面是一个可以直接复制到项目里的可复用模板支持不同实体、不同布局public abstract class MyAdapterT extends BaseAdapter { private ArrayListT mData; private int mLayoutRes; public MyAdapter(ArrayListT mData, int mLayoutRes) { this.mData mData; this.mLayoutRes mLayoutRes; } Override public int getCount() { return mData ! null ? mData.size() : 0; } Override public T getItem(int position) { return mData.get(position); } Override public long getItemId(int position) { return position; } Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder ViewHolder.bind( parent.getContext(), convertView, parent, mLayoutRes, position); bindView(holder, getItem(position)); return holder.getItemView(); } public abstract void bindView(ViewHolder holder, T obj); public void add(T data) { if (mData null) mData new ArrayList(); mData.add(data); notifyDataSetChanged(); } public void remove(int position) { if (mData ! null) mData.remove(position); notifyDataSetChanged(); } public void clear() { if (mData ! null) mData.clear(); notifyDataSetChanged(); } public static class ViewHolder { private SparseArrayView mViews; private View item; private int position; private Context context; private ViewHolder(Context context, ViewGroup parent, int layoutRes) { mViews new SparseArray(); this.context context; View convertView LayoutInflater.from(context) .inflate(layoutRes, parent, false); convertView.setTag(this); item convertView; } public static ViewHolder bind(Context context, View convertView, ViewGroup parent, int layoutRes, int position) { ViewHolder holder; if (convertView null) { holder new ViewHolder(context, parent, layoutRes); } else { holder (ViewHolder) convertView.getTag(); holder.item convertView; } holder.position position; return holder; } SuppressWarnings(unchecked) public T extends View T getView(int id) { T t (T) mViews.get(id); if (t null) { t item.findViewById(id); mViews.put(id, t); } return t; } public View getItemView() { return item; } public ViewHolder setText(int id, CharSequence text) { View view getView(id); if (view instanceof TextView) ((TextView) view).setText(text); return this; } public ViewHolder setImageResource(int id, int drawableRes) { View view getView(id); if (view instanceof ImageView) { ((ImageView) view).setImageResource(drawableRes); } else { view.setBackgroundResource(drawableRes); } return this; } public ViewHolder setOnClickListener(int id, View.OnClickListener listener) { getView(id).setOnClickListener(listener); return this; } } }使用的时候你只需要在 Activity 里 new 一个匿名子类实现 bindViewMyAdapterAnimal adapter new MyAdapterAnimal(dataList, R.layout.item_list_animal) { Override public void bindView(ViewHolder holder, Animal obj) { holder.setImageResource(R.id.img_icon, obj.getIcon()); holder.setText(R.id.txt_aName, obj.getName()); holder.setText(R.id.txt_aSpeak, obj.getSpeak()); } }; listView.setAdapter(adapter);这个模板的好处是换一个实体、换一个布局只需要改 bindView 里的几行不用重写整个 Adapter。两个 ListView 用同一个 MyAdapter 类也能搞定只要布局和 bindView 不同即可。4. 验证请求与成功结果用日志和布局检查确认复用生效写完代码不能只看「能跑」要确认复用真的生效了。最直接的办法是打日志。在 getView 里加两行 LogOverride public View getView(int position, View convertView, ViewGroup parent) { if (convertView null) { Log.d(AdapterReuse, inflate new view, position position); convertView LayoutInflater.from(mContext) .inflate(R.layout.item_list_animal, parent, false); ViewHolder holder new ViewHolder(); // ... findViewById 和 setTag convertView.setTag(holder); } else { Log.d(AdapterReuse, reuse convertView, position position); } // ... 绑定数据 return convertView; }运行后打开 Logcat过滤 AdapterReuse。你会看到前几次都是 inflate new view对应屏幕上第一批可见行之后滑动时大量出现 reuse convertView。如果滑了很久还在频繁 inflate说明复用没生效检查是不是在 if 外面又 inflate 了一次。第二个验证手段是布局检查。在 Android Studio 里用 Layout Inspector 连接设备选中 ListView展开它的子 View。你会发现屏幕上可见的 item 数量是固定的滑动时这些 View 对象被重新绑定数据而不是不断创建新对象。如果你在 getView 里给每个 item 设置一个唯一的 tag 或背景色滑动时观察颜色是否跟着 position 变化也能判断绑定是否正确。第三个验证是数据正确性。快速滑动列表看有没有出现「第 3 行显示了第 8 行的数据」这种错位。如果有通常是 ViewHolder 里的控件引用和 convertView 没对上或者你在 if/else 之外错误地重新 findViewById 了。复用机制本身不会导致数据错乱错乱一定是绑定逻辑写错了。成功的结果应该是滑动流畅Logcat 里 inflate 次数远小于数据总数Layout Inspector 里 item 数量稳定快速滑动无白块、无错位。做到这几点你的 BaseAdapter ViewHolder 就算真正跑通了。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth虽然这篇主线是 Android Adapter但你在用 AI 辅助写代码时可能会遇到接入层的报错。这里把几个高频错误和 Adapter 本身的坑一起列出来方便对照。401 Unauthorized通常是 API Key 没填、填错或过期。检查你配置的 Key 是否和控制台里创建的一致Base URL 是否写成了 https://taotoken.net/api 。如果用的是 Claude Code 或 Cline 这类工具确认三件套齐全Base URL、Key、Model ID。缺任何一个都会 401。local proxy failed这个报错一般出现在本地网络配置或工具代理设置上。检查你的工具是否配置了额外的网络层或者本地端口是否被占用。把代理相关配置清掉直接用官方 API 地址重试。reading choices 相关报错如果你在代码里解析模型返回的 JSON报错提示读取 choices 失败通常是返回体结构和预期不一致。先打印完整响应体确认字段名是 choices 还是其他再调整解析逻辑。Android 里用 Gson 或 Moshi 时字段名要和返回 JSON 严格对应。OAuth 相关错误如果你用的是需要 OAuth 授权的工具报错通常和 token 过期或回调地址不匹配有关。重新走一遍授权流程确认回调地址和工具里配置的一致。Adapter 本身的常见坑一是在 getView 里做耗时操作比如读数据库、加载大图导致滑动卡顿二是 ViewHolder 没有声明为 static虽然功能上没问题但非静态内部类会持有外部类引用在复用场景下容易造成内存泄漏三是忘记调用 notifyDataSetChanged数据变了界面不刷新四是 convertView 复用时没有重置所有可变状态比如某个 item 的选中状态被复用到另一行。提示排查 Adapter 问题时先看 Logcat 里 inflate 次数再看 Layout Inspector 里 item 数量最后检查数据绑定逻辑。三步能定位绝大多数复用问题。6. 语义一致 CTA把 Adapter 写顺之后用 AI 帮你继续啃 ListView 进阶BaseAdapter 和 ViewHolder 是 ListView 的地基把这个模板吃透后面学 RecyclerView 会轻松很多因为 RecyclerView 的 ViewHolder 思路和这里一脉相承只是把复用逻辑封装得更彻底。如果你在写 Adapter 时想让模型帮你检查 getView 有没有漏掉复用分支或者想让它根据你的实体类生成 bindView 代码可以用模型对话页面直接贴代码问。地址是 https://taotoken.net/api 配合你在控制台创建的 Key 就能用。控制台入口在 https://taotoken.net/api API Keys 管理也在那里。如果你打算长期做 Android 开发经常要让 AI 帮你读源码、改 Adapter、写自定义控件Coding Plan 更适合入口是 https://taotoken.net/api 。接入文档在 https://taotoken.net/api 里面有完整的请求格式和参数说明。最后留一个实用技巧把这篇里的 MyAdapter 模板保存成 Android Studio 的 Live Template以后新建 Adapter 时直接展开省去重复敲代码的时间。模板里的 bindView 是你唯一需要改的地方其余部分不用动。这样你写十个列表页Adapter 的复用逻辑都是统一的排查问题也更快。