查看原文
其他

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

Android Android 开发者 2022-08-18

作者 / Manuel Vivo, Android DevRel @ Google


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


  • 应用架构指南
    https://developer.android.google.cn/topic/architecture


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


  • Android 架构蓝图

    https://github.com/android/architecture-samples

△ 架构蓝图应用演示

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


  • Jetpack Compose State API
    https://developer.android.google.cn/jetpack/compose/state
  • 单向数据流模式
    https://developer.android.google.cn/jetpack/guide/ui-layer#udf


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


  • LiveData
    https://developer.android.google.cn/topic/libraries/architecture/livedata


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


  • 应用架构指南
    https://developer.android.google.cn/jetpack/guide
  • 数据层
    https://developer.android.google.cn/jetpack/guide/data-layer
  • 网域层
    https://developer.android.google.cn/jetpack/guide/domain-layer


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

https://github.com/android/architecture-samples/tree/dev-compose



✍️ 规划逐步迁移



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

https://developer.android.google.cn/jetpack/compose/navigation


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


  • 导航
    https://developer.android.google.cn/guide/navigation
  • 互操作性
    https://developer.android.google.cn/jetpack/compose/navigation#interoperability


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

  • 将每个屏幕的内容迁移至 Compose。每个屏幕均可单独迁移至 Compose,包括其界面测试。然后 Fragment 将成为每个已迁移屏幕的容器。

  • 将应用迁移至 Navigation Compose (此操作会移除项目中的所有 Fragment) 并将 Activity 界面逻辑迁移至基于 Composable。端到端测试也会在此时迁移。

  • 移除 View 系统依赖项。


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


  • 将 Statistics 迁移至 Compose
    https://github.com/android/architecture-samples/pull/821
  • 将 AddEditTask 屏幕迁移至 Compose
    https://github.com/android/architecture-samples/pull/823
  • 将 TaskDetail 迁移至 Compose
    https://github.com/android/architecture-samples/pull/822
  • 将 Tasks 迁移至 Compose
    https://github.com/android/architecture-samples/pull/826
  • 将 Activity 和 NavGraph 迁移至 Compose
    https://github.com/android/architecture-samples/pull/827
  • 移除未使用的 View 依赖
    https://github.com/android/architecture-samples/pull/827/commits/2810a37c479ef4b23b4cabf095c55df7b342235e

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



💡 迁移重点



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


🧪 界面测试


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

https://developer.android.google.cn/jetpack/compose/testing


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


  • launchFragmentInContainer<FragmentType>
    https://developer.android.google.cn/guide/fragments/test#create
  • createAndroidComposeRule<ComponentActivity>
    https://developer.android.google.cn/jetpack/compose/testing
  • AddEditTaskFragmentTest
    https://github.com/android/architecture-samples/blob/653a563e9fe0874b4ae3fba539ce4b6518a2f796/app/src/sharedTest/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskFragmentTest.kt
  • AddEditTaskScreenTest
    https://github.com/manuelvicnt/architecture-samples/blob/8a203594541b25e5eec2daac63415c05884242ad/app/src/sharedTest/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskScreenTest.kt
  • androidx.compose.ui:ui-test-manifest
    https://developer.android.google.cn/jetpack/compose/testing#setup

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

https://github.com/manuelvicnt/architecture-samples/blob/249a636ea9a3f16aab5c284e3245069ef56a557f/app/src/androidTestMock/java/com/example/android/architecture/blueprints/todoapp/tasks/AppNavigationTest.kt


🤙 ViewModel 事件


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


  • 处理 ViewModel 事件

    https://developer.android.google.cn/jetpack/guide/ui-layer/events#handle-viewmodel-events

  • 事件封装容器

    https://github.com/android/architecture-samples/blob/8e1e0527a0d043b41da58925a39fb8e03d62829a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/Event.kt


让我们看看在屏幕上显示消息的事件用例,我们将 LiveData 的 Event<Int> 类型替换为 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()+ }+ }+ }+ }


  • 逻辑类型

    https://developer.android.google.cn/jetpack/guide/ui-layer#logic-types

  • 状态和逻辑的类型

    https://developer.android.google.cn/jetpack/compose/state#types-of-state-and-logic

  • AddEditTaskState

    https://github.com/manuelvicnt/architecture-samples/blob/88cf650fd1759486cce198878b5cf08e823012dc/app/src/main/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskState.kt

👌 请优先确保应用正确性


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


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

https://github.com/manuelvicnt/architecture-samples/blob/249a636ea9a3f16aab5c284e3245069ef56a557f/app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksFragment.kt



🧐 挑战



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


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


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

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

    https://issuetracker.google.com/226103829


  • Tivi 的导航图

    https://github.com/chrisbanes/tivi/blob/main/app/src/main/java/app/tivi/AppNavigation.kt


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

    https://issuetracker.google.com/226315955


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


  • 使用 NavigationUI 更新界面组件

    https://developer.android.google.cn/guide/navigation/navigation-ui

  • 示例: TodoNavigation

    https://github.com/android/architecture-samples/blob/dev-compose/app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoNavigation.kt#L79

  • 文档: 与底部导航栏集成

    https://developer.android.google.cn/jetpack/compose/navigation#bottom-nav



🧑‍🏫 小结



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


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


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

https://github.com/android/architecture-samples/tree/dev-compose


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

  • 统计信息 (Statistics) 屏幕:

    https://github.com/android/architecture-samples/pull/821

  • 添加/编辑任务 (Add/Edit task) 屏幕:

    https://github.com/android/architecture-samples/pull/823

  • 任务详细信息 (Task detail) 屏幕:

    https://github.com/android/architecture-samples/pull/822

  • 任务 (Tasks) 屏幕:

    https://github.com/android/architecture-samples/pull/826

  • 以及最终 PR,此操作将 Navigation 和 Activity 逻辑迁移至 Compose,包括移除未使用的 View 系统依赖项:


  • 最终 PR
    https://github.com/android/architecture-samples/pull/827

  • 移除未使用的 View 系统依赖项
    https://github.com/android/architecture-samples/pull/827/commits/2810a37c479ef4b23b4cabf095c55df7b342235e


您可以通过下方二维码或在文章底部私信,向我们提交反馈,分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!



推荐阅读

如页面未加载,请刷新重试

 点击屏末 | 阅读原文 | 即刻了解更多应用架构指南




您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存