
1. ListView 滑动卡顿与接口分散的真实场景ListView 在 Android 里算是个老面孔了但直到今天很多存量项目、内部工具、机顶盒应用、车机面板还在用它。它的核心逻辑很朴素一个纵向可滚动的容器配合 Adapter 把数据映射成一行行的 View。ArrayAdapter、SimpleAdapter、SimpleCursorAdapter、BaseAdapter 这几兄弟基本覆盖了从「只显示一行字」到「带图片带按钮还能响应点击」的全部需求。问题往往不在 ListView 本身而在两个地方叠加爆发。第一是滑动卡顿getView 里做了耗时操作比如读本地大图、查数据库、或者——最要命的——在 getView 里直接发网络请求。第二是接口调用分散列表页要展示 AI 生成的摘要、标签、推荐语每个业务模块各自维护一套 API Key 和 Base URL散落在 BuildConfig、SharedPreferences、硬编码常量里改一次地址要全局搜索替换测试环境切生产环境更是灾难。我见过一个典型项目一个 AI 资讯列表页每行显示标题、缩略图、AI 摘要。摘要的请求写在 getView 里滑动时疯狂触发不仅卡顿还把接口调用量打爆了。同时项目里有三处不同的 Base URL分别指向不同的服务商Key 也是三套。这种结构下任何一次模型切换或地址迁移都是体力活。这篇要解决的就是这个组合问题把 ListView 的滑动性能拉回来同时把 AI 接口的 Base URL 统一收敛到 TaoToken让列表页的 AI 能力调用变成一处配置、全局生效。适合正在维护存量 Android 项目、又需要给列表页接入 AI 能力的开发者。下面从环境准备开始一步步给出可复制的配置和代码。2. TaoToken 前置准备统一 Base URL 与 Key 管理在动手改 ListView 之前先把接口层的地基打好。TaoToken 的核心价值在于你只需要一个 Base URL 和一个 API Key就能通过 OpenAI 兼容协议调用多种模型。对于 Android 项目来说这意味着你不再需要为每个模型维护不同的 SDK 和地址网络层可以统一成一套 OkHttp 或 Retrofit 配置。先拿到凭证。打开官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册后在控制台创建 API Key。控制台地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite Key 只在创建时完整显示一次复制后妥善保存。如果你还没想好用什么模型可以先去模型对话页面 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 试一下不同模型的效果确认哪个适合你的列表页场景——比如摘要生成用轻量模型就够复杂推理再上大模型。Base URL 统一使用 https://taotoken.net/api 注意这个地址不带任何查询参数。协议是 OpenAI 兼容的所以请求路径是 /v1/chat/completions完整地址就是 https://taotoken.net/api/v1/chat/completions 。这一点很关键很多同学把 Base URL 写成带 /v1 的形式然后在代码里又拼一次 /v1结果 404。记住 Base URL 就是到 /api 为止。Key 的管理方式我建议不要硬编码在代码里。Android 项目可以用 local.properties 配合 BuildConfig或者用 gradle.properties。下面是一个 local.properties 的示例# local.properties TAOTOKEN_API_KEYsk-你的实际Key TAOTOKEN_BASE_URLhttps://taotoken.net/api然后在 module 的 build.gradle 里读取并注入 BuildConfig// app/build.gradle def localProps new Properties() def localFile rootProject.file(local.properties) if (localFile.exists()) { localProps.load(new FileInputStream(localFile)) } android { defaultConfig { buildConfigField String, TAOTOKEN_API_KEY, \${localProps[TAOTOKEN_API_KEY] ?: }\ buildConfigField String, TAOTOKEN_BASE_URL, \${localProps[TAOTOKEN_BASE_URL] ?: https://taotoken.net/api}\ } }这样代码里通过 BuildConfig.TAOTOKEN_BASE_URL 引用切换环境只改 local.properties不用动业务代码。如果你需要更细粒度的 Key 权限管理可以在 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 创建多个 Key按模块分配。对于长期做编码和 Agent 场景的团队Coding Plan 页面 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 有更划算的套餐适合把 AI 能力深度集成到开发流程里的情况。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 遇到协议细节可以对照查阅。前置准备的核心就三件事拿到 Key、确认 Base URL 是 https://taotoken.net/api 、把配置注入到构建流程。做完这些下面开始改 ListView 的代码。3. 可复制配置Adapter 中发起请求与 ViewHolder 复用这一节是重头戏。我们要解决两个问题一是把网络请求从 getView 里挪出去二是用 ViewHolder 模式复用 View三是把请求统一走 TaoToken 的 Base URL。先看一个「错误示范」这是很多卡顿的根源// 错误示范在 getView 里直接发请求 Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView null) { convertView mInflater.inflate(R.layout.item_ai_news, null); holder new ViewHolder(); holder.title convertView.findViewById(R.id.title); holder.summary convertView.findViewById(R.id.summary); convertView.setTag(holder); } else { holder (ViewHolder) convertView.getTag(); } NewsItem item mData.get(position); holder.title.setText(item.getTitle()); // 这里直接发请求滑动时每帧都可能触发 String summary callAiApi(item.getContent()); holder.summary.setText(summary); return convertView; }这段代码的问题getView 会被频繁调用每次滑动都触发网络请求主线程被阻塞列表必然卡顿。正确做法是把请求提前到数据加载阶段或者用异步回调 缓存。改造思路Adapter 只负责展示数据在 ViewModel 或 Presenter 层预取。如果确实需要懒加载用 LruCache 缓存已请求的结果并且用标志位防止重复请求。下面是改造后的 Adapter 核心代码包含 ViewHolder 复用和请求状态管理public class AiNewsAdapter extends BaseAdapter { private final LayoutInflater mInflater; private final ListNewsItem mData; private final LruCacheString, String mSummaryCache; private final SetInteger mLoadingPositions new HashSet(); private final AiApiClient mApiClient; public AiNewsAdapter(Context context, ListNewsItem data) { this.mInflater LayoutInflater.from(context); this.mData data; this.mSummaryCache new LruCache(100); this.mApiClient new AiApiClient(); } 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; } static class ViewHolder { TextView title; TextView summary; ProgressBar loading; } Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView null) { convertView mInflater.inflate(R.layout.item_ai_news, parent, false); holder new ViewHolder(); holder.title convertView.findViewById(R.id.title); holder.summary convertView.findViewById(R.id.summary); holder.loading convertView.findViewById(R.id.loading); convertView.setTag(holder); } else { holder (ViewHolder) convertView.getTag(); } NewsItem item mData.get(position); holder.title.setText(item.getTitle()); String cached mSummaryCache.get(item.getId()); if (cached ! null) { holder.summary.setText(cached); holder.loading.setVisibility(View.GONE); } else { holder.summary.setText(生成中...); holder.loading.setVisibility(View.VISIBLE); requestSummary(position, item, holder); } return convertView; } private void requestSummary(int position, NewsItem item, ViewHolder holder) { if (mLoadingPositions.contains(position)) return; mLoadingPositions.add(position); mApiClient.fetchSummary(item.getContent(), new AiApiClient.Callback() { Override public void onSuccess(String summary) { mSummaryCache.put(item.getId(), summary); mLoadingPositions.remove(position); notifyDataSetChanged(); } Override public void onFailure(String error) { mLoadingPositions.remove(position); holder.summary.setText(生成失败); holder.loading.setVisibility(View.GONE); } }); } }注意几个关键点ViewHolder 用 static 内部类避免内存泄漏LruCache 缓存摘要结果滑动回来不用重新请求mLoadingPositions 防止同一位置重复请求notifyDataSetChanged 在回调里触发刷新。这里有个坑notifyDataSetChanged 会重绘整个列表如果列表很长会有性能损耗更好的做法是用 notifyDataSetChanged 配合局部刷新或者用 RecyclerView 的 notifyItemChanged。但 ListView 没有局部刷新 API所以缓存命中率就很重要。接下来是 AiApiClient这是统一走 TaoToken 的网络层public class AiApiClient { private static final String BASE_URL BuildConfig.TAOTOKEN_BASE_URL; private static final String API_KEY BuildConfig.TAOTOKEN_API_KEY; private static final String MODEL_ID gpt-4o-mini; private final OkHttpClient client new OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build(); public interface Callback { void onSuccess(String summary); void onFailure(String error); } public void fetchSummary(String content, Callback callback) { JSONObject body new JSONObject(); try { body.put(model, MODEL_ID); JSONArray messages new JSONArray(); JSONObject msg new JSONObject(); msg.put(role, user); msg.put(content, 用一句话总结以下内容 content); messages.put(msg); body.put(messages, messages); body.put(max_tokens, 100); } catch (JSONException e) { callback.onFailure(e.getMessage()); return; } Request request new Request.Builder() .url(BASE_URL /v1/chat/completions) .addHeader(Authorization, Bearer API_KEY) .addHeader(Content-Type, application/json) .post(RequestBody.create(body.toString(), MediaType.parse(application/json))) .build(); client.newCall(request).enqueue(new okhttp3.Callback() { Override public void onFailure(Call call, IOException e) { callback.onFailure(e.getMessage()); } Override public void onResponse(Call call, Response response) throws IOException { if (!response.isSuccessful()) { callback.onFailure(HTTP response.code()); return; } try { String respBody response.body().string(); JSONObject json new JSONObject(respBody); String summary json.getJSONArray(choices) .getJSONObject(0) .getJSONObject(message) .getString(content); callback.onSuccess(summary.trim()); } catch (JSONException e) { callback.onFailure(解析失败: e.getMessage()); } } }); } }这里 Base URL 来自 BuildConfig路径拼接是 BASE_URL /v1/chat/completions最终请求地址是 https://taotoken.net/api/v1/chat/completions 。Model ID 这里用了 gpt-4o-mini你可以根据实际需求换成其他模型具体可用模型列表在模型对话页面能查到。如果你用的是 Retrofit配置会更简洁。下面是一个 Retrofit 的接口定义和实例化片段public interface AiService { POST(v1/chat/completions) CallChatResponse chat(Header(Authorization) String auth, Body ChatRequest request); } // 实例化 Retrofit retrofit new Retrofit.Builder() .baseUrl(BuildConfig.TAOTOKEN_BASE_URL /) .addConverterFactory(GsonConverterFactory.create()) .client(okHttpClient) .build(); AiService service retrofit.create(AiService.class);注意 Retrofit 的 baseUrl 必须以 / 结尾所以是 https://taotoken.net/api/ 然后接口路径写 v1/chat/completions拼接后就是正确的完整地址。这个细节不注意就会 404。配置片段总结成一张对照表配置项值说明Base URLhttps://taotoken.net/api不带 /v1不带查询参数请求路径/v1/chat/completionsOpenAI 兼容协议完整地址https://taotoken.net/api/v1/chat/completions实际请求 URL认证头Authorization: Bearer sk-xxxKey 从控制台获取Model IDgpt-4o-mini 等按场景选择4. 验证请求与耗时对比日志与调用链路确认配置写完了怎么确认真的生效了这一节给出验证方法包括日志打印、耗时对比、以及调用链路的确认。先加日志。在 AiApiClient 的请求前后打时间戳long start System.currentTimeMillis(); client.newCall(request).enqueue(new okhttp3.Callback() { Override public void onResponse(Call call, Response response) throws IOException { long cost System.currentTimeMillis() - start; Log.d(AiApi, 请求耗时: cost ms, code response.code()); // ... 解析逻辑 } });然后在 Adapter 的 getView 里也打点观察滑动时 getView 的调用频率和耗时Override public View getView(int position, View convertView, ViewGroup parent) { long t0 System.currentTimeMillis(); // ... 原有逻辑 long t1 System.currentTimeMillis(); if (t1 - t0 16) { Log.w(ListViewPerf, getView 耗时过长: (t1 - t0) ms, position position); } return convertView; }16ms 是 60fps 的单帧预算超过这个值就可能掉帧。改造前getView 里直接发请求耗时动辄几百毫秒改造后getView 只做 View 复用和缓存读取耗时应该稳定在个位数毫秒。用 Android Studio 的 Logcat 过滤 AiApi 和 ListViewPerf 两个 tag滑动列表观察输出。正常情况下你应该看到getView 耗时都在 16ms 以下AiApi 的请求只在首次加载时出现滑动回来命中缓存不再请求。再进一步用 Profiler 看网络请求的时间线。Android Studio 的 Network Profiler 能直观展示每个请求的发起时间、持续时长、响应大小。改造前你会看到滑动时请求密集爆发改造后请求集中在列表初始化阶段滑动时网络线程是空闲的。验证请求是否真的打到了 TaoToken最直接的方法是看响应内容。在 onResponse 里打印 respBody 的前 200 个字符String respBody response.body().string(); Log.d(AiApi, 响应预览: respBody.substring(0, Math.min(200, respBody.length())));如果看到 choices 数组和 message.content 字段说明协议对接正确。如果看到 401说明 Key 有问题如果看到 404说明路径拼接错了如果看到 model not found说明 Model ID 写错了。耗时对比可以做一个简单的基准测试准备一个 50 条的列表分别在改造前后滑动到底部记录总耗时和卡顿次数。改造前可能滑动一次触发几十个请求总耗时数秒改造后请求数等于列表项数且只请求一次滑动本身不触发请求流畅度明显提升。还有一个验证点确认 Base URL 确实统一了。在项目里全局搜索 http看看还有没有散落的其他地址。理想情况下所有 AI 相关请求都应该走 BuildConfig.TAOTOKEN_BASE_URL只有这一处配置。如果你在验证过程中遇到问题可以对照接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 检查协议细节或者在模型对话页面 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 用相同的参数手动发一次请求对比返回结果。5. 本篇常见错误排查401、404、解析失败与 OAuth 问题这一节把踩过的坑集中列出来对照真实报错定位问题。401 Unauthorized。这是最常见的错误原因是 Key 无效或没带上。检查三点Key 是否从控制台正确复制注意前后空格请求头是否是 Authorization: Bearer sk-xxx 格式Bearer 后面有一个空格Key 是否已经过期或被删除。如果你在 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 重新生成了 Key记得同步更新 local.properties 并重新构建。404 Not Found。路径拼接错误。Base URL 是 https://taotoken.net/api 请求路径是 /v1/chat/completions完整地址是 https://taotoken.net/api/v1/chat/completions 。如果你把 Base URL 写成 https://taotoken.net/api/v1 然后又拼 /v1/chat/completions就会变成 /api/v1/v1/chat/completions必然 404。Retrofit 用户注意 baseUrl 结尾的斜杠https://taotoken.net/api/ 加 v1/chat/completions 才是对的。local proxy failed。这个报错通常出现在模拟器或某些网络环境下表示本地代理配置有问题。检查 Android Studio 的代理设置以及模拟器的网络配置。如果你在公司内网确认网络策略允许访问外部 API。这个错误和 TaoToken 本身无关是本地网络环境问题。reading choices 失败 / JSONException。响应解析出错。可能原因响应体不是预期的 JSON 结构或者 choices 数组为空。先打印完整响应体看看实际返回了什么。如果是错误响应通常会有 error 字段说明原因。另外注意 response.body().string() 只能调用一次调用两次第二次会返回空如果你在日志里打印了又在解析时调用就会出问题。正确做法是先存成字符串变量再处理。OAuth 相关报错。如果你在项目里同时用了其他认证体系注意不要和 Bearer Token 混淆。TaoToken 用的是 API Key 的 Bearer 认证不需要 OAuth 流程。如果看到 OAuth 相关的错误检查是不是请求被其他拦截器改写了认证头。模型不存在 / model not found。Model ID 写错了。不同模型的 ID 不一样去模型对话页面确认可用的 Model ID。注意大小写和连字符比如 gpt-4o-mini 不能写成 gpt-4o_mini 或 GPT-4O-MINI。滑动仍然卡顿。如果改造后还卡检查几个点getView 里是否还有隐藏的耗时操作比如图片解码、数据库查询notifyDataSetChanged 是否调用过于频繁LruCache 的容量是否太小导致频繁淘汰列表项布局是否过于复杂嵌套层级过深。可以用 Layout Inspector 查看 View 层级用 Profiler 看 CPU 占用。请求重复触发。如果同一位置发了多次请求检查 mLoadingPositions 的逻辑是否正确以及 notifyDataSetChanged 是否导致了 getView 的连锁调用。一个更稳妥的做法是在数据层做去重而不是在 Adapter 层。Key 泄露风险。不要把 Key 提交到 Git 仓库。local.properties 默认在 .gitignore 里确认一下。如果 Key 已经泄露立即去控制台删除并重新生成。排查的核心思路是先看 HTTP 状态码再看响应体最后看本地日志。状态码告诉你请求是否到达服务端响应体告诉你服务端的处理结果本地日志告诉你请求是怎么发出去的。三者结合大部分问题都能定位。6. 统一调用后的维护与扩展建议把 Base URL 统一到 TaoToken 之后项目的接口层变得干净了。但统一只是起点后续的维护和扩展还有几件事值得做。第一件事是抽象出统一的 ApiClient。现在 AiApiClient 里写死了 chat/completions 的调用如果以后要加 embedding、图片生成等能力可以扩展成通用的请求方法。把 Base URL、Key、超时、重试策略都收敛到一个 OkHttpClient 实例里所有请求共用。这样切换模型或调整参数只需要改一处。第二件事是给列表页加请求节流。虽然改造后请求不再在滑动时触发但如果列表项很多初始化时可能瞬间发出大量请求。可以用线程池限制并发数或者用队列逐个处理。OkHttp 的 Dispatcher 默认最大并发是 64对于移动端来说偏高可以调低到 4 到 8。第三件事是缓存策略。LruCache 是内存缓存进程重启就没了。如果摘要内容不常变可以加一层磁盘缓存用 Room 或简单的文件存储。这样冷启动时也能快速展示。缓存 key 用内容 hash 或 item id注意处理内容更新的情况。第四件事是错误重试和降级。网络请求失败是常态给关键请求加重试逻辑但要注意幂等性。对于摘要生成这种场景失败后可以降级显示原文而不是一直转圈。第五件事是监控。记录请求成功率、平均耗时、错误分布这些数据能帮你发现模型切换后的性能变化。可以在 AiApiClient 里埋点上报到自己的监控系统。关于成本控制不同模型的单价差异很大。列表页这种高频场景用轻量模型做摘要就够了没必要上大模型。在模型对话页面可以对比不同模型的效果和价格选性价比合适的。如果调用量很大Coding Plan 页面 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 的套餐可能更划算。最后说一个实际经验接口统一之后最大的收益不是技术上的而是协作上的。以前每个模块各自维护 Key 和地址出问题互相甩锅现在一处配置全局生效谁改了什么一目了然。测试环境切生产环境改一个 local.properties 就行不用重新打包三套代码。这种整洁感维护过老项目的人都懂。如果你还没开始接入建议先去控制台创建 Key然后拿模型对话页面手动发一次请求确认协议通了再写代码。这样能省掉很多调试时间。接入文档里有完整的请求示例对照着改就行。