关于 android-dev
面向编码 Agent 的 Android 与 Kotlin Multiplatform 开发技能,提供架构、UI 状态、模块、测试和技能路由规范,并要求优先遵循现有项目做法。
主要功能
- 01Android 与 KMP 架构默认规范
- 02Compose UI 状态与效果模式
- 03模块和模型组织指导
- 04使用完整限定名路由技能
- 05协程与平台边界实践
适用场景
- 01为新的 Android 功能设定规范
- 02组织屏幕状态与一次性效果
- 03为任务选择专用技能
- 04检查 KMP 代码的模块边界
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ rcosteira79/ android-skills --skill android-dev安装后在 Claude Code 中输入/android-dev确认已加载。
详细介绍
Android / KMP 基线
通用默认规范——无需提醒或重新推导,直接应用;若项目的实际约定有所不同,则遵循项目约定:
- DI: Hilt + KSP(或项目使用 Koin 时采用
android-skills:koin)。异步: Coroutines/Flow——新代码中不使用LiveData。JSON:kotlinx.serialization。图像: Coil。 - 网络/本地: Android 使用 Retrofit/OkHttp + Room;KMP 共享代码使用 Ktor + Room 或 SQLDelight。Retrofit 仅适用于 Android——绝不放入共享模块。
- 模块: 按功能垂直划分包和模块;
:core:model不依赖任何 Android 组件;:feature:*模块之间不得相互依赖。 - 错误: 在仓库边界映射为领域类型——平台异常不得越过该边界;UI 状态应明确表示加载中 / 成功 / 错误(参见
android-skills:android-data-layer)。
技能路由
针对任务加载相应技能,始终使用完全限定的 android-skills: 前缀——绝不使用短名称(compose、koin 等)。
| 用途 | 加载 |
|---|---|
Compose 细节——稳定性、remember、修饰符、副作用、列表、动画、导航 | android-skills:compose |
| M3 体验——触控目标、自适应/折叠屏布局、无障碍与 M3 合规性审查 | android-skills:android-ux |
Coroutines 与 Flow——操作符、Channel 与 SharedFlow 的取舍、结构化并发 | android-skills:kotlin-coroutines、android-skills:kotlin-flows |
| 仓库 / 数据层 + 错误模型 | android-skills:android-data-layer |
| 网络 | android-skills:android-retrofit(Android)· android-skills:kmp-ktor(KMP) |
| 分页 | android-skills:paging |
| 图像加载 | android-skills:coil-compose |
| 偏好设置 / 类型化本地存储 | android-skills:datastore |
KMP expect/actual 边界设计 | android-skills:kmp-boundaries |
| 从 RxJava 迁移到 Coroutines/Flow | android-skills:rxjava-migration |
| 测试 | android-skills:android-testing |
| 使用 Koin 进行 DI | android-skills:koin |
| 构建逻辑 / 约定插件 | android-skills:android-gradle-logic |
| 构建速度、从 kapt 迁移到 KSP | android-skills:gradle-build-performance |
| 调试——Logcat、崩溃、ANR、性能分析 | android-skills:android-debugging |
| AOSP / AndroidX 源码查找 | android-skills:android-source-search |
| 多模块可见性与模块边界 | android-skills:modularization |
| 平台 PDF 批注 / 页面对象编辑(API 36.1 / SDK ext 18) | android-skills:pdf-annotations |
新项目 UI 约定(从零开始)
适用于没有既定约定的新项目或功能。在现有代码中,应遵循已有做法——请参阅下文的复用项目现有机制。
UI 层采用 MVVM,并采用 MVI 风格的状态/效果拆分:
- 每个屏幕有一个不可变的
UiState,以StateFlow<UiState>的形式公开,并按以下四个类别组织。内容可组合项负责渲染状态并发出回调,不做其他事情。 - 用一个效果流处理只执行一次的命令式操作——导航、snackbar/toast、滚动到指定位置、分享表单、触觉反馈。使用
Channel(Channel.BUFFERED).receiveAsFlow(),不要使用SharedFlow:屏幕处于后台时发出的效果会缓冲,并在恢复时重放,而不是被丢弃。通过LaunchedEffect收集(由生命周期作用域通过repeatOnLifecycle管理),绝不要使用collectAsStateWithLifecycle。关于Channel与SharedFlow的选择理由,请参阅android-skills:kotlin-flows。 - 状态还是效果——“配置变更后它是否仍然成立?” 旋转屏幕/进程死亡后仍然成立的内容属于状态(要显示的错误应作为
UiState中的字段);UI 执行一次后便不再关心的内容属于效果。这是compose/references/state-management.md中的durable-state-over-events规则:持久内容应保留在状态中;效果流仅用于一次性命令式操作。 - 当回调达到约 4–5 个或更多时,将其提升为
@Stable Actions接口(或者在同一组回调需要穿过多个可组合项层级时)。数量更少时,单独的 lambda 更简单——不要过早抽象。ViewModel 实现该接口;内容可组合项依赖FooActions,绝不依赖 ViewModel,这样才能保持纯粹并支持预览(在预览中使用无操作的object : FooActions {})。
data class FooUiState(/* the four buckets — see below */)
sealed interface FooEffect {
data class NavigateTo(val id: String) : FooEffect
data class ShowSnackbar(val message: String) : FooEffect
}
@Stable // promote here once lambdas pile up (~4-5+)
interface FooActions {
fun onItemClick(id: String)
fun onRefresh()
}
class FooViewModel(/* … */) : ViewModel(), FooActions {
private val _uiState = MutableStateFlow(FooUiState())
val uiState: StateFlow<FooUiState> = _uiState.asStateFlow()
private val _effects = Channel<FooEffect>(Channel.BUFFERED) // not SharedFlow — buffers while backgrounded
val effects = _effects.receiveAsFlow()
override fun onItemClick(id: String) { /* _uiState.update { … } */ _effects.trySend(FooEffect.NavigateTo(id)) }
override fun onRefresh() { /* … */ }
}
@Composable
fun FooScreen(viewModel: FooViewModel = hiltViewModel(), onNavigate: (String) -> Unit) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(Unit) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.effects.collect { effect ->
when (effect) {
is FooEffect.NavigateTo -> onNavigate(effect.id)
is FooEffect.ShowSnackbar -> { /* show snackbar */ }
}
}
}
}
FooContent(uiState = uiState, actions = viewModel) // VM passed as FooActions — FooContent sees only the interface
}
Kotlin 2.4+: 使用显式支持字段合并
_uiState/uiState这一对(val uiState: StateFlow<FooUiState>+field = MutableStateFlow(…))——仅保留uiState,绝不用于效果Channel。完整用法和版本要求请参阅android-skills:kotlin-flows。
四类状态建模
对于交互丰富的屏幕(表单、计算器、多步骤向导),如果将状态放在一个扁平的 data class 中,会变得难以管理。应将 UiState 划分为四个明确的类别,并且将计算得出的值定义为类属性,而不是构造函数参数:
data class CheckoutUiState(
// 1. Editable input — what the user types
val email: String = "",
val cardNumber: String = "",
// 3. Persisted snapshot — last value read from the repository / stored cross-screen
val savedShippingAddress: Address? = null,
// 4. Transient UI-only — flags that must NOT survive the screen
val isSubmitting: Boolean = false,
val showCardScannerOverlay: Boolean = false,
) {
// 2. Derived — getters, NOT constructor params, so no caller can copy() into an
// inconsistent state (e.g. emailValid = false next to a valid email).
val emailValid: Boolean get() = email.isValidEmail()
val canSubmit: Boolean get() = emailValid && cardNumber.passesLuhn() && !isSubmitting
}
类别决定生命周期和持久化方式,而不是字段本身。持久化 isSubmitting 会导致进程死亡后微调器一直显示;在类外计算 canSubmit 会使其与输入值脱节;跨屏幕持久化 cardNumber 会泄露个人身份信息(PII)。混淆这些类别会引发看似属于架构层面的问题。
UI 状态和 UI 模型应放在不同文件中
UiState 是屏幕与其 ViewModel 之间的契约,而不是 UI 模型——它应放在自己的文件中(如果项目采用这种布局,也可以放在 ViewModel 所在的文件中)。不要把 UI 模型一并塞进去。“每种类型都应有一个归属者/它们属于同一个变更单元/真正的分界在于每个文件承担的角色”这些原则恰恰能说明这种做法有误:状态及其所包含的模型承担的是不同角色。
应按组合关系对模型分组,绝不要按屏幕分组:
- 共同构成一个更大模型的模型应与该模型放在同一个文件中,且文件以更大模型命名——
ChallengeDetailUi.kt包含ChallengeDetailUi,以及用于构建它的ChallengeTaskUi/SponsorshipUi。 - 相互独立的模型各自放在单独的文件中。
FooModels.kt/FooUiModels.kt永远不是正确选择——采用这种兜底式名称,说明并未找到聚合根;这意味着这些类型彼此独立,应分别放在各自的文件中。
Kotlin 的“相关声明可以共享一个以主声明命名的文件”是实现方式,不是允许将状态指定为主声明,再把模型一并塞进该文件的借口。
复用项目现有机制
在添加任何新机制之前——事件分发器、effects Channel/SharedFlow、用例层或并行状态字段——先打开同一功能中的同级 ViewModel,复用现有机制。这里很容易漏掉的一点是重复实现现有机制,而不是扩展它——在已有的 shouldDisplayUndoX 标志旁边再添加一个,而不是对现有标志进行泛化。如果现有代码与“最佳实践”相矛盾,就遵循代码并指出不一致;绝不要悄悄推翻项目架构。
注释——每条都要有存在的理由
检验每条注释:读者能否从旁边的代码快速推断出注释内容?如果能,它就是多余的——删掉。 注释只有在传达代码无法表达的内容时才应保留:不明显的原因——决策、限制、变通方案或注意事项。绝不要叙述代码做了什么;清晰的命名和简洁的函数已经说明了这一点。“某个广为人知的类型或调用的作用”是读者可以查到的内容,不是原因。
只写通过上述检验所需的最少注释——即使任务要求“提高可读性”或“面向初级开发者”,也同样如此。可读性来自命名和结构;如果新手需要注释才能理解代码做了什么,这说明应该重命名或提取代码,而不是添加注释。
保留真正解释原因的注释(// 先重新抛出异常——宽泛的捕获会吞掉 CancellationException)、对出人意料的调用位置作出解释的注释、能提供签名之外信息的公共 API KDoc,以及 TODO(owner-or-link)。遵从明确的文档编写要求。
看到就删——这些内容都能从旁边的代码轻易推断出来:
// ---- domain model ---- // section-divider / banner (any width)
/** The user profile as the app cares about it. */ // KDoc restating the class name
val uiState = _uiState.asStateFlow() // private mutable, public read-only (restates the idiom)
} catch (e: IOException) { // no connectivity, timeout, DNS failure (restates what the type means)
KMP
到处注入 CoroutineDispatcher,而不是直接调用 Dispatchers.Main / Dispatchers.IO:没有 -ktx artifacts 时,无法保证所有 KMP 目标都支持 Dispatchers.Main;注入也是进行调度器替换测试的前提。平台特定功能(文件 I/O、推送令牌、生物识别)使用 expect/actual;在 iOS 上优先使用不可变共享状态。