
1. 老项目里的 SimpleCursorTreeAdapter 为什么突然要接模型通道如果你维护过 2015 年前后的 Android 项目大概率见过SimpleCursorTreeAdapter。它是ExpandableListView的经典搭档分组行来自一个 Cursor点开某个分组时getChildrenCursor()再返回一个子 Cursor框架自动把列绑到 XML 里的TextView或ImageView。整套机制不依赖 RecyclerView也不需要手动写 ViewHolder在联系人、订单、设备列表这类「父项 子项」结构里非常省事。问题出在现在的需求变了。以前子列表的数据来自本地 SQLite 或ContentProvider查询是同步的、离线的、确定的。现在产品会要求展开某个分组时子项要带上模型生成的摘要、标签、风险提示甚至要根据父项内容动态拉一批候选。于是getChildrenCursor()这个原本只做数据库查询的方法被迫承担一次网络请求而网络请求又必须走统一的 API 通道否则 Key 散落在各处、日志对不上、限流也没法统一管。这篇就聚焦这个具体场景Android 老项目里 SimpleCursorTreeAdapter 展开子列表时的数据加载链路怎么把 Cursor 查询与模型调用统一走 TaoToken 的 API 通道。TaoToken 在这里的角色是一个统一的模型调用入口Base URL 是https://taotoken.net/api官网在https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end。你不需要改掉SimpleCursorTreeAdapter的继承结构只需要在getChildrenCursor()里把「查本地库」和「调模型」两件事编排好再把结果塞回一个MatrixCursor框架照旧能绑定。适合谁看手上还有ExpandableListView老代码、不想大改 UI 层、但需要接入模型能力的 Android 开发者。核心检索词就是 SimpleCursorTreeAdapter 与统一通道下面所有步骤都围绕它展开。先说清楚一个前提getChildrenCursor()是在主线程被调用的框架期望你尽快返回一个 Cursor。所以真正要做的是「先返回一个占位 Cursor异步请求完成后再通知适配器刷新」而不是在方法里阻塞等网络。这个细节决定了后面所有代码的写法。2. TaoToken 前置Base URL、Key 与模型 ID 三件套怎么备齐在动SimpleCursorTreeAdapter之前先把通道侧的东西准备好。TaoToken 的调用方式和主流 OpenAI 兼容接口一致你需要三样东西Base URL、API Key、Model ID。这三件套缺一不可后面在 Android 里拼请求、在日志里核对都靠它们。Base URL 固定用https://taotoken.net/api注意不要带多余的路径后缀拼接时一般是https://taotoken.net/api/v1/chat/completions这种形式。API Key 在控制台的 API Keys 页面创建创建后只显示一次复制下来存到本地安全位置。Model ID 取决于你要用的模型在模型列表或文档里能看到具体字符串。获取入口我按用途分一下方便你按场景点进去需要创建和管理 Key进控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite在 API Keys 里新建。想先验证模型通不通用模型对话https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite直接在页面上发一条消息看返回。长期做编码或 Agent 类任务看 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite适合高频调用。查接口字段和错误码接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite。单独管理 Key 列表API Keyshttps://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite。在 Android 项目里Key 绝对不能硬编码进 Java 源码然后提交到 Git。推荐放在local.properties或gradle.properties后者不要提交再通过BuildConfig注入。下面是一个可复制的配置片段路径按你项目实际结构调整# local.properties不要提交到版本库 TAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_API_KEYsk-你的Key TAOTOKEN_MODEL_ID你的模型ID然后在模块级build.gradle里读取并生成字段android { defaultConfig { buildConfigField String, TAOTOKEN_BASE_URL, \${project.findProperty(TAOTOKEN_BASE_URL) ?: }\ buildConfigField String, TAOTOKEN_API_KEY, \${project.findProperty(TAOTOKEN_API_KEY) ?: }\ buildConfigField String, TAOTOKEN_MODEL_ID, \${project.findProperty(TAOTOKEN_MODEL_ID) ?: }\ } }如果你更习惯用settings.gradle或gradle.properties管理逻辑一样关键是让 Key 只存在于本地构建环境不进入仓库。实测下来把三件套集中在一处配置后面排查 401 或模型不存在时能省很多时间因为你能一眼确认请求里带的到底是哪个 Key、哪个 Model ID。还有一点Android 9 以后默认禁止明文 HTTPTaoToken 的接口是 HTTPS所以不用额外开usesCleartextTraffic。但要在AndroidManifest.xml里声明网络权限uses-permission android:nameandroid.permission.INTERNET /到这里前置就齐了。接下来进入正题把SimpleCursorTreeAdapter的子节点加载链路改成走统一通道。3. 可复制配置Cursor 树节点映射与统一通道请求代码这一节是核心我会给出完整的可复制代码。思路分三步第一getChildrenCursor()里先返回一个空的MatrixCursor占位保证框架不崩第二异步发起 TaoToken 请求拿到模型结果第三把结果写进一个新的MatrixCursor通过CursorTreeAdapter的setChildrenCursor()通知刷新。先看适配器骨架。假设分组 Cursor 有_id和name两列子项要显示模型生成的summary列public class ModelTreeAdapter extends SimpleCursorTreeAdapter { private final Context context; private final MapLong, Cursor childCache new HashMap(); public ModelTreeAdapter(Context context, Cursor groupCursor, int groupLayout, int childLayout, String[] groupFrom, int[] groupTo, String[] childFrom, int[] childTo) { super(context, groupCursor, groupLayout, groupFrom, groupTo, childLayout, childFrom, childTo); this.context context; } Override protected Cursor getChildrenCursor(Cursor groupCursor) { long groupId groupCursor.getLong( groupCursor.getColumnIndexOrThrow(_id)); String groupName groupCursor.getString( groupCursor.getColumnIndexOrThrow(name)); // 命中缓存直接返回 if (childCache.containsKey(groupId)) { return childCache.get(groupId); } // 1. 先返回占位 Cursor列名要和 childFrom 对应 MatrixCursor placeholder new MatrixCursor(new String[]{_id, summary}); placeholder.addRow(new Object[]{groupId * 1000, 加载中...}); // 2. 异步请求统一通道 requestSummary(groupId, groupName); return placeholder; } }注意占位 Cursor 的列名必须和构造器里childFrom声明的列一致否则绑定阶段会抛IllegalStateException。这是老项目里最常见的坑之一。接下来是请求部分。用HttpURLConnection就够了不引入额外依赖方便老项目直接粘贴private void requestSummary(long groupId, String groupName) { new Thread(() - { HttpURLConnection conn null; try { URL url new URL(BuildConfig.TAOTOKEN_BASE_URL /v1/chat/completions); conn (HttpURLConnection) url.openConnection(); conn.setRequestMethod(POST); conn.setRequestProperty(Content-Type, application/json); conn.setRequestProperty(Authorization, Bearer BuildConfig.TAOTOKEN_API_KEY); conn.setDoOutput(true); conn.setConnectTimeout(10000); conn.setReadTimeout(30000); JSONObject body new JSONObject(); body.put(model, BuildConfig.TAOTOKEN_MODEL_ID); JSONArray messages new JSONArray(); JSONObject userMsg new JSONObject(); userMsg.put(role, user); userMsg.put(content, 用一句话概括 groupName); messages.put(userMsg); body.put(messages, messages); try (OutputStream os conn.getOutputStream()) { os.write(body.toString().getBytes(StandardCharsets.UTF_8)); } int code conn.getResponseCode(); if (code ! 200) { Log.e(TaoToken, HTTP code groupId groupId); return; } String resp readStream(conn.getInputStream()); JSONObject json new JSONObject(resp); String summary json.getJSONArray(choices) .getJSONObject(0) .getJSONObject(message) .getString(content); Log.d(TaoToken, groupId groupId summary summary); // 3. 构造真实子 Cursor 并刷新 MatrixCursor real new MatrixCursor(new String[]{_id, summary}); real.addRow(new Object[]{groupId * 1000 1, summary}); childCache.put(groupId, real); new Handler(Looper.getMainLooper()).post(() - setChildrenCursor((int) groupId, real)); } catch (Exception e) { Log.e(TaoToken, request failed groupId groupId, e); } finally { if (conn ! null) conn.disconnect(); } }).start(); }这里有个关键点setChildrenCursor(int groupPosition, Cursor childrenCursor)的第一个参数是分组在列表中的位置不是数据库里的_id。上面代码里我直接用groupId强转是简化写法实际项目里你需要维护一个groupId - groupPosition的映射或者在getChildrenCursor()里把groupPosition一起传下去。这是最容易出错的地方务必核对。readStream是个普通工具方法private String readStream(InputStream is) throws IOException { StringBuilder sb new StringBuilder(); try (BufferedReader br new BufferedReader( new InputStreamReader(is, StandardCharsets.UTF_8))) { String line; while ((line br.readLine()) ! null) sb.append(line); } return sb.toString(); }如果你用的是 Kotlin 或协程逻辑一样把Thread换成lifecycleScope.launch即可。核心不变占位 Cursor 先返回真实 Cursor 后刷新。4. 验证请求用日志确认子节点加载成功代码写完不能只看编译通过要实际验证子节点确实加载出来了。我一般分三层验证请求层、解析层、UI 层。请求层看Log.d(TaoToken, ...)有没有打出groupId和summary。如果只看到HTTP 401说明 Key 不对或没带上如果看到HTTP 404多半是 Base URL 拼错了检查是不是漏了/v1或多了斜杠。正常返回时日志里应该能看到类似D/TaoToken: groupId1 summary这是一段模型生成的摘要 D/TaoToken: groupId2 summary另一段摘要解析层要确认choices[0].message.content拿到了非空字符串。如果这里抛JSONException把完整响应体打出来看常见的是返回了错误对象而不是正常结构。UI 层最直观展开分组子项从「加载中...」变成真实摘要。如果一直停在「加载中...」说明setChildrenCursor()没生效重点检查groupPosition映射。可以在setChildrenCursor调用前后各打一条日志Log.d(TaoToken, before setChildrenCursor pos groupPosition); setChildrenCursor(groupPosition, real); Log.d(TaoToken, after setChildrenCursor pos groupPosition);如果after打出来了但 UI 没变可能是MatrixCursor的列名和childFrom不匹配或者notifyDataSetChanged被别处覆盖了。还有一个验证技巧临时把childFrom改成只显示_id看子项数量对不对。数量对但内容不对是列名问题数量都不对是 Cursor 没刷新。实测下来把这三层日志都打开定位问题通常不超过十分钟。老项目最怕的是「改了没反应」而日志能告诉你到底是请求没发、响应没解析还是刷新没生效。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth这一节把真实会撞到的报错列出来对照着改。401 Unauthorized最常见。原因通常是 Key 没带、带错、或者带了多余空格。检查Authorization头是不是Bearer sk-xxx格式中间一个空格。另外确认BuildConfig.TAOTOKEN_API_KEY真的被注入了有时候local.properties没读到字段是空字符串请求就变成Bearer自然 401。可以在启动时打一条日志确认 Key 前缀。local proxy failed / connection refused这类报错一般出现在模拟器或公司网络环境。先确认设备能正常访问 HTTPS 外网再确认 Base URL 是https://taotoken.net/api而不是别的地址。如果是模拟器检查 DNS 设置。注意不要用任何非官方的转发工具直接走标准 HTTPS 即可。reading choices 时抛 JSONException 或 NullPointer说明响应结构和你预期的不一样。可能是模型返回了错误信息或者choices为空。把完整响应体打出来确认是不是{error: {...}}结构。如果是按错误信息处理比如模型 ID 不存在就换一个正确的 Model ID。OAuth 相关报错如果你在项目里同时接了别的鉴权体系注意不要把 OAuth token 和 TaoToken 的 API Key 混用。TaoToken 用的是 Bearer Key不是 OAuth 流程。两者请求头格式不同混用会直接 401。子项一直显示「加载中...」回到第 4 节的groupPosition映射问题。setChildrenCursor的第一个参数错了框架就刷新了错误的分组看起来像没反应。展开后崩溃 IllegalStateException绑定阶段找不到合适的 View 或列。检查childFrom的列名是否都在 Cursor 里存在childTo的控件 ID 是否在 child 布局里。SimpleCursorTreeAdapter只支持TextView和ImageView如果你在 child 布局里放了别的控件并试图绑定就会抛这个异常。Key 泄露风险如果发现 Key 被提交到了 Git立刻去控制台https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite吊销重建。老项目经常多人协作这一步别省。把上面这些对照一遍基本能覆盖 90% 的接入问题。剩下的多半是业务逻辑问题比如模型返回内容太长导致 UI 卡顿那就加个截断。6. 统一通道之后让 Cursor 树和模型调用各司其职走到这里SimpleCursorTreeAdapter的子节点加载链路已经能同时处理本地 Cursor 和模型调用了。回头看真正的工作量不在适配器本身而在于把「同步返回 Cursor」和「异步网络请求」这两件事解耦。占位 Cursor 是桥梁setChildrenCursor是刷新开关统一通道则保证了所有模型调用都走同一个 Base URL 和同一套 Key 管理。如果你后续还要扩展比如给分组行也加模型生成的标签思路是一样的在bindGroupView里做缓存和异步刷新。但要注意别在绑定方法里发请求那会随滚动反复触发一定要配合缓存。长期做这类编码和 Agent 任务的话可以关注 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite高频调用下更省心。接口字段和错误码细节都在接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite里遇到没见过的报错先去查一遍。最后留一个实用技巧把groupId - groupPosition的映射维护在适配器内部用SparseIntArray存getChildrenCursor时记录setChildrenCursor时查表。这样即使分组顺序变化刷新也不会错位。老项目里这个映射能帮你省掉大量「明明请求成功但 UI 不动」的排查时间。