简介这是一份面向 Android 开发者的 Kimi 大模型 API 流式输出示例工程围绕如何在移动端接入月之暗面 Kimi 服务并解析流式返回而设计。资源包含完整可导入 Android Studio 的工程结构既有 Kotlin/Java 源码、XML 布局与资源、JSON 配置也有最终的 APK 与多次构建产生的 DEX/class 文件便于对照学习或直接运行验证。代码中涉及网络请求、消息会话管理、流式数据接收与界面增量更新等关键环节能帮助开发者绕过常见鉴权与解析坑点。工程共 813 个文件压缩包约 14.99MB主要文件类型包括 134 个 flat 资源文件、129 个 JSON 配置、101 个 XML 布局与清单、15 个 Kotlin 文件、22 个 DEX、18 个 class 及 jar 等涵盖从源码到构建产物的完整链路。目前已有 1231 人学习下载适合具备 Android 基础、希望接入大模型能力的进阶开发者。通过源码与构建产物对照可省去自行摸索接口调用细节的时间直接聚焦流式输出的核心实现。1. 从“逐字蹦字”说起Kimi API 流式输出在 Android 端的一次落地把 Kimi API 的流式输出接到 Android 上最直观的效果是对话框里的文字像人打字一样一个字一个字往外蹦。做过一次才发现流式接入的难点不在“发请求”而在“收响应”服务端把一段回答切成十几个甚至几十个数据块推过来客户端接慢了会卡顿接快了会串台稍不留神中文还会半个字半个字地断。这篇文章从工程结构、SSE 协议解析、增量渲染到二次封装完整拆一遍我落地这个项目时的思路和踩过的坑适合手里已有 Kimi 或兼容 OpenAI 协议的 API Key、想在 Android 上做流式对话的开发者。读完你不仅能跑通一个逐字输出的 Demo还能知道怎么把它改造成自己项目里的通用流式对话组件。2. 工程结构与数据流一个流式请求从点击到上屏的完整链路2.1 整体分层与目录设计这个项目的代码规模不大但分层必须清晰因为流式请求的链路比普通请求长用户点击发送后请求要经过网络层、协议解析层、数据回调层最后才到 UI 渲染层任何一层处理不当都会把“流式”变成“一次性”。我按职责把工程拆成了五个包com.example.kimi.stream ├── api/ # 网络请求接口定义 │ └── ChatApi.kt ├── model/ # 请求体、响应体、增量数据模型 │ ├── ChatRequest.kt │ ├── ChatResponse.kt │ └── SseEvent.kt ├── network/ # OkHttp 客户端、SSE 解析器 │ ├── OkHttpClientFactory.kt │ └── SseParser.kt ├── repo/ # 仓库层统一管理多轮对话上下文与请求 │ └── ChatRepository.kt └── ui/ # Activity / ViewModel / RecyclerView 适配器 ├── ChatActivity.kt ├── ChatViewModel.kt └── MessageAdapter.kt这个结构对应的是常见的单向数据流ViewModel 拿到用户输入交给 Repository 拼装请求经由 OkHttp 发起网络请求流式响应被 SseParser 逐行解析成数据事件再通过回调或协程 Flow 传回 ViewModel最后在 RecyclerView 里增量渲染。为什么不把 SseParser 直接写进 Activity原因在于流式响应的生命周期不可控网络抖动、用户退出页面、请求被取消这些都需要在独立层处理。解析器单独放一个类方便写单元测试。我一般会先在本地 mock 一段 SSE 文本喂给 SseParser确认输出事件顺序没问题再接入真实网络排查问题的范围会小很多。2.2 技术选型OkHttp 与协程的组合流式请求在 Android 上最常见的实现组合是 OkHttp kotlinx.coroutines这个项目也延续了这个组合。OkHttp 负责 HTTP 层的连接管理、读超时控制协程负责把回调式的流式数据转成挂起函数避免在主线程里做 IO。依赖配置大致如下implementation(com.squareup.okhttp3:okhttp:4.12.0) implementation(org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3)OkHttp 4.x 在 Kotlin 环境下用起来比 3.x 顺手API 是 Kotlin 风格的拦截器写起来也直观。这里不写死版本号如果项目里已有更高版本直接复用即可流式解析逻辑不受版本影响。网络层核心是 OkHttpClient 的构建。流式请求对超时时间的设置和普通请求不太一样连接超时保持正常但读超时必须拉长否则模型思考时间长一点客户端就报 SocketTimeoutException。我一般把读超时设为 60 秒实际项目里可以根据模型响应速度调整。fun createStreamClient(): OkHttpClient { return OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) // 流式响应间隔可能较长读超时不能太短 .writeTimeout(30, TimeUnit.SECONDS) .addInterceptor { chain - val request chain.request().newBuilder() .header(Authorization, Bearer ${BuildConfig.KIMI_API_KEY}) .header(Accept, text/event-stream) .build() chain.proceed(request) } .build() }这段代码里两个容易忽略的点一是 Accept 头必须写成 text/event-stream有些网关会依据这个头判断是否走流式通道漏掉它可能直接给你返回一个完整的 JSON二是拦截器里统一塞鉴权头避免每个请求都重复写。BuildConfig 里放 API Key 是常见做法但注意这只适用于本地 Demo不要把这个文件提交到公开仓库。请求体遵循 OpenAI 兼容协议Kimi API 也走这套格式所以这个项目的请求模型可以直接复用到其他兼容服务上。流式开关就是 stream 字段设为 true同时 messages 数组负责携带多轮对话历史。data class ChatRequest( val model: String kimi-latest, val messages: ListMessage, val stream: Boolean true, val temperature: Double 0.3 ) { data class Message( val role: String, // system / user / assistant val content: String ) }参数说明model 指定使用的模型标识不同服务商的默认值不同建议做成可配置项messages 是整个请求的关键它不只放当前问题还要放前几轮的问答内容模型才能理解上下文temperature 控制随机性对话场景我习惯设在 0.3 左右太低显得机械太高容易跑题。2.3 一个流式请求的完整时序从用户点击发送到文本逐字上屏整个调用链可以归纳为四步拼装请求、发起连接、解析流、更新 UI。用户输入“什么是协程”后ChatRepository 先从历史记录里取出最近几轮消息连同当前问题一起放进 ChatRequest然后通过 OkHttp 的 newCall 发起异步请求。这里关键点在于 Response.body() 拿到的是一个 ResponseBody把它转成 source 输入流SseParser 逐行读取——每一行都是一个 SSE 事件。事件解析完成后回调 onEvent 函数把增量文本传给 ViewModelViewModel 更新当前正在生成的 message 对象再调用 MessageAdapter 的 notifyItemChanged 触发 RecyclerView 刷新。整个过程都在 IO 线程完成解析UI 更新通过协程切回主线程。需要注意一个细节流式响应的每个数据块只携带增量内容不是全量文本。比如第一块是“协”第二块是“程”第三块是“的”UI 层要做的是在旧文本后面追加而不是覆盖。如果写成了 setText(event.content)屏幕上就永远只有最后一个字这是初次接流式最容易犯的错。3. 核心模块拆解SSE 协议解析与增量渲染的代码实战3.1 SSE 数据格式与事件模型Server-Sent Events 是一种基于 HTTP 的轻量推送协议Kimi API 的流式响应用的就是这套格式。服务端把数据按事件流的方式推送过来每条事件由若干字段组成最常见的是 data 字段事件之间用空行分隔。一个典型的流式响应片段长这样data: {id:chatcmpl-xxx,choices:[{delta:{content:协},index:0}]} data: {id:chatcmpl-xxx,choices:[{delta:{content:程},index:0}]} data: [DONE]可以看到每个 data 后面是一行 JSONchoices[0].delta.content 就是本次增量内容。当所有内容推送完毕服务端会发送一个固定的结束标记data: [DONE]客户端收到它就该停止继续读取。理解了数据格式解析器的职责就清楚了按行读取输入流遇到空行意味着一个事件结束提取 data 字段的内容做 JSON 反序列化再判断是不是结束标记。这个逻辑看上去简单但在字节层有一个隐蔽的坑网络传输不保证按行到达这一包可能只有半行下一包才补全所以不能直接在字节流上按行切。OkHttp 的 source 输入流已经帮我们处理了字节缓冲我们只需要用 readUtf8Line 逐行读逻辑就清爽很多。项目里的 SseEvent 模型设计了三个状态用来区分事件类型sealed class SseEvent { object Done : SseEvent() // 流结束 data class Delta(val content: String) : SseEvent() // 增量文本 data class Error(val message: String) : SseEvent() // 异常事件 }为什么要用 sealed class 而不是直接回调 String因为流式响应里除了增量内容还可能出现错误事件和结束标记用类型区分可以让调用方写 when 表达式时强制处理所有分支编译器帮你兜底漏掉 Done 或 Error 的情况就少了。3.2 SseParser逐行解析的完整实现SseParser 是项目里最值得细看的类核心逻辑只有三十行左右但每一行都影响流式体验的稳定性。下面是我在项目里落地的版本class SseParser( private val onEvent: (SseEvent) - Unit ) { private val json Json { ignoreUnknownKeys true } fun parse(responseBody: ResponseBody) { val source responseBody.source() try { while (true) { val line source.readUtf8Line() ?: break // 流关闭则结束 if (line.isNullOrEmpty()) continue // 空行是事件分隔符忽略 if (!line.startsWith(data:)) continue // 只认 data 字段 val data line.removePrefix(data:).trim() if (data [DONE]) { onEvent(SseEvent.Done) break } val delta parseDelta(data) ?: continue // 解析不到增量就跳过 onEvent(SseEvent.Delta(delta)) } } catch (e: IOException) { onEvent(SseEvent.Error(e.message ?: stream read failed)) } } private fun parseDelta(data: String): String? { return try { val payload json.decodeFromStringDeltaPayload(data) payload.choices.firstOrNull()?.delta?.content } catch (e: Exception) { null // 某些事件块没有 delta 字段忽略即可 } } Serializable data class DeltaPayload( val choices: ListChoice ) { Serializable data class Choice( val delta: Delta? null ) { Serializable data class Delta( val content: String? null ) } } }逻辑说明逐段拆readUtf8Line 每调用一次取一行返回 null 说明流已经结束这是退出循环的唯一条件data 前缀判断确保其他字段比如 comment 开头的行不会干扰[DONE] 检测放在 JSON 解析之前省掉一次无意义反序列化。parseDelta 里的 ignoreUnknownKeys true 很重要。真实返回的 JSON 里可能会有 usage、created、model 这些字段它们不影响增量展示如果解析器遇到未知字段就抛异常整个流就断了。设置为忽略未知键之后只有 choices 结构会被映射其他键安全跳过。这段代码还有一个值得留意的设计onEvent 回调是同步执行的。也就是说解析线程读到一行 delta 就立刻回调回调里如果再执行网络请求或复杂计算会阻塞后续行的读取。所以调用方在 onEvent 里应该只做数据透传和轻量 UI 更新重活放到其他线程。3.3 网络请求发起把回调转成 FlowSseParser 是纯回调风格的它与 ViewModel 之间需要一座桥。Android 开发者更习惯在 ViewModel 里用 Flow 观察数据流我在项目里写了一个扩展方法把请求挂起函数与解析过程结合起来suspend fun ChatApi.streamChat(request: ChatRequest): FlowSseEvent flow { val httpRequest buildHttpRequest(request) val client createStreamClient() client.newCall(httpRequest).execute().use { response - if (!response.isSuccessful) { emit(SseEvent.Error(http ${response.code})) returnflow } val body response.body ?: run { emit(SseEvent.Error(empty body)) returnflow } val parser SseParser { event - try { emit(event) } catch (e: Exception) { // 下游取消时抛出 CancellationException正常结束 } } parser.parse(body) } }.flowOn(Dispatchers.IO)这段代码解决的问题是OkHttp 的 execute 是同步方法必须在后台线程调用同时 SseParser 的解析也是阻塞的所以整体包在 flow 构建器里再用 flowOn(Dispatchers.IO) 把收集操作切到主线程。这里有个协程取消的细节容易被忽略当用户在 UI 层取消收集比如退出页面flow 的 emit 会抛出 CancellationException此时 use 块会自动关闭 response底层连接随之释放。所以流式请求的取消不需要额外写 cancel 方法只要下游停止收集整条链就断掉了。3.4 UI 层增量渲染RecyclerView 的逐字刷新流式 UI 的核心诉求只有一个解析器每吐出一个增量字符界面上就要立刻看到。最直接的做法是持有当前消息的引用每收到一个 Delta 就追加文本然后通知适配器刷新这一条。class MessageAdapter( private val messages: MutableListChatMessage ) : RecyclerView.AdapterMessageAdapter.VH() { fun appendToLastMessage(delta: String) { val lastIndex messages.lastIndex if (lastIndex 0) return val last messages[lastIndex] messages[lastIndex] last.copy(content last.content delta) notifyItemChanged(lastIndex) } // ViewHolder 绑定逻辑省略内容直接 setText(message.content) }appendToLastMessage 的语义是“追加到最后一个消息”这要求 Repository 在发起请求时就先往 messages 里插入一个空的 assistant 消息占位后续所有增量都追加到这个占位对象上。消息列表里现在的最后一项一定是对应本次回答的消息不会出现找不到目标的情况。性能上需要注意notifyItemChanged 每次都触发 onBindViewHolder 和 setText频率高。对于几百字的回答增量事件大概几十到上百次RecyclerView 完全扛得住。但如果模型生成长文每次 setText 都把整段文本重新设置一遍也会有一定损耗。常见的优化方案是设置文本时用 Spannable 或直接在 TextView 上 append但这属于体验调优基础功能先用 notifyItemChanged 保证正确性即可。我在项目里还加了一个细节把 TextView 的 movementMethod 设置为 LinkMovementMethod因为模型偶尔会输出 Markdown 链接默认 TextView 是不响应点击的。这一步和流式无关但没有它二次开发时点链接没反应又得排查半天。4. 避坑指南流式接入最容易翻车的六个现场4.1 现象文本显示不全最后一个字永远不出现流式回答已经全部接收完毕界面上的文本却停在倒数第二个字等一会儿也没有反应。排查发现是结束标记处理有误有些实现把data: [DONE]当作普通 JSON 去解析parseDelta 捕获异常后返回 null代码里写的是?: continue于是解析器跳过这一行继续读流可服务端已经把连接关掉了readUtf8Line 返回 null 才退出循环但 UI 还没来得及展示最后一段增量。原因是 [DONE] 之后的处理逻辑没有跳出循环导致最后一个 Delta 早已 emit但 UI 刷新可能被后续的空转覆盖。解决办法是在检测到 [DONE] 时立即 break 并回调 Done 事件给 UI 一个明确的“流已结束”信号。从那以后我再写流式解析都会先把结束标记的处理单独拎出来测试而不是混在正常 JSON 解析路径里。4.2 现象中文出现半个字回答里出现“”这样的乱码字符而且毫无规律。原因是流的字节缓冲区被按固定长度切分时一个 UTF-8 中文字符三字节被切成了两半解析器读到不完整的字节序列就尝试转成字符串转换失败或产生乱码。解决这个问题要看懂 OkHttp 的 readUtf8Line 做了什么它在内部维护了字节缓冲只有遇到换行符才会返回一行完整内容。所以只要保证“按行解析”而不是“按包解析”就不会出现半个字的问题。容易踩坑的写法是自己用 ByteArray 循环读 InputStream然后直接转 String这种写法在分片边界上必然翻车。流式解析一定要交给带行缓冲的读取器不要按数据包切文本。4.3 现象流式响应变成一次性 JSON明明设置了 stream: true但服务端返回的 Content-Type 是 application/json文本一次性全部到达没有逐字效果。原因多半是请求头里没有 Accept: text/event-stream或者网关层把流式请求拦截转成了普通请求。解决方式是检查两处一是请求头必须显式带上 text/event-stream二是用拦截器在控制台打印响应头确认 Content-Type 确实是 text/event-stream。如果局域网内有代理或网关也要确认它没有缓冲响应体否则即使服务端推流客户端读到的还是聚合后的结果。4.4 现象读超时偶发回答越长越容易断流式请求在模型思考或生成长回答时两个数据块之间的间隔可能超过几秒。如果 OkHttp 的 readTimeout 设置成 5 秒或 10 秒一旦模型停顿稍久客户端就抛出 SocketTimeoutException连接被强制关闭。这个问题的根源在于readTimeout 指的是两次读操作之间的最大间隔不是总耗时。模型在生成过程中可能长时间不下发数据所以流式客户端的读超时一定要放大我在项目里设了 60 秒。如果服务端支持心跳或注释行也可以在解析器里处理冒号开头的行把它们当作保活信号不触发超时。4.5 现象UI 卡顿收到消息后界面冻结半秒文本可以正常逐字输出但偶尔会卡一下尤其当模型回答特别长时。原因是 onEvent 回调里直接做了 UI 刷新而 emit 发生在 IO 线程协程切换的时机不当造成了主线程堆积。排查后发现是我在 onEvent 里调用了 notifyDataSetChanged而不是 notifyItemChanged。notifyDataSetChanged 会触发整个列表全部重绘数据多时损耗很大。解决办法是改成只刷新被修改的那一条如果有图片或者其他复杂绑定逻辑还可以用 payload 机制配合局部刷新。流式高频更新这个场景全量刷新就是灾难。4.6 现象退出页面后日志里还在打印流数据用户退出对话页面但看日志发现网络请求还在继续解析线程也没停。原因是没有处理好协程的取消传播flow 构建器里的 use 块依赖下游取消来关闭连接但如果 ViewModel 里的状态没有被清空collect 的协程可能一直存在。解决方式是在 ViewModel 里使用 viewModelScope 发起流收集并把 collectedJob 保存下来在 onCleared 里手动 cancel。同时在 Repository 层可以传入一个 Job 参数统一由调用方持有。现在我再写类似的逻辑都会强制确认每一个 flow 都有对应的作用域不允许“火忘关”的协程飘在后台。5. 二次开发把 Demo 改造成可复用的流式对话组件跑通 Demo 只是起点真要拿它去接业务还需要解决三个问题多轮上下文的自动管理、流式与全量模式的切换、错误重试机制的补全。这三件事做完这个项目基本就从一个一次性 Demo 变成了可复用的对话组件。多轮上下文管理我在 Repository 里维护了一个消息队列每轮回答结束后把用户消息和助手消息都追加进去然后以最近十轮为窗口截取发送给 API。注意不要把 system 提示词漏掉它要始终放在 messages 数组首位。class ChatRepository(private val api: ChatApi) { private val history mutableListOfChatRequest.Message() fun addSystemPrompt(prompt: String) { history.add(0, ChatRequest.Message(system, prompt)) } fun addUserMessage(content: String) { history.add(ChatRequest.Message(user, content)) } fun addAssistantMessage(content: String) { history.add(ChatRequest.Message(assistant, content)) } fun buildRequest(): ChatRequest { val recent history.takeLast(20) // 窗口机制防止 token 超限 return ChatRequest(messages recent, stream true) } }窗口截取这段代码建议保留因为多轮对话越多messages 数组越长迟早会撞上模型的上下文长度上限。十到二十轮是个折中值具体取决于模型支持的最大 token 数和平均回答长度。流式与全量切换的逻辑我倾向于在 ChatRequest 里直接加参数而不是维护两套请求模型。stream 设为 false 时响应就是普通的 JSON但 UI 层依然走 Delta 事件回调只不过只触发一次。这样切换模式不影响渲染层Model 服务商哪天想换回一次性响应改动只是请求参数而已。至于重试机制我一般会在 Repository 层加一个简单的带次数上限的重试循环只在 Error 事件出现且当前没有收到任何 Delta 时才重发。已经收到增量内容后不重试因为服务端可能已经生成了部分内容重发会造成回复重复。suspend fun streamChatWithRetry(request: ChatRequest, maxRetries: Int 2): FlowSseEvent flow { var attempts 0 var hasDelivered false while (attempts maxRetries) { var shouldRetry false api.streamChat(request).collect { event - when (event) { is SseEvent.Delta - { hasDelivered true emit(event) } is SseEvent.Error - if (!hasDelivered) shouldRetry true is SseEvent.Done - emit(event) } } if (!shouldRetry) break attempts delay(1000L * attempts) // 退避等待避免连续重试打爆服务 } }这段重试逻辑有一个关键原则hasDelivered 一旦为 true任何错误都不再触发重试因为 UI 已经展示了部分内容再重发会让用户看到两段割裂的回答。同时重试间隔用线性退避第一次失败等 1 秒第二次等 2 秒既不急躁也不过分等待。组件化改造的最后一步是把 ChatViewModel 里的流收集逻辑抽成一个通用基类通过泛型传入 Repository 和事件回调业务方只需实现一个渲染接口。这样新页面接入对话能力只需要写几十行配置代码而不是复制整个网络层。说到底流式接入的核心不在于用哪个库、哪家模型而在于对“行”和“事件”这两个抽象的理解对不对。从那以后我每次接入新的流式接口都会强制过一遍三件事确认响应头是 text/event-stream、确认解析器按行读取、确认结束标记有独立处理。这套习惯帮我省掉了大量线上排查时间希望这篇笔记也能帮到你。本文还有配套的精品资源点击获取