mcpField
I
implementing-android-code

implementing-android-code

github.com/bitwarden开发9.6k50 次安装今天更新
代码仓库

关于 implementing-android-code

面向 Bitwarden Android 贡献者的实现指南,介绍 ViewModel、导航、数据层、Compose UI、安全和测试方面的项目约定。

主要功能

  1. 01State-Action-Event ViewModel 模式
  2. 02类型安全的导航路由
  3. 03分层数据架构
  4. 04Compose UI 规范
  5. 05安全与测试模式

适用场景

  1. 01实现支持状态恢复的 ViewModel
  2. 02添加类型安全的屏幕路由
  3. 03构建 Repository 与数据源层

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/bitwarden/android --skill implementing-android-code

安装后在 Claude Code 中输入/implementing-android-code确认已加载。

附带文件templates.md

详细介绍

实现 Android 代码 - Bitwarden 快速参考

此技能提供针对 Bitwarden 特定模式的实用指导。 有关全面的架构决策和完整的代码风格规则,请参阅 docs/ARCHITECTURE.md 和 docs/STYLE_AND_BEST_PRACTICES.md。


关键模式参考

A. ViewModel 实现(State-Action-Event 模式)

所有 ViewModel 都通过 BaseViewModel<State, Event, Action> 遵循 State-Action-Event(SAE) 模式。

关键要求:

  • 使用 @HiltViewModel 注解
  • 状态类必须是 @Parcelize data class : Parcelable
  • 实现 handleAction(action: A)——必须为同步方法
  • 在协程中使用 sendAction() 发送内部操作
  • 通过 SavedStateHandle[KEY_STATE] 保存/恢复状态
  • 私有操作处理函数:遵循 private fun handle* 命名约定

模板:参见 ViewModel 模板

模式摘要:

@HiltViewModel
class ExampleViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val repository: ExampleRepository,
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
    initialState = savedStateHandle[KEY_STATE] ?: ExampleState(),
) {
    init {
        stateFlow.onEach { savedStateHandle[KEY_STATE] = it }.launchIn(viewModelScope)
    }

    override fun handleAction(action: ExampleAction) {
        // Synchronous dispatch only
        when (action) {
            is Action.Click -> handleClick()
            is Action.Internal.DataReceived -> handleDataReceived(action)
        }
    }

    private fun handleClick() {
        viewModelScope.launch {
            val result = repository.fetchData()
            sendAction(Action.Internal.DataReceived(result))  // Post internal action
        }
    }

    private fun handleDataReceived(action: Action.Internal.DataReceived) {
        mutableStateFlow.update { it.copy(data = action.result) }
    }
}

参考:

  • ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt(参见 handleAction 方法)
  • app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt(参见类声明)

关键注意事项:

  • ❌ 绝不要在协程中直接更新 mutableStateFlow
  • ✅ 始终在协程中发送内部操作,并在 handleAction() 中更新状态
  • ❌ 绝不要忘记为敏感数据添加 @IgnoredOnParcel(否则会导致安全泄露)
  • ✅ 始终在状态类上使用 @Parcelize,以便在进程终止后恢复
  • ✅ 只要正确保存到 SavedStateHandle,状态就会自动恢复

B. 导航实现(类型安全)

所有导航都使用基于 kotlinx.serialization 的类型安全路由。

模式结构:

  1. 使用参数声明 @Serializable 路由数据类
  2. 使用 ...Args 辅助类从 SavedStateHandle 中提取数据
  3. 使用 NavGraphBuilder.{screen}Destination() 扩展将屏幕添加到图表
  4. 使用 NavController.navigateTo{Screen}() 扩展发起导航调用

模板:参见 导航模板

模式摘要:

@Serializable
data class ExampleRoute(val userId: String, val isEditMode: Boolean = false)

data class ExampleArgs(val userId: String, val isEditMode: Boolean)

fun SavedStateHandle.toExampleArgs(): ExampleArgs {
    val route = this.toRoute<ExampleRoute>()
    return ExampleArgs(userId = route.userId, isEditMode = route.isEditMode)
}

fun NavController.navigateToExample(
    userId: String,
    isEditMode: Boolean = false,
    navOptions: NavOptions? = null,
) {
    this.navigate(route = ExampleRoute(userId, isEditMode), navOptions = navOptions)
}

fun NavGraphBuilder.exampleDestination(onNavigateBack: () -> Unit) {
    composableWithSlideTransitions<ExampleRoute> {
        ExampleScreen(onNavigateBack = onNavigateBack)
    }
}

参考: app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt(参见 LoginRoute 和扩展函数)

主要优点:

  • ✅ 类型安全:缺少参数时会在编译时出错
  • ✅ 导航代码中无需使用字符串字面量
  • ✅ 自动序列化/反序列化
  • ✅ 明确界定屏幕依赖项的契约

C. 屏幕/Compose 实现

所有屏幕都遵循一致的 Compose 模式。

模板:参见 屏幕/Compose 模板

关键模式:

@Composable
fun ExampleScreen(
    onNavigateBack: () -> Unit,
    viewModel: ExampleViewModel = hiltViewModel(),
) {
    val state by viewModel.stateFlow.collectAsStateWithLifecycle()

    EventsEffect(viewModel = viewModel) { event ->
        when (event) {
            ExampleEvent.NavigateBack -> onNavigateBack()
        }
    }

    BitwardenScaffold(
        topBar = {
            BitwardenTopAppBar(
                title = stringResource(R.string.title),
                navigationIcon = rememberVectorPainter(BitwardenDrawable.ic_back),
                onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
            )
        },
    ) {
        // UI content
    }
}

参考: app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt(参见 LoginScreen 可组合函数)

基本要求:

  • ✅ 使用 hiltViewModel() 进行依赖注入
  • ✅ 使用 collectAsStateWithLifecycle() 获取状态(不要使用 collectAsState())
  • ✅ 使用 EventsEffect(viewModel) 处理一次性事件
  • ✅ 使用 :ui 模块中以 Bitwarden* 为前缀的组件

状态提升规则:

  • ViewModel 状态:需要在进程终止后保留或会影响业务逻辑的数据
  • 仅 UI 状态:临时 UI 状态(滚动位置、文本字段焦点),使用 remember 或 rememberSaveable

D. 数据层实现

数据层严格遵循仓库、管理器和数据源的模式。

接口与实现分离(始终遵循)

模板:参见数据层模板

模式摘要:

// Interface (injected via Hilt)
interface ExampleRepository {
    suspend fun fetchData(id: String): ExampleResult
    val dataFlow: StateFlow<DataState<ExampleData>>
}

// Implementation (NOT directly injected)
class ExampleRepositoryImpl(
    private val exampleDiskSource: ExampleDiskSource,
    private val exampleService: ExampleService,
) : ExampleRepository {
    override suspend fun fetchData(id: String): ExampleResult {
        // NO exceptions thrown - return Result or sealed class
        return exampleService.getData(id).fold(
            onSuccess = { ExampleResult.Success(it.toModel()) },
            onFailure = { ExampleResult.Error(it.message) },
        )
    }
}

// Sealed result class (domain-specific)
sealed class ExampleResult {
    data class Success(val data: ExampleData) : ExampleResult()
    data class Error(val message: String?) : ExampleResult()
}

// Hilt Module
@Module
@InstallIn(SingletonComponent::class)
object ExampleRepositoryModule {
    @Provides
    @Singleton
    fun provideExampleRepository(
        exampleDiskSource: ExampleDiskSource,
        exampleService: ExampleService,
    ): ExampleRepository = ExampleRepositoryImpl(exampleDiskSource, exampleService)
}

参考:

  • app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt
  • app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt

三层数据架构:

  1. 数据源 - 原始数据访问(网络、磁盘、SDK)。返回 Result<T>,绝不抛出异常。
  2. 管理器 - 遵循单一职责原则的业务逻辑。封装 OS/外部服务。
  3. 仓库 - 汇总数据源/管理器。返回特定于领域的密封类。

关键规则:

  • ❌ 绝不在数据层抛出异常
  • ✅ 始终使用接口 + ...Impl 模式
  • ✅ 始终注入接口,绝不注入实现
  • ✅ 数据源返回 Result<T>,仓库返回领域密封类
  • ✅ 对持续观察的数据使用 StateFlow

接口与实现上的 KDoc

接口成员上的 KDoc 描述的是契约——调用方可以依赖什么。它不得描述实现如何履行该契约。实现细节(查询哪个数据源、缓存了什么、记录了什么日志、委托给哪个管理器、内部调用的顺序等)应写在 ...Impl 成员上——且仅当代码本身无法清楚说明其原因时才需要写。

这适用于代码库中的所有接口:仓库、管理器、数据源、验证器以及 UI 层接口。此规则与 CLAUDE.md 中关于一般注释的规定相同——描述契约或不明显的原因,而不是命名清晰的标识符已经表达的内容。

❌ 错误——接口 KDoc 泄露了实现细节:

interface ExampleRepository {
    /**
     * Fetches data by reading from [ExampleDiskSource] first, falling back to
     * [ExampleService] on cache miss and writing the network result back to disk.
     */
    suspend fun fetchData(id: String): ExampleResult
}

✅ 正确——接口 KDoc 描述契约;实现细节(如确有必要)写在重写成员上:

interface ExampleRepository {
    /** Returns the [ExampleData] for [id], or an error result on failure. */
    suspend fun fetchData(id: String): ExampleResult
}

class ExampleRepositoryImpl(...) : ExampleRepository {
    // No KDoc needed — the disk-then-network pattern is visible in the code.
    override suspend fun fetchData(id: String): ExampleResult { ... }
}

以下迹象表明接口 KDoc 已偏离契约,进入实现细节:

  • 提及具体协作者(...DiskSource、...Service、...Manager、...Impl)
  • 描述内部调用顺序(“先……再……”、“回退到……”)
  • 提及调用方不依赖的缓存、重试、日志记录或线程行为
  • 用文字复述方法签名(“挂起,直到 X 返回 Y”)

如果调用方确实依赖某种行为(例如“此方法在保险库解锁前可以安全调用”、“结果会在会话期间缓存”),那么这就是契约的一部分,应写在接口上。判断方法是:其他有效实现是否可以自由改变这一点?如果可以,那就是实现细节——删掉它。


E. UI 组件

优先使用现有组件:

:ui 模块提供了可复用的 Bitwarden* 前缀组件。创建新组件前,请先搜索。

常用组件:

  • BitwardenFilledButton - 主要操作按钮
  • BitwardenOutlinedButton - 次要操作按钮
  • BitwardenTextField - 文本输入框
  • BitwardenPasswordField - 带显示/隐藏功能的密码输入框
  • BitwardenSwitch - 切换开关
  • BitwardenTopAppBar - 工具栏/应用栏
  • BitwardenScaffold - 带脚手架的屏幕容器
  • BitwardenBasicDialog - 简单对话框
  • BitwardenLoadingDialog - 加载指示器

组件查找: 在 ui/src/main/kotlin/com/bitwarden/ui/platform/components/ 中搜索现有的 Bitwarden* 组件。构建、测试和代码库查找命令请使用 build-test-verify 技能。

何时创建新的可复用组件:

  • 组件用于 3 处或更多位置
  • 组件需要在整个应用中使用一致的主题
  • 组件具有语义含义(无障碍)
  • 组件具有复杂的状态管理

新组件要求:

  • 使用 Bitwarden 前缀
  • 接受来自 BitwardenTheme 的主题颜色/样式
  • 包含用于测试的预览可组合项
  • 支持无障碍(内容描述、语义)

字符串资源:

新字符串应放在 :ui 模块中:ui/src/main/res/values/strings.xml

  • 使用排版用撇号和引号,避免使用转义字符:you’ll 而非 you\'ll,“word” 而非 \"word\"
  • 通过生成的 BitwardenString 资源 ID 引用字符串
  • 除非有明确指示,否则不要向其他模块添加字符串

F. 安全模式

加密与未加密存储:

模板:参见安全模板

模式摘要:

class ExampleDiskSourceImpl(
    @EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
    @UnencryptedPreferences sharedPreferences: SharedPreferences,
) : BaseEncryptedDiskSource(
    encryptedSharedPreferences = encryptedSharedPreferences,
    sharedPreferences = sharedPreferences,
),
    ExampleDiskSource {
    fun storeAuthToken(token: String) {
        putEncryptedString(KEY_TOKEN, token)  // Sensitive — uses base class method
    }

    fun storeThemePreference(isDark: Boolean) {
        putBoolean(KEY_THEME, isDark)  // Non-sensitive — uses base class method
    }
}

Android Keystore(生物识别密钥):

  • 用户范围的加密密钥:BiometricsEncryptionManager
  • 密钥存储在 Android Keystore 中(条件允许时由硬件支持)
  • 生物识别状态变化时进行完整性验证

输入验证:

// Validation returns boolean, NEVER throws
interface RequestValidator {
    fun validate(request: Request): Boolean
}

// Sanitization removes dangerous content
fun String?.sanitizeTotpUri(issuer: String?, username: String?): String? {
    if (this.isNullOrBlank()) return null
    // Sanitize and return safe value
}

安全检查清单:

  • ✅ 对凭据、密钥和令牌使用 @EncryptedPreferences
  • ✅ 对 UI 状态和偏好设置使用 @UnencryptedPreferences
  • ✅ 对敏感的 ViewModel 状态使用 @IgnoredOnParcel
  • ❌ 绝不记录敏感数据(密码、令牌、保险库项目)
  • ✅ 处理前验证所有用户输入
  • ✅ 仅使用 Timber 记录非敏感信息

G. 测试模式

ViewModel 测试:

模板:参见测试模板

模式摘要:

class ExampleViewModelTest : BaseViewModelTest() {
    private val mockRepository: ExampleRepository = mockk()

    @Test
    fun `ButtonClick should fetch data and update state`() = runTest {
        val expectedResult = ExampleResult.Success(data = "test")
        coEvery { mockRepository.fetchData(any()) } returns expectedResult

        val viewModel = createViewModel()
        viewModel.trySendAction(ExampleAction.ButtonClick)

        viewModel.stateFlow.test {
            assertEquals(EXPECTED_STATE.copy(data = "test"), awaitItem())
        }
    }

    private fun createViewModel(): ExampleViewModel = ExampleViewModel(
        savedStateHandle = SavedStateHandle(mapOf(KEY_STATE to EXPECTED_STATE)),
        repository = mockRepository,
    )
}

参考: app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt

关键测试模式:

  • ✅ 扩展 BaseViewModelTest,以正确管理调度器
  • ✅ 使用 kotlinx.coroutines.test 中的 runTest
  • ✅ 使用 Turbine 的 .test { awaitItem() } 断言 Flow
  • ✅ 使用 MockK:挂起函数使用 coEvery,同步函数使用 every
  • ✅ 同时测试状态变化和事件发射
  • ✅ 同时测试 Result 的成功和失败路径

使用 Turbine 测试 Flow:

// Test state and events simultaneously
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
    viewModel.trySendAction(ExampleAction.Submit)
    assertEquals(ExpectedState.Loading, stateFlow.awaitItem())
    assertEquals(ExampleEvent.ShowSuccess, eventFlow.awaitItem())
}

MockK 快速参考:

coEvery { repository.fetchData(any()) } returns Result.success("data")  // Suspend
every { diskSource.getData() } returns "cached"  // Sync
coVerify { repository.fetchData("123") }  // Verify

H. 时钟/时间处理

所有需要当前时间的代码都必须注入 Clock,以便进行测试。

关键要求:

  • ✅ 通过 Hilt 在 ViewModel 中注入 Clock
  • ✅ 在扩展函数中将 Clock 作为参数传入
  • ✅ 使用 clock.instant() 获取当前时间
  • ❌ 绝不直接调用 Instant.now() 或 DateTime.now()
  • ❌ 测试中绝不对日期时间类使用 mockkStatic

模式摘要:

// ViewModel with Clock
class MyViewModel @Inject constructor(
    private val clock: Clock,
) {
    val timestamp = clock.instant()
}

// Extension function with Clock parameter
fun State.getTimestamp(clock: Clock): Instant =
    existingTime ?: clock.instant()

// Test with fixed clock
val FIXED_CLOCK = Clock.fixed(
    Instant.parse("2023-10-27T12:00:00Z"),
    ZoneOffset.UTC,
)

参考:

  • docs/STYLE_AND_BEST_PRACTICES.md(参见“时间与 Clock 处理”部分)
  • core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt(参见 provideClock 函数)

重要注意事项:

  • ❌ Instant.now() 会产生隐藏依赖,且无法测试
  • ❌ mockkStatic(Instant::class) 不可靠,可能会影响其他测试
  • ✅ Clock.fixed(...) 可提供确定性的测试行为

I. Kotlin 风格规则

代码评审中强制执行的项目专属风格约定。这些约定是对 docs/STYLE_AND_BEST_PRACTICES.md 的补充,而非替代。

when 分支的右侧表达式换行时,必须使用花括号。

如果 when 分支的表达式太长,无法与箭头写在同一行,需要换到下一行,则必须用 { } 括起分支体。裸写 ->,后接单独一行的缩进表达式,会在评审中被拒绝。

❌ 错误——换行的分支体没有花括号:

when (type) {
    VaultItemCipherType.LOGIN -> VaultAddEditState.ViewState.Content.ItemType.Login()
    VaultItemCipherType.BANK_ACCOUNT ->
        VaultAddEditState.ViewState.Content.ItemType.BankAccount()
    VaultItemCipherType.DRIVERS_LICENSE ->
        VaultAddEditState.ViewState.Content.ItemType.DriversLicense()
}

✅ 正确——换行的分支体使用了花括号:

when (type) {
    VaultItemCipherType.LOGIN -> VaultAddEditState.ViewState.Content.ItemType.Login()
    VaultItemCipherType.BANK_ACCOUNT -> {
        VaultAddEditState.ViewState.Content.ItemType.BankAccount()
    }
    VaultItemCipherType.DRIVERS_LICENSE -> {
        VaultAddEditState.ViewState.Content.ItemType.DriversLicense()
    }
}

单行分支(分支体与 -> 写在同一行)不需要花括号。


Bitwarden 专属反模式

常见反模式记录在 CLAUDE.md 中。 本节介绍 Bitwarden 的 State-Action-Event、导航和数据层模式中特有的违规情况:

❌ 绝不要在协程中直接更新 ViewModel 状态

  • 提交内部操作,并在 handleAction() 中同步更新状态

❌ 绝不要注入 ...Impl 类

  • 通过 Hilt 只注入接口

❌ 绝不要创建缺少 @Serializable 路由的导航

  • 不使用基于字符串的导航,始终采用类型安全的方式

❌ 绝不要在存储库中使用原始 Result<T>

  • 使用领域专属的密封类,以便更好地处理错误

❌ 绝不要创建缺少 @Parcelize 的状态类

  • 所有 ViewModel 状态都必须能在进程死亡后保留

❌ 绝不要跳过 ViewModel 的 SavedStateHandle 持久化

  • 进程死亡后,用户会丢失表单进度

❌ 绝不要忘记为密码或令牌添加 @IgnoredOnParcel

  • 这会导致安全漏洞(敏感数据进入 parcel)

❌ 绝不要使用通用的 Exception 捕获

  • 只捕获特定异常(RemoteException、IOException)

❌ 绝不要直接调用 Instant.now() 或 DateTime.now()

  • 通过 Hilt 注入 Clock,使用 clock.instant() 以便测试

❌ 绝不要在接口 KDoc 中写入实现细节

  • 接口 KDoc 描述契约;具体协作者、调用顺序、缓存和回退行为应写在 ...Impl 的重写方法中(且仅在 why 不明显时才写)。示例请参阅 D 节。

快速参考

如需构建、测试和探索代码库的命令,请使用 build-test-verify 技能。

文件引用格式: 指向具体代码时,请使用:file_path:line_number

示例:ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt(参见 handleAction 方法)

评论

支持 Markdown