
📰 科技要闻
• Apple 宣布下调中国区 App Store 佣金率,国内开发者的分成压力有望减轻。
• a16z 最新《Top 100 AI 应用报告》显示 ChatGPT 用户规模仍是 Claude 近 80 倍,但专业工具赛道(Notion、ElevenLabs 等)正在迅速崛起,AI 应用已远超传统对话框形态。
• 三星调整 Galaxy 设备底层刷机机制,未来旁加载和解锁 Bootloader 的流程将有所变化,影响开发者测试环境搭建。
前几周和一个做 B 端 App 的朋友吃饭,他说他们产品经理要求"给 App 加个 AI 助手",问我怎么弄。我问他想接哪个模型,他说"随便,哪个好用接哪个"。
好,"随便"这两个字,就是一坑的开始。
说实话,我自己第一次在 Android 里接 LLM API 也踩了不少坑——不是什么高深的算法坑,是那种很蠢的工程坑:流式输出没处理好导致 UI 卡顿,Token 超限了没有兜底,上下文一直在累积最后把 API 费用搞爆了……这些问题不难,但你第一次做确实容易一一踩到。
这篇文章就是把我踩过的坑和最终用下来还算顺手的方案整理一下,主要针对 Android 工程师——不是 AI 科普文,是工程实践。
先说选型:别一上来就卷"最强模型"
a16z 最新出的 Top 100 AI 应用报告里有个数据让我印象深:ChatGPT 的用户规模是 Claude 的近 80 倍,但 Claude 在专业工具赛道上的渗透率反而在快速上升。这个现象放到 Android 接入场景里同样成立——面向 C 端用户的通用问答,和面向 B 端专业场景的任务型 AI,选型逻辑完全不同。
我目前接触过的几个方案:
• Google Gemini API:Android 的亲儿子,有官方 SDK,文档完善,免费额度够用来做原型。如果你的 App 已经在用 Firebase,接入成本最低。
• OpenAI API(GPT 系列):生态最成熟,第三方库多,格式是事实标准。但国内访问需要代理,对 C 端 App 是硬伤。
• Claude API(Anthropic):长上下文处理能力强(200K token),复杂任务推理质量高。适合文档处理、代码审查这类专业场景。
• 国内厂商 API:阿里通义、百度文心、腾讯混元……访问稳定,部分提供免费额度,但质量参差不齐,格式上大多兼容 OpenAI 的 Chat Completion 接口。
• 端侧模型:Google 的 Gemini Nano(通过 MediaPipe / ML Kit 接入)、Qualcomm AI Hub 上的量化模型、Facebook 的 llama.cpp Android 移植。无需网络,隐私好,但能力受限。
我的判断:如果你在中国大陆做 C 端 App,老实选国内厂商 API 或端侧方案;如果是 B 端或出海,Gemini 或 Claude 都不错。别被"哪个模型最强"带跑偏,工程上先跑通,再谈替换。
而且绝大多数厂商都兼容 OpenAI Chat Completion 格式,这意味着你的客户端代码基本不用改,只换 baseUrl 和 apiKey 就能切模型——这点后面代码层会体现出来。
网络层:别直接在客户端调 API
这是第一个坑,也是最容易被忽略的。
很多教程(包括官方 Quickstart)直接把 API Key 塞进客户端,用 OkHttp 或者 Retrofit 直接调。本地 Demo 当然没问题,但一旦上生产,你的 API Key 就暴露了。任何人反编译你的 APK,或者抓包,Key 就没了。
正确的姿势是:客户端 → 你自己的后端 → LLM API。
后端可以是很轻量的 Node.js / Python 服务,专门做转发、鉴权和 Token 计量。这样你还可以在服务端做很多事:限制单用户 Token 消耗、屏蔽违规内容、缓存相同请求、A/B 测试不同模型……
如果你真的有场景需要客户端直调(比如端侧模型),那就用 Android Keystore 存 Key,不要硬编码。
流式输出:SSE 的正确打开方式
这是我踩得最惨的坑。
大模型的响应是逐 token 生成的,如果你用普通的请求-响应模式,用户要等整段回复生成完才能看到内容,体验极差。所有现代 LLM API 都支持 stream: true,用的是 Server-Sent Events(SSE)协议。
问题在于 OkHttp 默认不支持 SSE 的增量读取——它会等 response body 全部结束才回调。我当时就纳闷了半天,为啥加了 stream 参数还是没有流式效果,后来才发现得手动处理响应流。
用 OkHttp + Flow 实现流式读取
先定义数据结构:
@Serializable
data class ChatMessage(
val role: String,
val content: String
)@Serializable
data class ChatRequest(
val model: String,
val messages: List<ChatMessage>,
val stream: Boolean = true,
val max_tokens: Int = 1024
)// SSE 单条数据块
data class StreamChunk(
val delta: String, // 本次增量文本
val isDone: Boolean = false
)核心的流式请求实现:
class LlmRepository(
private val client: OkHttpClient,
private val baseUrl: String,
private val apiKey: String
) {
fun streamChat(
messages: List<ChatMessage>
): Flow<StreamChunk> = callbackFlow {
val body = Json.encodeToString(
ChatRequest(
model = "gpt-4o-mini",
messages = messages
)
).toRequestBody(
"application/json"
.toMediaType()
)val request = Request.Builder()
.url("$baseUrl/chat/completions")
.header(
"Authorization",
"Bearer $apiKey"
)
.post(body)
.build()val call = client.newCall(request)
invokeOnClose { call.cancel() }call.enqueue(object : Callback {
override fun onFailure(
call: Call,
e: IOException
) { close(e) }override fun onResponse(
call: Call,
resp: Response
) {
resp.body?.source()
?.use { source ->
while (!source.exhausted()) {
val line = source
.readUtf8Line()
?: break
if (!line.startsWith(
"data: ")) continue
val data = line
.removePrefix(
"data: ")
if (data == "[DONE]") {
trySend(StreamChunk(
"",
isDone = true
))
break
}
parseChunk(data)?.let {
trySend(it)
}
}
}
close()
}
})
awaitClose()
}private fun parseChunk(
json: String
): StreamChunk? = runCatching {
val obj = JSONObject(json)
val delta = obj
.getJSONArray("choices")
.getJSONObject(0)
.getJSONObject("delta")
.optString("content", "")
StreamChunk(delta)
}.getOrNull()
}关键点:resp.body?.source() 用的是 Okio 的 BufferedSource,readUtf8Line() 会按行阻塞读取,每读到一行 SSE 数据就推给 Flow。这样才真正做到逐 token 流式输出。
⚠️ 注意:OkHttpClient 的 readTimeout 默认 10 秒,流式请求会被截断。记得把它设为 0(无限)或者一个足够长的值,比如 60s。
ViewModel 层:状态管理不要偷懒
流式输出接进来之后,UI 层怎么消费是个问题。我见过最烂的写法是直接在 Activity 里 CoroutineScope.launch 然后更新 TextView——这种东西配置变更一旦触发,状态全丢。
正确的做法是把状态放 ViewModel,用 StateFlow 驱动 UI:
@HiltViewModel
class ChatViewModel @Inject constructor(
private val repo: LlmRepository
) : ViewModel() {data class UiState(
val messages: List<ChatMessage> =
emptyList(),
val streamingText: String = "",
val isLoading: Boolean = false,
val error: String? = null
)private val _uiState = MutableStateFlow(
UiState()
)
val uiState: StateFlow<UiState> =
_uiState.asStateFlow()private var streamJob: Job? = nullfun sendMessage(userInput: String) {
streamJob?.cancel()val newMessages = _uiState.value.messages +
ChatMessage(
role = "user",
content = userInput
)_uiState.update {
it.copy(
messages = newMessages,
streamingText = "",
isLoading = true,
error = null
)
}streamJob = viewModelScope.launch {
val sb = StringBuilder()
repo.streamChat(
trimContext(newMessages)
).catch { e ->
_uiState.update {
it.copy(
isLoading = false,
error = e.message
)
}
}.collect { chunk ->
if (chunk.isDone) {
// 流结束,把完整回复
// 追加到 messages
_uiState.update {
it.copy(
messages = it.messages +
ChatMessage(
role = "assistant",
content = sb.toString()
),
streamingText = "",
isLoading = false
)
}
} else {
sb.append(chunk.delta)
_uiState.update {
it.copy(
streamingText =
sb.toString()
)
}
}
}
}
}
}UI 里监听 streamingText 实时更新打字机效果,流结束后把它清空,完整消息追加到 messages。这样状态变更清晰,配置变更也不丢。
上下文管理:不控制就等着账单爆炸
这个问题我是被账单教育的。
LLM API 按 Token 计费,而且是输入和输出都算。多轮对话的时候,每次请求都要把完整历史 messages 带上——这意味着对话越长,每次请求的输入 Token 就越多。假设用户聊了 20 轮,第 20 次请求可能要带上前 19 轮的所有内容,输入成本是第 1 次的十几倍。
上面 ViewModel 代码里有个 trimContext(),这就是用来控制上下文长度的:
private fun trimContext(
messages: List<ChatMessage>
): List<ChatMessage> {
// 粗估 Token 数,1 token ≈ 4 字符
fun estimateTokens(
msgs: List<ChatMessage>
) = msgs.sumOf {
it.content.length / 4 + 10
}val maxInputTokens = 3000
if (estimateTokens(messages)
<= maxInputTokens) {
return messages
}// 保留 system prompt(index 0)
// + 最近 N 条消息
val systemMsg = messages
.firstOrNull {
it.role == "system"
}
val history = messages
.filter {
it.role != "system"
}
.takeLast(10) // 保留最近10条return listOfNotNull(
systemMsg
) + history
}这个是最粗暴的方案:直接截断,只保留最近 N 条。更精细的做法是用 摘要压缩——超出长度时,先让模型把前面的对话总结成一段摘要,再把摘要作为 system message 保留,这样语义损失更小。但实现复杂度也更高,看你的场景是否值得。
System Prompt 的技巧
说实话,我最开始也不信 system prompt 有多大用,觉得不就是一段描述么。后来几次对比测试下来,写好的 system prompt 能让回复质量提升一个档次,尤其是在要求固定格式输出(JSON、Markdown 列表)的场景。
几个实用的技巧:
• 明确输出格式:"你的回复必须是合法的 JSON,不要有任何额外文字"
• 限制回复长度:"回复不超过 100 字,简洁直接"
• 注入业务上下文:"你是一个 Android 开发工具,用户的问题都与 Android 开发相关"
• 防止越界:"你只回答与 [业务领域] 相关的问题,其他问题礼貌拒绝"
Compose UI:打字机效果实现
最后说 UI 层。Compose 实现打字机效果其实很简单,StateFlow 的增量更新天然就适合做这个:
@Composable
fun ChatScreen(
viewModel: ChatViewModel =
hiltViewModel()
) {
val state by viewModel.uiState
.collectAsStateWithLifecycle()
val listState = rememberLazyListState()// 新消息时自动滚到底
LaunchedEffect(
state.messages.size,
state.streamingText
) {
if (state.messages.isNotEmpty()) {
listState.animateScrollToItem(
state.messages.size
)
}
}LazyColumn(state = listState) {
items(state.messages) { msg ->
MessageBubble(message = msg)
}
// 流式输出中的气泡
if (state.isLoading ||
state.streamingText.isNotEmpty()
) {
item {
StreamingBubble(
text = state.streamingText
)
}
}
}
}@Composable
fun StreamingBubble(text: String) {
// 光标闪烁动画
val cursorAlpha by
rememberInfiniteTransition()
.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(500),
repeatMode = RepeatMode.Reverse
)
)
Text(
text = text + "▋",
color = Color.Black.copy(
alpha = cursorAlpha
)
)
}一个小细节:流式更新时不要用 LazyColumn 的 key,否则每次 streamingText 变化都会触发 item 重组,滚动会抖动。
错误处理与降级策略
LLM API 不稳定是常态,尤其是高峰期。必须做好错误处理:
• 429 Rate Limit:指数退避重试,最多 3 次
• 500/503 服务故障:提示用户稍后重试,不要静默失败
• 超时(网络慢):给用户进度提示,允许取消
• Content Filter 拒绝:捕获特定错误码,给友好提示
流的取消已经在 ViewModel 里处理了(streamJob?.cancel()),用户发新消息或者按取消按钮时,OkHttp 的 call 会被取消,不会有僵尸请求泄漏。
a16z 报告里有个观点让我印象很深:未来两年内,任何一个新产品如果没有"立刻让用户感觉它懂我",就会给人留下坏印象——"新用户引导"这个概念本身会消失。对 Android 工程师来说,这意味着 AI 功能不能只是接上 API 就完事,上下文记忆、个性化、连贯的多轮对话,这些工程细节才是真正区分产品体验的地方。
性能注意事项
主线程安全
callbackFlow 里的 OkHttp 回调在 OkHttp 的 IO 线程上执行,trySend 是线程安全的。ViewModel 里用 viewModelScope,默认调度器是 Dispatchers.Main.immediate,StateFlow 的 update 会自动分发到主线程。这条链路是安全的,不需要手动切线程。
高频更新的节流
有些模型吐 token 很快,每 50ms 就来一个 chunk,导致 StateFlow 高频 emit,Compose 重组也非常频繁。如果发现有掉帧,可以加个节流:
repo.streamChat(messages)
.sample(50) // 每 50ms 取最新值
.collect { chunk -> ... }不过 sample 会丢弃中间值,要在 collect 之前拿到完整字符串才能用(而不是增量)。我的做法是在 Repository 层改成每次 emit 都包含当前完整文本,而不是 delta,这样 sample 就没有信息损失了。
整体架构回顾
把上面的东西串起来,整体分层大概是这样:
• UI 层(Composable):订阅 StateFlow,展示消息列表和流式气泡,处理用户输入
• ViewModel:维护对话状态,管理流式 Job 生命周期,控制上下文长度
• Repository:封装 LLM API 调用,返回 Flow<StreamChunk>
• Network 层:OkHttp + Okio 流式读取,解析 SSE 格式
• 后端代理(推荐):转发请求,保护 API Key,计量 Token
这套架构模型无关,换 Gemini、换 Claude、换国内厂商,只需改 Repository 里的 baseUrl 和 model 参数,其他层完全不动。
接下来我想研究一下 Function Calling——让模型决定什么时候调用 Android 本地能力(查日历、发通知、读传感器),这才是真正意义上的"AI Native App",而不只是包了个对话框。这个坑估计不小,慢慢踩。