mcpField
A
android-dev

android-dev

github.com/rcosteira79开发165159 次安装6 周前更新
代码仓库

关于 android-dev

面向编码 Agent 的 Android 与 Kotlin Multiplatform 开发技能,提供架构、UI 状态、模块、测试和技能路由规范,并要求优先遵循现有项目做法。

主要功能

  1. 01Android 与 KMP 架构默认规范
  2. 02Compose UI 状态与效果模式
  3. 03模块和模型组织指导
  4. 04使用完整限定名路由技能
  5. 05协程与平台边界实践

适用场景

  1. 01为新的 Android 功能设定规范
  2. 02组织屏幕状态与一次性效果
  3. 03为任务选择专用技能
  4. 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/Flowandroid-skills:rxjava-migration
测试android-skills:android-testing
使用 Koin 进行 DIandroid-skills:koin
构建逻辑 / 约定插件android-skills:android-gradle-logic
构建速度、从 kapt 迁移到 KSPandroid-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 上优先使用不可变共享状态。

评论

支持 Markdown