1. 从 SD 卡音乐列表说起:SimpleCursorAdapter 到底在做什么
如果你在 Android 上做过「扫描 SD 卡里的音乐文件并显示成列表」这类需求,大概率绕不开SimpleCursorAdapter。它是什么?一句话:它是CursorAdapter的便捷子类,负责把数据库或ContentResolver查询出来的Cursor,按你给的「列名 → 控件 ID」映射关系,自动填充到ListView的每一行。适合谁?适合早期 Android(API 11 之前没有RecyclerView、没有LoaderManager那套)里做联系人、短信、媒体库这类「游标驱动」的列表。
但很多人第一次用它都会懵:我明明只调了一次changeCursor(),为什么newView()和bindView()会被反复触发?newView()里setTag()存进去的ViewHolder,到bindView()里还能不能拿到?changeCursor()到底该在什么时候调,调早了列表空白,调晚了又报IllegalStateException?
我当年写一个 SD 卡音乐播放器的列表页时,就被这三个方法的调用顺序坑了整整一个下午。日志里newView和bindView交替刷屏,changeCursor却只在开头出现一次,完全对不上直觉。后来把断点一个个打进去,才理清它们各自的职责边界。
这篇就按「SD 卡音乐文件列表」这个真实场景,把newView()、bindView()、changeCursor()三者的调用时机、职责划分、以及视图复用时的实际执行顺序,用可复制的代码和日志验证一遍。核心检索词先摆出来:SimpleCursorAdapter的newView()负责创建行视图,bindView()负责把当前游标行的数据塞进视图,changeCursor()负责替换数据源并触发整表刷新。搞懂这三者的分工,你写列表就不会再出现「数据错位」「滚动后文字乱掉」这类经典 bug。
下面所有代码都基于ListActivity+SimpleCursorAdapter子类的结构,查询走MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,也就是扫描 SD 卡(外置存储)上的音频。你可以直接照着搭一个工程跟做。
2. TaoToken 前置:把模型对话接进你的调试流程
写 Android 列表调试时,我经常需要一边看日志一边问模型「这段bindView为什么拿不到 tag」。与其在多个窗口之间切来切去,不如把模型对话能力直接接进自己的工具链。TaoToken 提供的就是这样一个统一入口:官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 端点是 https://taotoken.net/api (这个地址不加 UTM 参数,配置时直接用)。
它是什么?简单说,它把多家模型的调用收敛成一套兼容 OpenAI 风格的接口,你用同一个 Base URL 和 Key,就能在命令行、编辑器插件、脚本里切换模型。能做什么?对你现在这个场景来说,最实用的两件事:一是把bindView里的异常日志贴给模型,让它帮你判断是getColumnIndexOrThrow抛的IllegalArgumentException还是游标已经关闭;二是让模型帮你生成Cursor查询的selection拼接,避免手写where时漏掉IS_MUSIC=1这种条件。
适合谁?适合已经在用 Android Studio、习惯在终端里跑adb logcat的开发者。你不需要改工程结构,只要在本地配好环境变量,就能在调试间隙直接发请求。
具体怎么拿 Key:打开 https://taotoken.net/api-keys ,登录后创建一个 API Key,复制出来。注意这个 Key 只显示一次,丢了就重新建。拿到之后,把它写进你的 shell 配置里,别硬编码进代码仓库。
export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api"如果你更想在浏览器里直接对话验证模型输出,可以用模型对话页面: https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 。把bindView的报错粘进去,让它先给你一个排查方向,再回到 Android Studio 里打断点验证。
需要说明的是,TaoToken 在这里扮演的是「模型调用入口」的角色,它不替代 Android Studio,也不替代adb。你的列表能不能正常显示,最终还是取决于Cursor查询和Adapter绑定逻辑写没写对。模型只是帮你更快定位问题。
对于长期要写 Android 列表、Agent 类调试工具的读者,可以考虑 Coding Plan: https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite ,它更适合高频调用、需要稳定配额的使用方式。如果你只是偶尔问两句,用 API Keys 按量走就够了。
3. 可复制配置:Cursor 查询 + Adapter 子类 + 布局绑定
这一节把完整可跑的配置给出来。先看布局,res/layout/track_list_item.xml,两行文本,对应标题和艺术家:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="8dp"> <TextView android:id="@+id/line1" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="16sp" android:singleLine="true" /> <TextView android:id="@+id/line2" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="12sp" android:textColor="#888888" android:singleLine="true" /> </LinearLayout>然后是查询列的定义。注意MediaStore.Audio.Media.DATA在旧版本里对应数据库列名_data,getColumnIndexOrThrow时要用_data,这是很多人踩的坑:
private static final String[] CURSOR_COLS = new String[] { MediaStore.Audio.Media._ID, MediaStore.Audio.Media.TITLE, MediaStore.Audio.Media.DATA, MediaStore.Audio.Media.ALBUM, MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.ARTIST_ID, MediaStore.Audio.Media.DURATION };selection的拼接要保证只查音乐、且标题非空,否则会把铃声、通知音也扫进来:
StringBuilder where = new StringBuilder(); where.append(MediaStore.Audio.Media.TITLE).append(" != ''"); where.append(" AND ").append(MediaStore.Audio.Media.IS_MUSIC).append(" = 1"); String selection = where.toString();Adapter 子类的构造里,from和to可以先传空数组,因为我们重写了bindView(),不依赖父类的自动映射。但super()调用必须传,否则SimpleCursorAdapter内部状态不完整:
public class TrackListAdapter extends SimpleCursorAdapter { private final TrackQueryHandler queryHandler; public TrackListAdapter(Context context, int layout, Cursor c, String[] from, int[] to) { super(context, layout, c, from, to); this.queryHandler = new TrackQueryHandler(context.getContentResolver()); } @Override public View newView(Context context, Cursor cursor, ViewGroup parent) { Log.d("TrackAdapter", "newView"); View v = super.newView(context, cursor, parent); ViewHolder vh = new ViewHolder(); vh.line1 = (TextView) v.findViewById(R.id.line1); vh.line2 = (TextView) v.findViewById(R.id.line2); v.setTag(vh); return v; } @Override public void bindView(View view, Context context, Cursor cursor) { Log.d("TrackAdapter", "bindView"); ViewHolder vh = (ViewHolder) view.getTag(); vh.line1.setText(cursor.getString( cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE))); vh.line2.setText(cursor.getString( cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST))); } @Override public void changeCursor(Cursor c) { Log.d("TrackAdapter", "changeCursor"); super.changeCursor(c); } static class ViewHolder { TextView line1; TextView line2; } }AsyncQueryHandler负责在后台线程查游标,查完回调onQueryComplete,在那里调changeCursor():
class TrackQueryHandler extends AsyncQueryHandler { public TrackQueryHandler(ContentResolver cr) { super(cr); } void doQuery(Uri uri, String[] projection, String selection, String[] selectionArgs, String orderBy) { startQuery(0, null, uri, projection, selection, selectionArgs, orderBy); } @Override protected void onQueryComplete(int token, Object cookie, Cursor cursor) { Log.d("TrackAdapter", "onQueryComplete, count=" + cursor.getCount()); changeCursor(cursor); } }Activity 里初始化时,先setListAdapter,再发起查询。注意getTrackCursor返回的是null,因为真正的游标在异步回调里才拿到:
public class TTActivity extends ListActivity { private TrackListAdapter mAdapter; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); mAdapter = new TrackListAdapter(this, R.layout.track_list_item, null, new String[]{}, new int[]{}); setListAdapter(mAdapter); mAdapter.getQueryHandler().doQuery( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, CURSOR_COLS, selection, null, null); } }这套配置里,from/to传空是刻意的,因为我们在bindView里手动取列。如果你想让父类自动绑定,就得把from写成{TITLE, ARTIST}、to写成{R.id.line1, R.id.line2},但那样就没法在绑定前后插日志了。调试阶段建议手动绑,看得清楚。
4. 验证请求与成功结果:日志里看三者的真实执行顺序
配置写完,跑起来看adb logcat -s TrackAdapter。你会看到类似这样的输出顺序:
onQueryComplete, count=37 changeCursor newView bindView newView bindView newView bindView ...关键结论在这里:changeCursor()只执行一次,newView()和bindView()成对出现,且数量取决于当前屏幕能显示多少行。假设你的手机一屏能显示 8 行,那么首次刷新只会触发 8 次newView+ 8 次bindView,而不是 37 次。这就是ListView的视图复用机制在起作用。
继续往下滚,日志会变成:
bindView bindView bindView只有bindView,没有newView。因为滚出屏幕的行视图被回收进了Recycler,滚进来的新行直接复用旧视图,只重新绑定数据。这就是为什么newView()里setTag()存的ViewHolder,在bindView()里能拿到——同一个View对象被反复使用,tag一直挂着。
再验证一次数据刷新:在 Activity 里手动调mAdapter.changeCursor(newCursor),日志会重新出现changeCursor,然后又是一轮newView+bindView。这说明changeCursor()的职责是「替换数据源并通知列表整体失效」,它会触发notifyDataSetChanged()的效果,让ListView重新走一遍getView(),进而按需调用newView()和bindView()。
用断点验证更直观:在newView()第一行打断点,条件设为cursor.getPosition() == 0,你会发现它只在列表首次填充时命中;在bindView()打断点,条件设为cursor.getPosition() == 0,滚动回顶部时会再次命中。这直接证明了两者的调用时机差异。
成功结果就是:列表显示 37 首歌,滚动流畅,没有重复创建视图,bindView里读到的title、artist与当前行一致。如果你在bindView里打印cursor.getPosition(),会发现它和屏幕上的行号是对应的,滚动时不断变化。
5. 本篇常见错排查:401、游标关闭、列名找不到
第一个高频错误:java.lang.IllegalArgumentException: column '_data' does not exist。原因是你查询的projection里没包含MediaStore.Audio.Media.DATA,但bindView里却用_data去取。解决方法是确保CURSOR_COLS里有DATA,并且取列时用getColumnIndexOrThrow(MediaStore.Audio.Media.DATA),让常量帮你对齐列名,别手写字符串。
第二个:java.lang.IllegalStateException: attempt to re-open an already-closed object。这通常发生在changeCursor()之后,旧游标被SimpleCursorAdapter自动关闭,但你的代码里还持有旧游标引用去读数据。记住changeCursor()会接管新游标的所有权,旧游标由它负责关闭,你不需要手动close(),也不能再碰旧游标。
第三个:列表空白但日志显示onQueryComplete, count=37。检查changeCursor()是不是在onQueryComplete里调的。如果你在doQuery之后立刻调changeCursor(null),那列表当然是空的。正确顺序是:startQuery→ 后台查询 →onQueryComplete(cursor)→changeCursor(cursor)。
第四个:滚动后文字错位。这是ViewHolder复用没处理好,典型表现是第 3 行显示了第 1 行的标题。原因通常是bindView里用了if/else分支,某些分支没设置文本,复用时残留了旧值。解决办法是每个分支都显式setText,或者把ViewHolder的字段全部重新赋值。
第五个:CursorIndexOutOfBoundsException。在bindView里调cursor.moveToPosition()会打乱Adapter自己的游标位置管理。bindView收到的cursor已经定位到当前行了,你直接getString就行,千万别再move。
如果你在接入模型辅助排查时遇到401 Unauthorized,先检查TAOTOKEN_API_KEY有没有导出成功,用echo $TAOTOKEN_API_KEY确认;再检查请求头里的Authorization: Bearer有没有拼错。本地代理类报错(比如local proxy failed)通常是环境变量里残留了旧的代理配置,清掉http_proxy、https_proxy再试。如果返回体里出现reading choices相关字段解析失败,说明你用的模型返回格式和解析代码不匹配,换一个兼容 OpenAI 格式的模型 ID 即可。涉及 OAuth 的报错,检查 token 是否过期,重新在 API Keys 页面生成。
排查完这些,回到列表本身:newView管创建、bindView管填充、changeCursor管换数据源,三者各司其职,日志顺序就是它们协作的证据。
6. 继续深入:把调试经验沉淀成可复用的接入方式
列表调通之后,你可能会想把这套「日志 + 断点 + 模型辅助」的流程固化下来。我的做法是把常用的查询和 Adapter 模板抽成一个基类,bindView里统一打日志,子类只负责具体字段绑定。这样每次新建列表页,直接继承,省掉重复的ViewHolder样板代码。
如果你需要频繁让模型帮你读日志、生成selection拼接、或者解释Cursor相关异常,可以把 API Key 配到本地脚本里,用curl直接发请求:
curl https://taotoken.net/api/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "你的模型ID", "messages": [{"role": "user", "content": "bindView 里 getColumnIndexOrThrow 抛异常怎么排查"}] }'模型 ID 和可用列表在文档里查: https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。控制台里可以看调用量和余额: https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。
最后留一个实用技巧:在bindView里用cursor.getPosition()配合Log.d打行号,滚动时观察行号变化,比单纯看newView/bindView次数更能理解复用逻辑。等你看到「行号 0 到 7 反复出现、8 到 15 只在滚动时出现」,就真正摸清ListView的脾气了。