引言

在使用 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,还存在 UserActionNewsActioin 等多种交互逻辑的复用需求,如果一个页面需要同时两个多个 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 中的 UseCaseRepository,就需要通过属性进行传递:

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 中定义必要的属性(viewModelpostReponewsRepo),并在需要时使用它们。

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 的多继承诉求,是总体而言的最佳方案。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐