Repository、DI 与模块化都在管理边界
它们分别决定数据从哪来、对象由谁装、变化要重编多少代码;混成「解耦」两个字就失去判断力。
为了单元测试 ViewModel,最重要的第一步是什么?
- 给每个类都加接口
- 让 ViewModel 在内部调用 Hilt EntryPoint
- 通过构造函数显式接收 Repository 与调度政策
- 把依赖放进全局 Service Locator
依赖注入先是一种写法
依赖注入的核心不是 Hilt 注解,而是对象不在内部偷偷创建自己依赖的东西。构造函数注入让依赖在类型签名上可见,实例在创建后保持完整,也最容易传入测试替身。
class ChatViewModel(
private val repository: ChatRepository,
private val clock: Clock,
private val savedState: SavedStateHandle
) : ViewModel()
Hilt、Dagger 或 Koin 解决的是对象图装配、生命周期 scope 与样板代码。面试里不必把工具偏好说成真理:大型纯 Android 项目常用 Hilt 获得编译期图检查与 Jetpack 集成;小项目可以手工装配;多平台项目可能选择不依赖 Android 注解处理的方案。先说明需求,再说工具。
scope 不是缓存标签
单例表示整个进程共享一个实例,不表示对象永不销毁,也不表示线程安全。ActivityRetained、ViewModel、Activity 等 scope 对应不同组件寿命;短命对象依赖长命对象通常安全,长命对象反向持有短命 Context 则容易泄漏。
Repository 是否单例取决于它有没有共享资源或状态。无状态的薄对象做单例通常无害;持有登录用户、数据库连接或 WebSocket 时,要明确账号切换和注销怎样清理。把所有东西标成 Singleton 只是把生命周期问题推迟。
接口要放在需要边界的地方
「每个实现配一个接口」会制造一对一镜像。接口有价值的场景包括:跨模块依赖倒置、有多个真实实现、需要稳定协议、或测试替身能显著降低成本。Room DAO、Retrofit API 本身已经是合同;再包一层只有在 Repository 增加策略时才值得。
测试不一定需要 mocking 框架。一个几十行内存 Fake 往往比配置复杂 mock 更接近真实语义,尤其适合 Flow 与状态机。Fake 还可以复用在 Preview、开发菜单和端到端测试里。
模块化管理变化半径
按 feature 分模块能限制依赖、并行构建与团队所有权;core 模块承载真正共享且稳定的能力。不要因为架构图好看就把每个 screen 拆模块:Gradle 配置、公开 API、循环依赖与导航协调都有成本。
版本目录集中依赖坐标,Compose BOM 对齐 Compose 库版本,约定插件集中重复 Android/Kotlin 配置。现代 Compose 编译器插件跟 Kotlin 版本走。构建慢时先看 Build Analyzer、配置缓存、注解处理与模块依赖图,不要把「再拆模块」当万能答案。
compileSdk 决定可调用 API,targetSdk 选择新行为合同,minSdk 决定最低设备。不用 mocking 框架,给 Repository 写一个可观察 Fake。测试与 Preview 都能主动控制状态:
initial=[] after send=[hello] same fake can drive ViewModel test
class FakeChatRepository : ChatRepository {
private val messages = MutableStateFlow<List<Message>>(emptyList())
override fun observeMessages(id: ConversationId): Flow<List<Message>> =
messages
override suspend fun queueMessage(
id: ConversationId,
draft: Draft
): ClientMessageId {
val clientId = ClientMessageId("fake-${messages.value.size}")
messages.update { it + Message.pending(clientId, draft.text) }
return clientId
}
override suspend fun retry(id: ClientMessageId) = Unit
}在 Android Studio 的 test source set 中实现这个 Fake,注入 ViewModel,用 runTest 断言状态变化。若接口为 Fake 付出的代码比生产实现还多,重新检查边界是否过宽。
聊天、相机、认证和 AI 记录功能可能由不同团队演进。模块边界应围绕变化与所有权,而不是技术层强行横切成一个巨型 data 模块。与此同时,加密、日志与网络政策等横向能力适合稳定 core 合同。
「用了 Hilt 就是松耦合。」若 ViewModel 依赖二十个具体类,容器只会更方便地制造一个耦合很重的对象。
构造函数先暴露最小依赖,Repository 与用例承载有意义的边界,容器只负责装配与 scope。模块化再根据变化半径落刀。
答案是第 3 项。显式构造函数依赖让测试直接传 Fake,也让对象图可读。接口与 DI 框架是后续工具,不该用 EntryPoint 或全局定位器把依赖重新藏起来。
这一章的一句话
Repository 管数据真相,DI 管对象寿命,模块管变化半径;三者都必须有可说明的成本收益。
下一章进入医疗产品最不能含糊的边界:数据库加密并不阻止通知预览、截图、日志、剪贴板和内存转储泄露;真正的安全从一张数据流图开始。