![]()
2023年Stack Overflow开发者调查显示,Kotlin在Android开发中的使用率突破72%,但一个诡异的现象是:超过60%的开发者承认自己在用"类型体操"时属于复制粘贴,根本不懂原理。
这就像一个厨师用了三年烤箱,却说不清上下火区别的代价——你的代码能跑,但架构层面早已埋下地雷。
今天要拆解的RandomPokemon仓库,恰好暴露了一个典型场景:ViewModel卡在编译时类型和运行时数据之间的灰色地带,而作者用「自引用泛型」这个冷门武器,把类型错配扼杀在编译阶段。
类型擦除:编译器的"失忆症"
先看问题根源。UseCase类定义了一个逆变类型参数O(contravariant,用in修饰),它只出现在protected fun O?.update()这个方法里——意思是UseCase消费O,从不对外返回O。
但暴露给外界的state属性,类型是StateFlow。O在这里被彻底擦除。
ResultState.Complete虽然携带泛型数据,但ViewModel拿到的是裸ResultState。编译器此时已经"失忆":它知道有个Complete,但不知道里面装的是Pokemon列表还是错误日志。
传统解法是强制类型转换,或者干脆用Any?然后祈祷。RandomPokemon的作者选了第三条路:让ViewModel"自言自语",通过自引用泛型把类型信息钉死在继承链里。
自引用泛型:自己给自己当信使
BaseViewModel的签名长这样:
abstract class BaseViewModel> : ViewModel()
这个VM : BaseViewModel的递归约束,看起来像蛇吞尾巴。但它的实际作用是:强制每个子类在声明时,必须把自己作为类型参数传回给父类。
换句话说,PokemonViewModel继承BaseViewModel,而不是BaseViewModel<*>。
父类因此获得了"预知未来"的能力——它知道最终实例的具体类型,可以把类型安全操作封装在泛型方法里,而子类只需要实现约定好的钩子。
具体到代码,BaseViewModel提供了一个受保护的collect方法:
protected inline fun Flow.collect( crossinline onLoading: suspend VM.() -> Unit = {}, crossinline onError: suspend VM.(Throwable) -> Unit = { throw it }, crossinline onSuccess: suspend VM.(T) -> Unit = {} )
注意三个细节:reified T保留了泛型实参的运行时信息;三个回调的接收者都是VM(即子类自身);crossinline保证lambda不会搞乱控制流。
密封类+实化类型:双重保险
ResultState被设计成密封类(sealed class),这给了when表达式穷举检查的能力。配合reified T,collect方法内部可以安全地执行类型匹配:
when (state) { is ResultState.Running -> onLoading() is ResultState.Error -> onError(state.error ?: Throwable("Unknown")) is ResultState.Complete<*> -> { val data = state.data as? T if (data != null || state.data == null) { onSuccess(data as T) } else { throw TypeMismatchException("Expected ${T::class}, got ${state.data::class}") } } }
这里的关键设计:类型错配不会静默失败,而是抛出自定义异常。
作者刻意保留了null的合法性(data == null时允许通过),但拒绝类型不兼容的情况。这比Kotlin原生的智能转换更严格,也更适合MVVM架构中"空状态是业务语义,类型错误是程序bug"的区分。
ViewModel的使用方代码变得极其干净:
class PokemonViewModel @Inject constructor( private val getPokemonList: GetPokemonListUseCase ) : BaseViewModel() { init { viewModelScope.launch { getPokemonList.state.collect>( onLoading = { /* VM作为接收者 */ }, onSuccess = { pokemonList -> /* 类型已推断为List */ } ) } } }
尖括号里的List只写一次,后续所有回调都享受类型推导。onSuccess的it就是List,不是Any?,不需要as?转换。
方差注解:数据流向的密码
回看ResultState和UseCase的方差设计,会发现作者对Kotlin的类型系统有精确把控。
Complete用out修饰,因为O只出现在data属性的getter位置——ResultState生产O。UseCase用in修饰,因为O只出现在update()的参数位置——UseCase消费O。
如果互换这两个注解,编译器会在数据流断裂的地方给出精确报错。
这种设计不是装饰。它确保了:你可以把ResultState.Complete>赋值给ResultState.Complete(协变安全),但不能把UseCase赋值给UseCase>(逆变保护消费端)。
自引用泛型在这个体系里扮演的角色,是把"编译时已知的类型约束"传递到"运行时才能确定的数据边界"。没有它,ViewModel只能在泛型擦除后的废墟里做不安全的强制转换。
RandomPokemon仓库的commit历史显示,这个模式经历过三次迭代:最初用反射读取泛型实参,性能开销明显;后来尝试泛型工厂类,调用点代码臃肿;最终定型为现在的自引用泛型+实化类型组合。
作者在最近一条issue回复里写道:「我试过用Flow的map操作符在ViewModel层做类型转换,但那样每个ViewModel都要写一样的样板代码。现在的方案把样板压进父类,子类只关心业务回调。」
这个选择背后是一个常被忽视的事实:Kotlin的inline函数配合reified,本质上是在编译期生成特化代码,没有运行时反射开销。而自引用泛型的递归约束,全部由编译器在类型检查阶段验证。
如果你正在维护一个类似的架构边界——比如把RxJava的Single迁移到Flow,或者统一处理多个数据源的加载状态——这个模式值得直接搬运。但搬运之前,建议先删掉RandomPokemon里的具体业务代码,只看BaseViewModel和ResultState的交互。类型安全的本质从来不是语法糖堆叠,而是让错误在最早可能的阶段暴露。
最后一个细节:collect方法的三个回调默认实现里,onError直接抛出异常。这在生产环境显然不够,但作者故意不做全局错误处理——「每个ViewModel对错误的响应不同,有的要弹Toast,有的要导航到错误页。强制统一是架构的傲慢。」
这种克制,和Kotlin类型系统本身的哲学一致:提供足够强的约束防止愚蠢错误,但保留足够的灵活性让正确的事情容易做。
你的项目里,有多少处强制转换其实可以用自引用泛型干掉?
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.