1. 从一次滑动卡顿说起:ListView 到底是什么、能做什么、适合谁
如果你刚开始接触 Android,或者已经写过几个页面但列表一滑就掉帧,那 ListView 大概率是你绕不开的第一个“性能课”。它是一个以垂直滚动列表形式展示数据的控件,能根据数据条数自适应显示内容,早期几乎所有的联系人、设置项、消息流都用它实现。虽然现在官方更推荐 RecyclerView,但大量存量项目、教学示例、老版本兼容场景里 ListView 依然活跃,而且它把“数据怎么变成一行行 View”这件事暴露得非常直白,特别适合用来理解 Adapter 模式、ViewHolder 复用和事件分发。
我见过太多初学者把数据一股脑塞进getView()里,每次滑动都重新inflate布局、重新findViewById,结果列表一长就卡成幻灯片。这篇就按“能直接跑起来”的标准,把 ListView 的三个核心元素——ListView 本身、Adapter 适配器、数据源——拆开讲清楚,再给出 Java 和 Kotlin 两套可复制代码,最后用真实报错带你排查滑动卡顿。适合谁?适合刚学完 Activity 想练手列表的初学者,也适合想回头补一补复用机制原理的进阶开发者。
核心检索词先记住:Android ListView 的 Adapter 绑定与 ViewHolder 复用机制。理解了这个,你再看 RecyclerView 会轻松很多,因为后者的思路几乎是从这里演化出去的。
2. 前置准备:Adapter 三种类型怎么选,以及一个顺手的调试入口
ListView 的显示离不开三个元素:ListView 负责展示,Adapter 负责把数据映射成 View,数据源则是具体的字符串、图片或对象集合。按适配器类型分,常见的有 ArrayAdapter、SimpleAdapter 和 SimpleCursorAdapter。ArrayAdapter 最简单,只能展示一行文字;SimpleAdapter 扩展性最好,可以自定义图片、按钮、复选框;SimpleCursorAdapter 则是 SimpleAdapter 和数据库 Cursor 的结合,方便把查询结果直接铺到列表上。
选型其实有个很实用的判断:纯文本列表用 ArrayAdapter,图文混排用 SimpleAdapter,数据来自 ContentProvider 或数据库查询用 SimpleCursorAdapter。但只要你需要在行内放可点击按钮、或者要精细控制复用,就必须继承 BaseAdapter 自己写。
在动手写代码前,我习惯先确认一下手头的模型调用链路是否正常,避免把环境问题和代码问题混在一起排查。如果你也在用大模型辅助生成或润色 Android 代码片段,可以先用 TaoToken 模型对话 快速验证一段 Adapter 代码的逻辑,确认没问题再贴进工程里。它的接入文档在 TaoToken 接入文档,API 入口统一是https://taotoken.net/api,不需要额外配置复杂环境。
这里要提醒一句:模型对话适合验证“这段 getView 写得对不对”,但不要让它直接连你的生产数据库或真实设备,代码最终还是要落到本地工程里跑一遍。前置准备做到位,后面配置和排障才不会手忙脚乱。
3. 可复制配置:从 ArrayAdapter 到自定义 BaseAdapter 的完整代码
先看最简单的 ArrayAdapter。新建一个 Activity,直接用系统布局android.R.layout.simple_list_item_1,它只显示一行文字:
public class MyListView extends Activity { private ListView listView; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); listView = new ListView(this); listView.setAdapter(new ArrayAdapter<String>( this, android.R.layout.simple_list_item_1, getData())); setContentView(listView); } private List<String> getData() { List<String> data = new ArrayList<>(); data.add("测试数据1"); data.add("测试数据2"); data.add("测试数据3"); data.add("测试数据4"); return data; } }ArrayAdapter 的构造需要三个参数:Context、行布局文件、数据源 List。注意这里的布局文件描述的是“每一行”的样子,不是整个页面。setAdapter()完成最后绑定。
接下来是图文混排的 SimpleAdapter。先定义行布局vlist.xml:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:id="@+id/img" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="5dp" /> <LinearLayout android:orientation="vertical" android:layout_width="wrap_content" android:layout_height="wrap_content"> <TextView android:id="@+id/title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textColor="#FFFFFFFF" android:textSize="22sp" /> <TextView android:id="@+id/info" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textColor="#FFFFFFFF" android:textSize="13sp" /> </LinearLayout> </LinearLayout>对应代码用List<Map<String, Object>>作为数据源,每个 Map 对应一行:
public class MyListView3 extends ListActivity { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); SimpleAdapter adapter = new SimpleAdapter( this, getData(), R.layout.vlist, new String[]{"title", "info", "img"}, new int[]{R.id.title, R.id.info, R.id.img}); setListAdapter(adapter); } private List<Map<String, Object>> getData() { List<Map<String, Object>> list = new ArrayList<>(); Map<String, Object> map = new HashMap<>(); map.put("title", "G1"); map.put("info", "google 1"); map.put("img", R.drawable.i1); list.add(map); // 继续 add 更多 map... return list; } }SimpleAdapter 的后两个参数是关键:一个 String 数组表示 Map 的键,一个 int 数组表示布局里组件的 id,两者按顺序一一对应完成映射。
但真正要讲复用,必须继承 BaseAdapter。行布局vlist2.xml在刚才基础上加一个按钮:
<Button android:id="@+id/view_btn" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/s_view_btn" android:focusable="false" android:layout_gravity="bottom|right" />注意android:focusable="false",不加这行按钮会抢走 ListView 的焦点,导致整行点不动。下面是带 ViewHolder 复用的完整 Adapter:
public class MyAdapter extends BaseAdapter { private LayoutInflater mInflater; private List<Map<String, Object>> mData; public MyAdapter(Context context, List<Map<String, Object>> data) { this.mInflater = LayoutInflater.from(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) { ViewHolder holder; if (convertView == null) { holder = new ViewHolder(); convertView = mInflater.inflate(R.layout.vlist2, null); holder.img = convertView.findViewById(R.id.img); holder.title = convertView.findViewById(R.id.title); holder.info = convertView.findViewById(R.id.info); holder.viewBtn = convertView.findViewById(R.id.view_btn); convertView.setTag(holder); } else { holder = (ViewHolder) convertView.getTag(); } holder.img.setBackgroundResource((Integer) mData.get(position).get("img")); holder.title.setText((String) mData.get(position).get("title")); holder.info.setText((String) mData.get(position).get("info")); holder.viewBtn.setOnClickListener(v -> showInfo()); return convertView; } public static class ViewHolder { public ImageView img; public TextView title; public TextView info; public Button viewBtn; } }Kotlin 版本更简洁,用ViewHolder加getTag/setTag思路完全一致:
class MyAdapter( private val context: Context, private val data: List<Map<String, Any>> ) : BaseAdapter() { private val inflater = LayoutInflater.from(context) override fun getCount(): Int = data.size override fun getItem(position: Int): Any = data[position] override fun getItemId(position: Int): Long = position.toLong() override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View { val view: View val holder: ViewHolder if (convertView == null) { view = inflater.inflate(R.layout.vlist2, parent, false) holder = ViewHolder(view) view.tag = holder } else { view = convertView holder = view.tag as ViewHolder } holder.title.text = data[position]["title"] as String holder.info.text = data[position]["info"] as String holder.img.setBackgroundResource(data[position]["img"] as Int) holder.viewBtn.setOnClickListener { showInfo() } return view } class ViewHolder(view: View) { val img: ImageView = view.findViewById(R.id.img) val title: TextView = view.findViewById(R.id.title) val info: TextView = view.findViewById(R.id.info) val viewBtn: Button = view.findViewById(R.id.view_btn) } }如果你在团队里用 Claude Code 或类似工具做长期编码,把这类 Adapter 模板沉淀成可复用片段会省很多事,TaoToken Coding Plan 适合这种持续性的编码场景。Key 的获取在 TaoToken API Keys,控制台在 TaoToken Console。配置时记住三件套:Base URL 填https://taotoken.net/api,Key 用你申请的那串,Model ID 按文档里对应模型填,三者缺一不可。
4. 验证请求:跑起来看结果,确认复用真的生效
代码写完后,最直接的验证就是跑一遍。用 ListActivity 承载时,onCreate里setListAdapter(adapter)即可;用普通 Activity 则先setContentView(R.layout.main),再findViewById拿到 ListView 调setAdapter。
点击事件分两种。整行点击重写onListItemClick:
@Override protected void onListItemClick(ListView l, View v, int position, long id) { Log.v("MyListView4-click", (String) mData.get(position).get("title")); }行内按钮点击则在getView里给按钮单独设监听,弹个对话框:
public void showInfo() { new AlertDialog.Builder(this) .setTitle("我的listview") .setMessage("介绍...") .setPositiveButton("确定", null) .show(); }怎么确认 ViewHolder 复用生效?在getView里加一行日志,打印convertView == null的次数:
Log.d("ListView", "position=" + position + ", convertView is null? " + (convertView == null));滑动一个 100 条数据的列表,你会看到前几屏convertView为 null,之后全是复用,日志里 null 的次数稳定在屏幕可见行数附近。如果每次滑动都打印 null,说明复用没生效,多半是getView里每次都inflate了新布局,或者setTag没写对。
再验证一下getCount()的作用:把它临时改成返回 0,列表直接不显示;返回 1,只显示一行。这能帮你确认数据长度确实由getCount()决定,系统先调它拿到长度,再逐行调getView绘制。
如果你想让模型帮你检查这段日志逻辑或生成测试数据,可以用 TaoToken 模型对话 贴代码问,但最终验证一定要在真机或模拟器上跑,日志和帧率不会骗人。
5. 常见报错排查:从 401 到滑动卡顿,对照真实错误逐个解决
排障这块我按真实遇到的报错来列,你可以对照自己的日志找。
报错一:401 Unauthorized或local proxy failed。如果你在接入模型辅助编码时遇到这个,先检查 Key 是否填对、Base URL 是否是https://taotoken.net/api。local proxy failed通常是本地代理配置和实际网络环境冲突,把代理关掉、直连 API 入口再试。这类问题跟 ListView 本身无关,但会卡住你查文档的节奏,先解决掉。
报错二:java.lang.NullPointerException在getView里。九成是convertView.getTag()拿到的不是 ViewHolder,或者findViewById返回 null。检查setTag(holder)是否在convertView == null分支里执行,以及布局文件里 id 是否和代码一致。
报错三:reading choices相关解析失败。这通常出现在你让模型返回结构化数据、结果格式不对时。解决办法是在提示里明确要求返回 JSON,并给出字段示例,别让它自由发挥。
报错四:OAuth鉴权失败。如果你用的是需要 OAuth 的编码工具,检查 token 是否过期、回调地址是否配置正确。这类工具接入时同样记住三件套:Base URL、Key、Model ID,缺一个都会鉴权失败。
报错五:滑动卡顿、掉帧。这是 ListView 最典型的性能问题。排查顺序:第一,确认getView里没有做耗时操作,比如读文件、网络请求、大量字符串拼接;第二,确认 ViewHolder 复用生效,别每次inflate;第三,图片加载别在主线程解码大图,用 Glide 或 Coil 这类库;第四,行布局层级别太深,measure/layout次数会随层级上升。我试过把行布局从五层压到两层,滑动帧率肉眼可见地稳了。
报错六:按钮点击无响应。前面提过,给 Button 加android:focusable="false",否则它抢焦点导致整行点不动。如果按钮和整行点击都要响应,按钮单独设监听,整行用onListItemClick。
报错七:IllegalStateException: The content of the adapter has changed but ListView did not receive a notification。数据变了但没通知刷新。改完数据源后调adapter.notifyDataSetChanged(),并且确保数据修改在主线程。
排查时建议开 GPU 渲染模式分析,看每一帧的耗时条。如果某帧突然变长,基本就是getView里藏了重活。把重活挪到异步,或者用缓存,卡顿会明显改善。
6. 把复用机制吃透,再去看 RecyclerView 就顺了
ListView 的getView(position, convertView, parent)这三个参数,其实把复用的全部秘密都摆在了明面上:convertView就是系统回收给你的旧行视图,你复用它、更新数据、再返回,就完成了一次复用。ViewHolder 只是把findViewById的结果缓存进tag,避免重复查找。这套思路在 RecyclerView 里被抽象成了onCreateViewHolder和onBindViewHolder两个方法,职责更清晰,但底层逻辑一脉相承。
所以别觉得 ListView 过时就不值得学。把 Adapter 绑定、ViewHolder 复用、点击事件分发、getCount与getView的调用时机这几件事亲手写一遍、跑一遍、用日志验证一遍,你对 Android 列表控件的理解就扎实了。真到用 RecyclerView 时,你只需要关注它多出来的 LayoutManager 和 ItemDecoration,核心的复用思想你已经会了。
最后留个实用习惯:每次写完 Adapter,先在getView里打一行复用日志,滑一遍确认 null 次数正常,再删日志。这个动作花不了两分钟,但能帮你避开后面几小时的卡顿排查。代码跑起来,日志看起来,比看十篇教程都管用。