其他注意事项

虽然从 View 迁移到 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)。对于 View 和 Compose,都有可用的迁移指南

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

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

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

如果您在应用程序中使用 Navigation 组件,请参阅 使用 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 无缝协作。如果应用程序使用其他类型的体系结构模式(如 Model View Presenter (MVP)),我们建议您在采用 Compose 之前或同时将 UI 的那部分迁移到 UDF。

在 Compose 中使用 ViewModel

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

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

例如,如果可组合项托管在活动中,则 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 系统需要更新可变状态以及使用该状态的 View。

在以下示例中,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 元素通常会了解它们所在的位置:在一个 Activity、一个 Dialog、一个 Fragment 或者另一个 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 进行嵌套滚动

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

Compose 在 RecyclerView

由于 RecyclerView 版本 1.3.0-alpha02,RecyclerView 中的可组合项具有高性能。确保你使用的是至少 1.3.0-alpha02 版本的 RecyclerView 才能看到这些优势。

WindowInsets 与 View 的交互

当你的屏幕在同一个层级结构中同时包含 View 和 Compose 代码时,你可能需要覆盖默认内边距。在这种情况下,你需要明确指定哪个应该消耗内边距,哪个应该忽略内边距。

例如,如果你的最外层布局是 Android View 布局,你应该在 View 系统中消耗内边距,并为 Compose 忽略内边距。或者,如果你的最外层布局是可组合项,你应该在 Compose 中消耗内边距,并相应地填充 AndroidView 可组合项。

默认情况下,每个 ComposeView 都会在 WindowInsetsCompat 的消耗级别消耗所有内边距。若要更改此默认行为,请将 ComposeView.consumeWindowInsets 设置为 false

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