3. 接口契约设计

接口契约(Interface Contract)是 ViewModel 与 UI 夹具之间最关键的边界定义。它明确了 ViewModel 对外暴露的“控制权与数据权”切分点:输入协议决定 UI 能发送什么事件,输出协议决定 UI 能观察到什么状态,回调接口处理双向异步结果,数据绑定接口负责可观察数据源的生命周期安全。本章从原理出发,逐步给出每个接口的代码实现,并集成在一个完整的契约文件中。

核心原则:ViewModel 对 UI 一无所知,UI 通过契约接口与 ViewModel 交互,杜绝直接引用 Activity/Fragment 对象。

3.1 接口契约总览

在正式编码前,先通过一张结构图理解五个核心角色之间的协作关系。

ViewModel 接口契约关系图 ViewModelContract InputProtocol OutputProtocol CallbackInterface DataBindingInterface UI Actor(发送事件) UI Observer(观察状态) 箭头方向表示调用/数据流向,UI 不直接持有 ViewModel 引用,仅通过接口通信

图中 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
}
方法返回类型说明
getCachedSearchQueryString上次搜索的缓存文本,用于屏幕重建时恢复输入框
getLastPageIndexInt当前已加载的最后一页序号,用于分页判断
getCurrentSortOrderSortOrder当前排序方式,UI 可以据此保持排序图标状态
isRefreshEnabledBoolean是否允许下拉刷新(例如加载中时禁止)
性能优化:输出协议不应执行耗时操作,所有方法必须是 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?>
}
属性类型行为
searchResultsStateFlow<UiState<List<Item>>>带状态包裹的结果列表,包含 Loading / Success / Error
loadingProgressStateFlow<Float>加载进度 0~1,UI 可显示进度条
errorMessageSharedFlow<String>一次性错误消息,使用 SharedFlow 避免重复重放
selectedItemStateFlow<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 推动更新。