3. 接口契约设计
接口契约(Interface Contract)是 ViewModel 与 UI 夹具之间最关键的边界定义。它明确了 ViewModel 对外暴露的“控制权与数据权”切分点:输入协议决定 UI 能发送什么事件,输出协议决定 UI 能观察到什么状态,回调接口处理双向异步结果,数据绑定接口负责可观察数据源的生命周期安全。本章从原理出发,逐步给出每个接口的代码实现,并集成在一个完整的契约文件中。
核心原则:ViewModel 对 UI 一无所知,UI 通过契约接口与 ViewModel 交互,杜绝直接引用 Activity/Fragment 对象。
3.1 接口契约总览
在正式编码前,先通过一张结构图理解五个核心角色之间的协作关系。
图中 ViewModelContract 为中心,四个子接口分别处理 UI 到 ViewModel 的输入、ViewModel 到 UI 的输出、异步结果回调以及可观测数据绑定。UI 夹具仅依赖这些接口,无需知道内部实现。
3.2 ViewModel 顶层接口定义
将 ViewModel 的行为抽象为一个纯净的接口,不继承任何 Android 框架类,方便测试时直接 mock。
interface ViewModelContract<Input, Output, Callback, DataBinding> {
val input: Input
val output: Output
val callback: Callback
val dataBinding: DataBinding
}
设计意图:通过泛型参数将四个子接口组合在一起,ViewModel 的具体实现只需组合它们并注入业务逻辑。UI 夹具通过
input 发送事件,通过 output 获取状态快照,通过 callback 注册异步回调,通过 dataBinding 订阅可观测数据。
3.3 输入协议(InputProtocol)
定义 UI 可以向 ViewModel 发送的所有事件。每个事件对应一个方法,参数仅限于基本类型或不可变数据类。
interface InputProtocol {
fun onSearch(query: String)
fun onRefresh()
fun onItemClicked(itemId: Long)
fun onRetryLastAction()
}
- 方法名体现用户意图(onSearch、onRefresh),而不是 ViewModel 内部处理逻辑。
- 所有参数均为不可变值,禁止传递 Context、View 或 Activity 引用。
- 返回类型为 Unit(无返回值),事件发送是单向的。
常见错误:在输入协议中定义返回 LiveData 或挂起函数的方法。输入协议只负责“通知”,不负责“查询”。查询应使用输出协议或数据绑定接口。
3.4 输出协议(OutputProtocol)
提供 UI 可同步读取的状态快照,通常用于表单初始值或列表分页计数等需要立即获取的场景。
interface OutputProtocol {
fun getCachedSearchQuery(): String
fun getLastPageIndex(): Int
fun getCurrentSortOrder(): SortOrder
fun isRefreshEnabled(): Boolean
}
| 方法 | 返回类型 | 说明 |
|---|---|---|
| getCachedSearchQuery | String | 上次搜索的缓存文本,用于屏幕重建时恢复输入框 |
| getLastPageIndex | Int | 当前已加载的最后一页序号,用于分页判断 |
| getCurrentSortOrder | SortOrder | 当前排序方式,UI 可以据此保持排序图标状态 |
| isRefreshEnabled | Boolean | 是否允许下拉刷新(例如加载中时禁止) |
性能优化:输出协议不应执行耗时操作,所有方法必须是 O(1) 或 O(log n) 的无阻塞调用。真正的异步数据通过数据绑定接口推送。
3.5 回调接口(CallbackInterface)
处理需要双向确认的操作,例如导航、权限请求或删除确认对话框。UI 实现回调,ViewModel 在需要时调用。
interface CallbackInterface {
fun onNavigateTo(destination: String, extras: Map<String, Any?>)
fun onRequestPermission(permission: String, requestCode: Int)
fun onShowConfirmDialog(title: String, message: String): Boolean
fun onLaunchAction(action: Action)
}
- 回调中可以返回布尔值(如 onShowConfirmDialog)让 ViewModel 获得决策结果。
- 避免在回调中传递大量数据;复杂参数应封装为不可变数据类。
- 回调的调用必须发生在主线程(ViewModel 内部使用 viewModelScope 进行线程切换)。
3.6 数据绑定接口(DataBindingInterface)
提供可观测数据流,UI 通过 Lifecycle 感知订阅,自动处理重建与销毁。
interface DataBindingInterface {
val searchResults: StateFlow<UiState<List<Item>>>
val loadingProgress: StateFlow<Float>
val errorMessage: SharedFlow<String>
val selectedItem: StateFlow<ItemDetail?>
}
| 属性 | 类型 | 行为 |
|---|---|---|
| searchResults | StateFlow<UiState<List<Item>>> | 带状态包裹的结果列表,包含 Loading / Success / Error |
| loadingProgress | StateFlow<Float> | 加载进度 0~1,UI 可显示进度条 |
| errorMessage | SharedFlow<String> | 一次性错误消息,使用 SharedFlow 避免重复重放 |
| selectedItem | StateFlow<ItemDetail?> | 当前选中项,UI 用于详情面板展示 |
生命周期安全:UI 在 onStart 处通过
repeatOnLifecycle 或 collectAsStateWithLifecycle 订阅,在 onStop 时自动取消,无需手动管理。ViewModel 内使用 stateIn / shareIn 确保流在无观察者时不生产数据。
3.7 完整契约集成示例
将四个子接口与 ViewModelContract 组装,调用方只需注入一次。
class MainViewModel : ViewModelContract<InputProtocol, OutputProtocol, CallbackInterface, DataBindingInterface> {
override val input: InputProtocol = InputHandler()
override val output: OutputProtocol = StateSnapshot()
override val callback: CallbackInterface = CallbackDelegate()
override val dataBinding: DataBindingInterface = ObservableData()
// 内部委托类实现各接口逻辑
private inner class InputHandler : InputProtocol {
override fun onSearch(query: String) { /* ... */ }
override fun onRefresh() { /* ... */ }
override fun onItemClicked(itemId: Long) { /* ... */ }
override fun onRetryLastAction() { /* ... */ }
}
private inner class StateSnapshot : OutputProtocol { /* ... */ }
private inner class CallbackDelegate : CallbackInterface { /* ... */ }
private inner class ObservableData : DataBindingInterface { /* ... */ }
}
测试优势:在单元测试中,创建 MockInputProtocol、MockOutputProtocol 等,注入 ViewModel 即可验证业务逻辑,无需启动 Activity 或 Fragment。
3.8 接口契约的调试与验证
- 编译时检查:接口方法签名变更会导致 UI 方立即报错,避免运行时崩溃。
- 单元测试覆盖:为每个接口方法编写至少一个正向用例和一个异常输入用例。
- 调用追踪:在接口实现类的各个方法入口打印日志,配合
Timber或Logcat监控事件流。
// 调试日志示例(位于 InputHandler.onSearch)
override fun onSearch(query: String) {
Timber.d("InputProtocol.onSearch called with query=[%s]", query)
// ... 业务处理
}
性能陷阱:不要在数据绑定接口的 getter 或属性每次访问时触发 I/O 或网络请求;所有数据应预存在内存中,通过 StateFlow 推动更新。