关于 implementing-android-code
面向 Bitwarden Android 贡献者的实现指南,介绍 ViewModel、导航、数据层、Compose UI、安全和测试方面的项目约定。
主要功能
- 01State-Action-Event ViewModel 模式
- 02类型安全的导航路由
- 03分层数据架构
- 04Compose UI 规范
- 05安全与测试模式
适用场景
- 01实现支持状态恢复的 ViewModel
- 02添加类型安全的屏幕路由
- 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 的类型安全路由。
模式结构:
- 使用参数声明
@Serializable路由数据类 - 使用
...Args辅助类从SavedStateHandle中提取数据 - 使用
NavGraphBuilder.{screen}Destination()扩展将屏幕添加到图表 - 使用
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.ktapp/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt
三层数据架构:
- 数据源 - 原始数据访问(网络、磁盘、SDK)。返回
Result<T>,绝不抛出异常。 - 管理器 - 遵循单一职责原则的业务逻辑。封装 OS/外部服务。
- 仓库 - 汇总数据源/管理器。返回特定于领域的密封类。
关键规则:
- ❌ 绝不在数据层抛出异常
- ✅ 始终使用接口 +
...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 方法)