写在前面
最近翻 Now in Android 仓库的时候,注意到根目录多了一个 AGENTS.md。
这个文件很容易被忽略——长得太像 README 了,都是 Markdown。但它的读者不是人,是 Claude Code、Codex、Gemini in Android Studio 这类 AI Coding Agent。换句话说,README 是给我们看的,AGENTS.md 是给 AI 看的。
那为什么需要这么一个文件?
因为 AI 写代码,很多时候不是不会写,而是不知道你项目里的约定。它知道 Compose,知道 Hilt,知道 Gradle,也知道 Navigation。但它不知道 NIA 里面 feature 是怎么拆的,不知道当前导航到底是 Navigation 2 还是 Navigation 3,更不知道截图测试的 baseline 能不能从本机提交。
这些东西如果不写出来,它就只能猜。
这篇文章就不泛泛讲 AGENTS.md 是什么了,直接拿 NIA 这个项目开刀。
一、先看一下 NIA 的模块结构
Now in Android 是一个很适合讲 AGENTS.md 的项目。它不是 demo,模块拆分很完整。
看 settings.gradle.kts,项目里有这些模块:
:app
:app-nia-catalog
:benchmarks
:core:analytics
:core:common
:core:data
:core:database
:core:datastore
:core:designsystem
:core:domain
:core:model
:core:navigation
:core:network
:core:notifications
:core:testing
:core:ui
...
:feature:foryou:api
:feature:foryou:impl
:feature:interests:api
:feature:interests:impl
:feature:bookmarks:api
:feature:bookmarks:impl
:feature:topic:api
:feature:topic:impl
:feature:search:api
:feature:search:impl
:feature:settings:impl
这里面最值得说的是 feature 的拆法。
NIA 不是一 feature 一 module。大部分 feature 都拆成了 api 和 impl 两个子模块。拿 For You 举例:
feature/foryou/
api/
impl/
api 放对外契约,比如导航用的 NavKey。impl 放真正的实现——Screen、ViewModel、entryProvider。
这个约定对我们来说看几次目录就明白了,但 Agent 不一定。你让它新增一个页面,如果它不知道这个拆法,大概率把 Screen、ViewModel、NavKey 全写到一个地方。
所以 AGENTS.md 里面最应该先写的,不是"本项目使用 Kotlin + Compose",而是这类项目边界。比如:
Feature modules are split into `api` and `impl`.
- `feature:*:api` owns public navigation contracts, such as `NavKey`.
- `feature:*:impl` owns Screen, ViewModel and entryProvider.
- Feature `api` modules should not depend on other feature modules.
- Feature `impl` modules may depend on another feature's `api`, but not `impl`.
这种内容对 Agent 才是有用的。它不需要你教它什么是 Compose,它需要知道的是 NIA 里 Compose 代码应该放在哪里。
二、README 都有了,还要 AGENTS.md 干嘛?
NIA 的 README 写得已经够详细了——项目用途、开发环境、build variant、测试、截图测试、Baseline Profile、Compose compiler metrics,全都有。
那 AGENTS.md 是不是重复了?
我觉得不是。README 是给开发者看的学习路线,AGENTS.md 是给 Agent 看的操作手册。
举个例子。README 里会解释:
demoflavor 用本地静态数据prodflavor 用真实后端- 日常开发用
demoDebug - UI 性能测试用
demoRelease
对开发者来说这些描述很友好。但 Agent 执行任务的时候,更想看到的是直接的命令:
- Build normal development variant: `./gradlew assembleDemoDebug`
- Run local tests: `./gradlew testDemoDebug`
- Run screenshot tests: `./gradlew verifyRoborazziDemoDebug`
- Do not run `./gradlew test`, because it runs all variants.
这个区别很关键。
README 可以解释背景,AGENTS.md 要减少 Agent 的选择空间。尤其 Android 项目,variant 一多,命令写模糊就很容易跑错。NIA 的 README 里专门提醒过,不要跑 ./gradlew test 或 ./gradlew connectedAndroidTest,因为会跑所有 build variants,而当前只有 demoDebug 支持测试。这种话就非常适合进 AGENTS.md。
三、当前 NIA 的 AGENTS.md 写了什么?
NIA 根目录现在同时有 AGENTS.md 和 AGENT.md。AGENT.md 实际上只是指向 AGENTS.md 的兼容入口——这也跟 Android Studio 的历史有关:Narwhal 3 Feature Drop 用 AGENT.md,Narwhal 4 Feature Drop Canary 4+ 开始支持 AGENTS.md。
看当前内容,主要写了这些:
- 项目是 Kotlin Android app
- UI 用 Compose + Material 3
- 状态管理用 UDF + Flow
- DI 用 Hilt
- 数据层用 Repository
- Room、DataStore、Retrofit、OkHttp、WorkManager 各自的职责
- app、feature、core 的目录组织
- 构建、spotless、测试、截图测试的命令
- UI feature 测试用
ComposeTestRule+ComponentActivity - 本地测试可以用 kotlinx.coroutines、Turbine、Truth
- 截图测试由 CI 生成,不要从工作站提交
整体方向是对的,尤其是命令和测试边界,对 Agent 帮助很大。
但是我读到 Navigation 那一段的时候,发现了一个很典型的问题。
四、AGENTS.md 还停在 Navigation 2
当前 NIA 的 AGENTS.md 里写着:
Navigation is handled by Jetpack Navigation 2 for Compose
但看代码,项目已经不是这个结构了。
app/build.gradle.kts 里已经有 Navigation 3 相关依赖:
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.viewModel.navigation3)
再看 NiaApp.kt,导航入口已经是 Navigation 3 的写法。核心代码大概是这样:
val entryProvider = entryProvider {
forYouEntry(navigator)
bookmarksEntry(navigator)
interestsEntry(navigator)
topicEntry(navigator)
searchEntry(navigator)
}
NavDisplay(
entries = appState.navigationState.toEntries(entryProvider),
sceneStrategy = listDetailStrategy,
onBack = { navigator.goBack() },
)
这跟之前 Navigation 2 的 NavHost + composable(...) 已经不是一个思路了。
现在 NIA 的导航分工大概是这样:
feature/*/api
定义 NavKey
feature/*/impl
定义 EntryProviderScope<NavKey>.xxxEntry(...)
core:navigation
定义 Navigator 和 NavigationState
app
把所有 feature entry 组合起来,交给 NavDisplay
拿 Topic 页面看一下。
feature/topic/api/.../TopicNavKey.kt:
@Serializable
data class TopicNavKey(val id: String) : NavKey
fun Navigator.navigateToTopic(
topicId: String,
) {
navigate(TopicNavKey(topicId))
}
feature/topic/impl/.../TopicEntryProvider.kt:
fun EntryProviderScope<NavKey>.topicEntry(navigator: Navigator) {
entry<TopicNavKey>(
metadata = ListDetailSceneStrategy.detailPane(),
) { key ->
val id = key.id
TopicScreen(
showBackButton = true,
onBackClick = { navigator.goBack() },
onTopicClick = navigator::navigateToTopic,
viewModel = hiltViewModel<TopicViewModel, Factory>(
key = id,
) { factory ->
factory.create(id)
},
)
}
}
然后在 NiaApp.kt 里注册:
val entryProvider = entryProvider {
forYouEntry(navigator)
bookmarksEntry(navigator)
interestsEntry(navigator)
topicEntry(navigator)
searchEntry(navigator)
}
看到这里就很清楚了。当前项目需要的不是 route string,不是改一个 NavHost,而是新增 NavKey、entryProvider,并在 app 层注册 entry。
所以这里有个很现实的问题:AGENTS.md 还写着 Navigation 2,Agent 很可能会被带偏。
这也是我觉得 AGENTS.md 最需要注意的地方。它不是写完就没事了,项目架构变了,它也要跟着变。错误的 AGENTS.md 比没有 AGENTS.md 更麻烦,因为它给了 Agent 一个错误前提。
五、这一段应该怎么改?
如果让我来改,导航部分我会写得更具体:
## Navigation
This project uses Navigation 3.
- Navigation keys are `NavKey` implementations.
- Feature `api` modules own public navigation keys.
- Feature `impl` modules own `EntryProviderScope<NavKey>.xxxEntry(...)` functions.
- `core:navigation` owns `Navigator` and `NavigationState`.
- `app` composes all feature entries in `NiaApp`.
When adding a destination:
1. Add a `NavKey` in the feature `api` module.
2. Add an entry provider in the feature `impl` module.
3. Add navigation helper functions on `Navigator` if another feature needs to navigate to it.
4. Register the entry in `NiaApp`.
Do not add Navigation 2 `NavHost` / route-string based destinations.
最后一句我觉得非常关键。
很多 Agent 会优先生成训练数据里更常见的写法。Navigation 2 的资料明显比 Navigation 3 多,如果不明确禁止,它很自然就会往旧方案上靠。
所以 AGENTS.md 不能只写"当前怎么做",也要写"不要回到哪种旧做法"。
六、回到工程实际:Agent 最容易在哪些地方返工?
单看 AGENTS.md 很容易把它写成一份规则清单。但从 NIA 这种项目来看,我觉得更应该反过来想:哪些地方最容易让 Agent 返工?
6.1 测试命令跑错
Android 项目和后端项目不太一样,很多命令跟 variant 绑定。NIA 就是典型:日常开发是 demoDebug,性能相关又有 demoRelease 和 benchmark。
如果 AGENTS.md 只写一句:
Run tests with Gradle.
基本等于没写。Agent 很可能直接跑 ./gradlew test,然后开始等一个很大、很慢、本来就不该跑的任务。
NIA 的 README 里已经提醒过,不要跑 ./gradlew test 或 ./gradlew connectedAndroidTest,因为会跑所有 build variants,而当前主要支持的是 demoDebug 这条测试路径。
更工程化的写法是把常用命令写死:
- Run local tests: `./gradlew testDemoDebug`
- Run instrumented tests: `./gradlew connectedDemoDebugAndroidTest`
- Run screenshot tests: `./gradlew verifyRoborazziDemoDebug`
- Compare screenshot failures: `./gradlew compareRoborazziDemoDebug`
- Do not run `./gradlew test` or `./gradlew connectedAndroidTest`.
这不是洁癖,是省时间。Agent 跑错命令以后,很容易基于错误结果继续修,最后变成一串没必要的改动。
6.2 测试风格写错
NIA 的测试有个很明显的倾向:不用 mocking libraries,用 test doubles。
README 里解释得很清楚,很多生产实现会在测试里替换成 test repository 或 test implementation。这些 test doubles 实现同样的接口,同时提供测试 hook。这样测试不是只验证某个 mock 方法有没有被调用,而是能跑到更多真实逻辑。
这个习惯如果不写,Agent 很容易顺手加 Mockito 或者按常见 mock 写法补测试。代码可能能跑,但风格跟项目就不一致了。
AGENTS.md 里可以写:
- Do not introduce mocking libraries.
- Prefer test doubles over mocks.
- ViewModel tests should use test repositories when available.
- Coroutine tests may use kotlinx.coroutines test utilities and Turbine.
这类内容不是"测试规范"那么简单。它实际是在告诉 Agent:NIA 更相信接口替换和真实数据流,而不是到处 mock。
6.3 截图测试 baseline 被随手更新
NIA 用 Roborazzi 做截图测试。截图测试最麻烦的地方大家都懂:不同系统、字体、渲染环境可能有细微差异。
README 里专门说了,仓库里的正确截图是在 Linux CI 上生成的。本机——尤其 macOS——可能有差异。所以如果 Agent 看到截图测试失败,然后直接跑 recordRoborazziDemoDebug 更新 baseline,这个操作就很危险。
AGENTS.md 需要写的不是"项目使用 Roborazzi",而是边界:
- Use `verifyRoborazziDemoDebug` to verify screenshots.
- Use `compareRoborazziDemoDebug` to inspect failures.
- Do not commit screenshot baselines generated from a workstation unless explicitly requested.
这句话其实是在保护 review。截图 baseline 一更新,diff 里看起来就是几张图片变了,但背后代表 UI 行为已经被接受。这个决策不能让 Agent 自己默默做。
6.4 生成文件被手改
Baseline Profile 也类似。
NIA 的 baseline profile 在:
app/src/main/baseline-prof.txt
它不是手写配置,是通过 benchmarks 模块里的 benchmark test 生成的。README 里也说了,如果 release 构建触及启动路径相关代码,需要考虑重新生成。
所以 AGENTS.md 里可以写:
- `app/src/main/baseline-prof.txt` is generated from benchmark tests.
- Do not hand-edit baseline profile rules.
- If startup-critical code changes, mention that baseline profile regeneration may be required.
不是说 Agent 永远不能改它,而是告诉 Agent:这类文件有来源,不能为了让 diff 看起来合理就手写几行。
七、从 NIA 推出来的 AGENTS.md 实践建议
讲到这里再看所谓最佳实践,就不应该是"写清楚项目结构"这种空话了。结合 NIA 这种项目,我觉得 AGENTS.md 至少要覆盖下面几类。
7.1 写项目专属规则,不写通用技术科普
"本项目使用 Jetpack Compose"当然可以写,但意义有限。
更有价值的是:
- UI only uses Jetpack Compose. Do not add XML layouts.
- Feature `api` modules expose NavKey only.
- Feature `impl` modules own Screen, ViewModel and entryProvider.
- Shared UI should go to `core:ui` or `core:designsystem`, not another feature.
Agent 知道 Compose 是什么,不用再教。真正需要告诉它的是:NIA 里 Compose 代码怎么组织。
7.2 命令写到能直接复制执行
不要写:
Run unit tests.
写:
./gradlew testDemoDebug
如果有不能跑的命令,也写出来:
Do not run `./gradlew test`; use `./gradlew testDemoDebug`.
这在 Android 项目里特别重要。variant 错了,后面的验证结果就没意义。
7.3 写"不要做什么"
很多人写 AGENTS.md 只写正向规则,但我觉得负向规则更关键。
拿 NIA 来说:
- Do not add Navigation 2 `NavHost` / route-string based destinations.
- Do not introduce mocking libraries.
- Do not commit screenshot baselines generated from a workstation.
- Do not hand-edit `app/src/main/baseline-prof.txt`.
Agent 补代码很在行,但它不知道哪些操作在这个项目里属于越界。把这些边界写出来,比写一堆"follow best practices"有用得多。
7.4 架构迁移时别忘了更新 AGENTS.md
NIA 这个 Navigation 2 / Navigation 3 的例子说明了一个问题:AGENTS.md 不是一次性文档。
我觉得比较合理的做法是,在架构类 PR 里顺手检查一下:
- [ ] If navigation / modularization / testing strategy changes, update AGENTS.md.
- [ ] If build variants or Gradle tasks change, update AGENTS.md.
- [ ] If generated files change ownership, update AGENTS.md boundaries.
这不是形式主义。AGENTS.md 一旦过期,后面所有 Agent 都会拿着旧上下文干活。
7.5 大项目可以分层,不要把根目录写成百科
NIA 这种项目很适合做分层 AGENTS.md。
根目录写全局规则,feature/*/api 写 api 模块约束,feature/*/impl 写实现模块约束,benchmarks 写性能测试和 Baseline Profile 生成规则。
比如:
# feature/*/api/AGENTS.md
- Navigation keys live here.
- Do not put UI code here.
- Do not put ViewModels here.
- Do not depend on feature implementation modules.
# benchmarks/AGENTS.md
- Macrobenchmark tests live here.
- Baseline Profile generation is handled here.
- Prefer stable CUJ flows over broad random UI interactions.
比根目录一个大文件舒服多了。不同模块关心的问题不同,Agent 修改哪个目录,就给它更贴近那个目录的上下文。
八、怎么判断 AGENTS.md 写得好不好?
拿 NIA 举例。假设我要让 Agent 新增一个 Events 页面。
如果 AGENTS.md 写得足够清楚,它至少不应该在这些事情上犯错:
- 要不要新建
feature:events:api和feature:events:impl EventsNavKey应该在apiEventsScreen、EventsViewModel、eventsEntry应该在implNiaApp.kt里需要注册eventsEntry(navigator)- 不要按 Navigation 2 的
NavHost写法新增页面 - 本地测试应该跑
testDemoDebug - UI 可见变化要考虑 Roborazzi
- 不要随便更新截图 baseline
这些信息都没写的话,Agent 也许仍然能写出代码,但很可能写成另一个项目的风格。这个差别挺微妙的:它不是不会 Android,是不懂 NIA。
结语
AGENTS.md 本身没什么神秘的,就是一个 Markdown 文件。
但放到 NIA 这种项目里看,它的用处就很明显了。Android 项目里有太多项目级约定——variant、feature 拆分、导航方案、测试方式、截图基准、Baseline Profile——不写出来,Agent 每次都要猜。
不过 NIA 当前这个例子也提醒我们:AGENTS.md 会过期。项目已经迁到 Navigation 3,但文档里还写着 Navigation 2,这就直接影响 Agent 的判断。
所以我觉得 AGENTS.md 不能当成一次性的配置文件,应该当成项目架构的一部分来维护。架构变了,模块变了,测试命令变了,它也跟着变。
以前这些经验可能散落在 README、Wiki、PR 评论,或者团队成员脑子里。现在 AI 编程工具越来越多,把这些经验整理成一份 Agent 能读懂的说明书,我觉得会慢慢变成 Android 项目里很自然的一件事。