4 天搭出 Android AI 聊天 App
1. 为什么做这个项目
我在 Meta 做了 9 年多 Android 开发。
Meta 内部移动开发环境很成熟:代码库、构建系统、依赖管理、review 流程、实验系统、发布链路、监控和基础设施都有自己的内部平台、工具和框架。一方面,这让我们的工作很便利;另一方面,我也担心自己会被 Meta 工具链保护得太好,离开这套环境后对外部 Android 开发的真实摩擦不够敏感。
今年我开始大量使用 agentic coding。但这仍然发生在 Meta 的工程语境里。我想知道这套工作方式离开 Meta 之后还能不能成立:哪些地方会变快,哪些地方会暴露新的成本?这些问题必须亲手做一遍才知道。
我选择做一个 ChatGPT 风格的 AI 聊天应用,原因很简单:我每天都用它,也足够熟悉它的交互。它自然覆盖了很多 Android 设计模式:列表和输入框、会话导航、异步状态、流式更新、本地持久化、网络边界、图片选择、后台状态、通知、错误处理和测试。它不是一个刻意设计出来的玩具项目;只要稍微往前做几步,就会碰到真实移动应用里常见的状态和生命周期问题。
一开始我只打算写一个聊天界面,借它熟悉外部 Android 开发流程。但 Codex 的推进速度比我预期快很多:于是范围不断往外推,从单聊天到多会话,从内存状态到 Room 持久化,从假回复到 OpenAI 流式回复,从文字聊天到图片草稿和多模态发送。这个项目也从一个聊天界面长成了一个小而完整的 AI 聊天应用。
2. 时间线
- 4 天,194 个 commit,34,205 行代码变更
- 35 份设计文档,12 份是主开发阶段的实现文档
- 116 个单元测试,40 个集成测试
- Codex 会话记录约 5.46 亿 token:输入 token 约 5.44 亿,其中缓存输入约 5.26 亿,输出 token 约 168 万
Day 1:聊天 MVP、会话列表、单活跃响应模型
- 用硬编码的内存流式回复跑通最小聊天闭环。
- 建立内存里的会话数据模型,跑通 MVVM 架构。
- 加上会话列表、会话切换和导航界面。
Day 2:持久化数据层、网络层、真实 OpenAI 接入
- 定义重试规则,明确重试某条消息时后续对话如何处理。
- 接入 Room,建立持久化数据层,让会话和消息可以跨重启恢复。
- 接入真正的 OpenAI API,补上网络层,验证真实流式回复。
Day 3:依赖注入、自动验证、对话历史交互
- 用 Hilt 把依赖注入从 Activity 里移出去。
- 把 Detekt、ktfmt、单元测试和真机集成测试收进统一验证流程。
- 提示词编辑:支持编辑发过的提示词。
Day 4:多会话后台响应、图片草稿、多模态发送、会话完成通知推送
- 允许会话在后台运行,用户可以切到另一个会话继续聊。
- 支持图片上传,从而支持多模态处理。
- 加入后台完成通知,处理前台、后台、冷启动和通知点击时应该回到哪个会话。
12 个阶段每个阶段都尽量独立,只推进一组相关能力:先跑通对话界面,再设计数据模型,再接持久化和真实流式回复,再补依赖注入和自动验证,最后处理多会话、多模态和生命周期。这样才能控制复杂度,避免需求范围失控。
3. 与 Codex 协作
最初我让 Codex 先为一个 AI 聊天应用写整体计划。很快我发现,即便只是聊天界面,也需要先定义大量边界。直接硬上也能产出代码,但后面很难验证它有没有写在正确方向上,也难以进一步扩展。
于是我先写一篇总计划,里面包含若干阶段:先做哪些,哪些后做,哪些东西只保留在设计里。之后每个阶段再单独写两份文档:一份设计计划,一份实现计划。
- 设计计划主要回答高层架构问题:状态怎么建模,边界在哪里,哪些方案不做。
- 实现计划则更具体,要能支持我逐条审阅:改哪些文件,按什么顺序做,怎么验证,什么情况下算完成。
- 我写实现计划时也刻意参考了 OpenAI 关于 execution plans 和 Codex best practices 的建议,确保每个阶段至少写清楚四件事:goal、context、constraints、done when。
这些文档的主要作用是管理上下文,让 Codex 每次只在一个相对清楚的范围里工作,减少它把下一阶段的复杂度提前带进来。
从会话数据看,真正让 Codex 写代码的时间并不长。36 个核心构建会话里,计划、审阅和文档约 27 小时,编码、测试和修复约 11.3 小时。这个数字不等于纯手工投入,但能说明重心在哪里:大量时间花在审阅、修改和收束设计计划/实现计划上。
可不可以少写一些计划?我很怀疑。模型能力会继续增强,但移动开发里很多决定仍然需要人工输入:产品流程、架构选型、状态边界、生命周期处理、验证标准。场景、需求、技术栈稍微变一下,最后实现都会差很多。没有这些上下文,Codex 可以很快写出代码,但很难保证写的是当前阶段真正需要的代码。
4. 应用架构
这个项目最后已经超出了聊天气泡 Demo 的范围。它有了一个现代 Android 应用常见的骨架:Compose UI、ViewModel、可测试的状态更新、本地持久化、真实网络层、依赖注入、多会话后台响应、多模态附件和设备端验证。
- View 层:Jetpack Compose,负责聊天界面、会话列表、图片草稿和后台完成提示。
- ViewModel 层:暴露界面状态,并把用户操作、模型回复、持久化读写和通知事件串起来。真正的状态变化尽量放进 reducer 里处理,这样发送消息、接收流式片段、取消回复、切换会话、编辑提示词这些行为都能用单元测试覆盖。
- Model 层:承接应用状态和产品语义,Room 负责本地持久化。会话、消息、附件草稿和回复状态都需要跨重启恢复。
- 网络层:可以接测试用的内存流式回复,也可以接 OkHttp 和真实 OpenAI API。文本回复走流式接口,图片附件先走本地草稿,再进入上传和多模态发送。
- 多会话:Day 1 已经有会话列表,但当时仍然只有一个活跃回复。到 Day 4,回复状态按会话跟踪:一个会话在后台继续生成,用户可以切到另一个会话继续操作。后台完成后只发通知,不自动切换用户当前正在看的会话。
- 测试/验证:随着功能增多,验证范围也越来越大。最后的检查包含 Detekt、ktfmt、Room DAO 测试、Hilt smoke test、Compose 设备端集成测试,以及少量手动真机/模拟器检查。
5. 关键实现
这一节记录几个已经落进应用的实现点:数据模型、流式回复、通知、Room 和 Hilt。
5.1 核心数据模型
对话历史的核心模型并不复杂:Conversation 只保存列表需要的元信息,消息用 UserMessage 和 GptMessage 分开建模,助手消息额外带状态。
sealed interface ChatMessage {
val id: String
val conversationId: String
val content: String
val createdAtMillis: Long
}
data class UserMessage(
override val id: String,
override val conversationId: String,
override val content: String,
override val createdAtMillis: Long,
) : ChatMessage
data class GptMessage(
override val id: String,
override val conversationId: String,
override val content: String,
val status: GptMessageStatus,
override val createdAtMillis: Long,
) : ChatMessage
enum class GptMessageStatus {
Streaming,
Complete,
Error,
Canceled,
}
data class GptMessageRef(
val conversationId: String,
val gptMessageId: String,
)
几个细节会影响后面的实现:
- 每条消息都带
conversationId,多会话后台回复时不会把增量片段写到错误会话。 GptMessageStatus把流式中、完成、失败、取消变成明确状态,UI、Room 和测试都能对齐。GptMessageRef同时带会话 id 和助手消息 id,用来定位正在流式更新的那条消息。
5.2 流式回复:同一接口,多个实现
模型层只有一个接口:
interface ModelService {
val mode: ModelServiceMode
fun streamReply(request: ModelRequest): Flow<ModelStreamEvent>
}
sealed interface ModelStreamEvent {
data class Delta(val text: String) : ModelStreamEvent
data class Failure(val message: String) : ModelStreamEvent
data object Complete : ModelStreamEvent
}
测试和演示用的内存实现也走这个接口。比如快速成功版本会分块发出 Delta,最后发 Complete;慢速版本专门用来观察取消;失败版本直接发 Failure。
代码:FakeFastStreamingModelService.kt
override fun streamReply(request: ModelRequest): Flow<ModelStreamEvent> = flow {
val chunks =
listOf(
"This is a fake ",
"streaming response ",
"from the local GPT service. ",
"It arrives ",
"chunk by chunk, ",
)
for (chunk in chunks) {
delay(chunkDelayMillis)
emit(ModelStreamEvent.Delta(chunk))
}
emit(ModelStreamEvent.Complete)
}
真实 OpenAI 实现也返回同一种 Flow<ModelStreamEvent>。它用 Kotlin callbackFlow 把 OkHttp 回调包起来,逐行读取 SSE,并在 Flow 取消时取消底层 HTTP 请求。
代码:OpenAiStreamingModelService.kt
override fun streamReply(request: ModelRequest): Flow<ModelStreamEvent> = callbackFlow {
val call = client.newCall(buildHttpRequest(request))
call.enqueue(
object : Callback {
override fun onFailure(call: Call, e: IOException) {
trySend(ModelStreamEvent.Failure(e.message ?: "OpenAI network error"))
close()
}
override fun onResponse(call: Call, response: Response) {
response.use { httpResponse ->
if (!httpResponse.isSuccessful) {
trySend(ModelStreamEvent.Failure(httpResponse.toFailureMessage()))
close()
return
}
val source = httpResponse.body.source()
while (!source.exhausted() && !call.isCanceled()) {
val line = source.readUtf8Line() ?: break
parser.parseSseLine(line)?.let { event ->
trySend(event)
if (event is ModelStreamEvent.Failure || event is ModelStreamEvent.Complete) {
close()
return
}
}
}
}
close()
}
}
)
awaitClose { call.cancel() }
}
ViewModel 这边只收统一事件:Delta 追加文本,Complete 收尾,Failure 写入错误状态。这样内存模型和 OpenAI 模型可以共用同一条状态更新路径。
modelService
.streamReply(ModelRequest(messages = requestMessages, attachments = attachmentRefs))
.collect { event ->
when (event) {
is ModelStreamEvent.Delta -> appendToGptMessage(streamingRef, event.text)
ModelStreamEvent.Complete -> finishGptMessage(streamingRef, GptMessageStatus.Complete)
is ModelStreamEvent.Failure -> failGptMessage(streamingRef, event.message)
}
}
5.3 流式回复如何更新 UI
流式回复开始时,ViewModel 会先创建一条空的助手消息,状态是 Streaming。后续每个 Delta 都通过 GptMessageRef(conversationId, gptMessageId) 定位同一条助手消息,把文本追加到它的 content 上,避免把每个片段都渲染成新的气泡。
这里有两个关键点:
GptMessageRef同时带会话 id 和消息 id,所以后台会话继续生成时,不会把增量片段写到当前打开的会话里。- UI 列表用 message id 作为 key,所以 Compose 会更新同一条助手气泡,避免列表不断插入新的气泡。
ViewModel 收到 delta 后先确认这条回复仍然是该会话当前运行中的回复,再更新 UI state:
private fun appendToGptMessage(
streamingRef: GptMessageRef,
delta: String,
) {
if (_uiState.value.runningGptMessages[streamingRef.conversationId] != streamingRef) return
_uiState.update { it.withAppendedGptDelta(streamingRef, delta) }
}
真正的列表更新放在 reducer 里。它只替换目标会话里的目标助手消息,然后把新的消息列表写回 messagesByConversationId:
private fun ChatUiState.updateStreamingGptMessage(
streamingRef: GptMessageRef,
transform: (GptMessage) -> GptMessage,
): ChatUiState {
val currentMessages = messagesByConversationId[streamingRef.conversationId] ?: return this
val updatedMessages =
currentMessages
.map { message ->
if (message is GptMessage && message.id == streamingRef.gptMessageId) {
transform(message)
} else {
message
}
}
.toPersistentList()
return copy(
messagesByConversationId =
messagesByConversationId.put(streamingRef.conversationId, updatedMessages),
)
}
Compose 这边只读取当前选中会话的消息列表。LazyColumn 用消息 id 做稳定 key;助手气泡根据 GptMessageStatus.Streaming 决定是否显示流式圆点:
LazyColumn(
state = listState,
) {
items(messages, key = { it.id }) { message ->
when (message) {
is UserMessage -> UserMessageRow(message = message, ...)
is GptMessage -> GptMessageRow(message = message, ...)
}
}
}
@Composable
private fun AssistantMessageBubble(message: GptMessage) {
Row {
if (message.content.isNotBlank()) {
Text(text = message.content)
}
if (message.status == GptMessageStatus.Streaming) {
StreamingDots()
}
}
}
所以完整路径是:ModelService 发出 Delta,ViewModel 用 GptMessageRef 找到正在更新的消息,reducer 产出新的 ChatUiState,Compose 根据稳定 message id 重组同一条气泡。Complete、Failure 和 Canceled 只改变终态;Room 只保存稳定下来的消息状态,不把临时流式过程当成持久化事实。
5.4 前台提示和系统通知
多会话后台回复完成后,应用内 Snackbar 和系统通知走两套提示:
- 应用在前台时,
backgroundUpdateNoticeConversationId触发 Snackbar。用户可以点Open切到完成的会话。 - 应用在后台时,ViewModel 只在回复成功完成后调用
BackgroundCompletionNotifier。 - 系统通知的 PendingIntent 带会话 id,点击后回到对应会话。
前台提示是 Compose 里的一个副作用:
@Composable
private fun BackgroundUpdateSnackbarEffect(
conversationId: String?,
conversationTitle: String?,
snackbarHostState: SnackbarHostState,
onNoticeConsumed: () -> Unit,
onOpenConversation: (String) -> Unit,
) {
LaunchedEffect(conversationId) {
val noticeConversationId = conversationId ?: return@LaunchedEffect
val result =
snackbarHostState.showSnackbar(
message = "Response finished in ${conversationTitle ?: "chat"}",
actionLabel = "Open",
duration = SnackbarDuration.Short,
)
onNoticeConsumed()
if (result == SnackbarResult.ActionPerformed) {
onOpenConversation(noticeConversationId)
}
}
}
后台通知则由 ViewModel 判断是否应该发:
private fun maybeNotifyBackgroundCompletion(
conversation: Conversation,
gptMessage: GptMessage,
status: GptMessageStatus,
) {
if (status != GptMessageStatus.Complete) return
if (appForegroundTracker.isForeground) return
val preview = gptMessage.content.notificationPreview()
if (preview.isBlank()) return
backgroundCompletionNotifier.notifyCompletion(
BackgroundCompletionNotification(
conversationId = conversation.id,
title = conversation.title.ifBlank { RESPONSE_READY_TITLE },
preview = preview,
)
)
}
系统通知实现还要处理权限、notification channel 和点击路由:
代码:BackgroundCompletionNotifier.kt
override fun notifyCompletion(notification: BackgroundCompletionNotification) {
if (
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
return
}
ensureChannel()
val androidNotification =
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(notification.title)
.setContentText(notification.preview)
.setStyle(NotificationCompat.BigTextStyle().bigText(notification.preview))
.setContentIntent(launchIntentFactory.create(notification.conversationId))
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
NotificationManagerCompat.from(context)
.notify(notification.conversationId.hashCode() and Int.MAX_VALUE, androidNotification)
}
重点不在通知 API 本身。前后台状态、通知权限、通知 channel、冷启动路由、会话选择,以及用户当前上下文不能被后台完成强行打断,这些边界必须同时成立。
5.5 Room 持久化
Room 层只保存已经稳定下来的产品语义:会话、消息、附件引用和终态回复。messages 表通过 conversation_id 关联会话,附件再通过 message_id 关联用户消息。
@Entity(
tableName = "messages",
foreignKeys =
[
ForeignKey(
entity = ConversationEntity::class,
parentColumns = ["id"],
childColumns = ["conversation_id"],
onDelete = ForeignKey.CASCADE,
)
],
indices = [Index(value = ["conversation_id", "created_at_millis"])],
)
data class MessageEntity(
@PrimaryKey val id: String,
@ColumnInfo(name = "conversation_id") val conversationId: String,
@ColumnInfo(name = "role") val role: MessageRoleEntity,
@ColumnInfo(name = "content") val content: String,
@ColumnInfo(name = "gpt_status") val gptStatus: GptMessageStatusEntity?,
@ColumnInfo(name = "created_at_millis") val createdAtMillis: Long,
)
重试和编辑提示词依赖 DAO transaction,ViewModel 不需要手动拼多步数据库操作:
代码:ChatDao.kt
@Transaction
suspend fun persistRegenerationStart(
conversationId: String,
targetGptMessageId: String,
oldTargetCreatedAtMillis: Long,
preview: String,
) {
deleteMessagesCreatedAfter(
conversationId = conversationId,
createdAfterMillis = oldTargetCreatedAtMillis,
)
deleteGptMessage(
conversationId = conversationId,
messageId = targetGptMessageId,
)
updateConversationPreview(
conversationId = conversationId,
preview = preview,
)
}
生产环境的 Room 接入保持很小:Hilt 提供数据库、DAO,再把 ChatRepository 绑定成 ChatPersistence。
@Module
@InstallIn(SingletonComponent::class)
object DataProvidesModule {
@Provides
@Singleton
fun provideChatDatabase(@ApplicationContext context: Context): ChatDatabase {
return Room.databaseBuilder(
context,
ChatDatabase::class.java,
"chatgpt-lab.db",
)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
}
@Provides
fun provideChatDao(database: ChatDatabase): ChatDao {
return database.chatDao()
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class DataBindsModule {
@Binds
@Singleton
abstract fun bindChatPersistence(repository: ChatRepository): ChatPersistence
}
5.6 Hilt 生产/测试替换
Hilt 让生产环境和测试环境走同一套应用结构,同时替换边界依赖。生产环境里,模型服务映射表会根据 OpenAI API key 是否存在决定是否暴露真实 OpenAI 模式:
@Provides
@Singleton
fun provideModelServices(
fakeFastStreamingModelService: FakeFastStreamingModelService,
fakeLongStreamingModelService: FakeLongStreamingModelService,
fakeFailingModelService: FakeFailingModelService,
openAiStreamingModelService: OpenAiStreamingModelService,
openAiStreamingConfig: OpenAiStreamingConfig,
): PersistentMap<ModelServiceMode, @JvmSuppressWildcards ModelService> {
val services =
persistentMapOf<ModelServiceMode, ModelService>(
ModelServiceMode.FakeFast to fakeFastStreamingModelService,
ModelServiceMode.FakeLong to fakeLongStreamingModelService,
ModelServiceMode.FakeFail to fakeFailingModelService,
)
return if (openAiStreamingConfig.apiKey.isNotBlank()) {
services.put(ModelServiceMode.OpenAi, openAiStreamingModelService)
} else {
services
}
}
测试环境则用 Hilt 测试模块换成 in-memory Room 和脚本化模型服务:
@Module
@InstallIn(SingletonComponent::class)
object TestDataModule {
@Provides
@Singleton
fun provideChatDatabase(): ChatDatabase {
return Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
ChatDatabase::class.java,
)
.allowMainThreadQueries()
.build()
}
@Provides
fun provideChatDao(database: ChatDatabase): ChatDao {
return database.chatDao()
}
}
@Module
@InstallIn(SingletonComponent::class)
object TestModelServiceModule {
@Provides
@Singleton
fun provideModelServices(): PersistentMap<ModelServiceMode, @JvmSuppressWildcards ModelService> {
return persistentMapOf(
ModelServiceMode.FakeFast to ScriptedModelService(ModelServiceMode.FakeFast),
ModelServiceMode.FakeLong to ScriptedModelService(ModelServiceMode.FakeLong),
ModelServiceMode.FakeFail to ScriptedModelService(ModelServiceMode.FakeFail),
)
}
}
这让 Compose/Hilt/Room 集成测试可以验证真实 Android 组件协作,同时不依赖网络、不消耗 OpenAI API,也不会把通知路径变成不可观测的系统副作用。
6. 测试 & 验证
这个项目一开始不需要复杂验证。最初的目标只是跑通聊天界面,所以硬编码内存流式回复已经够用。
功能往外扩之后,测试和验证也被一层层补上:从初期的单元测试,到后面的 Android 集成测试,最后用 agent + adb 检查应用生命周期和真实交互:
- 单元测试:覆盖 ViewModel、repository、流解析器、OpenAI 流式回复等具体组件。
- Android 集成测试:覆盖 UI 交互、Hilt 集成、Room 数据库操作、Activity 启动和端到端测试。
- adb/emulator smoke:覆盖视觉节奏、权限弹窗、前后台切换、通知点击和冷启动路由这些传统自动化测试难以覆盖的流程。
到当前版本,代码库里有 116 个 JVM 单元测试和 40 个集成测试。它们覆盖了主要的应用路径:对话历史、Room 写入、Hilt 替换、模型流式回复、图片草稿、后台通知,都开始进入自动验证。
以下面的集成测试为例,它通过操作 Compose UI,走 Hilt 测试模块、脚本化模型服务和 UI 语义树,验证“编辑早期提示词会删除下游对话并重新生成回答”。
代码:MainActivityHiltSmokeTest.kt
@Test
fun promptEditUpdatesPromptDeletesDownstreamAndStreamsFreshAnswer() {
ActivityScenario.launch(MainActivity::class.java).use {
// 先构造两轮对话,让后续历史可以被提示词编辑删除。
sendPrompt("edit original")
waitForSingleConversationMessages("edit original", "edit answer 1")
sendPrompt("downstream follow up")
waitForSingleConversationMessages(
"edit original",
"edit answer 1",
"downstream follow up",
"downstream answer",
)
// 通过真实 Compose UI 编辑第一条用户提示词。
composeRule
.onNodeWithTag(ChatTestTags.editUserPromptButton(firstUserMessageId()))
.performClick()
composeRule.onNodeWithTag(ChatTestTags.PromptInput).performTextClearance()
composeRule.onNodeWithTag(ChatTestTags.PromptInput).performTextInput("edited original")
composeRule.onNodeWithTag(ChatTestTags.SendOrCancelButton).performClick()
// 下游对话被删除,当前会话只保留编辑后的提示词和新回答。
waitForSingleConversationMessages("edited original", "edited answer")
composeRule.onAllNodesWithText("downstream follow up").assertCountEquals(0)
assertThat(singleConversationMessages().map { it.content })
.containsExactly("edited original", "edited answer")
}
}
从第 7 阶段开始,我把静态检查、格式化、单元测试和集成测试收进两个 Gradle 验证任务:
tasks.register("checkAgentic") {
group = "verification"
description = "Runs Android Lab static checks, unit tests, and connected Android tests for agentic changes."
dependsOn(
":app:lintDebug",
":app:detektMain",
":app:detektTest",
":app:ktfmtCheck",
":app:testDebugUnitTest",
":app:connectedDebugAndroidTest",
)
}
tasks.register("checkAgenticFast") {
group = "verification"
description = "Runs the fastest Android Lab static check for local agentic iteration."
dependsOn(
":app:detektMain",
":app:detektTest",
":app:ktfmtCheck",
)
}
checkAgenticFast 用来快速静态检查。checkAgentic 更重,适合有 UI、Room、Hilt 或生命周期影响的改动。这组验证任务建好后,Codex 的工作方式也变了:它会在同一个会话里更频繁地跑验证、读失败、修测试,再交付一个更完整的结果。
我后来还把一套调试/修复习惯写进了 AGENTS.md 的 Failure Repair Loop:先用最小命令或最短手动路径复现问题,再写出一个具体假设,补最小诊断信号,确认后回到负责这一层的代码里修。比如状态问题回到 reducer 或 ViewModel,持久化问题回到 repository / DAO / mapper,Hilt 问题回到 module,Compose 交互问题回到 view 和集成测试。
这套流程和测试本身一样重要。长会话里,Codex 看到失败后不需要我逐条解释日志;它可以沿着固定流程复现、定位、修复、重跑原始失败和完整验证。它仍然需要人判断边界和语义,但机械调试和修补的人工介入明显变少。
引入自动化验证之后,和 Codex 的交互也产生了明显变化:
- 自动化验证之前,偏实现的会话平均 16.25 个 prompt;之后降到 8 个。
- 自动化验证之前,明显后续纠偏平均 1.5 次;之后降到 0.33 次。
这组数字不能简单理解成“测试越多,agent 就越聪明”。更准确地说,验证边界清楚以后,Codex 更容易知道什么时候算完成;我也更容易判断它交回来的代码是否能继续往下迭代。很多原本需要我手动复现、读日志、指出修复方向的工作,被测试、验证任务和修复循环提前吸收掉了。
不过移动应用不能只靠 Gradle 验证任务。很多问题只有放到设备上才明显:
- 流式回复圆点的动画节奏是否自然。
- 图片草稿选择、预览、取消是否连贯。
- 通知权限弹窗是否打断启动路径。
- 应用从后台回来后,当前会话是否被错误切走。
- 通知点击冷启动后,是否落到正确会话。
所以第三层验证没有放进 checkAgentic,但它在后续阶段一直存在。典型流程是安装调试包、用 adb 启动、切前后台、截图或查看 UIAutomator dump,再结合 logcat 判断问题。它比单元测试和集成测试慢,也更吃 token,但能替代一部分原本必须手工点一遍的移动端 QA。
因为这是一个小型实验应用,我没有去使用更重的白盒端到端移动端验证 CLI。对这个实验应用来说,这三层验证已经覆盖主要风险:单元测试测语义,集成测试测 Android 集成,少量 adb/emulator 冒烟验证测设备体验。专门构建验证 CLI 在这里并不划算。
7. The Good, the Bad and the Ugly
7.1 The Good:变快的部分
Codex 加速最多的是机械实现、测试补齐和文档同步。
在 agentic coding 工作流下,这个项目用了 4 天、38 个工时、36 个核心构建会话。其中计划、审阅和文档约 27 小时,编码、测试和修复约 11.3 小时。
如果不用 Codex,由一个熟悉 Android 的工程师全职连续做,做到这个质量(设计文档、阶段计划、Room / Hilt / OpenAI 流式回复、图片附件、后台通知、116 个单元测试、40 个集成测试,以及后面的验证体系),大概需要 6-9 周。如果只做一个主路径演示版,把文档、测试、边界场景和验证体系大幅砍掉,也需要 2-4 周以上。
当设计边界已经清楚时,把实现计划变成 Kotlin / Room / Hilt / Compose / 测试代码非常快。reducer、mapper、entity、DI wiring、测试断言、README、设计文档这些横向同步,也很适合交给 agent。很多以前容易被拖延的工程卫生工作,比如补测试、跑格式化、修 lint / detekt、同步实现计划,现在都变得很便宜。
建起来自动化验证之后,Codex 可以在同一个长会话里完成“实现、跑验证、读失败、修复、再跑验证”的自反馈循环。这部分工作过去经常需要我自己手动处理;现在只要边界清楚,它可以自己修复很多机械问题。长会话也让并行执行变得可行:我可以同时开 3 个会话,分别给计划、让它们执行,再逐个验收。类似的多会话工作流,在不少 agentic coding 实践分享里也反复出现。
7.2 The Bad:变重的部分
Codex 没有被压缩掉的是架构判断:
- 每个阶段包含什么,哪些事情应该留到后面。
- 数据模型应该包含哪些字段。
- 对话历史到底采用线性模型、分支模型,还是版本模型。
- 什么状态应该持久化,什么只应该留在 UI 状态。
- 提示词编辑是简单改一行文本,还是要截断下游历史并重新生成。
- 背景完成应该自动切回原会话,还是只提示用户。
这里最值得强调的是:由于 agentic coding 大幅压缩了代码时间,设计审查和代码评审的密度会显著上升。以前可能一两周才会发生一次的大块架构审查,现在一天里就可能出现好几次。代码写得更快,人的判断没有变少,只是被压缩到更短时间里,这会明显增加心智负担。这也是为什么很多人在使用 agentic coding 之后,反而觉得更累。
这些判断没有因为 agentic coding 变简单。某些地方甚至更容易退化:Codex 也倾向于过度设计,需要不停地简化、再简化。如果放任它沿着“完整性”往前补,很容易出现一堆看似合理、实际用处不大的字段、状态和抽象。如果计划写得太宽,范围会扩张得更快;如果非目标写得不够清楚,当前阶段很容易背上下一阶段的复杂度。人的工作没有消失,只是从“手写每一行代码”更多转向“定义边界、审计划、删复杂度、判断验证是否足够”。
7.3 The Ugly:未来的压力
agentic coding 会让明确边界内的实现变得便宜,但移动开发的核心复杂度不会消失。它只是改变了公司的用人方式和工程师的价值位置。
传统项目里一个 Staff、几个 Senior、几个 Junior 花几个月做的事,现在一个 Staff 开几个 Codex 会话,可能一个月内就能做出接近甚至更好的结果。那公司还会保留多少招聘动力?如果不招 Junior,新人又从哪里来?
所以岗位数量可能被压缩,但留下来的移动工程师需要覆盖更高杠杆的工作,比如:
- 把模糊的产品目标拆解成可实现、可验证、自包含的阶段。
- 为 agent 写出足够严格的实现计划和完成标准。
- 构建高覆盖的自动化验证,让 agent 进入自反馈循环,成功完成长会话任务。
所以我不觉得 agentic coding 会让移动工程能力失去价值。更可能发生的是:低杠杆的实现工作会变少,高密度的架构判断、计划审查和验证设计会变得更重要。移动开发者需要适应的重点,会从写更多代码,转向更频繁地为代码生成的方向负责。
这也回答了第一节里的问题:外部 Android 的工具链、依赖管理、验证环境和 Meta 内部工具链都不一样,但 agentic workflow 的核心模式没有变:找到目标,拆解目标到各个阶段,再写设计和实现计划,执行中不断把人工检查变成自动化验证,最后验收。这个 4 天实验验证了这套模式在外部工具链下依然成立。
标签: android, Codex, Agentic Coding, architecture, AI