将架构蓝图项目迁移至 Jetpack Compose

将架构蓝图项目迁移至 Jetpack Compose

作者 / Manuel Vivo, Android DevRel @ Google

在我们努力实现 应用架构指南 现代化的过程中,我们希望尝试各种用户界面模式,了解哪个模式最有效,找出替代方案之间的相似性和差异,并最终将这些内容整合为最佳实践。

为了让我们的结果尽可能易于理解,我们需要一个不太复杂的样本,并基于大家熟悉的商业案例。于是,我们选择了热门的 TODO 类应用。并在 架构蓝图 (Architecture Blueprints) 项目中来制作示例!架构蓝图以前本就是用于挑选架构的实验性项目,这正好完美契合了我们的需求!

△ 架构蓝图应用演示

△ 架构蓝图应用演示

我们想要尝试的模式显然受到了现今可用的多种 API 的影响。而我们这次要使用的是新推出的 Jetpack Compose State API!由于 Compose 可与任何 单向数据流模式 无缝衔接使用,因此我们将用 Compose 来渲染界面,让比较更加公平。

这篇文章介绍了我们的团队如何将架构蓝图迁移到 Jetpack Compose。由于 LiveData 也被视为我们实验中的备选方案,因此在迁移时,我们将样本保留原样。在这次重构中,ViewModel 类和数据层都未经改动

⚠️ 请注意: 在基于 LiveData 的代码库中使用的架构,并未完全遵循 最新的架构最佳实践。特别是,LiveData 不应该用于 数据层网域层,而应该采用 Flow 和协程。

现在项目背景已经明确,让我们来深入探究如何使用 Jetpack Compose 重构蓝图项目。您可以在 dev-compose 上查看完整代码。

✍️ 规划逐步迁移

在进行任何实际编码工作前,团队首先制定了一个迁移计划,以确保每个人都接受提出的更改意见。最终目标是让蓝图成为单一 Activity 应用,其各个屏幕为可组合函数,并使用推荐的 Compose Navigation 库在屏幕之间移动

幸运的是,蓝图已经是单一 Activity 应用,且使用 Jetpack Navigation 在通过 Fragment 实现的不同屏幕之间移动。为了迁移到 Compose,我们遵循 Navigation 互操作性指南,该指南建议混合型应用使用基于 Fragment 的 Navigation 组件,并使用 Fragment 来容纳基于视图的屏幕、Compose 屏幕,以及同时使用二者的屏幕。遗憾的是,您无法在同一 Navigation 图中混用 Fragment 和 Compose 目的地。

逐步迁移的目的是减少代码审查工作量,并在整个迁移过程中保持产品可交付。迁移计划涉及三个步骤:

  • 将每个屏幕的 内容 迁移至 Compose。每个屏幕均可单独迁移至 Compose,包括其界面测试。然后 Fragment 将成为每个已迁移屏幕的容器。
  • 将应用迁移至 Navigation Compose (此操作会移除项目中的所有 Fragment) 并将 Activity 界面逻辑迁移至基于 Composable。端到端测试也会在此时迁移。
  • 移除 View 系统依赖项。

我们也是这样操作的!时间快进到两周后,我们迁移了 统计信息 (Statistics) 屏幕添加/编辑任务 (Add/Edit task) 屏幕任务详细信息 (Task detail) 屏幕,以及 任务 (Tasks) 屏幕;同时我们合并了 最终 PR,此操作将 Navigation 和 Activity 逻辑迁移至 Compose,包括 移除未使用的 View 系统依赖项

△ 我们如何将蓝图逐步迁移至 Compose

△ 我们如何将蓝图逐步迁移至 Compose

💡 迁移重点

迁移过程中,我们遇到了一些针对 Compose 的问题,值得重点讲述:

🧪 界面测试

将 Compose 添加到应用后,断言 Compose 界面的测试需要使用 Compose 测试 API

对于屏幕级别的界面测试 ,我们不使用 launchFragmentInContainer API,而是使用 createAndroidComposeRule API,这样我们可以在测试中捕获字符串资源。这些测试可在 Espresso 和 Robolectric 中运行。因为 Compose 已经可为所有这一切提供支持,所以无需任何额外改动。例如,您可以比较 AddEditTaskFragmentTest 中已迁移至 AddEditTaskScreenTest 的代码。请注意,如果您使用 ComponentActivity,那么需要依赖 androidx.compose.ui:ui-test-manifest 组件。

对于端到端到集成测试 ,我们也未发现任何问题!得益于 Espresso 和 Compose 的互操作性,我们可以使用 Espresso 断言来查看 View,使用 Compose API 来查看 Compose 界面。您可以实际查看迁移至 Compose 期间某一时刻的 AppNavigationTest

🤙 ViewModel 事件

对于在蓝图中 处理 ViewModel 事件 的方式,我们确实遇到过问题。蓝图采用了 事件封装容器 解决方案,将命令从 ViewModel 发送到界面。但是,这在 Compose 中并不好用。最新的指南建议将这些 "事件" 建模为状态,我们在迁移中也是这么做的。

让我们看看在屏幕上显示消息的事件用例,我们将 LiveData 的 Event 类型替换为 Int?。这同样对没有要向用户显示任何消息的场景进行了建模。在这一特定用例中,当消息被显示时,ViewModel 还需要获得来自界面的确认。在下面的代码中可以看出两种实现之间的代码差异 (diff)。

/* Copyright 2022 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */

class AddEditTaskViewModel(
  private val tasksRepository: TasksRepository
) : ViewModel() {

-  private val _snackbarText = MutableLiveData<Event<Int>>()
-  val snackbarText: LiveData<Event<Int>> = _snackbarText

+  private val _snackbarText = MutableLiveData<Int?>()
+  val snackbarText: LiveData<Int?> = _snackbarText

+  fun snackbarMessageShown() {
+    _snackbarText.value = null
+  }
}

尽管乍一看似乎工作量变大了,但它能 保证 消息会在屏幕上显示!

在界面代码中,确保事件只处理一次的方法是调用 event.getContentIfNotHandled()。这种方法在 Fragment 中还算行得通,但在 Compose 中就完全失效了 (如果您编写的是完全原生的 Compose 代码的话)!因为在 Compose 中随时可能发生重新组合,事件封装容器并非有效的解决方案。如果在事件处理后,函数被重新组合 (在测试中经常发生这种现象),那么信息提示控件 (snackbar) 将被取消,用户可能会错过消息。这是一个无法接受的用户体验问题。事件封装容器解决方案不应在 Compose 应用中使用

请注意,您可以写出在某些情况下避免重新组合部分函数的 Compose 代码,然而,事件包装器解决方案限制了用户界面的实现方式。我们不鼓励大家在 Compose 中使用事件封装器解决方案

请查看以下带有 "之前" (事件封装容器) 和 "之后" (事件作为状态) 对照的代码片段。因为在屏幕上显示消息是 界面逻辑,而我们的屏幕可组合项变得越来越复杂,因此使用 纯状态容器类 来管理此复杂性 (比如 AddEditTaskState)。

/* Copyright 2022 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */

// FRAGMENTS CODE CONSUMING THE EVENT WRAPPER SOLUTION

- class AddEditTaskFragment : Fragment() {
-   override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-     ...
-     viewModel.snackbarText.observe(
-       lifecycleOwner,
-       Observer { event ->
-         event.getContentIfNotHandled()?.let {
-           showSnackbar(context.getString(it), Snackbar.LENGTH_SHORT)
-         }
-       }
-     )
-   }
- }


// COMPOSE CODE CONSUMING USER MESSAGES AS STATE

// State holder for the AddEditTask composable.
// This class handles AddEditTask's UI elements' state and UI logic.
+ class AddEditTaskState(...) {
+   init {
+     // Listen for snackbar messages
+     viewModel.snackbarText.observe(viewLifecycleOwner) { snackbarMessage ->
+       if (snackbarMessage != null) {
+       // If there's a previous message showing on the screen
+       // stop showing it in favor of the new one to be displayed
+       currentSnackbarJob?.cancel()
+       val snackbarText = context.getString(snackbarMessage)
+       currentSnackbarJob = coroutineScope.launch {
+         scaffoldState.snackbarHostState.showSnackbar(snackbarText)
+         viewModel.snackbarMessageShown()
+       }
+     }
+   }
+ }

👌 请优先确保应用正确性

重构期间,您可能很想把手上的 所有内容 迁移到 Compose。虽然这么做完全没问题,但您不应牺牲应用的用户体验或正确性。逐步迁移的全部意义在于,让应用始终处于可交付状态。

在将一些屏幕迁移到 Compose 时,我们也遇到了这种情况。我们不想同时进行过多迁移,所以在从事件封装容器迁移 "之前",先将一些屏幕迁移到了 Compose。与其在 Compose 中处理事件封装容器,获得不够理想的体验,不如继续在 Fragment 中处理这些消息,而屏幕的其他代码则使用 Compose 实现。例如,您可以参考 迁移过程中 TasksFragment 的状态

🧐 挑战

不是所有步骤都像看上去那么顺利。尽管将 Fragment 内容转换为 Compose 很简单,但从 Navigation Fragment 迁移到 Navigation Compose 需要花费更多的时间和心思。

我们有必要从各方面扩展和改进指南,让迁移到 Compose 的过程更加轻松。这项工作引起了广泛讨论,我们希望很快制定出这方面的全新指南!🎊

我在初次使用 Navigation ✋ 并处理向 Navigation Compose 迁移的问题时,面临了以下挑战:

  • 文档中没有任何代码显示如何 使用可选参数进行导航!多亏有 Tivi 的导航图,我才找到办法解决这个问题。您可以 关注此问题并改进文档

  • 从基于 XML 的导航图和 SafeArgs 迁移到 Kotlin DSL 应该是一项简单的机械式任务。但对我来说这项任务并不轻松,因为我并没有参与初始实现。一些有关如何正确操作的指南本应对我有所帮助。您可以 关注此问题并改进文档

  • 第三点与其说是挑战,不如说这就是一个问题。说到导航,NavigationUI 已经为您做了一些工作。由于 Compose 中不存在该界面,您需要注意这一点,并手动实现。例如,在 Drawer 屏幕之间导航时,保持后退堆栈的清洁需要特殊的 NavigationOptions (请参考 示例)。文档 中已经讲到了这一点,但您需要意识到自己需要这么做!

🧑‍🏫 小结

总的来说,从 Navigation Fragment 迁移到 Navigation Compose 是一项有趣的工作!有意思的是,我们花在等待同行审查上的时间,比迁移项目本身的时间还要多!制定迁移计划并让每个人都切实理解它,无疑有助于尽早确定期望结果,并提醒同事注意即将到来的漫长审查。

希望这篇文章对您有所帮助,让您了解了我们迁移到 Compose 的方法,同时我们期待分享更多我们在架构蓝图中进行的实验和改进。

如果您有兴趣了解 Compose 版的蓝图代码,请查看 dev-compose

如果您想浏览逐步迁移的 PR,请查看以下列表:

欢迎您 点击这里 向我们提交反馈,或分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!

版权声明

禁止一切形式的转载-禁止商用-禁止衍生 申请授权

脉脉不得语
脉脉不得语
Zhengzhou Website
Android Developer | https://androiddevtools.cn and https://androidweekly.io WebMaster | GDG Zhengzhou Funder & Ex Organizer | http://Toast.show(∞) Podcast Host

你已经成功订阅到 Android 开发技术周报
太棒了!接下来,完成检验以获得全部访问权限 Android 开发技术周报
欢迎回来!你已经成功登录了。
Unable to sign you in. Please try again.
成功!您的帐户已完全激活,您现在可以访问所有内容。
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.