其他注意事项

虽然从 Views 迁移到 Compose 纯粹与 UI 相关,但为了安全且增量地执行迁移,需要考虑许多因素。此页面包含在将基于 View 的应用迁移到 Compose 时的一些注意事项。

迁移应用的主题

Material Design 是 Android 应用主题设计的推荐设计系统。

对于基于 View 的应用,有三个版本的 Material 可用

  • 使用 AppCompat 库的 Material Design 1(即 Theme.AppCompat.*
  • 使用 MDC-Android 库的 Material Design 2(即 Theme.MaterialComponents.*
  • 使用 MDC-Android 库的 Material Design 3(即 Theme.Material3.*

对于 Compose 应用,有两个版本的 Material 可用

  • 使用 Compose Material 库的 Material Design 2(即 androidx.compose.material.MaterialTheme
  • 使用 Compose Material 3 库的 Material Design 3(即 androidx.compose.material3.MaterialTheme

如果您的应用设计系统能够做到这一点,我们建议您使用最新版本(Material 3)。Views 和 Compose 都提供了迁移指南

在 Compose 中创建新屏幕时,无论您使用哪个版本的 Material Design,请确保在任何从 Compose Material 库发出 UI 的可组合项之前应用 MaterialTheme。Material 组件(ButtonText 等)依赖于已就位的 MaterialTheme,如果没有它,它们的行为是未定义的。

所有 Jetpack Compose 示例 都使用构建在 MaterialTheme 之上的自定义 Compose 主题。

请参阅 Compose 中的设计系统将 XML 主题迁移到 Compose 以了解更多信息。

如果您在应用中使用 导航组件,请参阅 使用 Compose 进行导航 - 互操作性将 Jetpack Navigation 迁移到 Navigation Compose 以了解更多信息。

测试您的混合 Compose/Views UI

将应用的部分内容迁移到 Compose 后,测试对于确保您没有破坏任何内容至关重要。

当活动或片段使用 Compose 时,您需要使用 createAndroidComposeRule 而不是使用 ActivityScenarioRulecreateAndroidComposeRuleActivityScenarioRuleComposeTestRule 集成,使您可以同时测试 Compose 和 View 代码。

class MyActivityTest {
    @Rule
    @JvmField
    val composeTestRule = createAndroidComposeRule<MyActivity>()

    @Test
    fun testGreeting() {
        val greeting = InstrumentationRegistry.getInstrumentation()
            .targetContext.resources.getString(R.string.greeting)

        composeTestRule.onNodeWithText(greeting).assertIsDisplayed()
    }
}

请参阅 测试您的 Compose 布局 以了解有关测试的更多信息。有关与 UI 测试框架的互操作性,请参阅 与 Espresso 的互操作性与 UiAutomator 的互操作性

将 Compose 与您现有的应用架构集成

单向数据流 (UDF) 架构模式与 Compose 无缝协同工作。如果应用使用其他类型的架构模式,例如模型视图呈现器 (MVP),我们建议您在采用 Compose 之前或同时将 UI 的该部分迁移到 UDF。

在 Compose 中使用 ViewModel

如果您使用 架构组件 ViewModel 库,您可以通过调用 ViewModel viewModel() 函数从任何可组合项访问 ViewModel,如 Compose 和其他库 中所述。

采用 Compose 时,请注意在不同的可组合项中使用相同的 ViewModel 类型,因为 ViewModel 元素遵循 View 生命周期作用域。作用域将是主机活动、片段或导航库(如果使用导航库)的导航图。

例如,如果可组合项托管在活动中,viewModel() 始终返回相同的实例,该实例仅在活动结束时清除。在以下示例中,相同的用户(“user1”)被问候两次,因为相同的 GreetingViewModel 实例在主机活动下的所有可组合项中都被重用。创建的第一个 ViewModel 实例在其他可组合项中被重用。

class GreetingActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MaterialTheme {
                Column {
                    GreetingScreen("user1")
                    GreetingScreen("user2")
                }
            }
        }
    }
}

@Composable
fun GreetingScreen(
    userId: String,
    viewModel: GreetingViewModel = viewModel(  
        factory = GreetingViewModelFactory(userId)  
    )
) {
    val messageUser by viewModel.message.observeAsState("")
    Text(messageUser)
}

class GreetingViewModel(private val userId: String) : ViewModel() {
    private val _message = MutableLiveData("Hi $userId")
    val message: LiveData<String> = _message
}

class GreetingViewModelFactory(private val userId: String) : ViewModelProvider.Factory {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return GreetingViewModel(userId) as T
    }
}

由于导航图也作用于 ViewModel 元素,因此作为导航图目标的可组合项具有不同的 ViewModel 实例。在这种情况下,ViewModel 的作用域为目标的生命周期,并且在目标从回退堆栈中移除时将其清除。在以下示例中,当用户导航到“个人资料”屏幕时,将创建一个新的 GreetingViewModel 实例。

@Composable
fun MyApp() {
    NavHost(rememberNavController(), startDestination = "profile/{userId}") {
        /* ... */
        composable("profile/{userId}") { backStackEntry ->
            GreetingScreen(backStackEntry.arguments?.getString("userId") ?: "")
        }
    }
}

状态的真实来源

当您在 UI 的一部分中采用 Compose 时,Compose 和 View 系统代码可能需要共享数据。如果可能,我们建议您将共享状态封装在另一个遵循两个平台使用的 UDF 最佳实践的类中;例如,在一个 ViewModel 中,该 ViewModel 公开共享数据的流以发出数据更新。

但是,如果要共享的数据是可变的或与 UI 元素紧密绑定,则并非总是如此。在这种情况下,一个系统必须是真实来源,并且该系统需要将任何数据更新共享到另一个系统。作为一般经验法则,真实来源应由靠近 UI 层次结构根部的元素拥有。

Compose 作为真实来源

使用 SideEffect 可组合项将 Compose 状态发布到非 Compose 代码。在这种情况下,真实来源保存在可组合项中,该可组合项发送状态更新。

例如,您的分析库可能允许您通过将自定义元数据(此示例中的“用户属性”)附加到所有后续分析事件来细分用户群体。要将当前用户的用户类型传达给您的分析库,请使用 SideEffect 更新其值。

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

有关更多信息,请参阅 Compose 中的副作用

View 系统作为真实来源

如果 View 系统拥有状态并将其与 Compose 共享,我们建议您将状态包装在 mutableStateOf 对象中,以使其对 Compose 线程安全。如果您使用这种方法,可组合函数会简化,因为它们不再拥有真实来源,但 View 系统需要更新可变状态和使用该状态的 Views。

在以下示例中,CustomViewGroup 包含一个 TextView 和一个 ComposeView,其中包含一个 TextField 可组合项。 TextView 需要显示用户在 TextField 中键入的内容。

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

    // Source of truth in the View system as mutableStateOf
    // to make it thread-safe for Compose
    private var text by mutableStateOf("")

    private val textView: TextView

    init {
        orientation = VERTICAL

        textView = TextView(context)
        val composeView = ComposeView(context).apply {
            setContent {
                MaterialTheme {
                    TextField(value = text, onValueChange = { updateState(it) })
                }
            }
        }

        addView(textView)
        addView(composeView)
    }

    // Update both the source of truth and the TextView
    private fun updateState(newValue: String) {
        text = newValue
        textView.text = newValue
    }
}

迁移共享 UI

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

在 Compose 中,共享 UI 元素成为可在整个应用中重复使用的可组合项,无论元素是使用 XML 样式化还是自定义视图。例如,您可以为您的自定义调用操作 Button 组件创建一个 CallToActionButton 可组合项。

要在基于 View 的屏幕中使用可组合项,请创建一个从 AbstractComposeView 扩展的自定义视图包装器。在其覆盖的 Content 可组合项中,放置您在 Compose 主题中包装的可组合项,如下例所示

@Composable
fun CallToActionButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Button(
        colors = ButtonDefaults.buttonColors(
            containerColor = MaterialTheme.colorScheme.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("")
    var onClick by mutableStateOf({})

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

请注意,可组合的参数在自定义视图内变成了可变变量。这使得自定义CallToActionViewButton视图可以像传统视图一样膨胀和使用。请参见下面使用View Binding的示例。

class ViewBindingActivity : ComponentActivity() {

    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.greeting)
            onClick = { /* Do something */ }
        }
    }
}

如果自定义组件包含可变状态,请参见状态的唯一数据源

优先将状态与呈现分离

传统上,View是有状态的。View管理描述*显示内容*以及*如何显示*的字段。将View转换为Compose时,请尝试分离正在呈现的数据以实现单向数据流,这在状态提升中进一步解释。

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

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

@Composable
fun MyComposable(showCautionIcon: Boolean) {
    if (showCautionIcon) {
        CautionIcon(/* ... */)
    }
}

根据设计,CautionIcon不需要知道或关心它为什么被显示,并且没有visibility的概念:它要么在 Composition 中,要么不在。

通过清晰地分离状态管理和呈现逻辑,您可以更自由地更改如何将状态转换为 UI 来显示内容。能够在需要时提升状态还可以使可组合项更易于重用,因为状态所有权更灵活。

推广封装和可重用的组件

View元素通常知道它们在哪里:在ActivityDialogFragment内或另一个View层次结构中的某个位置。因为它们通常是从静态布局文件膨胀的,所以View的整体结构往往非常僵硬。这导致更紧密的耦合,并使View更难更改或重用。

例如,自定义View可能会假设它具有特定类型和特定 ID 的子视图,并直接响应某些操作更改其属性。这将这些View元素紧密地耦合在一起:如果找不到子元素,自定义View可能会崩溃或损坏,并且子元素可能无法在没有自定义View父元素的情况下重用。

在使用可重用可组合项的 Compose 中,这个问题不太严重。父级可以轻松指定状态和回调,因此您可以编写可重用可组合项,而无需知道它们将被使用的确切位置。

@Composable
fun AScreen() {
    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 使用普通的条件逻辑使完全在代码中更改布局变得容易得多。请参见使用窗口大小类以了解更多信息。

此外,请参考支持不同的屏幕尺寸以了解 Compose 提供的构建自适应 UI 的技术。

嵌套滚动与视图

有关如何在可滚动的 View 元素和可滚动的可组合项(双向嵌套)之间启用嵌套滚动互操作性的更多信息,请阅读嵌套滚动互操作性

RecyclerView中使用 Compose

RecyclerView 1.3.0-alpha02 版本起,RecyclerView中的可组合项性能很高。请确保您至少使用 1.3.0-alpha02 版本的RecyclerView才能获得这些好处。

WindowInsets与视图的互操作性

当您的屏幕在同一层次结构中同时包含 View 和 Compose 代码时,您可能需要覆盖默认插入。在这种情况下,您需要明确说明哪个应该使用插入,哪个应该忽略插入。

例如,如果您的最外层布局是 Android View 布局,则应在 View 系统中使用插入,并为 Compose 忽略它们。或者,如果您的最外层布局是可组合的,则应在 Compose 中使用插入,并相应地填充AndroidView可组合项。

默认情况下,每个ComposeView都在WindowInsetsCompat消费级别使用所有插入。要更改此默认行为,请将ComposeView.consumeWindowInsets设置为false

有关更多信息,请阅读Compose中的WindowInsets文档。