多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

利用SimpleCursorAdapter自定义SearchView实践:TaoToken统一Key接入AI辅助搜索建议

利用SimpleCursorAdapter自定义SearchView实践:TaoToken统一Key接入AI辅助搜索建议 1. 从本地历史到 AI 补全SearchView 搜索建议的真实痛点SearchView 是 Android 里一个很经典的控件配合 SimpleCursorAdapter 把 SQLite 里的历史搜索记录直接映射成 ListView 的条目这套组合在十年前的教学项目里几乎人手一份。它的好处很直接Cursor 指向哪一行ListView 就渲染哪一行from和to两个数组一配name字段自动填进ItemTitlephoto_id自动填进ItemImage连手动组装 List 都省了。但真把它放到今天的产品里问题会立刻暴露出来。最典型的是「输入即搜索」的命中率问题。用户输入「安卓 网络 请求」本地like %安卓 网络 请求%几乎必然返回空因为历史记录里存的是「Android 网络请求封装」这种完整词条中间差了一个空格、一个大小写、一个中英文混排模糊匹配就失效了。用户输入「retroft」这种拼写错误本地库更是完全无感。结果就是搜索框越用越像摆设用户宁可自己翻列表也不愿意打字。我试过在afterTextChanged里加拼音转换、加同义词表维护成本高得离谱而且每加一个词都要重新发版。真正合理的做法是把「补全」和「纠错」这两件事交给语义模型本地只负责存历史、做展示。这篇就按这个思路走SearchView SimpleCursorAdapter 保持原有骨架不动在输入回调里挂一条 AI 补全通道用 TaoToken 的统一 Key 把请求发出去把返回的建议词合并进 Cursor 展示。适合已经写过自定义 SearchView、想给它加一层语义能力的 Android 开发者。2. TaoToken 前置统一 Key 与 API 通道准备TaoToken 在这里扮演的角色是「一个 Key 打通多家模型」的接入层。你不需要在 App 里分别维护 OpenAI、Claude、通义等多家 SDK 和各自的鉴权逻辑只需要在服务端或本地配置里放一个统一 Key请求走同一个 API 地址模型名作为参数切换。对搜索建议这种场景来说好处是今天用便宜的小模型做补全明天想换更强的模型做语义纠错只改一个model字段客户端代码不用动。需要提前准备的东西有三样。第一是 TaoToken 的 API Key在控制台的 API Keys 页面创建建议单独建一个「搜索建议」用途的 Key方便后续按项目统计用量和限额。第二是 API 基础地址统一用https://taotoken.net/api注意这个地址不带任何查询参数干净接入。第三是确认你要调用的模型名补全和纠错这类短文本任务选响应快、单价低的模型即可不必上最贵的旗舰。配置建议放在settings.json或等价的配置文件里不要硬编码进 Java 源码。原因很实际Key 一旦提交进 Git 历史轮换起来非常麻烦。下面这段是配置片段字段名你可以按自己项目习惯调整关键是baseUrl和apiKey分离model可切换。{ taotoken: { baseUrl: https://taotoken.net/api, apiKey: sk-你的TaoTokenKey, model: 你的模型名, timeoutMs: 2500, maxSuggestions: 5 } }注意apiKey不要写进AndroidManifest.xml也不要打进 APK 的 assets 里明文分发。生产环境建议由你自己的后端代理转发客户端只持有短期令牌。本文为了演示链路完整假设你在本地调试环境直连。3. 可复制配置SearchView 骨架与 SimpleCursorAdapter 绑定先把原有骨架理清楚。自定义SearchView继承LinearLayout在initView()里 inflateR.layout.search_layout绑定et_search、listView、tv_clear、search_back四个控件。数据库用DBSqliteOpenHelperrecords表字段是id、name、photo_id、deletebtn。查询走rawQuery适配器用SimpleCursorAdapterfrom {name, photo_id}to {R.id.ItemTitle, R.id.ItemImage}标志位用CursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER。这里有一个必须注意的坑SimpleCursorAdapter要求 Cursor 里必须包含一个名为_id的列否则会抛IllegalArgumentException: column _id does not exist。所以 SQL 里一定要写select id as _id, name, photo_id ...这个as _id不能省。原代码里已经处理了但很多人抄的时候会漏掉。接下来是改造点。原来的afterTextChanged只做一件事queryData(tempName)。现在要拆成两步——先本地查询立即出结果保证输入不卡顿再异步请求 AI 补全拿到建议词后合并展示。合并的方式有两种一是把 AI 建议词临时插入 Cursor 对应的表二是用一个MatrixCursor把本地结果和 AI 结果拼在一起。推荐第二种不污染历史库。// 本地查询保持原样立即刷新 private void queryData(String tempName) { Cursor cursor helper.getReadableDatabase().rawQuery( select id as _id,name,photo_id,deletebtn from records where name like % tempName % order by id desc, null); String[] from new String[]{name, photo_id}; int[] to new int[]{R.id.ItemTitle, R.id.ItemImage}; adapter new SimpleCursorAdapter(context, R.layout.item, cursor, from, to, CursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER); listView.setAdapter(adapter); adapter.notifyDataSetChanged(); if (tempName.equals() cursor.getCount() ! 0) { textView_Clear.setVisibility(VISIBLE); } else { textView_Clear.setVisibility(INVISIBLE); } }然后是 AI 补全的触发逻辑。在afterTextChanged里加防抖避免每敲一个字符就发一次请求。防抖时间设 300ms 左右用户连续输入时只发最后一次。private Handler debounceHandler new Handler(Looper.getMainLooper()); private Runnable pendingSuggest; Override public void afterTextChanged(Editable s) { final String tempName editTextSearch.getText().toString().trim(); queryData(tempName); // 本地立即出结果 if (pendingSuggest ! null) { debounceHandler.removeCallbacks(pendingSuggest); } if (tempName.length() 2) { return; // 太短不发请求 } pendingSuggest new Runnable() { Override public void run() { requestAiSuggestions(tempName); } }; debounceHandler.postDelayed(pendingSuggest, 300); }请求部分用HttpURLConnection或 OkHttp 都行这里给一个不依赖第三方库的写法方便你直接贴进老项目。注意请求体是标准的 chat completions 结构messages里用 system 约束输出格式让模型只返回建议词不要返回解释。private void requestAiSuggestions(final String keyword) { new Thread(new Runnable() { Override public void run() { try { JSONObject body new JSONObject(); body.put(model, BuildConfig.TAOTOKEN_MODEL); JSONArray messages new JSONArray(); messages.put(new JSONObject() .put(role, system) .put(content, 你是搜索建议引擎。根据用户输入返回最多5个补全或纠错后的搜索词 每行一个不要编号不要解释不要标点。)); messages.put(new JSONObject() .put(role, user) .put(content, keyword)); body.put(messages, messages); body.put(max_tokens, 120); body.put(temperature, 0.2); URL url new URL(BuildConfig.TAOTOKEN_BASE_URL /v1/chat/completions); HttpURLConnection conn (HttpURLConnection) url.openConnection(); conn.setRequestMethod(POST); conn.setRequestProperty(Content-Type, application/json); conn.setRequestProperty(Authorization, Bearer BuildConfig.TAOTOKEN_KEY); conn.setConnectTimeout(2500); conn.setReadTimeout(2500); conn.setDoOutput(true); conn.getOutputStream().write(body.toString().getBytes(UTF-8)); int code conn.getResponseCode(); if (code ! 200) { return; // 静默失败不影响本地搜索 } BufferedReader reader new BufferedReader( new InputStreamReader(conn.getInputStream(), UTF-8)); StringBuilder sb new StringBuilder(); String line; while ((line reader.readLine()) ! null) { sb.append(line); } reader.close(); parseAndMergeSuggestions(sb.toString()); } catch (Exception e) { // 网络异常直接忽略本地结果照常展示 } } }).start(); }解析返回并合并进列表。这里用MatrixCursor把 AI 建议词拼到本地 Cursor 前面_id用负数避免和真实记录冲突。private void parseAndMergeSuggestions(String rawJson) { try { JSONObject root new JSONObject(rawJson); String content root.getJSONArray(choices) .getJSONObject(0) .getJSONObject(message) .getString(content); String[] lines content.split(\n); final MatrixCursor aiCursor new MatrixCursor( new String[]{_id, name, photo_id}); int fakeId -1; for (String line : lines) { String word line.trim(); if (word.isEmpty()) continue; aiCursor.addRow(new Object[]{fakeId--, word, historyimg}); if (fakeId -5) break; } runOnUiThread(new Runnable() { Override public void run() { mergeCursorAndShow(aiCursor); } }); } catch (Exception e) { // 解析失败不影响本地结果 } }合并展示时用MergeCursor把 AI 结果和本地结果串起来再交给SimpleCursorAdapter。注意MergeCursor要求两个 Cursor 的列名一致所以MatrixCursor的列名必须和本地查询的_id、name、photo_id对齐。private void mergeCursorAndShow(MatrixCursor aiCursor) { Cursor localCursor helper.getReadableDatabase().rawQuery( select id as _id,name,photo_id,deletebtn from records where name like % editTextSearch.getText().toString().trim() % order by id desc, null); Cursor merged new MergeCursor(new Cursor[]{aiCursor, localCursor}); String[] from new String[]{name, photo_id}; int[] to new int[]{R.id.ItemTitle, R.id.ItemImage}; adapter new SimpleCursorAdapter(context, R.layout.item, merged, from, to, CursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER); listView.setAdapter(adapter); adapter.notifyDataSetChanged(); }4. 验证请求命中率与响应延迟的实测动作配置写完之后必须做两件事来验证链路是否真的通了。第一件是确认请求能发出去、能拿到返回。最简单的办法是在requestAiSuggestions里加一行日志把conn.getResponseCode()和返回体打出来用 Logcat 观察。如果返回 401说明 Key 或 Authorization 头有问题返回 404检查baseUrl拼接后的完整路径是否正确返回 429说明触发了限流需要降低请求频率或换 Key。第二件是量化效果。准备一组测试输入覆盖三种情况完整词、拼写错误、中英混排。比如输入「数据结构」「datastructer」「安卓 网络」分别记录本地命中条数和 AI 补全条数。实测下来本地模糊匹配对完整词命中率还行但对拼写错误基本为零而 AI 补全能把「datastructer」纠正成「data structure」并给出「数据结构与算法」这类中文建议命中率提升非常明显。延迟方面在requestAiSuggestions开始和parseAndMergeSuggestions结束各打一个时间戳算出差值。局域网环境下短文本补全通常在 400ms 到 900ms 之间。如果超过 1.5s用户会明显感觉到列表「跳一下」这时候要么换更快的模型要么把防抖时间从 300ms 调到 500ms减少请求次数。下面是一个简单的验证表格你可以照着填自己的数据。测试输入本地命中AI 补全总延迟(ms)是否可接受数据结构14620是datastructer03780是安卓 网络05910是算法23550是提示验证阶段建议把max_tokens压到 120 以内补全词不需要长文本token 越少返回越快成本也越低。5. 本篇常见错排查第一个高频错误是column _id does not exist。前面提过SimpleCursorAdapter强依赖_id列SQL 里必须select id as _id。如果你用的是MergeCursor两个子 Cursor 都必须有_idMatrixCursor构造时的列名数组里也要包含_id否则合并后照样崩。第二个是MergeCursor列数不一致导致的IllegalArgumentException。本地查询返回四列_id, name, photo_id, deletebtn而MatrixCursor只建了三列合并时列数对不上就会抛异常。解决办法是让两边列完全一致MatrixCursor也补上deletebtn列值传null即可。第三个是网络请求在主线程执行导致NetworkOnMainThreadException。上面的代码已经用new Thread包住了但如果你图省事直接在afterTextChanged里调HttpURLConnection必崩。另外注意runOnUiThread只能在 Activity 里用如果自定义 View 没有持有 Activity 引用改用post(new Runnable(){...})。第四个是 Key 泄露风险。如果你把apiKey写进BuildConfig反编译 APK 是能拿到的。调试阶段无所谓上线前一定要改成后端代理。TaoToken 的控制台可以按 Key 设置额度和过期时间建议给移动端单独建一个受限 Key即使泄露损失也可控。第五个是 AI 返回内容带编号或解释导致建议词里混进「1.」「建议」这类前缀。这是 system prompt 约束不够强导致的。把 system 内容改成「只输出词每行一个禁止任何前缀、编号、标点、解释」temperature压到 0.2 以下基本能稳定。6. 接入路径与后续动作搜索建议这条链路跑通之后你会发现 SearchView 的能力边界被打开了不少。本地 Cursor 负责「确定性」——历史记录、已收藏词条AI 通道负责「可能性」——补全、纠错、近义词扩展。两者用MergeCursor拼在一起对SimpleCursorAdapter来说没有区别它只认 Cursor不关心数据从哪来。这种分层的好处是AI 挂了、超时了、限流了本地搜索照常工作用户体验不会断崖式下跌。如果你准备把这个能力接到真实项目里下一步建议先处理 Key 的安全问题。移动端直连只适合本地调试生产环境让请求走你自己的服务端服务端持有 TaoToken Key客户端只发关键词、收建议词。这样既能统一做缓存和限流也能避免 Key 被打包进 APK。需要创建 Key 或查看用量可以从 API Keys 页面进入接入细节和参数说明在接入文档里有完整字段表如果你想先在网页上试一下模型对搜索词的补全效果模型对话页面可以直接输入测试长期做 Android 编码辅助或 Agent 类功能Coding Plan 更适合按周期管理额度。地址统一从官网入口进API 基础地址固定用https://taotoken.net/api配置片段里的baseUrl就填这个不要带多余路径。
返回列表