搜狐技术产品

Android 多 ViewModel 间代码复用最佳实践

引言

在使用 ViewModel 的项目中经常会遇到这种情况:当多个页面需要显示相同用户组件时,页面对应的多个 ViewModel 需要处理相同的用户交互。例如一个社交应用中,许多页面都会存相同的转赞评功能等。

如果每个 ViewModel 中单独编写代码会很快导致大量代码重复。随着页面数量的增加,这个问题会变得更加严重,导致代码库难以维护,并出现可扩展性问题。

本文带大家探索几种常见的解决方案,并通过对比得出 ViewModel 代码复用的最佳实践,以实现更高效的用户界面交互管理。

问题背景:多页面有共同的用户交互

假设我们有一个社交应用,多个页面都需要对浏览的帖子进转赞评等交互,基于 MVI 架构,我们用密封接口定义这些用户交互。

sealedinterfacePostAction{
dataclassClicked(val id: String) : PostAction
dataclassLikeClicked(val id: String) : PostAction
dataclassShareClicked(val id: String) : PostAction
}

同时,我们还有一个 BaseViewModel 类,这里定义每个 ViewModel 都需要的通用功能。

abstractclassBaseViewModel : ViewModel() {

var showShackBar by mutableStateOf("")
var showBottomSheet by mutableStateOf("")

funnavigate() { 
//Implementation 
    }

funshowSnackbar(message: String) { 
      showSnackBar = message
    }
}

所有页面都存在这些交互基础能力,所以可以成为所有 ViewModel 的基类。

问题:当多个页面需要共享 PostAction 逻辑时,我们该如何思考逻辑复用?

解决方案 1:增加 ViewModel 基类

这是最容易想到的方案,在 BaseViewModel 之下在增加一层基类,处理 PostAction。

openclassBasePostViewModel : BaseViewModel() {

funhandleAction(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 来处理交互。

classPostActionHandler(privateval viewModel: BaseViewModel) {

funhandleAction(action: PostAction) = when (action) {
is PostAction.Clicked -> viewModel.navigate()
is PostAction.LikeClicked -> viewModel.showSnackBar("Liked")
is PostAction.ShareClicked -> { /*Implementation */ }
    }
}

需要注意的是,PostAction 需要依赖 BaseViewModel 的基本能力,所以在 PostScreenViewModel 中创建 PostActionHandler 的实例时,需要传入 this

classPostScreenViewModel : BaseViewModel() {
val actionHandler = PostActionHandler(this)
}

@Composable
funPostScreen(viewModel: PostScreenViewModel) {
    PostItem(onAction = viewModel.actionHandler::handleAction)
}

@Composable
funPostItem(onAction: (PostAction) -> Unit) {  

}

这种方法在不增加继承层级的前提下,解决了在多 ViewModel 代码复用的问题,但它也存在一些缺点:

  • 缺少灵活性:由于 PostActionHandler 是一个封装类,我们无法在子类中重写任何行为。

  • 调用成本高:我们无法通过 ViewModel 直接调用 handleAction,需要暴露 actionHandler 这个属性。这本是调用方无需关心的细节,有点违背“迪米特法则”(也称最小知识原则)

特别是“无法重写行为”让灵活性大打折扣,有间接违背了开闭原则:对扩展开放。

解决方案 3:使用 Kotlin 类委托

使用接口替代类可以解决单继承问题,借助 Kotlin 类委托可以让我们的接口具备默认实现

将 PostActionHandler 改造为一个接口,并提供默认实现类 PostActionHandlerImpl

interfacePostActionHandler{
suspendfunhandleAction(action: PostAction)
}

classPostActionHandlerImpl : PostActionHandler {

overridefunhandleAction(action: PostAction) = when (action) {
is PostAction.Clicked -> handlePostClick(action.id)
is PostAction.LikeClicked -> handleLikeClick(action.id)
is PostAction.ShareClicked -> handleShareClick(action.id)
    }

privatefunhandlePostClick(id: String) { }

privatefunhandleLikeClick(id: String) { }

privatefunhandleShareClick(id: String) { }

}

然后,看看我们让 ViewModel 实现此接口并关联默认实现。

classPostViewModel : BaseViewModel(), PostActionHandler by PostActionHandlerImpl() {

}

@Composable
funPostScreen(viewModel: PostViewModel) {
    PostItem(onAction = viewModel::handleAction)
}

@Composable
funPostItem(onAction: (PostAction) -> Unit) {

}

这种方法解决了 Helper 类存在的所有问题:

  • 灵活重写:通过使用接口和委托,我们可以轻松重写任何行为。
  • 直接访问功能:功能成为 ViewModel 的成员方法,我们可以像调用原生 ViewModel 方法一样调用这些函数。

然而,类委托仍然有一个缺点:无法访问 BaseViewModel 的功能,因为类位图无法传入 this 引用。

当我们尝试将 this 作为构造函数参数传递时,Android Studio 会报错。

Image

当然,我们也无法使用 ViewModelScope,无法直接在 ViewModel 中启动协程。

解决方案 4:使用 Kotlin 接口默认函数

Kotlin 1.8 开始引入接口的默认函数,可以帮我们完美解决类委托默认实现无法传入 this 的不足。我们可以通过 this 访问目标 ViewModel 的所有功能,包括 ViewModel 对 UseCase 或者 Repository 的调用,当然也包括 ViewModelScope

首先,在 PostActionHandler 中增加一个 viewModel 属性

interfacePostActionHandler{

val viewModel: BaseViewModel

funhandleAction(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)
        }
    }

suspendfunhandlePostClick(id: String) {
        viewModel.navigate()
    }

suspendfunhandleLikeClick(id: String) {
        viewModel.showToast("Liked")
    }

suspendfunhandleShareClick(id: String) {
//Implementation
    }

}

在 ViewModel 中实现此接口,通过重写 viewModel 赋值 this

classPostViewModel : BaseViewModel(), PostActionHandler {

//passing current baseViewModel reference
overrideval viewModel = this

overridefunhandleShareClick(id: String) {
//...
    }

}

在 UI(以 Composable 为例)中使用此 ViewModel 的效果如下:

@Composable
privatefunPostScreen(viewModel: PostViewModel) {
    PostItem(onAction = viewModel::handleAction)
}

@Composable
privatefunPostItem(
    onAction: (PostAction) -> Unit
)
 {

}

如上,通过将 ViewModel 引用作为接口属性,我们可以轻松地在 PostAction 逻辑中,调用 ViewModel 的基础能力,以及通过 ViewModelScope 启动协程。

使用接口默认函数的唯一缺点,就是需要通过接口属性传递必要参数。比如有时我们需要引用 ViewModel 中的 UseCase 或 Repository,就需要通过属性进行传递:

interfacePostActionHandler{
val postRepository: PostRepository
val viewModelScope: CoroutineScope

// 默认方法,处理点赞帖子的逻辑
funlikePost(postId: Int) {
        viewModelScope.launch {
            postRepository.likePost(postId)
        }
    }

// 默认方法,处理评论帖子的逻辑
funcommentOnPost(postId: Int, comment: String) {
        viewModelScope.launch {
            postRepository.commentOnPost(postId, comment)
        }
    }
}

// 模拟的帖子仓库类,用于处理帖子相关的数据操作
classPostRepository{
suspendfunlikePost(postId: Int) {
//Implementation
    }

suspendfuncommentOnPost(postId: Int, comment: String) {
//Implementation
    }
}


classPostScreenViewModel : ViewModel(), PostActionHandler {
overrideval postRepository = PostRepository()
overrideval viewModelScope = CoroutineScope(Dispatchers.Main)

}

上面代码中,重写了 postRepository 属性。

是否可以让 PostViewModel 封装对 PostRepository 的调用? 如果这封装后的方法只是服务 PostActionHandler 那就没必要,还是让后者直接访问 PostRepository 更合理。

当然,如果 PostRepository 是单例的话,就没必要通过 PostViewModel 传递了,可以在 PostActionHandler 中直接使用。

虽然有上述的小麻烦,但相对而言,这已经是 ViewModel 代码复用的最佳方案了。因为它用最小的代价最大限度避免了前几种方案中的所有不足。

实战解决方案4

假设我们有一个主页面,它以列表形式显示各种用户界面元素,如带有帖子、新闻文章等项目的提要。每个项目都有共享的用户界面交互,例如点赞或导航到详情页。

解决方案4 可以帮助我们高效地处理不同 ViewModel 之间的这些交互,确保在所有 ViewModel 中统一处理常见交互。

设计密封接口

就像在典型的 MVI 架构中一样,我们为每个用户界面交互设计密封接口。

sealedinterfacePostAction{

dataclassClicked(val post: Post) : PostAction
dataclassLikeClicked(val post: Post) : PostAction
dataclassShareClicked(val post: Post) : PostAction

}
sealedinterfaceNewsAction{

dataclassClicked(val news: News) : NewsAction
dataclassLikeClicked(val news: News) : NewsAction
dataclassBookmarkClicked(val news: News) : NewsAction

}

定义接口默认函数

interfacePostActionHandler{

val viewModel: BaseViewModel
val postRepo: PostRepository

funhandleAction(action: PostAction) = viewModel.viewModelScope.launch {
when (action) {
is PostAction.Clicked -> handlePostClick(action.id)
is PostAction.LikeClicked -> handleLikeClick(action.id)
        }
    }

suspendfunhandlePostClick(id: String) { 
      viewModel.navigate(......)
    }

suspendfunhandleLikeClick(id: String) { 
      postRepo.like(id)
      viewModel.showSnackbar("Post Liked")
    }

}
interfaceNewsActionHandler{

val viewModel: BaseViewModel
val newsRepo: NewsRepository

funhandleAction(action: NewsAction) = when (action) {
is NewsAction.Clicked -> handleNewsClick(action.id)
is NewsAction.Bookmark -> handleNewsBookmark(action.id)
    }

funhandleNewsClick(id: String) { 
      viewModel.navigate(.....)
    }

funhandleNewsBookmark(id: String) { 
      newsRepo.bookmark(id)
      viewModel.showSnackBar("News Bookmarked")
    }
}

为每个 ViewModel 创建一个 ActionHandler 接口。接口有两个属性,分别是 ViewModel 和 Repository。另外为每个 Action 类型都实现了一个单独的函数,每个函数的功能更具体。

创建 ViewModel

构建首页对应的 ViewModel,实现这两个接口。ViewModel 中定义必要的属性(viewModel、postRepo 和 newsRepo),并在需要时使用它们。

classHomeViewModel : BaseViewModel(), PostActionHandler, NewsActionHandler {

overrideval viewModel = this
overrideval postRepo = PostRepository()
overrideval newsRepo = NewsRepository()

/**
      Rest of ViewModel code
    **/


}

ViewModel 通过 ActionHandler 快速继承各类用户交互行为。尽量确保各个 ActionHandler 中相同属性的命名相同(例如 viewModel), 这样我们只需要重写一次属性即可。

实现用户界面

@Composable
funHomeScreen(viewModel: HomeViewModel) {
    LazyColumn {
        items(viewModel.items) {
when(it) {
is News -> NewsCard(it, onAction = viewModel::handleAction)
is Post -> PostCard(it, onAction = viewModel::handleAction)
            }
        }
    }
}

@Composable
privatefunNewsCard(
  news: News,
  onAction: (NewsAction) -> Unit
)
 { 

}

@Composable
privatefunPostCard(
  post: Post,
  onAction: (PostAction) -> Unit
)
 { 

}

主页中构建不用的视图组件,并传入 viewModel::handleAction,当然这里会根据 Action 类型,传入不同签名的 handleAction。

假设我们套实现另一个只显示 News 的页面,我们可以在 NewsViewModel 中快速复用 NewsActionHandler,实现预期的交互行为。

总结

在本文中,我们探讨了 ViewModel 中处理共享用户交互逻辑的四种解决方案:

  • 类继承:简单,但是有单继承限制
  • 辅助类:简单,但缺少重写灵活性。
  • 类委托:无法通过 this 提供 ViewModel 能力。
  • 接口默认函数:综合表现最好。

我们以表格形式型详细列举各自优缺点:

特性
类继承
辅助类
类委托
接口默认函数
可多继承
❌
✅
✅
✅
可重写性
✅
❌
✅
✅
VM可访问
✅
✅
❌
✅
推荐场景
页面功能相对简单,不存在多种组件组合的页面
不同页面的相同组件功能高度一致,无需差异化定制
VM可复用代码以业务逻辑为主,用户交互可复用较少
其他各类复杂场景

通过对比可以看到,接口默认函数的解决方案,即保证了各个 ActionHandler 的单一职责,又能满足 VM 对 ActionHandler 的多继承诉求,是总体而言的最佳方案。