简介:这份资源面向需要在 Android 端接入 KIMI 大模型接口的开发者,重点解决流式输出(Streaming)场景下的调用与集成问题,适合已具备一定 Android 与网络编程基础、希望实现逐字返回效果的中高级开发者参考。压缩包共 813 个文件,约 14.99MB,以 flat、json、xml、dex、webp、class、kt 等类型为主,涵盖资源索引、配置清单、编译产物与 Kotlin 源码等模块,另有 properties、gradle、jar 等构建与依赖文件,整体呈现一个可运行的 Android 工程结构。资源已有 1233 人学习下载,具备一定的参考热度。读者可从中了解流式响应的请求组织、数据分片接收与界面增量渲染思路,并借助工程目录与配置文件还原接入流程,排查编译与依赖问题,快速将 KIMI 流式对话能力落地到实际应用中。
1. 流式输出不是玄学:从一次“转圈十秒”的对话说起
做过对话类应用的人都遇到过这种尴尬:用户点下发送,界面转圈十秒,然后“啪”地蹦出一整段回答。用户不知道后台在干嘛,只觉得卡。KIMI API 的流式输出就是来解决这个问题的——它把大模型生成的内容按 token 逐段推回来,前端边收边渲染,首字延迟从“等全文”压到“等第一个字”。这份资源围绕 Android 端接入 Kimi 流式接口展开,适合正在做移动端 AI 对话、想把打字机效果和长文本响应体验做扎实的开发者。下面按“能干什么、怎么跑、怎么改”的顺序拆,代码能直接抄,坑也一并标出来。
2. 技术选型与工程结构:Android 端怎么接住一条流
2.1 为什么是 SSE 而不是 WebSocket
Kimi 的流式接口走的是 SSE(Server-Sent Events),本质是一条长连接的 HTTP 响应,服务端持续往响应体里写data:行。选它而不是 WebSocket,原因很实际:对话场景是“一问一答、服务端单向推”,不需要双向通道;SSE 基于普通 HTTP,鉴权头、超时、重试都能复用现有网络栈,Android 上 OkHttp 原生支持,不用额外引依赖。常见做法是用 OkHttp 的ResponseBody.source()逐行读,而不是等response.body().string()一次性读完——后者会把流“憋”成整块,流式就白做了。
工程上一般分三层:网络层负责建连和读流,解析层负责把 SSE 行切成事件,UI 层负责把增量文本喂给适配器。目录大致长这样:
app/src/main/java/com/example/kimichat/ ├── network/ │ ├── KimiClient.kt // OkHttp 单例、鉴权拦截器 │ └── StreamReader.kt // 逐行读 SSE、回调分发 ├── model/ │ ├── ChatRequest.kt // 请求体数据类 │ └── StreamEvent.kt // 解析后的事件模型 ├── ui/ │ ├── ChatActivity.kt // 页面与生命周期 │ └── MessageAdapter.kt // 增量刷新列表 └── util/ └── SseParser.kt // data: 行解析、[DONE] 判定这个分层不是摆样子。把解析从网络回调里拆出来,后面换模型、加多轮上下文、做断流重连时,改动面能控制在一个文件里。
2.2 请求体长什么样
流式开关就在请求体里一个字段,其余和普通对话请求一致。下面是一个最小可用的构造:
// ChatRequest.kt data class ChatRequest( val model: String = "kimi-latest", // 模型标识,按控制台实际可用值填 val messages: List<Message>, val stream: Boolean = true, // 关键:开启流式 val temperature: Double = 0.3 // 越低越稳,对话场景常用 0.2~0.5 ) data class Message(val role: String, val content: String)stream=true是整条链路的前提,漏了它服务端会一次性返回完整 JSON,前端再怎么读也读不出增量。temperature不是必填,但对话类应用调低一点,输出更聚焦,也减少流式过程中反复“改口”的观感。messages里 system 消息放最前,历史轮次按时间顺序追加,这是多轮上下文的标准姿势。
2.3 建连与逐行读取
网络层核心就两件事:带上鉴权头、拿到流后别一次性读完。
// StreamReader.kt fun streamChat(request: ChatRequest, onDelta: (String) -> Unit, onDone: () -> Unit, onError: (Throwable) -> Unit) { val body = gson.toJson(request).toRequestBody("application/json".toMediaType()) val req = Request.Builder() .url("https://api.moonshot.cn/v1/chat/completions") // 以官方文档地址为准 .addHeader("Authorization", "Bearer $apiKey") .addHeader("Accept", "text/event-stream") // 明确要事件流 .post(body) .build() client.newCall(req).enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { response.body?.source()?.use { source -> while (!source.exhausted()) { val line = source.readUtf8Line() ?: break SseParser.parse(line, onDelta, onDone) // 逐行交给解析层 } } } override fun onFailure(call: Call, e: IOException) = onError(e) }) }Accept: text/event-stream是告诉服务端“我要流”,虽然多数情况下stream=true已足够,但显式声明能避免中间层做缓冲。source.readUtf8Line()每次只取一行,读到exhausted()为真说明流结束。这里用use确保连接释放,否则长连接泄漏在移动端很致命。回调拆成onDelta/onDone/onError三个口子,UI 层只管消费,不关心底层怎么读。
3. SSE 解析与 UI 增量渲染:把 token 变成打字机
3.1 一行 data 的拆解逻辑
SSE 的每一行格式固定:data: {...},结束标记是data: [DONE]。解析层要做的就是剥前缀、判结束、取增量字段。
// SseParser.kt object SseParser { fun parse(line: String, onDelta: (String) -> Unit, onDone: () -> Unit) { if (line.isBlank() || !line.startsWith("data:")) return // 空行和注释行跳过 val payload = line.removePrefix("data:").trim() if (payload == "[DONE]") { onDone(); return } // 流结束标记 runCatching { val json = JSONObject(payload) val choices = json.optJSONArray("choices") ?: return val delta = choices.optJSONObject(0) ?.optJSONObject("delta") ?.optString("content") if (!delta.isNullOrEmpty()) onDelta(delta) // 只推有内容的增量 } } }几个细节值得说:line.isBlank()要跳过,因为 SSE 用空行分隔事件,不跳会解析出空 payload;removePrefix("data:")后要trim(),有的实现冒号后带空格;[DONE]必须单独判,它后面没有 JSON。runCatching包住解析,单行格式异常不该让整条流崩掉——线上偶尔会遇到半行截断,容错比严格更重要。delta.content为空时(比如首帧只带 role)不回调,避免 UI 收到空串做无意义刷新。
3.2 增量刷新别踩 RecyclerView 的坑
拿到增量后最直觉的写法是每来一个 token 就notifyDataSetChanged(),结果就是列表疯狂重绘、滚动位置乱跳、低端机直接掉帧。正确姿势是只更新“正在生成的那一条”,并且做节流。
// ChatActivity.kt 片段 private val buffer = StringBuilder() private var lastFlush = 0L private fun onDelta(text: String) { buffer.append(text) val now = SystemClock.uptimeMillis() if (now - lastFlush < 50) return // 50ms 节流,肉眼几乎无感 lastFlush = now adapter.updateStreaming(buffer.toString()) // 只刷最后一条 recyclerView.scrollToPosition(adapter.itemCount - 1) }StringBuilder累积全文,节流窗口内不刷新 UI,到点再一次性把当前全文塞给适配器。updateStreaming内部只调notifyItemChanged(lastIndex),不动整个列表。50ms 是经验值,低于这个频率人眼分辨不出,高于它又能明显减少重绘。scrollToPosition放在刷新之后,保证用户视线跟着最新内容走;如果用户手动上滑了,可以加个“是否贴底”的判断再决定要不要自动滚,这是体验分水岭。
3.3 生命周期与取消
流式请求是长连接,页面销毁不取消就是内存泄漏加流量浪费。onDestroy里必须call.cancel(),同时把isFinishing状态传给回调,避免已销毁的 Activity 还在更新 UI。常见做法是把Call引用存在 Activity 字段里,取消时判空。另外,用户点“停止生成”时也走同一个取消逻辑,把已累积的buffer落库成一条完整消息,下次进入还能看到半截回答——这个细节很多 Demo 都漏了。
4. 避坑与排查:流式接入最容易翻车的五处
4.1 现象:界面一直转圈,最后一次性弹出全文
原因基本是读了response.body().string(),或者中间有拦截器/网关把响应缓冲了。解决:确认读的是source()逐行;检查 OkHttp 拦截器里有没有对 body 做peekBody之类会触发全量读取的操作;如果走公司网关,确认它没开响应缓冲。
4.2 现象:中文被切成乱码或半个字
原因是用readUtf8Line()之外的方式按字节读,多字节字符被截断。解决:统一用按行读,SSE 以换行分帧,天然对齐字符边界;如果必须按块读,用InputStreamReader配 UTF-8 并处理残字节。
4.3 现象:偶发JsonSyntaxException导致整条流中断
原因是把单行解析异常当致命错误抛了。解决:解析包runCatching,异常行直接丢弃并打日志;同时确认没有把 SSE 的注释行(以:开头)当数据行解析。
4.4 现象:切到后台再回来,回答“断”了
原因是 Activity 重建后旧Call被取消,新页面没有恢复上下文。解决:把请求和累积文本放到 ViewModel 或前台 Service 里,页面只做观察者;重建时从状态层取回已生成内容继续渲染。
4.5 现象:长回答到后面越来越卡
原因是每帧都在做全量字符串拼接加整列表刷新。解决:节流刷新(见 3.2),文本累积用StringBuilder,列表只notifyItemChanged末项;超长文本考虑分段存储,别在一个 TextView 里塞几万字。
5. 二次开发:把流式能力做成可复用的对话内核
真正要落地,不会只在一个页面里写死流式逻辑。我一般会把这块抽成一个ChatEngine,对外只暴露send(text)、stop()、StateFlow<ChatState>三个口子,页面换、模型换都不动它。改造时优先动这三个点:
| 改造点 | 做法 | 验证方式 |
|---|---|---|
| 多轮上下文 | 维护messages列表,每轮追加 user/assistant | 连续问三轮,看模型是否记得第一轮 |
| 断流重连 | 记录已收 token 数,重连时带上下文重发 | 飞行模式切换后恢复,内容不重复 |
| 多模型切换 | 把 model 和 endpoint 抽成配置 | 换模型后流式行为一致 |
一个具体技巧:给ChatState加个isStreaming和partialText字段,UI 只根据状态渲染,取消、重连、错误全在引擎内部消化。这样二次开发时,加“重新生成”“编辑后重发”都只是往引擎里加方法,页面几乎不用改。
sealed class ChatState { data class Streaming(val text: String) : ChatState() // 生成中,text 为当前全文 data class Done(val text: String) : ChatState() // 完成 data class Error(val msg: String, val partial: String) : ChatState() // 出错但保留半截 }Error里带partial是我踩过坑之后加的:早期出错直接清空,用户辛苦等的一半内容没了,体验很差。保留半截让用户能复制、能重试,这才是对话产品该有的样子。从那以后我每次接流式接口,都强制把“取消/错误/重建”三条路径各跑一遍再提测,少一条都算没做完。希望帮到你。
本文还有配套的精品资源,点击获取