View 迁移至 Compose
主要学习内容
- 如何逐步将应用迁移到 Compose
- 如何将 Compose 添加到使用 Android View 构建的现有界面
- 如何在 Compose 中使用 Android View
- 如何在 Compose 中使用 View 系统中的主题
原理
我们使用 Compose 都是在ComponentActivity
中调用setContent
方法,那么与Activity的setContextView
有什么区别呢?
查看setContent
源码:
public fun ComponentActivity.setContent(
parent: CompositionContext? = null,
content: @Composable () -> Unit
) {
//获取decorView下的第一个子VIew
//显然第一次启动时 getChildAt(0) 返回null
val existingComposeView = window.decorView
.findViewById(android.R.id.content)
.getChildAt(0) as? ComposeView
if (existingComposeView != null) with(existingComposeView) {
setParentCompositionContext(parent)
setContent(content)
} else {
//会进入该分支
//创建ComposeView实例
ComposeView(this).apply {
// 在 setContentView 之前设置内容和父项
// 让 ComposeView 在 attach 时创建 composition
setParentCompositionContext(parent)
setContent(content)
// 在设置内容视图之前设置视图树Owner
// 以便 inflation process 和 attach listeners 能感知到它们存在
setOwners()
setContentView(this, DefaultActivityContentLayoutParams)
}
}
}
可以看到在setContent
方法中会去获取ComposeView
对象,最后会去调用setContentView
,而这个setContentView
就是Activity的setContentView
方法
androidx.activity.ComponentActivity
@Override
public void addContentView(@SuppressLint({"UnknownNullness", "MissingNullability"}) View view,
@SuppressLint({"UnknownNullness", "MissingNullability"})
ViewGroup.LayoutParams params) {
initViewTreeOwners();
super.addContentView(view, params);
}
那么ComposeView
又是什么呢?
class ComposeView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr){ ... }
abstract class AbstractComposeView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr){ ... }
可以看到ComposeView
其实本质上就是个ViewGroup
,也就是说 Compose 中设置的界面最终会在ComposeView
上进行展示,是 View和 Compose 的交界点
并不是说 Compose 可组合项最终都会变为View,而是可组合项形成的界面会在
ComposeView
上进行绘制显示
在 View 中要使用 Compose ,只需要通过创建ComposeView
,在ComposeView
中通过setContent
调用可组合项就可以了
而在 Compose 中调用 View就会稍微麻烦一点,会在之后进行介绍
迁移规划
要将 Jetpack Compose 集成到现有 Android 应用中,有多种不同的方法。常用的两种迁移策略为:
-
完全使用 Compose 开发一个新界面
在重构应用代码以适应新技术时,一种常用的方法是只在应用构建的新功能中采用新技术,比较适合在创建新的界面使用该方法,原本的应用部分继续使用 View 体系
-
选取一个现有界面,然后逐步迁移其中的各个组件
-
View作为外部布局
将部分界面迁移到 Compose,让其他部分保留在 View 系统中,例如:迁移
RecyclerView
,同时将界面的其余部分保留在 View 系统中 -
Compose 作为外部布局
使用 Compose 作为外部布局,并使用 Compose 中可能没有的一些现有 View,比如
MapView
或AdView
-
准备工作
官网示例下载
因为之后的代码都是基于其中的项目进行的,而且迁移的学习是基于一个较完善的项目中进行,存在多个界面之间的切换
所以建议下载示例,并通过
Import Project
方式导入其中的MigrationCodelab
项目
在解压文件中的MigrationCodelab
目录中存放本次学习的案例代码
在此学习中,我们将逐步把 Sunflower
的植物详情界面迁移到 Compose,并 Compose 和 View 结合起来
添加 Compose 依赖
android {
...
kotlinOptions {
jvmTarget = '1.8'
useIR = true
}
buildFeatures {
...
compose true
}
composeOptions {
kotlinCompilerExtensionVersion rootProject.composeVersion
}
}
dependencies {
...
// Compose
implementation "androidx.compose.runtime:runtime:$rootProject.composeVersion"
implementation "androidx.compose.ui:ui:$rootProject.composeVersion"
implementation "androidx.compose.foundation:foundation:$rootProject.composeVersion"
implementation "androidx.compose.foundation:foundation-layout:$rootProject.composeVersion"
implementation "androidx.compose.material:material:$rootProject.composeVersion"
implementation "androidx.compose.runtime:runtime-livedata:$rootProject.composeVersion"
implementation "androidx.compose.ui:ui-tooling:$rootProject.composeVersion"
implementation "com.google.android.material:compose-theme-adapter:$rootProject.composeVersion"
...
}
初步迁移 Compose
在植物详情界面中,我们需要将对植物的说明迁移到 Compose,同时让界面的总体结构保持完好
Compose 需要有 activity 或 fragment 才能呈现界面。在 Sunflower 中,所有界面都使用 fragment,因此我们需要使用 ComposeView
ComposeView
可以使用setContent
方法托管 Compose 界面内容
这里我们选取植物详情界面,然后逐步迁移其中的各个组件
移除XML代码
打开 fragment_plant_detail.xml
并执行以下操作:
- 切换到
code
视图 - 移除
NestedScrollView
中的ConstraintLayout
代码和嵌套的TextView
(建议使用代码注释的方式,方便进行比较和迁移) - 添加一个
ComposeView
,它会改为托管 Compose 代码,并以compose_view
作为 id
fragment_plant_detail.xml
添加Compose代码
在该项目中,我们可以将 Compose 代码添加到 plantdetail
文件夹下的 PlantDetailDescription.kt
文件中
plantdetail/PlantDetailDescription.kt
//文件中原本存在的可组合项函数
@Composable
fun PlantDetailDescription() {
Surface {
Text("Hello Compose")
}
}
我们要在ComposeView
中调用PlantDetailDescription
可组合项
在plantdetail/PlantDetailFragment.kt
中,访问 composeView
并调用 setContent
,以便在界面上显示 Compose 代码
Sunflower 项目中使用
DataBinding
的方式,我们可以直接访问composeView
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = DataBindingUtil.inflate(
inflater, R.layout.fragment_plant_detail, container, false
).apply {
...
composeView.setContent {
MaterialTheme {
PlantDetailDescription()
}
}
}
setHasOptionsMenu(true)
return binding.root
}
运行该应用,界面上会显示“Hello Compose!
”
从XML映射到可组合项
我们首先迁移植物的名称。更确切地说,是在 fragment_plant_detail.xml
中 ID 为 @+id/plant_detail_name
的 TextView
我们根据XML中的样式创建的新 PlantName
可组合项与之对应
@Composable
private fun PlantName(name: String) {
Text(
text = name,
style = MaterialTheme.typography.h5,
modifier = Modifier
.fillMaxWidth()
.padding(
start = dimensionResource(id = R.dimen.margin_small),
end = dimensionResource(id = R.dimen.margin_small)
)
.wrapContentWidth(Alignment.CenterHorizontally)
)
}
映射关系:
-
Text
的样式为MaterialTheme.typography.h5
,从 XML 代码映射到textAppearanceHeadline5
。 - 修饰符会修饰 Text,以将其调整为类似于 XML 版本:
-
fillMaxWidth
修饰符对应于 XML 代码中的android:layout_width="match_parent"
-
margin_small
的水平padding
,其值是使用dimensionResource
辅助函数从 View 系统获取的 -
wrapContentWidth
水平对齐Text
-
如何观察
LiveData
将在稍后介绍,因此先假设我们有可用的名称
通过预览查看效果
@Preview(showBackground = true, backgroundColor = 0XFFFFFF)
@Composable
private fun PlantNamePreview() {
MaterialTheme {
PlantName("Apple")
}
}
ViewModel和LiveData
现在,我们将PlantName
显示到界面。如需执行此操作,我们需要使用 PlantDetailViewModel
加载数据
ViewModel
由于 fragment 中使用了 PlantDetailViewModel
的实例,因此我们可以将其作为参数传递给 PlantDetailDescription
@Composable
fun PlantDetailDescription(plantDetailViewModel: PlantDetailViewModel) {
...
}
在 fragment 调用此可组合项时传递 ViewModel
实例:
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = DataBindingUtil.inflate(
inflater, R.layout.fragment_plant_detail, container, false
).apply {
...
composeView.setContent {
MaterialTheme {
PlantDetailDescription(plantDetailViewModel)
}
}
}
...
}
可组合项没有自己的
ViewModel
实例,相应的实例将在可组合项和托管 Compose 代码的生命周期所有者(activity 或 fragment)之间共享
如果您遇到了 ViewModel 无法使用的情况,或者您不希望将该依赖项传递给可组合项,则可以在可组合项中使用 viewModel
函数,以获取 ViewModel 的实例
如需使用
viewModel()
函数,请将androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1
依赖项添加到build.gradle
文件中
class ExampleViewModel : ViewModel() { /*...*/ }
@Composable
fun MyExample(
viewModel: ExampleViewModel = viewModel()
) {
...
}
viewModel()
会返回一个现有的 ViewModel
,或在给定作用域内创建一个新的 ViewModel。只要该作用域处于有效状态,就会保留 ViewModel
例如,如果在某个 Activity 中使用了可组合项,则在该 Activity 完成或进程终止之前,viewModel()
会返回同一实例
如果 ViewModel 具有依赖项,则 viewModel()
会将可选的 ViewModelProvider.Factory
作为参数
如需详细了解 Compose 中的
ViewModel
以及实例如何与 Compose Navigation 库或 activity 和 fragment 一起使用,请参阅互操作性文档
LiveData
PlantDetailDescription
可以通过PlantDetailViewModel
的 LiveData
字段,以获取植物的名称
如需从可组合项观察 LiveData,请使用 LiveData.observeAsState()
函数
LiveData.observeAsState()
开始观察LiveData
,并通过State
对象表示它的值。每次向LiveData
发布一个新值时,返回的State
都会更新,这会导致所有State.value
用法重组
由于 LiveData 发出的值可以为 null,因此我们需要将其用法封装在 null 检查中。有鉴于此,以及为了实现可重用性,最好将 LiveData 的使用和监听拆分到不同的可组合项中
因此,我们创建 PlantDetailDescription
的新可组合项,用于显示 Plant
信息
@Composable
fun PlantDetailDescription(plantDetailViewModel: PlantDetailViewModel) {
val plant by plantDetailViewModel.plant.observeAsState()
plant?.let {
PlantDetailContent(it)
}
}
@Composable
fun PlantDetailContent(plant: Plant) {
PlantName(plant.name)
}
迁移更多XML代码
现在我们继续迁移ConstraintLayout
中的 View:浇水信息和植物说明
fragment_plant_detail.xml
中浇水信息 XML 代码由两个 ID 为 plant_watering_header
和 plant_watering
的 TextView 组成
和之前的操作类似,创建 PlantWatering
新可组合项,以在界面上显示浇水信息:
@Composable
fun PlantWatering(wateringInterval: Int) {
Column(Modifier.fillMaxWidth()) {
val centerWithPaddingModifier = Modifier
.padding(horizontal = dimensionResource(R.dimen.margin_small))
.align(Alignment.CenterHorizontally)
val normalPadding = dimensionResource(R.dimen.margin_normal)
Text(
text = stringResource(R.string.watering_needs_prefix),
color = MaterialTheme.colors.primaryVariant,
fontWeight = FontWeight.Bold,
modifier = centerWithPaddingModifier.padding(top = normalPadding)
)
val wateringIntervalText = LocalContext.current.resources.getQuantityString(
R.plurals.watering_needs_suffix, wateringInterval, wateringInterval
)
Text(
text = wateringIntervalText,
modifier = centerWithPaddingModifier.padding(bottom = normalPadding)
)
}
}
由于
Text
可组合项会共享水平内边距和对齐修饰,因此您可以将修饰符分配给局部变量(即centerWithPaddingModifier
),以重复使用修饰符。修饰符是标准的 Kotlin 对象,因此可以重复使用Compose 的
MaterialTheme
与plant_watering_header
中使用的colorAccent
不完全匹配。现在,我们可以使用将在主题设置部分中加以改进的MaterialTheme.colors.primaryVariant
我们将各个部分组合在一起,然后同样在 PlantDetailContent
中调用 PlantWatering
,因为ConstraintLayout
还有 margin
值,我们还需要将值添加到 Compose 代码中
为了确保背景颜色和所用的文本颜色均合适,我们需要添加 Surface
用于处理这种设置
@Composable
fun PlantDetailContent(plant: Plant) {
Surface {
Column(Modifier.padding(dimensionResource(R.dimen.margin_normal))) {
PlantName(plant.name)
PlantWatering(plant.wateringInterval)
}
}
}
@Preview(showBackground = true, backgroundColor = 0XFFFFFF)
@Composable
private fun PlantNamePreview() {
val plant = Plant("id", "Apple", "description", 3, 30, "")
MaterialTheme {
PlantDetailContent(plant)
}
}
刷新预览
Compose 中调用View
接下来,我们来迁移植物说明
TextView中包含了app:renderHtml="@{viewModel.plant.description}"
,renderHtml
是一个绑定适配器,可在 PlantDetailBindingAdapters.kt
文件中找到。该实现使用 HtmlCompat.fromHtml
在 TextView
上设置文本
@BindingAdapter("wateringText")
fun bindWateringText(textView: TextView, wateringInterval: Int) {
val resources = textView.context.resources
val quantityString = resources.getQuantityString(
R.plurals.watering_needs_suffix,
wateringInterval, wateringInterval
)
textView.text = quantityString
}
但是,Compose 目前不支持 Spanned
类,也不支持显示 HTML 格式的文本。因此,我们需要在 Compose 代码中使用 View 系统中的 TextView
来绕过此限制
由于 Compose 目前还无法呈现 HTML 代码,因此您需要使用 AndroidView
API 程序化地创建一个 TextView
,从而实现此目的
AndroidView
接受程序化地创建的 View。如果您想嵌入 XML 文件,可以结合使用视图绑定与androidx.compose.ui:ui-viewbinding
库中的AndroidViewBinding
API
创建 PlantDescription
可组合项。此可组合项中使用 AndroidView
创建TextView
。在 factory
回调中,请初始化使用给定 Context
来回应 HTML 交互的 TextView
。在 update
回调中,用已保存的 HTML 格式的说明设置文本
@Composable
private fun PlantDescription(description: String) {
// Remembers the HTML formatted description. Re-executes on a new description
val htmlDescription = remember(description) {
//使用 HtmlCompat 解析 html
//HtmlCompat 内部做了版本适配
HtmlCompat.fromHtml(description, HtmlCompat.FROM_HTML_MODE_COMPACT)
}
// 在屏幕上显示 TextView 并在 inflate 时使用 HTML 描述进行更新
// 对 htmlDescription 的更新将使 AndroidView 重新组合并更新文本
AndroidView(
factory = { context ->
TextView(context).apply {
//对于链接点击的处理,若不设置movementMethod,则链接无效
movementMethod = LinkMovementMethod.getInstance()
}
},
update = {
it.text = htmlDescription
}
)
}
remember
中将description
作为 key ,如果 description
参数发生变化,系统会再次执行 remember
中的 htmlDescription
代码
同样,如果 htmlDescription
发生变化,AndroidView
更新回调会重组。在回调中读取的任何状态都会导致重组
我们将 PlantDescription
添加到 PlantDetailContent
可组合项
@Composable
fun PlantDetailContent(plant: Plant) {
Column(Modifier.padding(dimensionResource(id = R.dimen.margin_normal))) {
PlantName(plant.name)
PlantWatering(plant.wateringInterval)
PlantDescription(plant.description)
}
}
现在,我们就将原始 ConstraintLayout
中的所有内容迁移到 Compose了
ViewCompositionStrategy
默认情况下,只要 ComposeView
与窗口分离,Compose 就会处理组合。Compose 界面 View
类型(例如 ComposeView
和 AbstractComposeView
)使用定义此行为的 ViewCompositionStrategy
默认情况下,Compose 使用 DisposeOnDetachedFromWindow
策略。但是,在 Compose 界面 View 类型用于以下各项的部分情况下,默认策略可能不太合适:
- Fragment。Compose 界面
View
类型应该遵循 fragment 的视图生命周期去保存状态 - Transitions 动画。当 Transitions 过程中使用 Compose 界面
View
类型,系统会在转换开始(而不是转换结束)时将其与窗口分离,从而导致可组合项在它仍然在屏幕上时就开始 detach -
RecyclerView
或者带有生命周期管理的自定义控件
在上述某些情况下,除非您手动调用 AbstractComposeView.disposeComposition
,否则应用可能会因为组合实例泄漏内存
如需在不再需要组合时自动处理组合,请通过调用 setViewCompositionStrategy
方法设置其他策略或创建自己的策略
例如,DisposeOnLifecycleDestroyed
策略会在 lifecycle
被销毁时处理组合
此策略适用于与已知的 LifecycleOwner
具有一对一关系的 Compose 界面 View
类型
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = DataBindingUtil.inflate(
inflater, R.layout.fragment_plant_detail, container, false
).apply {
...
composeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnLifecycleDestroyed([email protected]))
setContent {
PlantDetailDescription(plantDetailViewModel)
}
}
}
...
}
当 LifecycleOwner
未知时,可以使用 DisposeOnViewTreeLifecycleDestroyed
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = DataBindingUtil.inflate(
inflater, R.layout.fragment_plant_detail, container, false
).apply {
...
composeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
PlantDetailDescription(plantDetailViewModel)
}
}
}
...
}
如需了解如何使用此 API,请参阅“Fragment 中的 ComposeView”部分
互操作性主题设置
我们已将植物详情的文本内容迁移到 Compose。不过,Compose 使用的主题颜色有误。当植物名称应该使用绿色时,它使用的是紫色
显然 View 体系使用的主题和MaterialTheme
并没有进行关联,我们需要 Compose 继承 View 系统中可用的主题,而不是从头开始在 Compose 中重新编写您自己的 Material 主题
如需在 Compose 中重复使用 View 系统的 Material Design 组件 (MDC) 主题,您可以使用 compose-theme-adapter
。MdcTheme
函数将自动读取主机上下文的 MDC 主题,并代表您将它们传递给 MaterialTheme
,以用于浅色和深色主题
dependencies {
...
implementation "com.google.android.material:compose-theme-adapter:$rootProject.composeVersion"
...
}
如需使用此库,请将 MaterialTheme
的用法替换为 MdcTheme
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = DataBindingUtil.inflate(
inflater, R.layout.fragment_plant_detail, container, false
).apply {
...
composeView.apply {
...
setContent {
MdcTheme {
PlantDetailDescription(plantDetailViewModel)
}
}
}
}
...
}