迁移到 Jetpack Compose

1. 简介

Compose 和 View 系统可以并行工作。

在本 Codelab 中,您将迁移 Sunflower 植物详情屏幕的部分内容到 Compose。我们为您创建了一个项目的副本,以便您尝试将一个真实的应用迁移到 Compose。

在本 Codelab 结束时,您将能够继续迁移并转换 Sunflower 的其余屏幕(如果您愿意)。

在您完成本 Codelab 的过程中,如需更多支持,请查看以下代码演示

您将学到什么

在本 Codelab 中,您将学习

  • 您可以遵循的不同迁移路径
  • 如何增量迁移应用到 Compose
  • 如何在使用 View 构建的现有屏幕中添加 Compose
  • 如何在 Compose 内部使用 View
  • 如何在 Compose 中创建主题
  • 如何测试使用 View 和 Compose 编写的混合屏幕

先决条件

您需要什么

2. 迁移策略

Jetpack Compose 从一开始就设计了与 View 的互操作性。为了迁移到 Compose,我们建议进行增量迁移,其中 Compose 和 View 在您的代码库中共存,直到您的应用完全迁移到 Compose 为止。

推荐的 迁移策略 如下所示

  1. 使用 Compose 构建新屏幕
  2. 在构建功能时,识别可重用的元素并开始创建常用 UI 组件的库
  3. 一次替换一个现有功能的屏幕

使用 Compose 构建新屏幕

使用 Compose 构建包含整个屏幕的新功能是推动您采用 Compose 的最佳方式。使用此策略,您可以添加功能并利用 Compose 的优势,同时仍然满足您公司的业务需求

新功能可能包含整个屏幕,在这种情况下,整个屏幕都将使用 Compose。如果您使用基于 Fragment 的导航,则表示您将创建一个新的 Fragment 并将其内容置于 Compose 中。

您也可以在现有屏幕中引入新功能。在这种情况下,View 和 Compose 将共存在同一屏幕上。例如,假设您要添加的功能是 RecyclerView 中的一种新视图类型。在这种情况下,新的视图类型将使用 Compose,同时保持其他项不变。

构建常用 UI 组件库

在使用 Compose 构建功能时,您会很快意识到最终会构建一个组件库。您需要识别可重用的组件以促进整个应用的重用,以便共享组件具有单一的事实来源。您构建的新功能随后可以依赖此库。

使用 Compose 替换现有功能

除了构建新功能外,您还希望逐步将应用中的现有功能迁移到 Compose。您如何处理此问题取决于您自己,但以下是一些不错的候选者

  1. 简单屏幕 - 应用中 UI 元素和动态性较少的简单屏幕,例如欢迎屏幕、确认屏幕或设置屏幕。这些是迁移到 Compose 的良好候选者,因为只需几行代码即可完成。
  2. 混合 View 和 Compose 屏幕 - 已经包含一些 Compose 代码的屏幕是另一个不错的候选者,因为您可以继续逐块迁移该屏幕中的元素。如果您有一个屏幕,其中只有 Compose 中的一个子树,则可以继续迁移树的其他部分,直到整个 UI 都在 Compose 中。这称为迁移的自下而上方法。

Bottom-up approach of migrating a mixed Views and Compose UI to Compose

本 Codelab 中的方法

在本 Codelab 中,您将对 Sunflower 的植物详情屏幕进行增量迁移到 Compose,使 Compose 和 View 协同工作。之后,您将了解足够的信息来继续迁移(如果您愿意)。

3. 设置

获取代码

从 GitHub 获取 Codelab 代码

$ git clone https://github.com/android/codelab-android-compose

或者,您可以将存储库下载为 Zip 文件

运行示例应用

您刚刚下载的代码包含所有可用 Compose Codelab 的代码。要完成本 Codelab,请在 Android Studio 中打开MigrationCodelab项目。

在本 Codelab 中,您将把 Sunflower 的植物详情屏幕迁移到 Compose。您可以通过点击植物列表屏幕中提供的植物之一来打开植物详情屏幕。

bb6fcf50b2899894.png

项目设置

该项目是在多个 Git 分支中构建的

  • main 分支是 Codelab 的起点。
  • end包含本 Codelab 的解决方案。

我们建议您从main分支中的代码开始,并按照 Codelab 的步骤一步一步地进行,按照自己的节奏进行。

在 Codelab 过程中,将向您显示需要添加到项目中的代码片段。在某些地方,您还需要删除代码片段注释中明确提到的代码。

要使用 Git 获取end分支,请cdMigrationCodelab项目的目录,然后使用以下命令

$ git checkout end

或从此处下载解决方案代码

常见问题

4. Sunflower 中的 Compose

您从 main 分支下载的代码中已添加了 Compose。但是,让我们看一下使其正常工作需要什么。

如果您打开应用级 build.gradle 文件,请查看它是如何导入 Compose 依赖项以及如何通过使用 buildFeatures { compose true } 标志启用 Android Studio 与 Compose 协同工作的。

app/build.gradle

android {
    //...
    kotlinOptions {
        jvmTarget = '1.8'
    }
    buildFeatures {
        //...
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion '1.3.2'
    }
}

dependencies {
    //...
    // Compose
    def composeBom = platform('androidx.compose:compose-bom:2024.09.02')
    implementation(composeBom)
    androidTestImplementation(composeBom)

    implementation "androidx.compose.runtime:runtime"
    implementation "androidx.compose.ui:ui"
    implementation "androidx.compose.foundation:foundation"
    implementation "androidx.compose.foundation:foundation-layout"
    implementation "androidx.compose.material3:material3"
    implementation "androidx.compose.runtime:runtime-livedata"
    implementation "androidx.compose.ui:ui-tooling"
    //...
}

这些依赖项的版本在项目级 build.gradle 文件中定义。

5. Hello Compose!

在植物详情屏幕中,我们将把植物的描述迁移到 Compose,同时保持屏幕的整体结构不变。

Compose 需要一个主机 Activity 或 Fragment 才能渲染 UI。在 Sunflower 中,由于所有屏幕都使用 Fragment,因此您将使用 ComposeView:一个 Android View,它可以使用其 setContent 方法托管 Compose UI 内容。

删除 XML 代码

让我们从迁移开始!打开 fragment_plant_detail.xml 并执行以下操作

  1. 切换到代码视图
  2. 删除 ConstraintLayout 代码和 NestedScrollView 内部的 4 个嵌套的 TextView(Codelab 将在迁移各个项目时比较和引用 XML 代码,将代码注释掉将很有用)
  3. 添加一个 ComposeView,它将使用 compose_view 作为视图 ID 托管 Compose 代码

fragment_plant_detail.xml

<androidx.core.widget.NestedScrollView
    android:id="@+id/plant_detail_scrollview"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipToPadding="false"
    android:paddingBottom="@dimen/fab_bottom_padding"
    app:layout_behavior="@string/appbar_scrolling_view_behavior">

    <!-- Step 2) Comment out ConstraintLayout and its children ->
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_margin="@dimen/margin_normal">

        <TextView
            android:id="@+id/plant_detail_name"
        ...
    
    </androidx.constraintlayout.widget.ConstraintLayout>
    <!-- End Step 2) Comment out until here ->

    <!-- Step 3) Add a ComposeView to host Compose code ->
    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</androidx.core.widget.NestedScrollView>

添加 Compose 代码

此时,您已准备好开始将植物详情屏幕迁移到 Compose!

在整个 Codelab 中,您将向 plantdetail 文件夹下的 PlantDetailDescription.kt 文件添加 Compose 代码。打开它,看看我们如何在项目中提供了占位符 "Hello Compose" 文本。

PlantDetailDescription.kt

@Composable
fun PlantDetailDescription() {
    Surface {
        Text("Hello Compose")
    }  
}

让我们通过从我们在上一步中添加的 ComposeView 调用此可组合项将其显示在屏幕上。打开 PlantDetailFragment.kt

由于屏幕使用 数据绑定,您可以直接访问 composeView 并调用 setContent 以在屏幕上显示 Compose 代码。在 MaterialTheme 内部调用 PlantDetailDescription 可组合项,因为 Sunflower 使用材质设计。

PlantDetailFragment.kt

class PlantDetailFragment : Fragment() {
    // ...
    override fun onCreateView(...): View? {
        val binding = DataBindingUtil.inflate<FragmentPlantDetailBinding>(
            inflater, R.layout.fragment_plant_detail, container, false
        ).apply {
            // ...
            composeView.setContent {
                // You're in Compose world!
                MaterialTheme {
                    PlantDetailDescription()
                }
            }
        }
        // ...
    }
}

如果运行应用,您会看到屏幕上显示“Hello Compose”。

66f3525ecf6669e0.png

6. 从 XML 创建可组合项

让我们从迁移植物名称开始。更确切地说,是您在 fragment_plant_detail.xml 中删除的 ID 为 @+id/plant_detail_nameTextView。以下是 XML 代码

<TextView
    android:id="@+id/plant_detail_name"
    ...
    android:layout_marginStart="@dimen/margin_small"
    android:layout_marginEnd="@dimen/margin_small"
    android:gravity="center_horizontal"
    android:text="@{viewModel.plant.name}"
    android:textAppearance="?attr/textAppearanceHeadline5"
    ... />

请查看它如何具有 textAppearanceHeadline5 样式,具有 8.dp 的水平边距,并且在屏幕上水平居中。但是,要显示的标题是从 PlantDetailViewModel 公开的 LiveData 中观察到的,该 PlantDetailViewModel 来自存储库层。

由于稍后将介绍观察 LiveData 的内容,因此让我们假设我们拥有该名称并将其作为参数传递给我们在 PlantDetailDescription.kt 文件中创建的 PlantName 可组合项。稍后将从 PlantDetailDescription 可组合项调用此可组合项。

PlantDetailDescription.kt

@Composable
private fun PlantName(name: String) {
    Text(
        text = name,
        style = MaterialTheme.typography.headlineSmall,
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = dimensionResource(R.dimen.margin_small))
            .wrapContentWidth(Alignment.CenterHorizontally)
    )
}

@Preview
@Composable
private fun PlantNamePreview() {
    MaterialTheme {
        PlantName("Apple")
    }
}

带有预览

d09fe886b98bde91.png

其中

  • Text 的样式为 MaterialTheme.typography.headlineSmall,这类似于 XML 代码中的 textAppearanceHeadline5
  • 修饰符装饰 Text 以使其看起来像 XML 版本
  • fillMaxWidth 修饰符用于使其占用可用的最大宽度。此修饰符对应于 XML 代码中 layout_width 属性的 match_parent 值。
  • padding 修饰符用于应用 margin_small 的水平填充值。这对应于 XML 中的 marginStartmarginEnd 声明。 margin_small 值也是使用 dimensionResource 辅助函数获取的现有尺寸资源。
  • wrapContentWidth 修饰符用于对齐文本,使其水平居中。这类似于在 XML 中具有 gravitycenter_horizontal

7. ViewModel 和 LiveData

现在,让我们将标题连接到屏幕上。为此,您需要使用 PlantDetailViewModel 加载数据。为此,Compose 集成了 ViewModelLiveData

ViewModel

由于 PlantDetailViewModel 的实例在 Fragment 中使用,我们可以将其作为参数传递给 PlantDetailDescription,这样就可以了。

打开 PlantDetailDescription.kt 文件,并将 PlantDetailViewModel 参数添加到 PlantDetailDescription

PlantDetailDescription.kt

@Composable
fun PlantDetailDescription(plantDetailViewModel: PlantDetailViewModel) {
    //...
}

现在,在从 Fragment 调用此可组合项时传递 ViewModel 的实例

PlantDetailFragment.kt

class PlantDetailFragment : Fragment() {
    ...
    override fun onCreateView(...): View? {
        ...
        composeView.setContent {
            MaterialTheme {
                PlantDetailDescription(plantDetailViewModel)
            }
        }
    }
}

LiveData

这样,您就可以访问 PlantDetailViewModelLiveData<Plant> 字段来获取植物的名称。

要从可组合项观察 LiveData,请使用 LiveData.observeAsState() 函数。

由于 LiveData 发出的值可能是 null,因此您需要将其用法包装在 null 检查中。因此,为了提高可重用性,最好将 LiveData 使用和监听拆分为不同的可组合项。因此,让我们创建一个名为 PlantDetailContent 的新可组合项,它将显示 Plant 信息。

通过这些更新,PlantDetailDescription.kt 文件现在应该如下所示

PlantDetailDescription.kt

@Composable
fun PlantDetailDescription(plantDetailViewModel: PlantDetailViewModel) {
    // Observes values coming from the VM's LiveData<Plant> field
    val plant by plantDetailViewModel.plant.observeAsState()

    // If plant is not null, display the content
    plant?.let {
        PlantDetailContent(it)
    }
}

@Composable
fun PlantDetailContent(plant: Plant) {
    PlantName(plant.name)
}

@Preview
@Composable
private fun PlantDetailContentPreview() {
    val plant = Plant("id", "Apple", "description", 3, 30, "")
    MaterialTheme {
        PlantDetailContent(plant)
    }
}

PlantNamePreview 应该反映我们的更改,而无需直接更新它,因为 PlantDetailContent 只是调用 PlantName

3e47e682cf518c71.png

现在,您已连接了 ViewModel,以便在 Compose 中显示植物名称。在接下来的几个部分中,您将构建其余的可组合项,并以类似的方式将其连接到 ViewModel。

8. 更多 XML 代码迁移

现在,更容易完成 UI 中缺少的部分:浇水信息和植物描述。遵循与之前类似的方法,您现在可以迁移屏幕的其余部分。

之前从 fragment_plant_detail.xml 中删除的浇水信息 XML 代码由两个 TextView 组成,其 id 分别为 plant_watering_headerplant_watering

<TextView
    android:id="@+id/plant_watering_header"
    ...
    android:layout_marginStart="@dimen/margin_small"
    android:layout_marginTop="@dimen/margin_normal"
    android:layout_marginEnd="@dimen/margin_small"
    android:gravity="center_horizontal"
    android:text="@string/watering_needs_prefix"
    android:textColor="?attr/colorAccent"
    android:textStyle="bold"
    ... />

<TextView
    android:id="@+id/plant_watering"
    ...
    android:layout_marginStart="@dimen/margin_small"
    android:layout_marginEnd="@dimen/margin_small"
    android:gravity="center_horizontal"
    app:wateringText="@{viewModel.plant.wateringInterval}"
    .../>

与之前所做的一样,创建一个名为 PlantWatering 的新可组合项,并添加 Text 可组合项以在屏幕上显示浇水信息

PlantDetailDescription.kt

@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun PlantWatering(wateringInterval: Int) {
    Column(Modifier.fillMaxWidth()) {
        // Same modifier used by both Texts
        val centerWithPaddingModifier = Modifier
            .padding(horizontal = dimensionResource(R.dimen.margin_small))
            .align(Alignment.CenterHorizontally)

        val normalPadding = dimensionResource(R.dimen.margin_normal)

        Text(
            text = stringResource(R.string.watering_needs_prefix),
            color = MaterialTheme.colorScheme.primaryContainer,
            fontWeight = FontWeight.Bold,
            modifier = centerWithPaddingModifier.padding(top = normalPadding)
        )

        val wateringIntervalText = pluralStringResource(
            R.plurals.watering_needs_suffix, wateringInterval, wateringInterval
        )
        Text(
            text = wateringIntervalText,
            modifier = centerWithPaddingModifier.padding(bottom = normalPadding)
        )
    }
}

@Preview
@Composable
private fun PlantWateringPreview() {
    MaterialTheme {
        PlantWatering(7)
    }
}

带有预览

6f6c17085801a518.png

一些需要注意的事项

  • 由于 Text 可组合项共享水平填充和对齐修饰符,您可以通过将其分配给局部变量(即 centerWithPaddingModifier)来重用修饰符。由于修饰符是普通的 Kotlin 对象,因此您可以这样做。
  • Compose 的 MaterialTheme 没有与 plant_watering_header 中使用的 colorAccent 完全匹配。现在,让我们使用 MaterialTheme.colorScheme.primaryContainer,您将在互操作主题部分对其进行改进。
  • 在 Compose 1.2.1 中,使用 pluralStringResource 需要选择加入 ExperimentalComposeUiApi。在 Compose 的未来版本中,可能不再需要此操作。

让我们将所有部分连接在一起,并从 PlantDetailContent 中调用 PlantWatering。我们在开头删除的 ConstraintLayout XML 代码有一个 16.dp 的边距,我们需要将其包含在我们的 Compose 代码中。

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_margin="@dimen/margin_normal">

PlantDetailContent 中,创建一个 Column 以一起显示名称和浇水信息,并将其作为填充。此外,为了使使用的背景颜色和文本颜色合适,请添加一个 Surface 来处理它。

PlantDetailDescription.kt

@Composable
fun PlantDetailContent(plant: Plant) {
    Surface {
        Column(Modifier.padding(dimensionResource(R.dimen.margin_normal))) {
            PlantName(plant.name)
            PlantWatering(plant.wateringInterval)
        }
    }
}

如果您刷新预览,您将看到以下内容

56626a7118ce075c.png

9. Compose 代码中的 View

现在,让我们迁移植物描述。 fragment_plant_detail.xml 中的代码有一个 TextView,其中 app:renderHtml="@{viewModel.plant.description}" 用于告诉 XML 在屏幕上显示什么文本。renderHtml 是一个绑定适配器,您可以在 PlantDetailBindingAdapters.kt 文件中找到它。该实现使用 HtmlCompat.fromHtmlTextView 上设置文本!

但是,Compose 目前不支持 Spanned 类,也不支持显示 HTML 格式的文本。因此,我们需要在 Compose 代码中使用 View 系统中的 TextView 来绕过此限制。

由于 Compose 尚未能够渲染 HTML 代码,因此您将以编程方式创建一个 TextView 来执行此操作,方法是使用 AndroidView API。

AndroidView 允许您在其 factory lambda 中构造一个 View。它还提供了一个 update lambda,该 lambda 在 View 已膨胀和后续重新组合时被调用。

让我们通过创建一个新的 PlantDescription 可组合项来执行此操作。此可组合项调用 AndroidView,它在其 factory lambda 中构造一个 TextView。在 factory lambda 中,初始化一个显示 HTML 格式文本的 TextView,然后将 movementMethod 设置为 LinkMovementMethod 的实例。最后,在 update lambda 中,将 TextView 的文本设置为 htmlDescription

PlantDetailDescription.kt

@Composable
private fun PlantDescription(description: String) {
    // Remembers the HTML formatted description. Re-executes on a new description
    val htmlDescription = remember(description) {
        HtmlCompat.fromHtml(description, HtmlCompat.FROM_HTML_MODE_COMPACT)
    }

    // Displays the TextView on the screen and updates with the HTML description when inflated
    // Updates to htmlDescription will make AndroidView recompose and update the text
    AndroidView(
        factory = { context ->
            TextView(context).apply {
                movementMethod = LinkMovementMethod.getInstance()
            }
        },
        update = {
            it.text = htmlDescription
        }
    )
}

@Preview
@Composable
private fun PlantDescriptionPreview() {
    MaterialTheme {
        PlantDescription("HTML<br><br>description")
    }
}

预览

deea1d191e9087b4.png

请注意,htmlDescription 记住给定作为参数传递的 description 的 HTML 描述。如果 description 参数更改,则 remember 内部的 htmlDescription 代码将再次执行。

因此,如果 htmlDescription 更改,AndroidView 更新回调将重新组合。在 update lambda 内部读取的任何状态都会导致重新组合。

让我们将 PlantDescription 添加到 PlantDetailContent 可组合项中,并将预览代码更改为也显示 HTML 描述

PlantDetailDescription.kt

@Composable
fun PlantDetailContent(plant: Plant) {
    Surface {
        Column(Modifier.padding(dimensionResource(R.dimen.margin_normal))) {
            PlantName(plant.name)
            PlantWatering(plant.wateringInterval)
            PlantDescription(plant.description)
        }
    }
}

@Preview
@Composable
private fun PlantDetailContentPreview() {
    val plant = Plant("id", "Apple", "HTML<br><br>description", 3, 30, "")
    MaterialTheme {
        PlantDetailContent(plant)
    }
}

带有预览

7843a8d6c781c244.png

至此,您已将原始 ConstraintLayout 内的所有内容迁移到 Compose。您可以运行应用程序以检查它是否按预期工作。

c7021c18eb8b4d4e.gif

10. ViewCompositionStrategy

每当 ComposeView 从窗口分离时,Compose 就会处理 组合。当 ComposeView 在 Fragment 中使用时,这是不希望的,原因有两个

  • 组合必须遵循 Fragment 的视图生命周期,以便 Compose UI View 类型保存状态。
  • 发生转换时,底层 ComposeView 将处于分离状态。但是,Compose UI 元素在这些转换期间仍然可见。

要修改此行为,请使用适当的ViewCompositionStrategy调用setViewCompositionStrategy,使其遵循片段的视图生命周期。具体来说,您需要使用DisposeOnViewTreeLifecycleDestroyed策略,以便在片段的LifecycleOwner被销毁时释放 Composition。

由于PlantDetailFragment具有进入和退出过渡动画(有关更多信息,请查看nav_garden.xml),并且我们稍后将在 Compose 中使用View类型,因此我们需要确保ComposeView使用DisposeOnViewTreeLifecycleDestroyed策略。尽管如此,在片段中使用ComposeView时,始终设置此策略是最佳实践

PlantDetailFragment.kt

import androidx.compose.ui.platform.ViewCompositionStrategy
...

class PlantDetailFragment : Fragment() {
    ...
    override fun onCreateView(...): View? {
        val binding = DataBindingUtil.inflate<FragmentPlantDetailBinding>(
            inflater, R.layout.fragment_plant_detail, container, false
        ).apply {
            ...
            composeView.apply {
                // Dispose the Composition when the view's LifecycleOwner
                // is destroyed
                setViewCompositionStrategy(
                    ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
                )
                setContent {
                    MaterialTheme {
                        PlantDetailDescription(plantDetailViewModel)
                    }
                }
            }
        }
        ...
    }
}

11. 材质主题

我们已将植物详细信息的文本内容迁移到 Compose。但是,您可能已经注意到 Compose 未使用正确的主题颜色。它在植物名称中使用紫色,而应该使用绿色。

要使用正确的主题颜色,您需要通过定义自己的主题并提供主题的颜色来自定义MaterialTheme

自定义MaterialTheme

要创建自己的主题,请打开theme包下的Theme.kt文件。Theme.kt定义了一个名为SunflowerTheme的可组合函数,它接受一个内容 lambda 并将其传递给MaterialTheme

它目前还没有做任何有趣的事情——您接下来会自定义它。

Theme.kt

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable

@Composable
fun SunflowerTheme(
    content: @Composable () -> Unit
) {
    MaterialTheme(content = content)
}

MaterialTheme允许您自定义其颜色、排版和形状。现在,请继续通过在 Sunflower View 的主题中提供相同的颜色来自定义颜色。SunflowerTheme还可以接受一个名为darkTheme的布尔参数,如果系统处于深色模式,则默认为true,否则为false。使用此参数,我们可以将正确的颜色值传递给MaterialTheme以匹配当前设置的系统主题。

Theme.kt

@Composable
fun SunflowerTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val lightColors  = lightColorScheme(
        primary = colorResource(id = R.color.sunflower_green_500),
        primaryContainer = colorResource(id = R.color.sunflower_green_700),
        secondary = colorResource(id = R.color.sunflower_yellow_500),
        background = colorResource(id = R.color.sunflower_green_500),
        onPrimary = colorResource(id = R.color.sunflower_black),
        onSecondary = colorResource(id = R.color.sunflower_black),
    )
    val darkColors  = darkColorScheme(
        primary = colorResource(id = R.color.sunflower_green_100),
        primaryContainer = colorResource(id = R.color.sunflower_green_200),
        secondary = colorResource(id = R.color.sunflower_yellow_300),
        onPrimary = colorResource(id = R.color.sunflower_black),
        onSecondary = colorResource(id = R.color.sunflower_black),
        onBackground = colorResource(id = R.color.sunflower_black),
        surface = colorResource(id = R.color.sunflower_green_100_8pc_over_surface),
        onSurface = colorResource(id = R.color.sunflower_white),
    )
    val colors = if (darkTheme) darkColors else lightColors
    MaterialTheme(
        colorScheme = colors,
        content = content
    )
}

要使用它,请替换MaterialThemeSunflowerTheme中的使用。例如,在PlantDetailFragment

PlantDetailFragment.kt

class PlantDetailFragment : Fragment() {
    ...
    composeView.apply {
        ...
        setContent {
            SunflowerTheme {
                PlantDetailDescription(plantDetailViewModel)
            }
        }
    }
}

以及PlantDetailDescription.kt文件中的所有预览可组合函数

PlantDetailDescription.kt

@Preview
@Composable
private fun PlantDetailContentPreview() {
    val plant = Plant("id", "Apple", "HTML<br><br>description", 3, 30, "")
    SunflowerTheme {
        PlantDetailContent(plant)
    }
}

@Preview
@Composable
private fun PlantNamePreview() {
    SunflowerTheme {
        PlantName("Apple")
    }
}

@Preview
@Composable
private fun PlantWateringPreview() {
    SunflowerTheme {
        PlantWatering(7)
    }
}

@Preview
@Composable
private fun PlantDescriptionPreview() {
    SunflowerTheme {
        PlantDescription("HTML<br><br>description")
    }
}

如您在预览中所见,颜色现在应该与 Sunflower 主题的颜色匹配。

886d7eaea611f4eb.png

您还可以通过创建一个新函数并将Configuration.UI_MODE_NIGHT_YES传递给预览的uiMode来预览深色主题下的 UI

import android.content.res.Configuration
...

@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PlantDetailContentDarkPreview() {
    val plant = Plant("id", "Apple", "HTML<br><br>description", 3, 30, "")
    SunflowerTheme {
        PlantDetailContent(plant)
    }
}

带有预览

cfe11c109ff19eeb.png

如果运行应用程序,它在亮色和深色主题下都与迁移之前完全相同。

438d2dd9f8acac39.gif

12. 测试

在将植物详细信息屏幕的部分内容迁移到 Compose 后,测试对于确保您没有破坏任何内容至关重要。

在 Sunflower 中,位于androidTest文件夹中的PlantDetailFragmentTest测试了应用程序的一些功能。打开该文件并查看当前代码

  • testPlantName检查屏幕上植物的名称
  • testShareTextIntent检查在点击共享按钮后是否触发了正确的 Intent

当活动或片段使用 Compose 时,您需要使用createAndroidComposeRule(它集成了ActivityScenarioRuleComposeTestRule,使您可以测试 Compose 代码),而不是使用ActivityScenarioRule

PlantDetailFragmentTest中,用createAndroidComposeRule替换ActivityScenarioRule的使用。当需要活动规则来配置测试时,请按如下方式使用createAndroidComposeRule中的activityRule属性

@RunWith(AndroidJUnit4::class)
class PlantDetailFragmentTest {

    @Rule
    @JvmField
    val composeTestRule = createAndroidComposeRule<GardenActivity>()
   
    ...

    @Before
    fun jumpToPlantDetailFragment() {
        populateDatabase()

        composeTestRule.activityRule.scenario.onActivity { gardenActivity ->
            activity = gardenActivity

            val bundle = Bundle().apply { putString("plantId", "malus-pumila") }
            findNavController(activity, R.id.nav_host).navigate(R.id.plant_detail_fragment, bundle)
        }
    }

    ...
}

如果运行测试,testPlantName将失败!testPlantName检查屏幕上是否存在 TextView。但是,您已将 UI 的该部分迁移到 Compose。因此,您需要使用 Compose 断言代替

@Test
fun testPlantName() {
    composeTestRule.onNodeWithText("Apple").assertIsDisplayed()
}

如果运行测试,您将看到所有测试都通过了。

dd59138fac1740e4.png

13. 恭喜

恭喜您已成功完成本 Codelab!

原始 Sunflower github 项目的compose分支将植物详细信息屏幕完全迁移到 Compose。除了您在本 Codelab 中所做的操作外,它还模拟了 CollapsingToolbarLayout 的行为。这涉及

  • 使用 Compose 加载图像
  • 动画
  • 更好的尺寸处理
  • 以及更多!

接下来是什么?

查看Compose 学习路径上的其他 Codelab

进一步阅读