首先我们来看如何在 View 体系中集成 Compose。
1、迁移策略
Codelab 给出了从 View 迁移到 Compose 的策略,以下内容基本上来自该 Codelab。
Jetpack Compose 从设计之初就考虑到了 View 互操作性。如需迁移到 Compose,我们建议您执行增量迁移(Compose 和 View 在代码库中共存),直到应用完全迁移至 Compose 为止。
推荐的迁移策略如下:
- 使用 Compose 构建新界面
- 在构建功能时,确定可重复使用的元素,并开始创建常见界面组件库
- 一次替换一个界面的现有功能
1.1 使用 Compose 构建新界面
使用 Compose 构建覆盖整个界面的新功能是提高 Compose 采用率的最佳方式。借助此策略,您可以添加功能并利用 Compose 的优势,同时仍满足公司的业务需求
一项新功能可能涵盖整个界面,在这种情况下,整个界面都在 Compose 中。如果您使用的是基于 fragment 的导航,这意味着您需要创建一个新的 fragment,并在 Compose 中添加其内容。
您还可以在现有界面中引入新功能。在这种情况下,View 和 Compose 将共存在同一个界面上。例如,假设您要添加的功能是 RecyclerView 中的一种新的视图类型。在这种情况下,新的视图类型将位于 Compose 中,而其他项目保持不变。
1.2 构建常见界面组件库
使用 Compose 构建功能时,您很快就会意识到,您最终会构建组件库。您需要确定可重复使用的组件,促使在应用中重复使用这些组件,以便共享组件具有单一可信来源。您构建的功能随后可以依赖于这个库。
1.3 使用 Compose 替换现有功能
除了构建新功能之外,您还需要逐步将应用中的现有功能迁移到 Compose。具体采用哪种方法由您决定,下面是一些适合的方法:
- 简单界面 - 包含少数界面元素和动态元素(例如欢迎界面、确认界面或设置界面)的简单界面。这些界面非常适合迁移到 Compose,因为只需几行代码就能搞定。
- 混合 View 和 Compose 界面 - 已包含少量 Compose 代码的界面是另一个不错的选择,因为您可以继续逐步迁移该界面中的元素。如果您的某个界面在 Compose 中只有一个子树,您可以继续迁移该树的其他部分,直到整个界面位于 Compose 中。这称为自下而上的迁移方法。
1.4 此 Codelab 采用的方法
在此 Codelab 中,您将逐步把 Sunflower 的植物详情界面迁移到 Compose,将 Compose 和 View 结合起来使用。之后,您将掌握足够的知识,可以在需要时继续进行迁移。
2、迁移内容
将 Codelab 的起始代码移植到项目中,参考 GitHub commit a49940c5,在此基础上进行修改。
2.1 View 中集成 Compose
以植物详情页面为例,我们想将详情信息由 View 改为 Compose 实现:
那么需要将布局文件 fragment_plant_detail 中展示详细信息的 ConstraintLayout 替换为 ComposeView:
<layout xmlns:android="http://schemas.android.com/apk/res/android"xmlns:app="http://schemas.android.com/apk/res-auto"xmlns:tools="http://schemas.android.com/tools"><data><import type="com.compose.migration.data.Plant" /><variablename="viewModel"type="com.compose.migration.viewmodels.PlantDetailViewModel" /><variablename="callback"type="com.compose.migration.plantdetail.PlantDetailFragment.Callback" /></data><androidx.coordinatorlayout.widget.CoordinatorLayoutandroid:layout_width="match_parent"android:layout_height="match_parent"android:background="?attr/colorSurface"android:fitsSystemWindows="true"tools:context="com.google.samples.apps.sunflower.GardenActivity"tools:ignore="MergeRootFrame"><com.google.android.material.appbar.AppBarLayoutandroid:id="@+id/appbar"android:layout_width="match_parent"android:layout_height="@dimen/plant_detail_app_bar_height"android:animateLayoutChanges="true"android:background="?attr/colorSurface"android:fitsSystemWindows="true"android:stateListAnimator="@animator/show_toolbar"android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"><com.google.android.material.appbar.CollapsingToolbarLayoutandroid:id="@+id/toolbar_layout"android:layout_width="match_parent"android:layout_height="match_parent"android:fitsSystemWindows="true"app:collapsedTitleGravity="center"app:collapsedTitleTextAppearance="@style/TextAppearance.Sunflower.Toolbar.Text"app:contentScrim="?attr/colorSurface"app:layout_scrollFlags="scroll|exitUntilCollapsed"app:statusBarScrim="?attr/colorSurface"app:title="@{viewModel.plant.name}"app:titleEnabled="false"app:toolbarId="@id/toolbar"><ImageViewandroid:id="@+id/detail_image"android:layout_width="match_parent"android:layout_height="@dimen/plant_detail_app_bar_height"android:contentDescription="@string/plant_detail_image_content_description"android:fitsSystemWindows="true"android:scaleType="centerCrop"app:imageFromUrl="@{viewModel.plant.imageUrl}"app:layout_collapseMode="parallax" /><androidx.appcompat.widget.Toolbarandroid:id="@+id/toolbar"android:layout_width="match_parent"android:layout_height="?attr/actionBarSize"android:background="@android:color/transparent"app:contentInsetStartWithNavigation="0dp"app:layout_collapseMode="pin"app:menu="@menu/menu_plant_detail"app:navigationIcon="@drawable/ic_detail_back"app:titleTextColor="?attr/colorOnSurface" /></com.google.android.material.appbar.CollapsingToolbarLayout></com.google.android.material.appbar.AppBarLayout><androidx.core.widget.NestedScrollViewandroid:id="@+id/plant_detail_scrollview"android:layout_width="match_parent"android:layout_height="match_parent"android:clipToPadding="false"android:paddingBottom="@dimen/fab_bottom_padding"app:layout_behavior="@string/appbar_scrolling_view_behavior"><!-- 将原本的 ConstraintLayout 替换为 ComposeView--><androidx.compose.ui.platform.ComposeViewandroid:id="@+id/compose_view"android:layout_width="match_parent"android:layout_height="wrap_content" /></androidx.core.widget.NestedScrollView><com.google.android.material.floatingactionbutton.FloatingActionButtonandroid:id="@+id/fab"style="@style/Widget.MaterialComponents.FloatingActionButton"android:layout_width="wrap_content"android:layout_height="wrap_content"android:layout_margin="@dimen/fab_margin"android:onClick="@{() -> callback.add(viewModel.plant)}"android:tint="@android:color/white"app:isGone="@{viewModel.isPlanted}"app:layout_anchor="@id/appbar"app:layout_anchorGravity="bottom|end"app:shapeAppearance="@style/ShapeAppearance.Sunflower.FAB"app:srcCompat="@drawable/ic_plus" /></androidx.coordinatorlayout.widget.CoordinatorLayout></layout>
在该布局对应的逻辑代码 PlantDetailFragment 中,由于使用了 DataBinding 可以直接通过 id 拿到这个 ComposeView,用于展示其内容:
override fun onCreateView(inflater: LayoutInflater,container: ViewGroup?,savedInstanceState: Bundle?): View {val binding = DataBindingUtil.inflate<FragmentPlantDetailBinding>(inflater, R.layout.fragment_plant_detail, container, false).apply {viewModel = plantDetailViewModellifecycleOwner = viewLifecycleOwnercallback = object : Callback {override fun add(plant: Plant?) {plant?.let {hideAppBarFab(fab)plantDetailViewModel.addPlantToGarden()Snackbar.make(root, R.string.added_plant_to_garden, Snackbar.LENGTH_LONG).show()}}}var isToolbarShown = false// scroll change listener begins at Y = 0 when image is fully collapsedplantDetailScrollview.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, _ ->// User scrolled past image to height of toolbar and the title text is// underneath the toolbar, so the toolbar should be shown.val shouldShowToolbar = scrollY > toolbar.height// The new state of the toolbar differs from the previous state; update// appbar and toolbar attributes.if (isToolbarShown != shouldShowToolbar) {isToolbarShown = shouldShowToolbar// Use shadow animator to add elevation if toolbar is shownappbar.isActivated = shouldShowToolbar// Show the plant name if toolbar is showntoolbarLayout.isTitleEnabled = shouldShowToolbar}})toolbar.setNavigationOnClickListener { view ->view.findNavController().navigateUp()}toolbar.setOnMenuItemClickListener { item ->when (item.itemId) {R.id.action_share -> {createShareIntent()true}else -> false}}// 展示 ComposeView 的内容composeView.setContent {PlantDetailDescription()}}setHasOptionsMenu(true)return binding.root}
PlantDetailDescription() 是 Codelab 已经准备好的可组合项,目前是初始内容:
@Composable
fun PlantDetailDescription() {Surface {Text("Hello Compose")}
}
下面来扩展 ComposeView 要展示的内容。
首先,详情信息需要知道你是哪一个植物才能展示其信息,该信息实际山可以通过 PlantDetailFragment 中定义的 PlantDetailViewModel 获取:
class PlantDetailFragment : Fragment() {private val args: PlantDetailFragmentArgs by navArgs()private val plantDetailViewModel: PlantDetailViewModel by viewModels {InjectorUtils.providePlantDetailViewModelFactory(requireActivity(), args.plantId)}
}
因此 PlantDetailDescription() 需要使用 PlantDetailViewModel 作为参数:
@Composable
fun PlantDetailDescription(plantDetailViewModel: PlantDetailViewModel) {// 使用 = plant 的类型是 State<Plant?>
// val plant = plantDetailViewModel.plant.observeAsState()// 使用 by plant 的类型是 Plantval plant by plantDetailViewModel.plant.observeAsState()plant?.let {PlantDetailContent(it)}
}@Composable
fun PlantDetailContent(plant: Plant) {Surface {Column(Modifier.padding(dimensionResource(id = R.dimen.margin_small))) {PlantName(plant.name)PlantWatering(plant.wateringInterval)}}
}// 标题信息
@Composable
fun PlantName(name: String) {Text(text = name,style = MaterialTheme.typography.h5,modifier = Modifier.fillMaxWidth().padding(horizontal = dimensionResource(id = R.dimen.margin_small)).wrapContentWidth(Alignment.CenterHorizontally))
}// 浇水信息
@Composable
fun PlantWatering(wateringInterval: Int) {Column(Modifier.fillMaxWidth()) {val centerWithPaddingModifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.margin_small)).align(Alignment.CenterHorizontally)val normalPadding = dimensionResource(id = R.dimen.margin_normal)Text(text = stringResource(id = R.string.watering_needs_prefix),color = MaterialTheme.colors.primaryVariant,fontWeight = FontWeight.Bold,modifier = centerWithPaddingModifier.padding(top = normalPadding))val waterIntervalText = LocalContext.current.resources.getQuantityString(R.plurals.watering_needs_suffix, wateringInterval, wateringInterval)Text(text = waterIntervalText,modifier = centerWithPaddingModifier.padding(bottom = normalPadding))}
}
效果如图:
2.2 Compose 中使用 View
Compose 暂不支持 Spanned 类,也不显示 HTML 格式文本。因此,需要在 Compose 代码中使用 View 系统的 TextView 来绕过此限制。
由于植物的描述信息中有介绍该植物的超链接,因此需要借助原生的 TextView 来实现点击超链接跳转的功能:
@Composable
fun PlantDetailContent(plant: Plant) {Surface {Column(Modifier.padding(dimensionResource(id = R.dimen.margin_small))) {PlantName(plant.name)PlantWatering(plant.wateringInterval)// 增加详情PlantDescription(plant.description)}}
}@Composable
fun PlantDescription(description: String) {// 使用 remember 进行优化,description 作为 key,在没有发生变化时不会进行重组,也就避免// 了括号内的重复计算,从而降低性能开销val htmlDescription = remember(description) {// 使用兼容模式将 HTML 转换为 Spanned,因为 SDK 小于 24 不支持一些 FLAG 标记HtmlCompat.fromHtml(description, HtmlCompat.FROM_HTML_MODE_COMPACT)}AndroidView(// factory 是创建 View 用于转换为 Compose 的代码块,返回 TextViewfactory = { context ->TextView(context).apply {// 设置 TextView 上的超链接,使其点击后可以打开链接movementMethod = LinkMovementMethod.getInstance()}},// update 是在 layout 被填充后的回调函数update = {it.text = htmlDescription})
}
效果如下:
2.3 共用主题
在早期的迁移阶段,可能希望 Compose 继承 View 系统中可用的主题,而不是从头开始在 Compose 中重写自己的 Material 主题。Material 主题与 Compose 附带的所有 Material Design 组件完美配合。
只需要在使用 Compose 的根可组合项的位置使用 MdcTheme 即可使用 View 系统中定义的主题:
override fun onCreateView(inflater: LayoutInflater,container: ViewGroup?,savedInstanceState: Bundle?): View {val binding = DataBindingUtil.inflate<FragmentPlantDetailBinding>(inflater, R.layout.fragment_plant_detail, container, false).apply {viewModel = plantDetailViewModellifecycleOwner = viewLifecycleOwnercallback = object : Callback {override fun add(plant: Plant?) {plant?.let {hideAppBarFab(fab)plantDetailViewModel.addPlantToGarden()Snackbar.make(root, R.string.added_plant_to_garden, Snackbar.LENGTH_LONG).show()}}}...composeView.setContent {// com.google.android.material:compose-theme-adapterMdcTheme {PlantDetailDescription(plantDetailViewModel)}}}setHasOptionsMenu(true)return binding.root}
MdcTheme 是 com.google.android.material:compose-theme-adapter
库中的函数,可以很方便的应用 View 体系下的主题。
3、Compose 导航
本节介绍如何在 Compose 中使用 Navigation 进行导航的相关知识,使用的是 Jetpack Compose Navigation 这个 Codelab,运行的效果如下:
我们先来简单看下代码结构:
class RallyActivity : ComponentActivity() {override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContent {RallyApp()}}
}@Composable
fun RallyApp() {RallyTheme {var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }Scaffold(topBar = {RallyTabRow(allScreens = rallyTabRowScreens,onTabSelected = { screen -> currentScreen = screen },currentScreen = currentScreen)}) { innerPadding ->Box(Modifier.padding(innerPadding)) {currentScreen.screen()}}}
}
RallyActivity 显示 RallyApp 的内容,Scaffold 的 topBar 是一个 RallyTabRow,由它来记录当前正在显示哪一个 Screen,某一个 Tab 被选中时如何处理。
详细点看,当前正在显示的 Screen 用 currentScreen 这个状态表示,类型是 RallyDestination 接口的实现类 Overview:
// RallyDestinations.kt:
interface RallyDestination {val icon: ImageVectorval route: Stringval screen: @Composable () -> Unit
}/*** Rally app navigation destinations*/
object Overview : RallyDestination {override val icon = Icons.Filled.PieChartoverride val route = "overview"// Slots API,用于展示 Tab 对应的具体页面内容override val screen: @Composable () -> Unit = { OverviewScreen() }
}
RallyTabRow 的第一个参数 allScreens 传的是 rallyTabRowScreens,这是一个 List,包含了 3 个 RallyDestination 的实现类:
/*** Rally app navigation destinations*/
object Overview : RallyDestination {override val icon = Icons.Filled.PieChartoverride val route = "overview"override val screen: @Composable () -> Unit = { OverviewScreen() }
}object Accounts : RallyDestination {override val icon = Icons.Filled.AttachMoneyoverride val route = "accounts"override val screen: @Composable () -> Unit = { AccountsScreen() }
}object Bills : RallyDestination {override val icon = Icons.Filled.MoneyOffoverride val route = "bills"override val screen: @Composable () -> Unit = { BillsScreen() }
}object SingleAccount : RallyDestination {// Added for simplicity, this icon will not in fact be used, as SingleAccount isn't// part of the RallyTabRow selectionoverride val icon = Icons.Filled.Moneyoverride val route = "single_account"override val screen: @Composable () -> Unit = { SingleAccountScreen() }const val accountTypeArg = "account_type"
}// Screens to be displayed in the top RallyTabRow
val rallyTabRowScreens = listOf(Overview, Accounts, Bills)
然后我们再看 RallyTabRow 的实现:
@Composable
fun RallyTabRow(allScreens: List<RallyDestination>,onTabSelected: (RallyDestination) -> Unit,currentScreen: RallyDestination
) {Surface(Modifier.height(TabHeight).fillMaxWidth()) {Row(Modifier.selectableGroup()) {allScreens.forEach { screen ->RallyTab(text = screen.route,icon = screen.icon,// 被选中时调用方会在 onTabSelected 内更新当前正在展示的 screenonSelected = { onTabSelected(screen) },selected = currentScreen == screen)}}}
}
大致就是这样,详细代码去参考 GitHub 上的代码。
3.1 使用 Navigation 导航
使用 Navigation 进行导航,与 View 体系下的内容有很多类似之处,首先要构建一个 NavHost,里面构建导航图:
@Composable
fun RallyNavHost(navController: NavHostController, modifier: Modifier) {NavHost(navController = navController,startDestination = Overview.route,modifier = modifier) {// 构建导航图composable(route = Overview.route) {OverviewScreen()}composable(route = Accounts.route) {AccountsScreen()}composable(route = Bills.route) {BillsScreen()}}
}
然后修改 RallyApp Scaffold 的 content 内容,不再直接展示布局内容,而是使用 RallyNavHost 通过 NavHostController 控制路由:
@Composable
fun RallyApp() {RallyTheme {var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }// 声明 NavHostController 用于控制导航val navController = rememberNavController()Scaffold(topBar = {RallyTabRow(allScreens = rallyTabRowScreens,// 选中之后通过路由进行导航onTabSelected = { screen -> navController.navigate(screen.route) },currentScreen = currentScreen)}) { innerPadding ->RallyNavHost(navController = navController,modifier = Modifier.padding(innerPadding),)/*Box(Modifier.padding(innerPadding)) {currentScreen.screen()}*/}}
}
现在这样可以实现跳转:
但是仔细观察左上角,会发现不论点击哪个 Tab 都显示第一个,需要添加代码:
@Composable
fun RallyApp() {RallyTheme {val navController = rememberNavController()val currentBackStackEntry = navController.currentBackStackEntryAsState()// 修改获取当前 RallyDestination 的逻辑var currentScreen = Overview.fromRoute(currentBackStackEntry.value?.destination?.route)Scaffold(topBar = {RallyTabRow(allScreens = rallyTabRowScreens,onTabSelected = { screen -> navController.navigate(screen.route) },currentScreen = currentScreen)}) { innerPadding ->RallyNavHost(navController = navController,modifier = Modifier.padding(innerPadding),)}}
}
fromRoute() 是一个新增的工具方法,将 route 字符串转换为 RallyDestination:
object Overview : RallyDestination {override val icon = Icons.Filled.PieChartoverride val route = "overview"override val screen: @Composable () -> Unit = { OverviewScreen() }fun fromRoute(route: String?): RallyDestination =when (route?.substringBefore("/")) {Accounts.route -> AccountsBills.route -> BillsOverview.route -> Overviewnull -> Overviewelse -> throw IllegalArgumentException("Route $route is not recognized!")}
}
这样处理后上面 Tab 的行为就正常了,最后还有一项,就是在 Overview 页面点击 Accounts 和 Bills 的 SEE ALL 会跳转到对应页面。这个功能在负责路由的 RallyNavHost 构建 OverviewScreen 的路由是添加相应的事件:
@Composable
fun RallyNavHost(navController: NavHostController, modifier: Modifier) {NavHost(navController = navController,startDestination = Overview.route,modifier = modifier) {// 构建导航图composable(route = Overview.route) {OverviewScreen(// 添加跳转到另外两个页面的事件处理,进行导航onClickSeeAllAccounts = { navController.navigate(Accounts.route) },onClickSeeAllBills = { navController.navigate(Bills.route) })}...}
}
效果图:
3.2 Navigation 传参
导航参数可以通过将一个或多个参数传递到路由并调整参数类型或默认值来使路由行为动态化。单击某个账户并进入一个界面,显示给定账户的数据:
首先我们增加单个账户的路由:
@Composable
fun RallyNavHost(navController: NavHostController, modifier: Modifier) {NavHost(navController = navController,startDestination = Overview.route,modifier = modifier) {// 构建导航图composable(route = Overview.route) {OverviewScreen(onClickSeeAllAccounts = { navController.navigate(Accounts.route) },onClickSeeAllBills = { navController.navigate(Bills.route) },// 增加跳转到单个账户页面的事件处理onAccountClick = { name -> navigateToSingleAccount(navController, name) })}...composable(route = "${Accounts.route}/{name}",arguments = listOf(navArgument("name") {type = NavType.StringType})) { entry ->val accountName = entry.arguments?.getString("name")val account = UserData.getAccount(accountName)SingleAccountScreen(accountType = account.name)}}
}
然后增加一个跳转到单个账户页面的方法:
fun navigateToSingleAccount(navController: NavHostController, accountName: String) {navController.navigate("${Accounts.route}/$accountName")
}
3.3 深层链接
代码实现,以跳转到单个账户页面为例,在路由中为其添加 deepLinks 属性:
@Composable
fun RallyNavHost(navController: NavHostController, modifier: Modifier) {NavHost(navController = navController,startDestination = Overview.route,modifier = modifier) {...composable(route = "${Accounts.route}/{name}",arguments = listOf(navArgument("name") {type = NavType.StringType}),// 添加深层连接deepLinks = listOf(navDeepLink {uriPattern = "rally://${Accounts.route}/{name}"})) { entry ->val accountName = entry.arguments?.getString("name")val account = UserData.getAccount(accountName)SingleAccountScreen(accountType = account.name)}}
}
在清单文件中为 Activity 添加深层链接的 intent-filter:
<activityandroid:name=".RallyActivity"android:exported="true"android:label="@string/app_name"android:windowSoftInputMode="adjustResize"><intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LAUNCHER" /></intent-filter><!-- 配置深层链接 --><intent-filter><action android:name="android.intent.action.VIEW" /><category android:name="android.intent.category.DEFAULT" /><category android:name="android.intent.category.BROWSABLE" /><dataandroid:host="accounts"android:scheme="rally" /></intent-filter></activity>
最后在退出应用后,通过 adb 命令执行深层链接:
User>adb shell am start -d "rally://accounts/Checking" -a android.intent.action.VIEW
Starting: Intent { act=android.intent.action.VIEW dat=rally://accounts/... }
效果图:
3.4 Compose 与 View 的关系
Compose 是通过 setContent 来展示 Compose 的内容:
// ComponentActivity 的扩展函数
public fun ComponentActivity.setContent(parent: CompositionContext? = null,content: @Composable () -> Unit
) {// android.R.id.content 的第一个子 View 是否为 ComposeViewval existingComposeView = window.decorView.findViewById<ViewGroup>(android.R.id.content).getChildAt(0) as? ComposeViewif (existingComposeView != null) with(existingComposeView) {setParentCompositionContext(parent)setContent(content)} else ComposeView(this).apply {// Set content and parent **before** setContentView// to have ComposeView create the composition on attachsetParentCompositionContext(parent)setContent(content)// Set the view tree owners before setting the content view so that the inflation process// and attach listeners will see them already presentsetOwners()setContentView(this, DefaultActivityContentLayoutParams)}
}
填充 android.R.id.content 的最大的子 View 如果是 ComposeView,就使用 setContent 展示内容,否则还是使用 setContentView 展示。
ComposeView 继承自 AbstractComposeView,后者继承自 ViewGroup,说明 Compose 的顶层 View 实际上还是几个 View 体系下的 ViewGroup,但是小的控件,像 Text 这些,就都是通过 Canvas 封装一层一层自己画的,与传统 View 没有关系了。当然,传统的 View 也是通过 Canvas 画的,只不过体系内部与 Compose 不同了。但正是因为 Canvas 这一点,使得二者可以集成在一起。