参加 Jetpack Compose #AndroidDevChallenge 竞赛即有机会赢取奖品,我们提供 1000 多项奖品,其中包括 Google Pixel 5。了解详情

Compose 互操作性

Jetpack Compose 旨在配合既有的基于视图的界面构造方式一起使用。如果您要构建新应用,最好的选择可能是使用 Compose 实现整个界面。但是,如果您要修改现有应用,那么请不要一次性迁移整个应用,而是可以将 Compose 与现有的界面设计实现相结合。

在应用中采用 Compose

将 Compose 与基于视图的界面结合使用,有两种主要方法:

  • 将 Compose 元素添加到现有界面中。具体方式是创建基于 Compose 的全新屏幕,或者将 Compose 元素添加到现有的 activity、fragment 或视图布局中。

  • 将基于视图的界面元素添加到可组合函数中。如此一来,您便可在基于 Compose 的设计中添加 Android View。

最好是按照项目所需的粒度逐步将整个应用迁移到 Compose。您可以一次迁移一个屏幕,甚或一次迁移一个 fragment 或其他任何可重复使用的界面元素。可通过多种不同的方式实现,具体如下:

  • “自下而上”方式:采用这种方式时先迁移屏幕上较小的界面元素(例如 ButtonTextView),然后迁移 ViewGroup 元素,直到将所有元素都转换为可组合函数。

  • “自上而下”方式:采用这种方式时先迁移 fragment 或视图容器(例如 FrameLayoutConstraintLayoutRecyclerView),然后迁移屏幕上较小的界面元素。

上述方式都假定每个屏幕都是独立的,但也可以将共享界面(例如设计系统)迁移到 Jetpack Compose。如需了解详情,请参阅下文的迁移共享界面

Interoperability API

在应用中采用 Compose 时,Compose 可以与基于视图的界面相结合。下面列出了一些可让您更轻松地过渡到 Compose 的 API、建议和提示。

Android View 中的 Compose

您可以将基于 Compose 的界面添加到采用基于视图的设计的现有应用中。

如需创建完全基于 Compose 的新屏幕,请让 activity 调用 setContent() 方法,并传递您想要使用的任何可组合函数。

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

        setContent { // In here, we can call composables!
            MaterialTheme {
                Greeting(name = "compose")
            }
        }
    }
}

@Composable
fun Greeting(name: String) {
    Text (text = "Hello $name!")
}

这段代码看起来就和只含 Compose 元素的应用中的代码一样。

如果您要将 Compose 界面内容并入 fragment 或现有视图布局,请使用 ComposeView 并调用其 setContent() 方法。ComposeView 是一个 Android View。您必须将 ComposeView 附加到一个 ViewTreeLifecycleOwnerViewTreeLifecycleOwner 允许反复附加和分离视图,同时让组成保持不变。ComponentActivityFragmentActivityAppCompatActivity 都是实现 ViewTreeLifecycleOwner 的类的示例。

您可以将 ComposeView 放在 XML 布局中,就像放置其他任何 View 一样:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/hello_world"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Hello Android!" />

    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

在 Kotlin 源代码中,通过 XML 中定义的布局资源使布局膨胀。然后,使用 XML ID 获取 ComposeView,并调用 setContent() 以使用 Compose。

class ExampleFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        // Inflate the layout for this fragment
        return inflater.inflate(
            R.layout.fragment_example, container, false
        ).apply {
            findViewById<ComposeView>(R.id.compose_view).setContent {
                // In Compose world
                MaterialTheme {
                    Text("Hello Compose!")
                }
            }
        }
    }
}

两个略有不同的文本元素,一个在另一个之上

图 1. 此图显示了在 View 界面层次结构中添加 Compose 元素的代码的输出。“Hello Android!”文本由 TextView 微件显示。“Hello Compose!”文本由 Compose 文本元素显示。

如果整个屏幕是使用 Compose 构建的,您还可以直接在 fragment 中添加 ComposeView,这样可让您完全避免使用 XML 布局文件。

class ExampleFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return ComposeView(requireContext()).apply {
            setContent {
                MaterialTheme {
                    // In Compose world
                    Text("Hello Compose!")
                }
            }
        }
    }
}

如果同一布局中存在多个 ComposeView 元素,每个元素必须具有唯一的 ID 才能使 savedInstanceState 发挥作用。如需了解详情,请参阅 SavedInstanceState 部分

class ExampleFragment : Fragment() {

  override fun onCreateView(...): View = LinearLayout(...).apply {
      addView(ComposeView(...).apply {
        id = R.id.compose_view_x
        ...
      })
      addView(TextView(...))
      addView(ComposeView(...).apply {
        id = R.id.compose_view_y
        ...
      })
    }
  }
}

ComposeView ID 在 res/values/ids.xml 文件中进行定义:

<resources>
    <item name="compose_view_x" type="id" />
    <item name="compose_view_y" type="id" />
</resources>

Compose 中的 Android View

您可以在 Compose 界面中添加 Android View 层次结构。如果您要使用 Compose 中尚未提供的界面元素(如 AdViewMapView),此方法特别有用。此方法还可让您重复使用您可能已设计的自定义视图。

如需添加视图元素或层次结构,请使用 AndroidView 可组合项。系统会向 AndroidView 传递一个返回 View 的 lambda。AndroidView 还提供了在视图膨胀时被调用的 update 回调。每当在该回调中读取的 State 发生变化时,AndroidView 都会重组。

@Composable
fun CustomView() {
    val selectedItem = remember { mutableStateOf(0) }

    // Adds view to Compose
    AndroidView(
        modifier = Modifier.fillMaxSize(), // Occupy the max size in the Compose UI tree
        viewBlock = { context ->
            // Creates custom view
            CustomView(context).apply {
                // Sets up listeners for View -> Compose communication
                myView.setOnClickListener {
                    selectedItem.value = 1
                }
            }
        },
        update = { view ->
            // View's been inflated or state read in this block has been updated
            // Add logic here if necessary

            // As selectedItem is read here, AndroidView will recompose
            // whenever the state changes
            // Example of Compose -> View communication
            view.coordinator.selectedItem = selectedItem.value
        }
    )
}

@Composable
fun ContentExample() {
    Column(Modifier.fillMaxSize()) {
        Text("Look at this CustomView!")
        CustomView()
    }
}

如需嵌入 XML 布局,请使用 androidx.compose.ui:ui-viewbinding 库提供的 AndroidViewBinding API。为此,您的项目必须启用视图绑定。

与其他许多内置可组合项一样,AndroidView 接受 Modifier 参数,例如,该参数可用于在父级可组合项中设置其位置。

@Composable
fun AndroidViewBindingExample() {
    AndroidViewBinding(ExampleLayoutBinding::inflate) {
        exampleView.setBackgroundColor(Color.GRAY)
    }
}

从 Compose 调用 Android 框架

Compose 与 Android 框架类密切相关。例如,Compose 托管在 Android View 类(如 ActivityFragment)上,并且可能需要利用 Android 框架类(例如 Context、系统资源、ServiceBroadcastReceiver)。

如需详细了解系统资源,请参阅 Compose 中的资源文档。

CompositionLocal

CompositionLocal 类允许通过可组合函数隐式传递数据。它们通常在界面树的某个节点具有一个值。该值可供其可组合项的后代使用,而无需在可组合函数中将 CompositionLocal 声明为参数。

CompositionLocal 用于为 Compose 中的 Android 框架类型(例如 ContextConfigurationView)传递值,其中 Compose 代码托管在相应的 LocalContextLocalConfigurationLocalView 中。请注意,CompositionLocal 类的前缀是 Local,IDE 中的自动补全功能可以很轻松地检测到这些类。

使用 CompositionLocalcurrent 属性访问前者当前值。例如,以下代码通过调用 LocalContext.current 使用 Compose 界面树相应部分中的 Context 创建自定义视图。

@Composable
fun rememberCustomView(): CustomView {
    val context = LocalContext.current
    return remember { CustomView(context).apply { /*...*/ } }
}

如需查看更完整的示例,请参阅本文档末尾的案例研究:BroadcastReceiver 部分。

其他交互

如果没有为您需要的交互定义实用程序,最佳做法是遵循常规 Compose 准则,即数据向下流动而事件向上流动(Compose 编程思想一文对此进行了更为详细的说明)。例如,以下可组合函数会启动一个不同的 activity:

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // get data from savedInstanceState
        setContent {
            MaterialTheme {
                ExampleComposable(data, onButtonClick = {
                    startActivity(/*...*/)
                })
            }
        }
    }
}

@Composable
fun ExampleComposable(data: DataExample, onButtonClick: () -> Unit) {
    Button(onClick = onButtonClick) {
        Text(data.title)
    }
}

与通用库集成

如需了解 Compose 如何与通用库(例如 ViewModelFlowPagingHilt)集成,请参阅 Compose 与通用库的集成指南

主题

建议您按照 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 个可信来源:基于视图的主题,以及 Compose 主题。对样式做出的任何更改都需要在多个地方进行。

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

MDC Compose 主题适配器

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

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 {
                // MaterialTheme colors and typography
                // will now contain copies of the context's theme
                ExampleComposable(/*...*/)
            }
        }
    }
}

默认组件样式

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

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

Compose 中的主题叠加层

将基于视图的屏幕迁移到 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 库文档

处理屏幕尺寸的变化

在迁移根据屏幕尺寸使用不同 XML 布局的应用时,请使用 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 */
        }
    }
}

架构和状态可信来源

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

Compose 中的 ViewModel

如果您使用架构组件 ViewModel 库,您可以通过调用 viewModel() 函数从任意可组合项访问 ViewModel,如 Compose 与通用库的集成指南中所述。

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

例如,如果可组合项托管在 activity 中,viewModel() 始终返回相同实例,该实例只有在 activity 完成时才被清除。在以下示例中,系统会向同一位用户显示两次问候语,因为主机 activity 中所有可组合项都重复使用了相同的 GreetingViewModel 实例。其他可组合项重复使用了创建的第一个 ViewModel 实例。

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

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

@Composable
fun Greeting(userId: String) {
    val greetingViewModel: GreetingViewModel = viewModel(
        factory = GreetingViewModelFactory(userId)
    )
    val messageUser by greetingViewModel.message.observeAsState("")

    Text(messageUser)
}

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

由于导航图还限定了 ViewModel 元素的范围,因此作为导航图中某个目标位置的可组合项具有不同的 ViewModel 实例。在这种情况下,ViewModel 的范围是目标位置的生命周期,它将在从返回堆栈中移除目标位置时被清除。在以下示例中,当用户转到“个人资料”屏幕时,系统会创建新的 GreetingViewModel 实例。

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

@Composable
fun Greeting(userId: String) {
    val greetingViewModel: GreetingViewModel = viewModel(
        factory = GreetingViewModelFactory(userId)
    )
    val messageUser by greetingViewModel.message.observeAsState("")

    Text(messageUser)
}

状态可信来源

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

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

将 Compose 视为可信来源

使用 SideEffect 可组合项向非 Compose 代码发布 Compose 状态。在这种情况下,可信来源会保留在发送状态更新的可组合项中。

例如,需要注册 OnBackPressedCallback 来监听通过 OnBackPressedDispatcher 按下的返回按钮。如需说明是否应启用回调,请使用 SideEffect 更新其值。

@Composable
fun BackHandler(
    enabled: Boolean,
    backDispatcher: OnBackPressedDispatcher,
    onBack: () -> Unit
) {

    // Safely update the current `onBack` lambda when a new one is provided
    val currentOnBack by rememberUpdatedState(onBack)

    // Remember in Composition a back callback that calls the `onBack` lambda
    val backCallback = remember {
        // Always intercept back events. See the SideEffect for a more complete version
        object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                currentOnBack()
            }
        }
    }

    // On every successful composition, update the callback with the `enabled` value
    // to tell `backCallback` whether back events should be intercepted or not
    SideEffect {
        backCallback.isEnabled = enabled
    }

    // If `backDispatcher` changes, dispose and reset the effect
    DisposableEffect(backDispatcher) {
        // Add callback to the backDispatcher
        backDispatcher.addCallback(backCallback)

        // When the effect leaves the Composition, remove the callback
        onDispose {
            backCallback.remove()
        }
    }
}

如需详细了解附带效应,请参阅生命周期和附带效应文档。

将 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 observable and 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 和基于视图的屏幕中使用它。

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

为了在基于视图的屏幕中使用可组合项,您需要创建一个从 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 */ }
        }
    }
}

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

测试

您可以使用 createAndroidComposeRule() API 同时测试 View 和 Compose 组合代码。如需了解详情,请参阅测试 Compose 布局

案例研究:BroadcastReceiver

举一个更实际的例子,您可能想要迁移一些功能或在 Compose 中实现一些功能,以及展示 CompositionLocal附带效应,在这种情况下,需要通过可组合函数注册 BroadcastReceiver

该解决方案利用 LocalContext 来使用当前上下文,以及展示 rememberUpdatedStateDisposableEffect 附带效应。

@Composable
fun SystemBroadcastReceiver(
    systemAction: String,
    onSystemEvent: (intent: Intent?) -> Unit
) {
    // Grab the current context in this part of the UI tree
    val context = LocalContext.current

    // Safely use the latest onSystemEvent lambda passed to the function
    val currentOnSystemEvent by rememberUpdatedState(onSystemEvent)

    // If either context or systemAction changes, unregister and register again
    DisposableEffect(context, systemAction) {
        val intentFilter = IntentFilter(systemAction)
        val broadcast = object : BroadcastReceiver() {
            override fun onReceive(context: Context?, intent: Intent?) {
                onSystemEvent(intent)
            }
        }

        context.registerReceiver(broadcast, intentFilter)

        // When the effect leaves the Composition, remove the callback
        onDispose {
            context.unregisterReceiver(broadcast)
        }
    }
}

@Composable
fun HomeScreen() {

    SystemBroadcastReceiver(Intent.ACTION_BATTERY_CHANGED) { batteryStatus ->
        val isCharging = /* Get from batteryStatus ... */ true
        /* Do something if the device is charging */
    }

    /* Rest of the HomeScreen */
}