将 Compose 与现有界面集成

如果您的应用界面是基于 View 系统,您可能不想一次全部重写整个界面。本页将帮助您向现有界面中添加新的 Compose 元素。

迁移共享界面

如果您要逐步迁移到 Compose,可能需要在 Compose 和 View 系统中都使用共享界面元素。例如,如果您的应用具有自定义 CallToActionButton 组件,您可能需要在 Compose 和基于 View 的屏幕中都使用它。

在 Compose 中,共享界面元素成为可在整个应用中重复使用的可组合项,无论元素是采用 XML 进行的样式设计还是一个自定义视图。例如,您将为自定义号召性用语 Button 组件创建 CallToActionButton 可组合项。

为了在基于 View 的屏幕中使用可组合项,您需要创建一个从 AbstractComposeView 扩展的自定义视图封装容器。在该容器被替换的 Content 可组合项中,将您创建的可组合项封装在 Compose 主题中,如下例所示:

@Composable
fun CallToActionButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Button(
        colors = ButtonDefaults.buttonColors(
            backgroundColor = MaterialTheme.colors.secondary
        ),
        onClick = onClick,
        modifier = modifier,
    ) {
        Text(text)
    }
}

class CallToActionViewButton @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : AbstractComposeView(context, attrs, defStyle) {

    var text by mutableStateOf<String>("")
    var onClick by mutableStateOf<() -> Unit>({})

    @Composable
    override fun Content() {
        YourAppTheme {
            CallToActionButton(text, onClick)
        }
    }
}

请注意,可组合项参数在自定义视图中会成为可变变量。这会使自定义 CallToActionViewButton 视图在使用视图绑定等功能时变得可膨胀且可以使用,像传统视图一样。请参见下面的示例:

class ExampleActivity : Activity() {

    private lateinit var binding: ActivityExampleBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityExampleBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.callToAction.apply {
            text = getString(R.string.something)
            onClick = { /* Do something */ }
        }
    }
}

如果自定义组件包含可变状态,请参阅状态可信来源部分。

主题

建议您按照 Material Design 的准则,采用适用于 Android 的 Material Design 组件(MDC) 库来设计 Android 应用的主题。如 Compose 主题文档中所述,Compose 使用 MaterialTheme 可组合项实现这些概念。

在 Compose 中创建新屏幕时,您应确保在使用任何从 Material 组件库中发出界面的可组合项之前使用 MaterialTheme。Material 组件(ButtonText 等)依赖于现有的 MaterialTheme,如果没有 MaterialTheme,这些组件的行为将处于未定义状态。

所有 Jetpack Compose 示例都使用基于 MaterialTheme 构建的自定义 Compose 主题。

多个可信来源

现有应用可能包含大量视图主题和样式。在现有应用中引入 Compose 时,您需要迁移主题才能对任意 Compose 屏幕使用 MaterialTheme。这意味着应用的主题将会有 2 个可信来源:基于 View 的主题,以及 Compose 主题。样式上的任何更改都需要在多处实施。

如果您计划将应用完全迁移到 Compose,最终还是要针对现有主题创建 Compose 版本。问题在于,在开发过程中创建 Compose 主题的时间越早,开发中需要进行的维护就越多。

MDC Compose 主题适配器

如果您在 Android 应用中使用 MDC 库,则可借助 MDC Compose 主题适配器库,在可组合项中轻松地重复使用基于 View 的现有主题的颜色排版形状主题:

import com.google.android.material.composethemeadapter.MdcTheme

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MdcTheme {
                // Colors, typography, and shape have been read from the
                // View-based theme used in this Activity
                ExampleComposable(/*...*/)
            }
        }
    }
}

如需了解详情,请参阅 MDC 库文档

AppCompat Compose 主题适配器

借助 AppCompat Compose 主题适配器库,您可以在 Jetpack Compose 中轻松地重复使用 AppCompat XML 主题。它会使用上下文主题的颜色排版值创建 MaterialTheme

import com.google.accompanist.appcompattheme.AppCompatTheme

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            AppCompatTheme {
                // Colors, typography, and shape have been read from the
                // View-based theme used in this Activity
                ExampleComposable(/*...*/)
            }
        }
    }
}

默认组件样式

MDC 和 AppCompat Compose 主题适配器库不会读取任何由主题定义的默认微件样式。这是因为 Compose 没有默认可组合项的概念。

如需详细了解组件样式自定义设计系统,请参阅主题文档

Compose 中的主题叠加层

将基于 View 的屏幕迁移到 Compose 时,请注意 android:theme 属性的用法。您可能需要在 Compose 界面树的相应部分添加新的 MaterialTheme

如需了解详情,请参阅主题指南

WindowInsets 和 IME 动画

您可以使用 accompanist-insets 库处理 WindowInsets,该库提供用于在布局中处理它们的可组合项和修饰符,以及对 IME 动画的支持。

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        WindowCompat.setDecorFitsSystemWindows(window, false)

        setContent {
            MaterialTheme {
                ProvideWindowInsets {
                    MyScreen()
                }
            }
        }
    }
}

@Composable
fun MyScreen() {
    Box {
        FloatingActionButton(
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .padding(16.dp) // normal 16dp of padding for FABs
                .navigationBarsPadding(), // Move it out from under the nav bar
            onClick = { }
        ) {
            Icon( /* ... */)
        }
    }
}

展示界面元素上下滚动以便为键盘腾出空间的动画

图 2. 采用 accompanist-insets 库的 IME 动画。

如需了解详情,请参阅 accompanists-insets 库文档

优先考虑将状态与呈现分开

过去,View 是有状态的。View 管理的字段用于描述要显示的内容以及显示方式。将 View 转换为 Compose 时,需要将正在渲染的数据隔离开以实现单向数据流,状态提升中对此进行了详细说明。

例如,View 具有 visibility 属性,用于描述该 View 是可见、不可见还是已消失。这是 View 固有的属性。虽然其他代码可能会改变 View 的可见性,但只有 View 本身知道它当前的真实可见性。用于确保 View 可见的逻辑很容易出错,并且通常与 View 本身相关联。

相比之下,Compose 通过使用 Kotlin 中的条件逻辑,可以轻松显示完全不同的可组合项:

if (showCautionIcon) {
    CautionIcon(/* ... */)
}

根据设计,CautionIcon 不需要知道或关心其显示的原因,也没有 visibility 的概念:它要么在组合中,要么不在。

通过将状态管理与内容呈现逻辑完全分开,您可以更自由地更改将内容作为状态转换显示到界面的方式。能够在需要时提升状态还会提升可组合项的可重用性,因为状态所有权更灵活。

提升封装组件和可重用组件

View 元素通常对自己所处位置有所感知:在 ActivityDialogFragment 内或另一个 View 层次结构中的某个位置。由于 View 通常是从静态布局文件膨胀而来,因此其整体结构往往非常严格。这会使耦合更紧密,并且使 View 更难以更改或重复使用。

例如,自定义 View 可能假定它具有某种类型的子视图(具有特定 ID),并直接根据某项操作更改其属性。这使得这些 View 元素紧密耦合在一起:如果自定义 View 找不到子级,则可能会发生崩溃或损坏;而如果没有自定义 View 父级,子级可能会无法重复使用。

这在具有可重用可组合项的 Compose 中则不是什么问题。父级可以轻松指定状态和回调,因此可以编写可重用可组合项,而不必知道它们具体将被用在哪里。

var isEnabled by rememberSaveable { mutableStateOf(false) }

Column {
    ImageWithEnabledOverlay(isEnabled)
    ControlPanelWithToggle(
        isEnabled = isEnabled,
        onEnabledChanged = { isEnabled = it }
    )
}

在上面的示例中,所有三个部分封装程度更高,但耦合程度更低:

  • ImageWithEnabledOverlay 只需知道当前的 isEnabled 状态,而不需知道 ControlPanelWithToggle 的存在,甚至不需要知道如何控制它。

  • ControlPanelWithToggle 不知道 ImageWithEnabledOverlay 的存在。isEnabled 可能以零种、一种或多种方式显示,而 ControlPanelWithToggle 无需更改。

  • 对父级而言,ImageWithEnabledOverlayControlPanelWithToggle 的嵌套深度无关紧要。这些子项的目的可能是为变化添加动画效果、换出内容或将内容传递给其他子项。

此模式称为“控制反转”,CompositionLocal 文档中对此做了更详细的介绍。

处理屏幕尺寸的变化

针对不同尺寸的窗口提供不同的资源是创建自适应 View 布局的主要方式之一。虽然在确定屏幕级别的布局时仍可选择使用限定资源,但 Compose 可让您使用常规条件逻辑完全在代码中更改布局,从而更轻松地实现此目的。使用 BoxWithConstraints 等工具,可以根据各元素可使用的空间来确定布局,使用限定资源却无法做到这一点:

@Composable
fun MyComposable() {
    BoxWithConstraints {
        if (minWidth < 480.dp) {
            /* Show grid with 4 columns */
        } else if (minWidth < 720.dp) {
            /* Show grid with 8 columns */
        } else {
            /* Show grid with 12 columns */
        }
    }
}

如需了解 Compose 提供了哪些技术来构建自适应界面,请参阅构建自适应布局

使用 View 实现嵌套滚动

遗憾的是,View 系统和 Jetpack Compose 之间的嵌套滚动尚不可用。您可以在此问题跟踪器 bug 中查看进度。

Compose 在 RecyclerView 中的运用

Jetpack Compose 将 DisposeOnDetachedFromWindow 用作默认 ViewCompositionStrategy。这意味着,每当视图与窗口分离时,系统都会处置组合

ComposeView 用作 RecyclerView ViewHolder 的一部分时,默认策略的效率并不高,因为在 RecyclerView 与窗口分离之前,底层组合实例将一直保留在内存中。最好在 RecyclerView 不再需要 ComposeView 时处置底层组合。

借助 disposeComposition 函数,您可以手动处置 ComposeView 的底层组合。您可以在相应视图被回收时调用此函数,如下所示:

import androidx.compose.ui.platform.ComposeView

class MyComposeAdapter : RecyclerView.Adapter<MyComposeViewHolder>() {

    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int,
    ): MyComposeViewHolder {
        return MyComposeViewHolder(ComposeView(parent.context))
    }

    override fun onViewRecycled(holder: MyComposeViewHolder) {
        // Dispose of the underlying Composition of the ComposeView
        // when RecyclerView has recycled this ViewHolder
        holder.composeView.disposeComposition()
    }

    /* Other methods */
}

class MyComposeViewHolder(
    val composeView: ComposeView
) : RecyclerView.ViewHolder(composeView) {
    /* ... */
}

正如 Interoperability API 指南中的“ComposeView 的 ViewCompositionStrategy”部分所述,为了使 Compose ViewHolder 能够适用于所有场景,有必要使用 DisposeOnViewTreeLifecycleDestroyed 策略。

import androidx.compose.ui.platform.ViewCompositionStrategy

class MyComposeViewHolder(
    val composeView: ComposeView
) : RecyclerView.ViewHolder(composeView) {

    init {
        composeView.setViewCompositionStrategy(
            ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
        )
    }

    fun bind(input: String) {
        composeView.setContent {
            MdcTheme {
                Text(input)
            }
        }
    }
}

如需了解 RecyclerView 中所用的 ComposeView 的实际运用,请查看 Sunflower 应用的 compose_recyclerview 分支