多 ViewModel 间代码复用最佳实践
为每个 ViewModel 创建一个接口。接口有两个属性,分别是 ViewModel 和 Repository。另外为每个 Action 类型都实现了一个单独的函数,每个函数的功能更具体。类继承:简单,但是有单继承限制辅助类:简单,但缺少重写灵活性。类委托:无法通过this提供 ViewModel 能力。接口默认函数:综合表现最好。特性类继承辅助类类委托接口默认函数可多继承❌✅✅✅可重写性✅❌✅✅
引言
在使用 ViewModel 的项目中经常会遇到这种情况:当多个页面需要显示相同用户组件时,页面对应的多个 ViewModel 需要处理相同的用户交互。例如一个社交应用中,许多页面都会存相同的转赞评功能等。
如果每个 ViewModel 中单独编写代码会很快导致大量代码重复。随着页面数量的增加,这个问题会变得更加严重,导致代码库难以维护,并出现可扩展性问题。
本文带大家探索几种常见的解决方案,并通过对比得出 ViewModel 代码复用的最佳实践,以实现更高效的用户界面交互管理。
问题背景:多页面有共同的用户交互
假设我们有一个社交应用,多个页面都需要对浏览的帖子进转赞评等交互,基于 MVI 架构,我们用密封接口定义这些用户交互。
sealed interface PostAction {
data class Clicked(val id: String) : PostAction
data class LikeClicked(val id: String) : PostAction
data class ShareClicked(val id: String) : PostAction
}
同时,我们还有一个 BaseViewModel 类,这里定义每个 ViewModel 都需要的通用功能。
abstract class BaseViewModel : ViewModel() {
var showShackBar by mutableStateOf("")
var showBottomSheet by mutableStateOf("")
fun navigate() {
//Implementation
}
fun showSnackbar(message: String) {
showSnackBar = message
}
}
所有页面都存在这些交互基础能力,所以可以成为所有 ViewModel 的基类。
问题:当多个页面需要共享 PostAction 逻辑时,我们该如何思考逻辑复用?
解决方案 1:增加 ViewModel 基类
这是最容易想到的方案,在 BaseViewModel 之下在增加一层基类,处理 PostAction。
open class BasePostViewModel : BaseViewModel() {
fun handleAction(action: PostAction) = when (action) {
is PostAction.Clicked -> navigate()
is PostAction.LikeClicked -> showSnackBar("Liked")
is PostAction.ShareClicked -> { /*Implementation */ }
}
}
需要 PostAction 功能的各页面 ViewModel 都继承 BasePostViewModel 就可以了。对于简单的应用,这通常没啥问题,随着功能逐渐复杂,“组合优于继承”的优势逐渐凸显
除了 PostAction,还存在 UserAction、NewsActioin 等多种交互逻辑的复用需求,如果一个页面需要同时两个多个 Action 时,“单继承”要求你只能选择复用一类 Action,限制了代码复用的空间。
有人也许会将所有的 Action 都融入到一个大的 Base 中,这显然更糟糕,违背了 SRP(单一职责) 和 ISP(接口隔离)这些基础设计原则。
随着项目复杂度提升,继承层级过深的危害会日益显现,接下来,重点看看其他的解决方案。
解决方案 2:使用 helper 辅助类
在这种方案中,我们创建一个单独的辅助类来封装 PostAction 逻辑,而后将这个辅助类作为属性添加到 ViewModel 中。
首先,定义一个辅助类 PostActionHandler 来处理交互。
class PostActionHandler(private val viewModel: BaseViewModel) {
fun handleAction(action: PostAction) = when (action) {
is PostAction.Clicked -> viewModel.navigate()
is PostAction.LikeClicked -> viewModel.showSnackBar("Liked")
is PostAction.ShareClicked -> { /*Implementation */ }
}
}
需要注意的是,PostAction 需要依赖 BaseViewModel 的基本能力,所以在 PostScreenViewModel 中创建 PostActionHandler 的实例时,需要传入 this
class PostScreenViewModel : BaseViewModel() {
val actionHandler = PostActionHandler(this)
}
@Composable
fun PostScreen(viewModel: PostScreenViewModel) {
PostItem(onAction = viewModel.actionHandler::handleAction)
}
@Composable
fun PostItem(onAction: (PostAction) -> Unit) {
}
这种方法在不增加继承层级的前提下,解决了在多 ViewModel 代码复用的问题,但它也存在一些缺点:
-
缺少灵活性:由于
PostActionHandler是一个封装类,我们无法在子类中重写任何行为。 -
调用成本高:我们无法通过 ViewModel 直接调用
handleAction,需要暴露actionHandler这个属性。这本是调用方无需关心的细节,有点违背“迪米特法则”(也称最小知识原则)
特别是“无法重写行为”让灵活性大打折扣,有间接违背了开闭原则:对扩展开放。
解决方案 3:使用 Kotlin 类委托
使用接口替代类可以解决单继承问题,借助 Kotlin 类委托可以让我们的接口具备默认实现
将 PostActionHandler 改造为一个接口,并提供默认实现类 PostActionHandlerImpl
interface PostActionHandler {
suspend fun handleAction(action: PostAction)
}
class PostActionHandlerImpl : PostActionHandler {
override fun handleAction(action: PostAction) = when (action) {
is PostAction.Clicked -> handlePostClick(action.id)
is PostAction.LikeClicked -> handleLikeClick(action.id)
is PostAction.ShareClicked -> handleShareClick(action.id)
}
private fun handlePostClick(id: String) { }
private fun handleLikeClick(id: String) { }
private fun handleShareClick(id: String) { }
}
然后,看看我们让 ViewModel 实现此接口并关联默认实现。
class PostViewModel : BaseViewModel(), PostActionHandler by PostActionHandlerImpl() {
}
@Composable
fun PostScreen(viewModel: PostViewModel) {
PostItem(onAction = viewModel::handleAction)
}
@Composable
fun PostItem(onAction: (PostAction) -> Unit) {
}
这种方法解决了 Helper 类存在的所有问题:
- 灵活重写:通过使用接口和委托,我们可以轻松重写任何行为。
- 直接访问功能:功能成为 ViewModel 的成员方法,我们可以像调用原生 ViewModel 方法一样调用这些函数。
然而,类委托仍然有一个缺点:无法访问 BaseViewModel 的功能,因为类位图无法传入 this 引用。
当我们尝试将 this 作为构造函数参数传递时,Android Studio 会报错。
当然,我们也无法使用 ViewModelScope,无法直接在 ViewModel 中启动协程。
解决方案 4:使用 Kotlin 接口默认函数
Kotlin 1.8 开始引入接口的默认函数,可以帮我们完美解决类委托默认实现无法传入 this 的不足。我们可以通过 this 访问目标 ViewModel 的所有功能,包括 ViewModel 对 UseCase 或者 Repository 的调用,当然也包括 ViewModelScope
首先,在 PostActionHandler 中增加一个 viewModel 属性
interface PostActionHandler {
val viewModel: BaseViewModel
fun handleAction(action: PostAction) = viewModel.viewModelScope.launch {
when (action) {
is PostAction.Clicked -> handlePostClick(action.id)
is PostAction.LikeClicked -> handleLikeClick(action.id)
is PostAction.ShareClicked -> handleShareClick(action.id)
}
}
suspend fun handlePostClick(id: String) {
viewModel.navigate()
}
suspend fun handleLikeClick(id: String) {
viewModel.showToast("Liked")
}
suspend fun handleShareClick(id: String) {
//Implementation
}
}
在 ViewModel 中实现此接口,通过重写 viewModel 赋值 this
class PostViewModel : BaseViewModel(), PostActionHandler {
//passing current baseViewModel reference
override val viewModel = this
override fun handleShareClick(id: String) {
//...
}
}
在 UI(以 Composable 为例)中使用此 ViewModel 的效果如下:
@Composable
private fun PostScreen(viewModel: PostViewModel) {
PostItem(onAction = viewModel::handleAction)
}
@Composable
private fun PostItem(
onAction: (PostAction) -> Unit
) {
}
如上,通过将 ViewModel 引用作为接口属性,我们可以轻松地在 PostAction 逻辑中,调用 ViewModel 的基础能力,以及通过 ViewModelScope 启动协程。
使用接口默认函数的唯一缺点,就是需要通过接口属性传递必要参数。比如有时我们需要引用 ViewModel 中的 UseCase 或 Repository,就需要通过属性进行传递:
interface PostActionHandler {
val postRepository: PostRepository
val viewModelScope: CoroutineScope
// 默认方法,处理点赞帖子的逻辑
fun likePost(postId: Int) {
viewModelScope.launch {
postRepository.likePost(postId)
}
}
// 默认方法,处理评论帖子的逻辑
fun commentOnPost(postId: Int, comment: String) {
viewModelScope.launch {
postRepository.commentOnPost(postId, comment)
}
}
}
// 模拟的帖子仓库类,用于处理帖子相关的数据操作
class PostRepository {
suspend fun likePost(postId: Int) {
//Implementation
}
suspend fun commentOnPost(postId: Int, comment: String) {
//Implementation
}
}
class PostScreenViewModel : ViewModel(), PostActionHandler {
override val postRepository = PostRepository()
override val viewModelScope = CoroutineScope(Dispatchers.Main)
}
上面代码中,重写了 postRepository 属性。
是否可以让 PostViewModel 封装对 PostRepository 的调用? 如果这封装后的方法只是服务 PostActionHandler 那就没必要,还是让后者直接访问 PostRepository 更合理。
当然,如果 PostRepository 是单例的话,就没必要通过 PostViewModel 传递了,可以在 PostActionHandler 中直接使用。
虽然有上述的小麻烦,但相对而言,这已经是 ViewModel 代码复用的最佳方案了。因为它用最小的代价最大限度避免了前几种方案中的所有不足。
实战解决方案4
假设我们有一个主页面,它以列表形式显示各种用户界面元素,如带有帖子、新闻文章等项目的提要。每个项目都有共享的用户界面交互,例如点赞或导航到详情页。
解决方案4 可以帮助我们高效地处理不同 ViewModel 之间的这些交互,确保在所有 ViewModel 中统一处理常见交互。
设计密封接口
就像在典型的 MVI 架构中一样,我们为每个用户界面交互设计密封接口。
sealed interface PostAction {
data class Clicked(val post: Post) : PostAction
data class LikeClicked(val post: Post) : PostAction
data class ShareClicked(val post: Post) : PostAction
}
sealed interface NewsAction {
data class Clicked(val news: News) : NewsAction
data class LikeClicked(val news: News) : NewsAction
data class BookmarkClicked(val news: News) : NewsAction
}
定义接口默认函数
interface PostActionHandler {
val viewModel: BaseViewModel
val postRepo: PostRepository
fun handleAction(action: PostAction) = viewModel.viewModelScope.launch {
when (action) {
is PostAction.Clicked -> handlePostClick(action.id)
is PostAction.LikeClicked -> handleLikeClick(action.id)
}
}
suspend fun handlePostClick(id: String) {
viewModel.navigate(......)
}
suspend fun handleLikeClick(id: String) {
postRepo.like(id)
viewModel.showSnackbar("Post Liked")
}
}
interface NewsActionHandler {
val viewModel: BaseViewModel
val newsRepo: NewsRepository
fun handleAction(action: NewsAction) = when (action) {
is NewsAction.Clicked -> handleNewsClick(action.id)
is NewsAction.Bookmark -> handleNewsBookmark(action.id)
}
fun handleNewsClick(id: String) {
viewModel.navigate(.....)
}
fun handleNewsBookmark(id: String) {
newsRepo.bookmark(id)
viewModel.showSnackBar("News Bookmarked")
}
}
为每个 ViewModel 创建一个 ActionHandler 接口。接口有两个属性,分别是 ViewModel 和 Repository。另外为每个 Action 类型都实现了一个单独的函数,每个函数的功能更具体。
创建 ViewModel
构建首页对应的 ViewModel,实现这两个接口。ViewModel 中定义必要的属性(viewModel、postRepo 和 newsRepo),并在需要时使用它们。
class HomeViewModel : BaseViewModel(), PostActionHandler, NewsActionHandler {
override val viewModel = this
override val postRepo = PostRepository()
override val newsRepo = NewsRepository()
/**
Rest of ViewModel code
**/
}
ViewModel 通过 ActionHandler 快速继承各类用户交互行为。尽量确保各个 ActionHandler 中相同属性的命名相同(例如 viewModel), 这样我们只需要重写一次属性即可。
实现用户界面
@Composable
fun HomeScreen(viewModel: HomeViewModel) {
LazyColumn {
items(viewModel.items) {
when(it) {
is News -> NewsCard(it, onAction = viewModel::handleAction)
is Post -> PostCard(it, onAction = viewModel::handleAction)
}
}
}
}
@Composable
private fun NewsCard(
news: News,
onAction: (NewsAction) -> Unit
) {
}
@Composable
private fun PostCard(
post: Post,
onAction: (PostAction) -> Unit
) {
}
主页中构建不用的视图组件,并传入 viewModel::handleAction,当然这里会根据 Action 类型,传入不同签名的 handleAction。
假设我们套实现另一个只显示 News 的页面,我们可以在 NewsViewModel 中快速复用 NewsActionHandler,实现预期的交互行为。
总结
在本文中,我们探讨了 ViewModel 中处理共享用户交互逻辑的四种解决方案:
- 类继承:简单,但是有单继承限制
- 辅助类:简单,但缺少重写灵活性。
- 类委托:无法通过
this提供 ViewModel 能力。 - 接口默认函数:综合表现最好。
我们以表格形式型详细列举各自优缺点:
| 特性 | 类继承 | 辅助类 | 类委托 | 接口默认函数 |
|---|---|---|---|---|
| 可多继承 | ❌ | ✅ | ✅ | ✅ |
| 可重写性 | ✅ | ❌ | ✅ | ✅ |
| VM可访问 | ✅ | ✅ | ❌ | ✅ |
| 推荐场景 | 页面功能相对简单,不存在多种组件组合的页面 | 不同页面的相同组件功能高度一致,无需差异化定制 | VM可复用代码以业务逻辑为主,用户交互可复用较少 | 其他各类复杂场景 |
通过对比可以看到,接口默认函数的解决方案,即保证了各个 ActionHandler 的单一职责,又能满足 VM 对 ActionHandler 的多继承诉求,是总体而言的最佳方案。
更多推荐



所有评论(0)