1. 从两个 Adapter 的切换说起:ListView 初学者的真实痛点
Android 里的 ListView 是个老牌组件,但它的使用逻辑放到今天依然值得练手:数据源和界面之间隔着一层 Adapter,你换数据源、换行布局,本质上都是在换 Adapter。ArrayAdapter 适合一行只有一段文字的极简列表,SimpleAdapter 适合一行里有图片、姓名、年龄这种多字段组合。很多初学者卡住的地方不是不会写 Adapter,而是两个 Adapter 的构造参数对不上号,切来切去就乱。
更麻烦的是,当你想在列表里加一个「模型切换」或者「请求通道」的入口时,多模型 API Key 分散在代码、local.properties、BuildConfig里,改一个环境就要重新编译。这篇就把两件事合在一起做:用 ArrayAdapter 和 SimpleAdapter 各跑通一个列表,再把多模型 Key 统一收进一份settings.json骨架,通过 TaoToken 的兼容接口读取。你跟着做,能拿到两个可运行的列表和一个可复用的配置结构。
适合谁:刚学完 Activity 和基础控件、想搞懂 Adapter 参数含义的 Android 初学者;以及手上有多个模型 Key、想统一管理请求入口的开发者。下面所有代码都可以直接贴进 Android Studio 跑。
2. TaoToken 前置:统一 Key 与 settings.json 骨架
TaoToken 在这里扮演的角色是「统一请求入口」。你不需要在每个 Adapter 或每个页面里硬编码不同厂商的 Key,而是把 Key 和模型信息集中写进一份settings.json,运行时读出来,再交给网络层。官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api 。
先准备 Key:打开控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,在 API Keys 页面创建一个 Key,页面地址是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。创建后复制出来,后面写进配置文件。
settings.json的骨架我建议放在app/src/main/assets/settings.json,这样打包进 APK 后可以直接用 AssetManager 读取。骨架长这样:
{ "endpoint": "https://taotoken.net/api", "defaultModel": "claude-3-5-sonnet", "models": [ { "name": "claude-3-5-sonnet", "display": "Claude 3.5 Sonnet", "apiKey": "sk-你的Key" }, { "name": "gpt-4o-mini", "display": "GPT-4o mini", "apiKey": "sk-你的Key" } ] }字段含义:endpoint是统一请求地址,defaultModel是默认模型,models数组里每一项对应一个模型,display用来在 ListView 里显示,apiKey就是你在控制台拿到的 Key。这样 ArrayAdapter 可以直接绑定display字段,SimpleAdapter 可以把display和name一起显示成两列。
注意:
settings.json里不要提交真实 Key 到公开仓库。本地调试可以放,正式项目建议用构建变量注入,或者只保留占位符。
3. 可复制配置:ArrayAdapter 与 SimpleAdapter 两套写法
3.1 ArrayAdapter 绑定模型列表
ArrayAdapter 的构造参数是(Context, 行布局资源, 数据数组/List)。这里数据源就是模型显示名列表,行布局用系统自带的android.R.layout.simple_list_item_1。
public class ModelListActivity extends ListActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); List<String> modelNames = new ArrayList<>(); modelNames.add("Claude 3.5 Sonnet"); modelNames.add("GPT-4o mini"); modelNames.add("Claude 3 Opus"); ArrayAdapter<String> adapter = new ArrayAdapter<>( this, android.R.layout.simple_list_item_1, modelNames ); setListAdapter(adapter); } }关键点:simple_list_item_1是系统内置的单行布局,里面只有一个 TextView,所以 ArrayAdapter 只能显示一段文字。如果你要显示「模型名 + 模型标识」两段信息,就得换 SimpleAdapter。
3.2 SimpleAdapter 绑定多字段行
SimpleAdapter 的构造参数是(Context, List<Map>, 行布局, 数据字段数组, 控件 id 数组)。数据字段数组和控件 id 数组必须一一对应,顺序不能错。
先写行布局item_model.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="horizontal" android:padding="12dp"> <ImageView android:id="@+id/img_icon" android:layout_width="40dp" android:layout_height="40dp" android:src="@mipmap/ic_launcher" /> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginStart="12dp"> <TextView android:id="@+id/tv_display" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="16sp" android:textStyle="bold" /> <TextView android:id="@+id/tv_name" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="13sp" android:textColor="#666666" /> </LinearLayout> </LinearLayout>再写 Activity:
public class ModelDetailListActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_model_detail); List<Map<String, Object>> data = new ArrayList<>(); String[][] models = { {"Claude 3.5 Sonnet", "claude-3-5-sonnet"}, {"GPT-4o mini", "gpt-4o-mini"}, {"Claude 3 Opus", "claude-3-opus"} }; for (String[] m : models) { Map<String, Object> row = new HashMap<>(); row.put("img", R.mipmap.ic_launcher); row.put("display", m[0]); row.put("name", m[1]); data.add(row); } SimpleAdapter adapter = new SimpleAdapter( this, data, R.layout.item_model, new String[]{"img", "display", "name"}, new int[]{R.id.img_icon, R.id.tv_display, R.id.tv_name} ); ListView listView = findViewById(R.id.list_models); listView.setAdapter(adapter); } }activity_model_detail.xml里放一个 ListView,id 设为list_models即可。到这里两个 Adapter 都能渲染列表了,但列表里的模型还没有和settings.json真正连起来。
3.3 读取 settings.json 并填充数据
把前面两个 Activity 里的硬编码数据换成从 assets 读取:
private List<Map<String, Object>> loadModelsFromSettings() { List<Map<String, Object>> result = new ArrayList<>(); try { InputStream is = getAssets().open("settings.json"); int size = is.available(); byte[] buffer = new byte[size]; is.read(buffer); is.close(); String json = new String(buffer, StandardCharsets.UTF_8); JSONObject root = new JSONObject(json); JSONArray models = root.getJSONArray("models"); for (int i = 0; i < models.length(); i++) { JSONObject m = models.getJSONObject(i); Map<String, Object> row = new HashMap<>(); row.put("img", R.mipmap.ic_launcher); row.put("display", m.getString("display")); row.put("name", m.getString("name")); result.add(row); } } catch (Exception e) { e.printStackTrace(); } return result; }这样 SimpleAdapter 的数据源就来自settings.json,你改配置不用改 Java 代码。ArrayAdapter 同理,把display字段抽出来组成List<String>即可。
4. 验证请求:确认列表渲染与请求通道都跑通
列表渲染的验证很直接:运行 App,看到三行模型信息,点击任意一行能拿到对应的name字段。可以在onItemClick里打日志:
listView.setOnItemClickListener((parent, view, position, id) -> { Map<String, Object> item = (Map<String, Object>) parent.getItemAtPosition(position); Log.d("ModelList", "selected model = " + item.get("name")); });请求通道的验证,用 curl 先确认 Key 和 endpoint 可用:
curl -X POST "https://taotoken.net/api/v1/chat/completions" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-你的Key" \ -d '{ "model": "claude-3-5-sonnet", "messages": [{"role": "user", "content": "只回复 ok"}] }'返回体里能看到choices字段就说明通道正常。Android 端用 OkHttp 发同样的请求:
OkHttpClient client = new OkHttpClient(); MediaType JSON = MediaType.parse("application/json; charset=utf-8"); String body = "{\"model\":\"claude-3-5-sonnet\",\"messages\":[{\"role\":\"user\",\"content\":\"只回复 ok\"}]}"; Request request = new Request.Builder() .url("https://taotoken.net/api/v1/chat/completions") .addHeader("Authorization", "Bearer " + apiKey) .post(RequestBody.create(body, JSON)) .build(); client.newCall(request).enqueue(new Callback() { @Override public void onFailure(Call call, IOException e) { Log.e("TaoToken", "request failed", e); } @Override public void onResponse(Call call, Response response) throws IOException { Log.d("TaoToken", "code=" + response.code() + " body=" + response.body().string()); } });两个验证都过了,说明列表和请求通道是通的。想直接在网页里试模型对话,可以打开 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite ,选模型发一句话看返回。
5. 本篇常见错排查
5.1 SimpleAdapter 显示空白
最常见的原因是from数组和to数组长度不一致,或者 Map 里的 key 和from里的字符串对不上。比如你写了row.put("display", ...),但from里写的是"title",那一列就是空的。检查方法:把from和to打印出来,逐项对照。
5.2 ArrayAdapter 报「You must supply a resource ID for a TextView」
这个错说明你传的行布局里没有 TextView,或者传了0。ArrayAdapter 要求行布局根节点或子节点里至少有一个 TextView。用simple_list_item_1不会出这个问题,自定义布局时记得放 TextView。
5.3 settings.json 读取抛 FileNotFoundException
确认文件路径是app/src/main/assets/settings.json,不是res/raw。assets 目录要手动创建,位置在app/src/main/下面。另外文件名大小写敏感,Settings.json和settings.json是两个文件。
5.4 请求返回 401
先检查Authorization头是不是Bearer sk-xxx格式,中间有一个空格。再检查 Key 有没有复制完整,控制台里重新生成一个对比。如果 curl 能通、Android 不通,多半是 Key 被写死在代码里但没同步更新。
5.5 列表点击后拿不到 name 字段
parent.getItemAtPosition(position)返回的是 Object,SimpleAdapter 返回的是 Map,ArrayAdapter 返回的是 String。如果你混用了两个 Adapter,强转类型会崩。统一用Map接收,或者分别处理。
6. 把配置和列表拆开,后面换模型不用改界面
这套结构跑通之后,你会发现真正稳定的部分是settings.json和请求通道,ListView 只是展示层。以后加模型,改 JSON 就行;换行布局,改item_model.xml就行;换 Adapter,只要保证数据字段对齐,界面代码几乎不用动。如果你后面要做长期编码或 Agent 类功能,可以看下 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。Claude Code 相关配置参考 https://taotoken.net/claude-code?utm_source=taotoken_aicg_blog_end&utm_content=claudecode&utm_campaign=rewrite 。
我自己的习惯是:settings.json里只放模型元数据和 Key 占位,真实 Key 用构建脚本注入,这样本地调试和打包发布互不干扰。列表点击事件里不要直接发请求,先弹一个确认框,避免误触消耗额度。