用途
该页面讨论的是为 Compose Multiplatform 项目提供一个共享的 ViewModel 基类。multiplatform 的 ViewModel 在各目标平台上都提供 viewModelScope 和 onCleared。项目基类在它之上只值得增加三类成员:一个容器句柄,使子类偶尔能拉取依赖而无需增加构造函数参数;一个统一的加载/错误展示面,避免每个屏幕各自发明一套;一个从具体类推导出的日志标签。
主要能力与示例结构
页面给出一个经过裁剪的基类示例,仅保留三个值得拥有的成员(名称已泛化):
abstract class BaseViewModel : ViewModel(), ContainerComponent {
protected val handler: PlaybackHandler by inject()
protected val tag: String = this::class.simpleName ?: "BaseViewModel"
private val _showLoadingDialog = MutableStateFlow(false to "")
val showLoadingDialog: StateFlow<Pair<Boolean, String>> get() = _showLoadingDialog
fun showLoadingDialog(message: String? = null) { … }
fun hideLoadingDialog() { … }
}
页面强调,基类中新增的每个成员都应逐一论证其必要性,因为基类是代码库中唯一一个依赖会同时对所有屏幕变成强制要求的地方。
使用陷阱
构造函数路径上的阻塞资源查找
multiplatform 字符串资源是异步加载的,getString 实际上是挂起函数。若用 runBlocking 桥接,放在属性初始化器中会在构造屏幕模型时阻塞主线程:
private val _showLoadingDialog = MutableStateFlow(false to getString(Res.string.loading))
建议给状态一个占位符,并在协程中填充,或让调用方传入已有字符串:
private val _showLoadingDialog = MutableStateFlow(false to "")
private var defaultLoadingText: String = ""
init { viewModelScope.launch { defaultLoadingText = getString(Res.string.loading) } }
可使用如下 grep 查找构造函数路径上的这类调用:
grep -rnE "^ *(private |protected |internal )?(val|var) .*= .*getString\(" --include="*.kt" src/
注意该模式仍会匹配函数体内的局部 val,需结合缩进判断成员初始化器。初始化器中的 runBlocking 是同一类陷阱,例如同步读取偏好存储。最糟糕的位置是组合(composition)而非构造:在 composable 中读取阻塞 getter 会在每次重组时阻塞帧线程。应将其发布为 flow,在屏幕顶部收集一次,再向下传递普通值。
fun isUserLoggedIn(): Boolean = runBlocking { dataStoreManager.cookie.first().isNotEmpty() }
fun isUserLoggedInFlow(): Flow<Boolean> = dataStoreManager.cookie.map { it.isNotEmpty() }
修复应限定在组合路径上;从生命周期回调或协程中调用阻塞形式是可以接受的。
onCleared 中取消 viewModelScope 是冗余的
viewModelScope 是注册在 ViewModel 上的可关闭作用域,清除 ViewModel 会关闭它并取消它。额外调用 viewModelScope.cancel() 没有作用。但若同一行被复制到类未创建的作用域(如注入的应用作用域)上,会取消进程内共享工作,而代码审查中看起来相同。
基类 init 中调用方法
Kotlin 先运行基类初始化器,因此基类 init 中触及的内容不可被重写、不可读取子类字段。示例中的调用安全仅因为它是 private。从基类 init 调用 open 或 abstract 成员会读取未初始化的子类状态,并间歇性失败。建议改用子类显式调用的生命周期方法。
属性初始化顺序
初始化器按声明顺序运行,init {} 块在其位置运行,因此放在状态字段上方的块无法读取该字段。在那里启动协程是安全的(稍后恢复),读取值则不安全。
容器感知是松动的契约
by inject() 将依赖移出构造函数,意味着直接构造该类的测试不再能看到它,也不再在编译期检查。应默认使用构造函数注入,仅为真正属于每个屏幕的内容保留容器句柄。
便捷方法将行为放入基类
如 loadMediaItem(...) 或 setQueueData(...) 这类委托给共享 handler 的辅助方法会被每个屏幕继承,包括那些绝不应触发它们的屏幕。如果子类调用它是错误的,它就不应放在基类上。
验证方式
测量构造函数而非屏幕:在基类 init 第一行和屏幕绘制第一帧处记录时间戳,然后依次打开多个屏幕。若出现与初始化器中资源或偏好读取次数成比例的数十毫秒一致间隔,即为阻塞查找,且不会出现在单元测试中。对于基类契约本身,直接构造一个子类且不配置容器;若抛出异常,说明某个 by inject() 成员位于构造函数路径上,基类已使自身不可测试。
然后审计阻塞访问器:@Composable 内的调用者是逐帧场景,完全无调用者的声明是死重。使用:
grep -rnE "fun [A-Za-z]+\(\): [A-Za-z]+ = runBlocking" --include='*.kt' --exclude-dir=build .
# then per name printed above: grep -rn "<name>" --include='*.kt' --exclude-dir=build . | grep -v "fun "
读取每个命中的接收者,另一个类上的同名成员会打印为调用者,缩小到 \.<name>\( 也不能排除它。
适用场景与限制
适用于使用 Compose Multiplatform 且需要统一 ViewModel 基础设施的项目。页面内容侧重于基类设计中的陷阱与验证方法,而非完整实现。关于该基类的具体安装方式、收费、平台支持等事实暂未提供。