其他注意事项

虽然从 View 迁移到 Compose 仅与界面相关,但若要执行安全的增量迁移,需要考虑的因素有很多。本页介绍了将基于 View 的应用迁移到 Compose 时的一些注意事项。

迁移应用主题

推荐使用 Material Design 设计系统来为 Android 应用设置主题。

对于基于 View 的应用,可以使用三个 Material 版本:

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

对于 Compose 应用,可以使用两个 Material 版本:

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

如果应用的设计系统符合要求,建议您使用最新版本 (Material 3)。View 和 Compose 都有相应的迁移指南:

在 Compose 中创建新屏幕时,无论您使用的是哪个版本的 Material Design,都请确保先应用 MaterialTheme,然后再应用任何从 Compose Material 库发出界面的可组合项。Material 组件(ButtonText 等)依赖于现有的 MaterialTheme,如果没有 MaterialTheme,这些组件的行为将处于未定义状态。

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

如需了解详情,请参阅 Compose 中的设计系统将 XML 主题迁移到 Compose

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

测试混合 Compose/View 界面

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

当 activity 或 fragment 使用 Compose 时,您需要使用 createAndroidComposeRule,而不是 ActivityScenarioRulecreateAndroidComposeRule 会将 ActivityScenarioRuleComposeTestRule 集成,让您可以同时测试 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 布局。如需了解与界面测试框架的互操作性,请参阅与 Espresso 的互操作性以及与 UiAutomator 的互操作性

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

单向数据流 (UDF) 架构模式可与 Compose 无缝协作。如果应用改用其他类型的架构模式,例如 Model View Presenter (MVP),我们建议您在采用 Compose 之前或期间将界面中的相应部分迁移到 UDF。

在 Compose 中使用 ViewModel

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

采用 Compose 时,请务必注意在不同的可组合项中使用相同的 ViewModel 类型,因为 ViewModel 元素遵循视图生命周期作用域。如果使用 Navigation 库,作用域是主机 activity、fragment 或导航图。

例如,如果可组合项托管在 activity 中,viewModel() 始终返回相同实例,该实例只有在 activity 完成时才被清除。在以下示例中,系统会向同一用户(“user1”)显示两次问候语,因为主机 activity 中所有可组合项都重复使用了相同的 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") ?: "")
        }
    }
}

状态可信来源

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

但是,如果要共享的数据会发生变化或与界面元素密切相关,这种方式就不可行。在这种情况下,必须有一个系统是可信来源,同时该系统需要与另一系统共享所有数据更新。一般来说,可信来源应由更靠近界面层次结构根目录的任一元素拥有。

将 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 系统需要更新可变状态以及使用相应状态的视图。

在以下示例中,CustomViewGroup 包含 TextView 以及内含 TextField 可组合项的 ComposeViewTextView 需要显示用户在 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
    }
}

迁移共享界面

如果您要逐步迁移到 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(
            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 视图变得可膨胀且可以使用,像传统视图一样。请参阅下面的使用视图绑定功能时的示例:

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 可见的逻辑很容易出错,并且通常与 View 本身相关联。

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

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

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

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

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

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 提供了哪些技术来构建自适应界面,请参阅构建自适应布局

使用 View 实现嵌套滚动

如需详细了解如何在可滚动的 View 元素与可滚动的可组合项之间实现嵌套滚动互操作(相互嵌套),请仔细阅读嵌套滚动互操作性

RecyclerView 中的 Compose

RecyclerView 版本 1.3.0-alpha02 发布以来,RecyclerView 中的可组合项性能一直非常出色。请确保您使用的 RecyclerView 版本不低于 1.3.0-alpha02,以便切实感受这些好处。

WindowInsets 与 View 的互操作性

当屏幕在同一层次结构中同时包含 View 和 Compose 代码时,您可能需要替换默认边衬区。在这种情况下,您需要明确指定哪个界面应使用边衬区,哪个应该忽略边衬区。

例如,如果最外层的布局是 Android View 布局,您应在 View 系统中使用边衬区,而针对 Compose 则忽略边衬区。或者,如果最外层的布局是可组合项,您应使用 Compose 中的边衬区,并相应地填充 AndroidView 可组合项。

默认情况下,每个 ComposeView 都会在 WindowInsetsCompat 的使用级别消耗所有边衬区。如需更改此默认行为,请将 ComposeView.consumeWindowInsets 设置为 false

如需了解详情,请参阅 Compose 中的 WindowInsets 文档。