迁移到 Jetpack Compose

Compose 和 View 系统可以结合使用。

在本 Codelab 中,您需要将 Sunflower 的部分植物详细信息界面迁移到 Compose。我们创建了项目的副本,供您尝试将一个真实的应用迁移到 Compose。

完成本 Codelab 后,您将能够继续进行迁移,并根据需要转换 Sunflower 的其余界面。

学习内容

在本 Codelab 中,您将学习:

  • 您可以遵循的不同迁移路径
  • 如何逐步将应用迁移到 Compose
  • 如何将 Compose 添加到使用 Android 视图构建的现有界面
  • 如何在 Compose 中使用 Android View
  • 如何在 Compose 中使用 View 系统中的主题
  • 如何使用 View 系统代码和 Compose 代码测试界面

前提条件

所需条件

如何迁移到 Compose 取决于您和您的团队。有很多不同的方法可将 Jetpack Compose 集成到现有 Android 应用中。两种比较常见的策略是仅迁移新界面,以及针对现有界面的一部分,使用 Compose 替代 View 系统。

新界面中的 Compose

在将应用重构为新技术时,一种常用的方法是在为应用构建的新功能中采用该技术。在这种情况下,适合使用新的界面。如果您需要为应用构建新界面,请考虑使用 Compose,而应用的其余部分可能仍会保留在 View 系统中。

在这种情况下,您需要在这些已迁移功能的边缘实现 Compose 互操作性。

搭配使用 Compose 和 View

对于特定界面,您可以将部分界面迁移到 Compose,让其他部分保留在 View 系统中。例如,您可以迁移 RecyclerView,同时将界面的其余部分保留在 View 系统中。

或者,使用 Compose 作为外部布局,并使用 Compose 中可能没有的一些现有视图,比如 MapView 或 Fiddler。

完成迁移

将全部 fragment 或界面迁移到 Compose,一次迁移一个。这种方式最为简单,但比较粗放。

在本 Codelab 中还可以学习哪些内容?

在本 Codelab 中,您将学习逐步把 Sunflower 的植物详细信息界面迁移到 Compose,让 Compose 和 View 协同工作。之后,您将了解足够的知识,可以在需要时继续进行迁移。

获取代码

从 GitHub 获取 Codelab 代码:

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

或者,您可以下载 Zip 文件形式的代码库:

下载 Zip 文件

打开 Android Studio

学习本 Codelab 需要最新版本的 Android Studio Arctic Fox。如果您需要下载 Android Studio,可以在此处下载。

运行示例应用

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

在本 Codelab 中,您需要将 Sunflower 的植物详细信息界面迁移到 Compose。点按植物列表界面中显示的某个植物,即可打开植物详细信息界面。

bb6fcf50b2899894.png

项目设置

该项目在多个 git 分支中构建而成:

  • main 是您签出或下载的分支,也是本 Codelab 的起点。
  • end 包含本 Codelab 的解决方案。

建议您从 main 分支中的代码着手,按照自己的节奏逐步完成 Codelab。

在本 Codelab 中,系统会为您显示需要添加到项目的代码段。在某些地方,您还需要移除代码,我们将在代码段的注释中明确标出这部分内容。

如需使用 Git 获取 end 分支,请使用以下命令:

$ git clone -b end https://github.com/googlecodelabs/android-compose-codelabs

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

下载最终代码

常见问题解答

Compose 已添加到您从 main 分支下载的代码中。不过,我们先来了解一下运行该工具需要具备哪些条件。

打开 app/build.gradle(或 build.gradle (Module: compose-migration.app))文件后,请查看该文件如何导入 Compose 依赖项,以及如何使用 buildFeatures { compose true } 标记,从而让 Android Studio 能够运行 Compose。

app/build.gradle

android {
    ...
    kotlinOptions {
        jvmTarget = '1.8'
        useIR = true
    }
    buildFeatures {
        ...
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion rootProject.composeVersion
    }
}

dependencies {
    ...
    // Compose
    implementation "androidx.compose.runtime:runtime:$rootProject.composeVersion"
    implementation "androidx.compose.ui:ui:$rootProject.composeVersion"
    implementation "androidx.compose.foundation:foundation:$rootProject.composeVersion"
    implementation "androidx.compose.foundation:foundation-layout:$rootProject.composeVersion"
    implementation "androidx.compose.material:material:$rootProject.composeVersion"
    implementation "androidx.compose.runtime:runtime-livedata:$rootProject.composeVersion"
    implementation "androidx.compose.ui:ui-tooling:$rootProject.composeVersion"
    implementation "com.google.android.material:compose-theme-adapter:$rootProject.composeVersion"
    ...
}

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

在植物详细信息界面中,我们需要将对植物的说明迁移到 Compose,同时保持界面的总体结构保持不变。这时,您需要遵循“规划迁移”部分中提到的“搭配使用 Compose 和 View”迁移策略。

Compose 需要有宿主 activity 或 fragment 才能呈现界面。由于 Sunflower 中的所有界面都使用 fragment,您需要使用 ComposeView:这个 Android View 可以使用其 setContent 方法托管 Compose 界面内容的。

移除 XML 代码

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

  1. 切换到代码视图
  2. 移除 NestedScrollView 中的 ConstraintLayout 代码和嵌套的 TextView(本 Codelab 会在迁移各项内容时比较和引用 XML 代码,将代码注释掉会非常有用)
  3. 添加一个 ComposeView,用于托管 Compose 代码,并以 compose_view 作为视图 ID

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) Remove the 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 中,您都需要将 Compose 代码添加到 plantdetail 文件夹下的 PlantDetailDescription.kt 文件中。打开该文件,看看项目中是否有占位符 "Hello Compose!" 文本。

plantdetail/PlantDetailDescription.kt

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

我们从在上一步中添加的 ComposeView 中调用此可组合项,即可在界面上显示此内容。打开 plantdetail/PlantDetailFragment.kt

当界面使用数据绑定时,您可以直接访问 composeView 并调用 setContent,以便在界面上显示 Compose 代码。由于 Sunflower 使用 Material Design,因此需在 MaterialTheme 内调用 PlantDetailDescription 可组合项。

plantdetail/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!”。

abb6b7763cc36838.png

我们首先迁移植物的名称。更确切地说,您在 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 中观察到的。

如何观察 LiveData 将在稍后介绍,因此先假设我们有可用的名称,并以参数形式传递给我们在 PlantDetailDescription.kt 文件中创建的新 PlantName 可组合项。稍后,将从 PlantDetailDescription 可组合项调用此可组合项。

PlantDetailDescription.kt

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

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

预览如下:

96f0ac15d8cd0745.png

其中:

  • Text 的样式为 MaterialTheme.typography.h5,它从 XML 代码映射到 textAppearanceHeadline5
  • 修饰符会修饰文本,以将文本调整为类似于 XML 版本:
  • fillMaxWidth 修饰符对应于 XML 代码中的 android:layout_width="match_parent"
  • margin_small 的水平 padding 是使用 dimensionResource 辅助函数从 View 系统获得的值。
  • wrapContentWidth 会水平对齐 Text

现在,我们将标题连接到界面。要执行此操作,您需要使用 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

有了 LiveData,您就有权访问 PlantDetailViewModelLiveData<Plant> 字段,以获取植物的名称。

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

由于 LiveData 发出的值可以为 null,因此您需要将其用法封装在 null 检查中。鉴于此,以及为了实现可重用性,最好将 LiveData 使用情况进行拆分,并在不同的可组合项中侦听。因此,请创建一个名为 PlantDetailContent 的新可组合项,用于显示 Plant 信息。

基于以上原因,添加 LiveData 观察后,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

3581b7b21b044e8d.png

现在,您已完成在 Compose 中显示植物名称所需的所有 ViewModel 连接。在接下来的几节中,您需要构建其余可组合项,并以类似的方式将它们连接到 ViewModel。

现在,可以更轻松地完成界面中缺少的内容:浇水信息和植物说明。按照前面介绍的 XML 代码方法,您已经可以迁移界面的其余部分了。

您之前从 fragment_plant_detail.xml 移除的浇水信息 XML 代码由两个 ID 为 plant_watering_headerplant_watering 的 TextView 组成。

<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

@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.colors.primaryVariant,
            fontWeight = FontWeight.Bold,
            modifier = centerWithPaddingModifier.padding(top = normalPadding)
        )

        val wateringIntervalText = LocalContext.current.resources.getQuantityString(
            R.plurals.watering_needs_suffix, wateringInterval, wateringInterval
        )
        Text(
            text = wateringIntervalText,
            modifier = centerWithPaddingModifier.padding(bottom = normalPadding)
        )
    }
}

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

预览如下:

741b92db42c262df.png

需要注意以下几点:

  • 由于 Text 可组合项会共享水平内边距和对齐修饰,因此您可以将修饰符分配给局部变量(即 centerWithPaddingModifier),以重复使用修饰符。修饰符是标准的 Kotlin 对象,因此可以重复使用。
  • Compose 的 MaterialThemeplant_watering_header 中使用的 colorAccent 不完全匹配。现在,我们可以使用将在主题设置部分中加以改进的 MaterialTheme.colors.primaryVariant

我们将各个部分组合在一起,并从 PlantDetailContent 调用 PlantWatering。我们一开始移除了外边距为 16.dp 的 ConstraintLayout XML 代码,需要将该代码添加到 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)
        }
    }
}

刷新预览后,您会看到以下内容:

97f35931b72c29b.png

现在,我们来迁移植物说明。fragment_plant_detail.xml 中的代码具有包含 app:renderHtml="@{viewModel.plant.description}"TextView,用于告知 XML 在界面上显示哪些文本。renderHtml 是一个绑定适配器,可在 PlantDetailBindingAdapters.kt 文件中找到。该实现使用 HtmlCompat.fromHtmlTextView 上设置文本!

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

Compose 目前还无法呈现 HTML 代码,因此您需要使用 AndroidView API 以程序化方式创建一个 TextView,从而实现此目的。

AndroidView 接受 View 作为参数,并在 View 膨胀时为您提供回调功能。

为此,请创建新的 PlantDescription 可组合项。此可组合项使用我们刚刚在 lambda 中保存的 TextView 调用 AndroidView。在 factory 回调中,请初始化使用给定 Context 来回应 HTML 交互的 TextView。在 update 回调中,用已保存的 HTML 格式的说明设置文本。

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")
    }
}

预览:

95d8ca1832c1ef26.png

请注意,htmlDescription 会记住作为参数传递的指定 description 的 HTML 说明。如果 description 参数发生变化,系统会再次执行 remember 中的 htmlDescription 代码。

同样,如果 htmlDescription 发生变化,AndroidView 更新回调会重组。在回调中读取的任何状态都会导致重组。

我们将 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)
    }
}

预览如下:

aa43efe444af4f75.png

现在,您已将原始 ConstraintLayout 中的所有内容迁移到 Compose。您可以运行该应用,检查它是否按预期运行。

e2f5c3ec20d4966f.gif

我们已将植物详细信息的文本内容迁移到 Compose。不过,您可能已经注意到,Compose 使用的主题颜色有误。当植物名称应该使用绿色时,使用的是紫色。

在这个迁移早期阶段,您可能希望 Compose 继承 View 系统中可用的主题,而不是从头开始在 Compose 中重新编写您自己的 Material 主题。Material 主题可与 Compose 附带的所有 Material Design 组件完美配合使用。

如需在 Compose 中重复使用 View 系统的 MDC 主题,可使用 compose-theme-adapterMdcTheme 函数将自动读取宿主上下文的 MDC 主题,并代表您将它们传递给 MaterialTheme,用于浅色和深色主题。即使您只需要适用于本 Codelab 的主题颜色,该库也会读取 View 系统的形状和字体排版。

该库已包含在 app/build.gradle 文件中,如下所示:

...
dependencies {
    ...
    implementation "com.google.android.material:compose-theme-adapter:$rootProject.composeVersion"
    ...
}

如需使用此库,请将 MaterialTheme 的用法替换成 MdcTheme。例如,在 PlantDetailFragment 中:

PlantDetailFragment.kt

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

此外还有 PlantDetailDescription.kt 文件中的所有预览可组合项:

PlantDetailDescription.kt

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

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

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

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

在预览中您可以看到,MdcTheme 会从 styles.xml 文件中的主题中提取颜色。

44dc929c9b63137d.png

您还可以在深色主题中预览界面,方式是创建新函数并将 Configuration.UI_MODE_NIGHT_YES 传递给预览的 uiMode

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

预览如下:

8b676db6b0793855.png

如果您运行应用,它在浅色主题和深色主题下的行为都将与迁移前完全相同:

2b95ea2dee5ed3ae.gif

将植物详细信息界面的部分内容迁移到 Compose 之后,务必要进行测试,以确保您没有损坏任何内容。

在 Sunflower 中,位于 androidTest 文件夹的 PlantDetailFragmentTest 用于测试应用的某些功能。请打开文件并查看当前的代码:

  • testPlantName 检查界面上的植物名称
  • testShareTextIntent 检查点按分享按钮后是否触发了正确的 intent

当 activity 或 fragment 使用 Compose 时,您不应使用 ActivityScenarioRule,而应使用 createAndroidComposeRule,它将 ActivityScenarioRuleComposeTestRule 集成,让您可以测试 Compose 代码。

PlantDetailFragmentTest 中,将用法 ActivityScenarioRule 替换为 createAndroidComposeRule。如果需要使用 activity 规则来配置测试,请使用 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。不过,您已将这部分的界面迁移到 Compose。因此,您需要改用 Compose 断言:

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

如果运行测试,您会看到所有测试均通过。

284a1c1cffbe911b.png

恭喜,您已成功完成本 Codelab!

原始 Sunflower GitHub 项目的 compose 分支会将植物详细信息界面完全迁移到 Compose。除了您在本 Codelab 中完成的操作之外,该分支还会模拟 CollapsingToolbarLayout 的行为。这些行为包括:

  • 使用 Compose 加载图片
  • 动画
  • 更出色的维度处理
  • 以及更多内容!

后续操作

请查看 Compose 衔接课程中的其他 Codelab。

深入阅读