稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

在Kotlin ViewModel中正确处理相同的UI组件交互

本文译自「Handling UI Actions the Right Way in Kotlin ViewModels」,原文链接https://proandroiddev.com/handling-ui-actions-the-right-way-in-kotlin-viewmodels-119a06bb43ef,由Vaibhav Jaiswal发布于2025年4月16日。

缘起

作为Android开发者,我们经常会遇到需要在多个ViewModel中实现相同或者非常类似UI功能的情况。

例如,我们有多个页面,它们具有类似的功能,例如显示帖子、撰写评论或处理用户交互。

在每个ViewModel中分别处理这些UI交互很快就会变得混乱,导致大量的代码重复。随着应用规模的扩大和页面数量的增加,这个问题会变得更加棘手,导致代码库难以维护,并带来可扩展性问题。

对于这种代码重复问题,我们Android开发者常用的几种常见解决方案包括:

  • 额外的辅助类方法
  • “继承”或“使用委托的组合”方法

在以下章节中,我们将详细探讨这些解决方案,了解每种方法如何解决代码重复问题,并重点介绍每种方法的局限性。然后,我们将深入探讨我的解决方案,它基于这些想法,并解决了它们的局限性,从而实现了更高效的UI交互管理。

这个解决方案我已经在 Medial 的应用(链接 https://medial.app/%EF%BC%89%E4%BB%A3%E7%A0%81%E5%BA%93%E4%B8%AD%E4%BD%BF%E7%94%A8%E8%BF%87%E4%BA%86%E2%80%94%E2%80%94%E5%B0%86 UI交互处理代码变成了近乎即插即用的体验。

我的解决方案根本不建议使用 BaseViewModel,也不基于 BaseViewModel。BaseViewModel 只是一个例子,用来说明我们可以使用从任何其他类/接口继承的功能,这些类/接口可以是 BaseViewModel、ViewModel、Decompose 的 ComponentContext 或其他任何东西。

⚡️ TL;DR: 处理ViewModel中的共享UI交互

当在多个页面上显示相同的UI组件时,在每个ViewModel中处理它们的交互会导致重复和逻辑混乱。我们探索了三种方法来解决这个问题:

  1. 辅助类方法
  2. ➖ 简单,但无法覆写任何行为
  3. ➖ 并非ViewModel的直接功能
  4. 通过 Kotlin 委托进行组合
  5. ✅ 更好的设计,支持覆写行为。
  6. ➖ 无法访问viewModelScope或其他ViewModel功能。
  7. ➖ 无法从任何其他继承类访问任何内容。
  8. 💡 我的解决方案 — 使用带有默认函数的接口
  9. ✅ 简洁、可复用,支持覆写(Override)行为。
  10. ✅ 完全访问ViewModel功能或任何其他继承类的功能。
  11. ✅ 只需从接口实现即可轻松插入任何 ViewModel

本博客中的所有解决方案均在此示例项目(链接 https://github.com/Vaibhav2002/Ui-Intreraction-Handler-Sample%EF%BC%89%E4%B8%AD%E5%AE%9E%E7%8E%B0%EF%BC%9A

🏗️ 设置

假设我们有一个后置UI 元素,它有一些UI 交互,并像 MVI 建议的那样,在一个密封的界面中呈现。

1
2
3
4
5
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所需的通用功能

1
2
3
4
5
6
7
8
9
10
11
12
13
abstract class BaseViewModel : ViewModel() {

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

    fun navigate() {
      // 实现
    }

    fun showSnackbar(message: String) {
      showSnackBar = message
    }
}

额外的辅助类方法

在这种方法中,我们创建一个单独的辅助类来封装UI交互的处理。我们将这个类作为ViewModel的一个属性。

首先,我们定义一个辅助类 PostActionHandler 来处理交互。

1
2
3
4
5
6
7
8
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 -> { /* 实现 */ }
    }
}

现在,在 PostScreenViewModel 中我们创建一个 PostActionHandler 实例

1
2
3
4
5
6
7
8
9
10
11
12
13
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本身,而是将其作为ViewModel的一个属性添加。

无法覆写(Override)行为是这种方法不建议用于UI交互处理的主要原因。

🧬 使用 Kotlin 委托进行组合(优雅但受限的解决方案)

这是互联网上解决这个问题的标准方法,也是最受推荐的方法。该解决方案基于继承,但不是继承自某个类,这样就无法再扩展任何类。我们利用 Kotlin 委托将实现委托给另一个类,这样就无需扩展某个类,也无需再终止继承。

以下是我们实现该解决方案的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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) { }

}

这就是我们的使用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
class PostViewModel : BaseViewModel(), PostActionHandler by PostActionHandlerImpl() {

}

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

@Composable
fun PostItem(onAction: (PostAction) -> Unit) {

}

这种方法解决了额外辅助类方法中存在的所有问题。

  • ✅ 覆写行为:通过使用接口和委托,我们可以轻松地覆写任何行为。
  • ✅ 更简洁的设计:功能直接成为ViewModel的一部分,允许我们像调用原生ViewModel方法一样调用这些函数。

然而,这种方法有一个主要缺点,使其在处理UI交互和UI逻辑方面不够完善。

访问从其他类继承的功能

此模式的一个主要挑战是我们无法访问从其他类继承的功能,因为我们无法传递引用。

让我们看看这个限制带来了哪些挑战:

  • 无法访问ViewModel功能:我们无法从实现类访问任何ViewModel功能。这是因为我们无法在实现类中传递当前类的引用。
  • 无法使用 viewModelScope:此限制带来的一个主要警告是,我们无法使用 viewModelScope,因此无法直接启动协程。我们必须在 viewModel 中创建包装函数,这会破坏可重用性,因为现在可组合函数会调用我们的ViewModel函数。

当我们尝试将其作为构造函数参数传递时,Android Studio 会抛出一个错误:

Kotlin Compiler showing error when passing “this”

🧩 我的解决方案:使用接口默认函数进行组合

在我自己实现使用委托的 Composition 方案时,我遇到了一个难题:如何传递当前ViewModel的引用来访问viewModelScope 和我的 BaseViewModel 功能。后来我想起了 Kotlin 接口中的默认函数,于是尝试了这个方案,完美地解决了这个问题。

通过这个方案,我可以:

  • 💡 支持功能重写
  • 🔗 完全访问我的BaseViewModel和ViewModel 的功能,例如viewModelScope 等,或任何其他类的功能。
  • ❌ 无需单独的实现类

在我们 Medial 的应用(链接 https://medial.app/%EF%BC%89%E7%94%9F%E4%BA%A7%E4%BB%A3%E7%A0%81%E4%B8%AD%EF%BC%8C%E6%88%91%E9%9B%86%E6%88%90%E4%BA%86%E8%BF%99%E4%B8%AA%E6%96%B9%E6%A1%88%EF%BC%8C%E4%BB%A5%E7%AE%80%E5%8C%96ViewModel%E5%A4%84%E7%90%86UI%E4%BA%A4%E4%BA%92%E7%9A%84%E6%96%B9%E5%BC%8F%E3%80%82%E7%BB%93%E6%9E%9C%E5%A6%82%E4%BD%95%EF%BC%9F%E6%AF%8F%E4%B8%AA%E6%96%B0%E9%A1%B5%E9%9D%A2%E5%8F%AA%E9%9C%80%E5%AE%9E%E7%8E%B0%E8%BF%99%E4%B8%AA%E6%8E%A5%E5%8F%A3%EF%BC%8C%E7%84%B6%E5%90%8E%EF%BC%8C%E5%AE%83%E5%B0%B1%E5%B9%B2%E5%87%80%E5%88%A9%E8%90%BD%E5%9C%B0%E8%8E%B7%E5%BE%97%E4%BA%86%E5%8A%9F%E8%83%BD%E3%80%82

让我们来探索一下这个方案是如何解决我们的问题的。

设计 ActionHandler 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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) {
        // 实现
    }

}

让我们的ViewModel实现这个接口

1
2
3
4
5
6
7
8
9
10
class PostViewModel : BaseViewModel(), PostActionHandler {

    // 传入当前的baseViewModel
    override valViewModel= this

    override fun handleShareClick(id: String) {
        // 添加 override实现
    }

}

这是它在 Composable 中的使用方式

1
2
3
4
5
6
7
8
9
10
11
@Composable
private fun PostScreen(viewModel: PostViewModel) {
    PostItem(onAction = viewModel::handleAction)
}

@Composable
private fun PostItem(
    onAction: (PostAction) -> Unit
) {

}

所以,正如你所见,我们只需要定义 viewModel 变量,就大功告成了,现在我们可以使用ViewModel和 BaseViewModel 提供的所有功能了。

请注意,我们是如何解决使用委托进行组合的主要缺点的:

  • 通过将 viewModel 引用保留为接口属性,我们可以轻松地在ViewModel中定义它。这使我们能够直接访问 BaseViewModel 或任何其他ViewModel提供的所有功能。
  • 我们简化了 viewModelScope 的使用。现在,启动协程、收集流程以及执行其他任务都变得非常简单,无需任何复杂的变通方法或 hack。这就是此解决方案带来的优雅和简洁之处。

这种方法只有一个小缺点,那就是

  • 我们必须在ViewModel中定义所有接口属性,这在大多数情况下是可以接受的,因为我们主要会引用 Repository 或 Domain Layer 类,以及一些可变状态(如果有的话)。

这个问题比我们在这个解决方案中克服的缺点要小得多,这使得它成为处理任何共享UI交互或任何共享UI业务逻辑的最佳解决方案。

嗯,这一部分比前面几部分简单得多。这里没有复杂的问题需要深入研究——只是一个简单的解决方案,它确实做到了它应该做的事情,并解决了我们所有的问题。

🥷🏻 让我们在实际用例中看看这个解决方案的实际应用。

假设我们有一个主页面,它以列表形式显示各种UI元素,例如包含帖子和新闻等项目的动态。每个元素都包含一组用户交互,例如点赞帖子、分享新闻文章或导航到详情视图。我们的解决方案可以帮助我们高效地处理跨不同ViewModel的这些交互,确保以可扩展的方式处理通用功能。

为每个UI组件设计UI交互

然后,我们使用 Kotlin 的 Sealed 接口,像在典型的 MVI 架构中一样设计UI交互:

1
2
3
4
5
6
7
sealed interface PostAction {

    data class Clicked(val post: Post) : PostAction
    data class LikeClicked(val post: Post) : PostAction
    data class ShareClicked(val post: Post) : PostAction

}
1
2
3
4
5
6
7
sealed interface NewsAction {

    data class Clicked(val news: News) : NewsAction
    data class LikeClicked(val news: News) : NewsAction
    data class BookmarkClicked(val news: News) : NewsAction

}

创建我们的动作处理程序接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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")
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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")
    }
}

如上所示,我们为每个数据模型创建了一个 Action Handler 接口。

我们在这里所做的就是遵循上一节所示的接口设计。

  • 我们有两个属性,分别是ViewModel和Repository 类。
  • 我们分别创建了处理每个 Action 的函数。当然,这是可选的。我保留这种方式是为了方便覆写任何特定的行为,并且我们为每个 Action 类型分别调用相应的 Action 处理函数。

创建 ViewModel

让我们构建一个实现这两个接口的 ViewModel,以继承它们的功能。

1
2
3
4
5
6
7
8
9
10
11
class HomeViewModel : BaseViewModel(), PostActionHandler, NewsActionHandler {

    override val viewModel = this
    override val postRepo = PostRepository()
    override val newsRepo = NewsRepository()

    /**
      ViewModel其余代码
    **/

}

然后,我们在ViewModel中定义必要的属性( viewModel 、 postRepo 和 newsRepo ),这些属性会被两个接口使用。

这种设计消除了冗余,并保持了代码的简洁性,因为我们只需设置一次属性,只要属性名称相同,两个接口就可以无缝地与其交互。 这种方法保持了ViewModel的中心地位,同时又可以轻松处理不同组件的UI交互和业务逻辑。

🪝连接一切:将操作连接到 UI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@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
) {

}

在我们的UI组件中,我们可以直接将这些函数用作ViewModel本身的函数,这样就完成了在 Screen 中添加交互处理的功能。

  • 现在,假设我们有另一个只显示新闻的页面(Screen)。我们需要做的就是在ViewModel中实现 NewsActionHandler 接口——就这样,一切就绪。
  • 我们可以无缝地从 BaseViewModel 或ViewModel调用任何函数,从而能够在一个单一的、集中的位置实现完整的端到端功能。如果我们在多个位置显示相同的数据模型,这将非常有利。

📦 整合所有功能

在这篇博文中,我们探讨了在扩展Android UI(基本上是任何 UI)时最容易被忽视的问题之一,即跨ViewModel的重复UI交互逻辑。我们探索了三种不同的方法来解决这个问题:

  1. 辅助类 - 一种快速解决方案,但存在严格的限制。
  2. 使用委托进行组合——一种更现代的继承驱动解决方案, 但它难以从外部访问功能。
  3. 使用默认函数的接口(我的解决方案)——一种实用、优雅且灵活的方法,克服了前两种方法的所有限制。

通过利用 Kotlin 的接口默认方法并将ViewModel作为接口内部的属性,我们解锁了完整的可扩展性,并能够从其他继承的类/接口访问扩展功能。

现在,让我们以表格的形式比较一下每个解决方案:

好处 通过辅助类 使用委托进行组合 带有默认函数的接口 (✅ 最好)
🔁 可重用性 ✅ Yes ✅ Yes ✅ Yes
🧠 覆写行为 ❌ 不可能 ✅ 可以,由于继承 ✅ 可以,由于继承
🔗 访问从其他来源继承的功能 ❌ 不可以,因为它是一个封装类 ❌ 不可以,因为我们无法传递当前引用 ✅ 可以,因为我们可以将继承类保留为接口属性
🏗️ 跨页面可扩展性 ⚠️ 差——当逻辑还涉及视图模型特定功能时会变得困难 ✅ 更好——可重复使用的逻辑 ✅ 最佳——跨页面即插即用
✅ 最佳用例 对于非UI交互封装功能 业务逻辑重用,非UI上下文 多个ViewModel中需要的UI交互

你可以在专用的 GitHub 代码库(链接:https://github.com/Vaibhav2002/Ui-Intreraction-Handler-Sample%EF%BC%89%E4%B8%AD%E6%8E%A2%E7%B4%A2%E6%9C%AC%E5%8D%9A%E5%AE%A2%E4%B8%AD%E6%8F%90%E5%88%B0%E7%9A%84%E6%89%80%E6%9C%89%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88%E3%80%82%E6%AC%A2%E8%BF%8E%E9%9A%8F%E6%84%8Fclone%E3%80%81fork%E6%88%96%E8%AF%95%E7%94%A8%E3%80%82

✌️ 告别

总而言之,希望这篇博客能帮助你解锁一些强大的技巧,提升你的Kotlin 和UI处理知识。有了Kotlin 的界面默认函数,你的构建将更加简洁、快速,并兼顾长期可扩展性。

如果你喜欢这篇文章并想随时了解最新动态:

  • 查看我的 LinkedIn、GitHub 和 X 个人资料,了解我正在做的事情。
  • 想要更多 Kotlin 的魔法?阅读我之前的博客,了解另一种可以让你的代码库更精简、更智能的技术。

感谢你的阅读,希望你喜欢!🚀

Comments