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 万

4 天时间线:每天的阶段主题、commit 数和用户可见能力

Day 1:聊天 MVP、会话列表、单活跃响应模型

  • 用硬编码的内存流式回复跑通最小聊天闭环。
  • 建立内存里的会话数据模型,跑通 MVVM 架构。
  • 加上会话列表、会话切换和导航界面。

Day 2:持久化数据层、网络层、真实 OpenAI 接入

  • 定义重试规则,明确重试某条消息时后续对话如何处理。
  • 接入 Room,建立持久化数据层,让会话和消息可以跨重启恢复。
  • 接入真正的 OpenAI API,补上网络层,验证真实流式回复。
Day 2:持久化会话历史和真实 OpenAI 流式回复。

Day 3:依赖注入、自动验证、对话历史交互

  • 用 Hilt 把依赖注入从 Activity 里移出去。
  • 把 Detekt、ktfmt、单元测试和真机集成测试收进统一验证流程。
  • 提示词编辑:支持编辑发过的提示词。

Day 4:多会话后台响应、图片草稿、多模态发送、会话完成通知推送

  • 允许会话在后台运行,用户可以切到另一个会话继续聊。
  • 支持图片上传,从而支持多模态处理。
  • 加入后台完成通知,处理前台、后台、冷启动和通知点击时应该回到哪个会话。
Day 4:多模态发送和后台响应处理。

12 个阶段每个阶段都尽量独立,只推进一组相关能力:先跑通对话界面,再设计数据模型,再接持久化和真实流式回复,再补依赖注入和自动验证,最后处理多会话、多模态和生命周期。这样才能控制复杂度,避免需求范围失控。

3. 与 Codex 协作

最初我让 Codex 先为一个 AI 聊天应用写整体计划。很快我发现,即便只是聊天界面,也需要先定义大量边界。直接硬上也能产出代码,但后面很难验证它有没有写在正确方向上,也难以进一步扩展。

于是我先写一篇总计划,里面包含若干阶段:先做哪些,哪些后做,哪些东西只保留在设计里。之后每个阶段再单独写两份文档:一份设计计划,一份实现计划。

Codex 协作流程:整体计划、拆阶段、设计计划、实现计划、执行验证、回写计划

  • 设计计划主要回答高层架构问题:状态怎么建模,边界在哪里,哪些方案不做。
  • 实现计划则更具体,要能支持我逐条审阅:改哪些文件,按什么顺序做,怎么验证,什么情况下算完成。
  • 我写实现计划时也刻意参考了 OpenAI 关于 execution plansCodex best practices 的建议,确保每个阶段至少写清楚四件事:goal、context、constraints、done when。

这些文档的主要作用是管理上下文,让 Codex 每次只在一个相对清楚的范围里工作,减少它把下一阶段的复杂度提前带进来。

从会话数据看,真正让 Codex 写代码的时间并不长。36 个核心构建会话里,计划、审阅和文档约 27 小时,编码、测试和修复约 11.3 小时。这个数字不等于纯手工投入,但能说明重心在哪里:大量时间花在审阅、修改和收束设计计划/实现计划上。

可不可以少写一些计划?我很怀疑。模型能力会继续增强,但移动开发里很多决定仍然需要人工输入:产品流程、架构选型、状态边界、生命周期处理、验证标准。场景、需求、技术栈稍微变一下,最后实现都会差很多。没有这些上下文,Codex 可以很快写出代码,但很难保证写的是当前阶段真正需要的代码。

4. 应用架构

这个项目最后已经超出了聊天气泡 Demo 的范围。它有了一个现代 Android 应用常见的骨架:Compose UI、ViewModel、可测试的状态更新、本地持久化、真实网络层、依赖注入、多会话后台响应、多模态附件和设备端验证。

ChatGPT 风格 Android 应用 MVVM 分层架构:界面层、界面状态、仓库、应用状态模型、本地持久化、网络层和平台能力

  • 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 只保存列表需要的元信息,消息用 UserMessageGptMessage 分开建模,助手消息额外带状态。

代码:ChatMessage.kt

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 流式回复:同一接口,多个实现

模型层只有一个接口:

代码:ModelService.kt

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 模型可以共用同一条状态更新路径。

代码:ChatViewModel.kt

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:

代码:ChatViewModel.kt

private fun appendToGptMessage(
    streamingRef: GptMessageRef,
    delta: String,
) {
  if (_uiState.value.runningGptMessages[streamingRef.conversationId] != streamingRef) return
  _uiState.update { it.withAppendedGptDelta(streamingRef, delta) }
}

真正的列表更新放在 reducer 里。它只替换目标会话里的目标助手消息,然后把新的消息列表写回 messagesByConversationId

代码:ChatStateReducers.kt

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 决定是否显示流式圆点:

代码:ChatMessageList.kt

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 重组同一条气泡。CompleteFailureCanceled 只改变终态;Room 只保存稳定下来的消息状态,不把临时流式过程当成持久化事实。

流式回复会追加到同一条助手消息里。

5.4 前台提示和系统通知

多会话后台回复完成后,应用内 Snackbar 和系统通知走两套提示:

  • 应用在前台时,backgroundUpdateNoticeConversationId 触发 Snackbar。用户可以点 Open 切到完成的会话。
  • 应用在后台时,ViewModel 只在回复成功完成后调用 BackgroundCompletionNotifier
  • 系统通知的 PendingIntent 带会话 id,点击后回到对应会话。

前台提示是 Compose 里的一个副作用:

代码:ChatScreen.kt

@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 判断是否应该发:

代码:ChatViewModel.kt

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 关联用户消息。

代码:MessageEntity.kt

@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

代码:DataModule.kt

@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 模式:

代码:ModelServiceModule.kt

@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 和脚本化模型服务:

代码:TestAppModule.kt

@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 验证任务:

代码:build.gradle.kts

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.mdFailure 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 会让明确边界内的实现变得便宜,但移动开发的核心复杂度不会消失。它只是改变了公司的用人方式和工程师的价值位置。

它也有真实的算力成本。36 个核心构建会话记录了约 5.46 亿 token,但其中大部分是缓存输入。如果把 GPT-5.5 标准 API 价格套到这组 Codex rollout 统计上,粗算约 402 美元:非缓存输入约 88 美元,缓存输入约 263 美元,输出约 50 美元。这不是我的 ChatGPT 或 Codex 订阅账单,但可以作为一个量级参考:长程 agentic coding 比几周高级工程师时间便宜很多,但并不是零成本。

传统项目里一个 Staff、几个 Senior、几个 Junior 花几个月做的事,现在一个 Staff 开几个 Codex 会话,可能一个月内就能做出接近甚至更好的结果。那公司还会保留多少招聘动力?如果不招 Junior,新人又从哪里来?

所以岗位数量可能被压缩,但留下来的移动工程师需要覆盖更高杠杆的工作,比如:

  • 把模糊的产品目标拆解成可实现、可验证、自包含的阶段。
  • 为 agent 写出足够严格的实现计划和完成标准。
  • 构建高覆盖的自动化验证,让 agent 进入自反馈循环,成功完成长会话任务。

所以我不觉得 agentic coding 会让移动工程能力失去价值。更可能发生的是:低杠杆的实现工作会变少,高密度的架构判断、计划审查和验证设计会变得更重要。移动开发者需要适应的重点,会从写更多代码,转向更频繁地为代码生成的方向负责。

这也回答了第一节里的问题:外部 Android 的工具链、依赖管理、验证环境和 Meta 内部工具链都不一样,但 agentic workflow 的核心模式没有变:找到目标,拆解目标到各个阶段,再写设计和实现计划,执行中不断把人工检查变成自动化验证,最后验收。这个 4 天实验验证了这套模式在外部工具链下依然成立。

标签: android, Codex, Agentic Coding, architecture, AI