Jetpack Compose 快来学学吧!

avatar
研发 @字节跳动

作者:大力智能技术团队-客户端 Verios

简介

官方入门文档:developer.android.com/jetpack/com…

Jetpack Compose 是 Google 在2019年 Google I/O 大会上公布的全新的 Android 原生 UI 开发框架,历时两年2021年7月29日,正式版终于问世。官方介绍有以下特点:

  • 更少的代码

    • 使用更少的代码实现更多的功能,并且可以避免各种错误,从而使代码简洁且易于维护。
  • 直观的 Kotlin API

    • 只需描述界面,Compose 会负责处理剩余的工作。应用状态变化时,界面会自动更新。
  • 加快应用开发

    • 兼容现有的所有代码,方便随时随地采用。借助实时预览和全面的 Android Studio 支持,实现快速迭代。
  • 功能强大

    • 凭借对 Android 平台 API 的直接访问和对于 Material Design、深色主题、动画等的内置支持,创建精美的应用。

如何理解“全新 UI 框架”?全新在于它直接抛弃了我们写了 N 年的 ViewViewGroup 那一套东西,从上到下撸了一整套全新的 UI 框架。就是说,它的渲染机制、布局机制、触摸算法以及 UI 的具体写法,全都是新的。

个人总结 Compose 特点有三个:代码写 UI、声明式 UI、全新 UI框架。

Motivation

参考自:深入详解 Jetpack Compose | 优化 UI 构建

解耦

目前 Android 写界面的方式,布局文件写在 layout.xml 中,而视图模型(也就是代码逻辑部分)写在 ViewModel 或者 Presenter 中,通过某些 API (例如 findViewById) 建立两者之间的联系。这两者之间耦合十分紧密,例如在 XML 中修改了 id 或者 View 的类型,需要在视图模型中修改对应代码;此外如果动态删除或增加了某个 View,布局 XML 不会更新,因此需要在视图模型手动维护。

造成上述现象的原因是因为 XML 布局和视图模型就应该是一体的。那能不能直接用代码写布局文件呢?当然是可以的,但肯定不是现在这样 new 一个 ViewGroup,然后 addView 的方式。比较容易想到的是通过 kotlin DSL,需要注意由于在不同情况下显示的 UI 可能不同,所以 DSL 一定含有逻辑。

历史包袱

Android已经十年多了,传统的Android UI 有很多历史遗留问题,而有些官方也很难修改。比如View.java有三万多行代码,ListView 已经废弃了。

为了避免重蹈覆辙,在 Jetpack Compose 的世界中使用函数替代了类型,用组合替代继承,抛弃原有Android View System,操作canvas直接进行绘制:

重点目标是解决耦合问题,解决基础类 View.java 爆炸问题。基于组合优于继承的思想,重新设计一套解偶的UI框架。

快速入门

环境准备

参考自官方文档:developer.android.com/jetpack/com…

  1. 下载 Android Studio Arctic Fox:developer.android.com/studio (注意一定要使用2021.7.29日后发布的AS)
  2. 加入依赖
android {
  buildFeatures {
    compose true
  }
    // compose_version = '1.0.0'
  composeOptions {
    kotlinCompilerExtensionVersion compose_version
    kotlinCompilerVersion '1.5.10'
   }
}

dependencies {
    implementation "androidx.compose.ui:ui:$compose_version"
    implementation "androidx.compose.material:material:$compose_version"
    implementation "androidx.compose.ui:ui-tooling:$compose_version"
    implementation 'androidx.activity:activity-compose:1.3.0'
 }
  1. 在代码中使用 Compose Activity
class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            HelloWorld()
        }
   }
}    

Fragment

class MainFragment: Fragment() {

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        return ComposeView(requireContext()).apply { 
            setContent { 
                HelloWorld()
            }
       }
   }
}

XML

<androidx.compose.ui.platform.ComposeView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/compose"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
findViewById<ComposeView>(R.id.compose).setContent {
    HelloWorld()
}

小试牛刀

我第一次写 Compose ,复刻大力辅导我的页面大约花费 2.5 小时(仅 UI,不含逻辑)。实际感受学习成本不高,如果大家有 flutter 基础,可以说非常容易上手。

设计稿Compose 还原图

展示一下头部帐户信息区域代码:

@Composable
fun AccountArea() {
    Row(
        verticalAlignment = Alignment.CenterVertically,
        modifier = Modifier
            .height(64.dp)
            .fillMaxWidth()
 ) {
        Image(
            painter = painterResource(id = R.drawable.avatar),
            contentDescription = "我的头像",
            modifier = Modifier
                .size(64.dp)
                .clip(shape = CircleShape)
        )

        Spacer(modifier = Modifier.width(12.dp))
        Column(
            modifier = Modifier.align(Alignment.CenterVertically)
        ) {
            Text(text = "华Lena爱生活", style = MyTabTheme.typography.h2, color = MyTabTheme.colors.textPrimary)
            Spacer(modifier = Modifier.height(10.dp))
            Row {
 GradeOrIdentity(text = "选择年级")
                Spacer(modifier = Modifier.width(12.dp))
                GradeOrIdentity(text = "选择身份")
            }

     }

        Spacer(modifier = Modifier.weight(1f))
        Image(
            painter = painterResource(id = R.drawable.icon_header_more),
            contentDescription = "进入个人信息设置",
            modifier = Modifier
                .size(16.dp)
        )
    }
}


@Composable
fun GradeOrIdentity(text: String) {
    Row(
        horizontalArrangement = Arrangement.Center,
        verticalAlignment = Alignment.CenterVertically,
        modifier = Modifier
            .size(74.dp, 22.dp)
            .clip(MyTabTheme.shapes.medium)
            .background(MyTabTheme.colors.background)
    ) {
        Text(text = text, style = MyTabTheme.typography.caption, color = MyTabTheme.colors.textSecondary)
        Spacer(modifier = Modifier.width(2.dp))
        Image(
            painter = painterResource(id = R.drawable.icon_small_more),
            contentDescription = text,
            modifier = Modifier
                .size(8.dp)
        )
    }
}

大致结构是通过组合 Row 和 Column,再加上 Text、Image、Spacer 等一些“控件”,和 Flutter 很像。Flutter 中万物皆 Widget,Column、ListView、GestureDetector、Padding 都是 Widget,Compose 呢?

@Composable 函数

可能直观上大家会觉得 Row/Text/Image 是某种 “View”?然而并不是,其实它们都是函数,唯一特殊的是带有了 @Composable 注解。该函数有个规则:@Composeable 函数必须在另一个 @Composeable 函数中被调用(和协程关键字 suspend 类似),我们自己写的函数也需要加上该注解。所以有种说法是,Compose 中一切皆函数,开发者通过组合 @Composeable 函数达到想要的效果。

// Composable
fun Example(a: () -> Unit, b: @Composable () -> Unit) {
   a() // 允许
   b() // 不允许
}

@Composable 
fun Example(a: () -> Unit, b: @Composable () -> Unit) {
   a() // 允许
   b() // 允许
}

Modifier

现有 Android View 体系中设置宽高、Margin、点击事件、背景色是通过设置 View 的某个属性来实现的。而 Flutter 通过包裹一层Widget实现(例如 Padding,SizedBox,GestureDetector、DecoratedBox)。刚刚我们说过,Compose 中一切皆函数,那 Compose 是选择为每个 @Composable 函数提供 height/width/margin 等参数,还是再包一层 @Composable 函数呢?答案是前者,Compose 把它们统一抽象成了 modifier 参数,通过串联 Modifier 设置大小、行为、外观。Modifier 功能很强大,除了设置宽高、点击、Padding 、背景色之外,还能设置宽高比、滚动、weight,甚至 draw、layout、拖拽、缩放都能做。

(大家如果熟悉 Flutter 应该知道,Flutter 为人诟病的一点是它的 Widget 层级极深,很难方便得找到想要的 Widget。而在 Compose 中不会有此问题,因为 Modifier 的设计,是的它可以做到和现有 XML 布局的层级是一样的。)

开发调试

现有 XML 布局的预览功能是很强大的,开发时能快速看到效果,Compose 作为 Google 下一代 UI 框架在这点上的支持也相当强大。(这里吐槽一下 Flutter,必须编译到手机上才能看到效果)。加上 @Preview 注解可实现预览,如下图。点击预览图上的控件也可以直接定位到代码。

除了静态预览之外,Compose 还支持简单的点击交互和调试动画。能实现这些的原因是 Compose 确确实实编译出了可执行的代码。(有些跨平台的影子)

常用 Composable

这里做个总结,大部分 Android 已有的能力使用 Compose 都能实现。

AndroidComposeFlutter说明
TextViewTextText
EditTextTextFieldTextField
ImageViewImageImage如果是加载网络图片,Android/Compose 需使用三方库
LinearLayoutColumn/RowColumn/Row
ListView/RecyclerViewLazyColumn/LazyRowListView
GridView/RecyclerViewLazyVerticalGrid(实验性)GridView
ConstraintLayoutConstraintLayout
FrameLayoutBoxStack
ScrollViewModifier.verticalScroll()SingleChildScrollView
NestedScrollViewModifier.nestedScroll()NestedScrollViewCompose 通过 modifier 实现
ViewPagerPageViewCompose 有开源方案:google.github.io/accompanist…
padding/marginModifier.padding()Padding
layout_height/layout_widthModifier.size()/height()/width()/fillMaxWidth()SizedBox
backgroundModifier.drawbehind{}DecoratedBox

Text

详见:Compose Text

@Composable
fun BoldText() {
    Text("Hello World", color = Color.Blue,
        fontSize = 30.sp, fontWeight = FontWeight.Bold,
        modifier = Modifier.padding(10.dp)
    )
}

TextField

@Composable
fun StyledTextField() {
    var value by remember { mutableStateOf("Hello\nWorld\nInvisible") }    
    TextField(    
        value = value,        
        onValueChange = { value = it },        
        label = { Text("Enter text") },        
        maxLines = 2,        
        textStyle = TextStyle(color = Color.Blue, fontWeight = FontWeight.Bold),
        modifier = Modifier.padding(20.dp)            
    )
}

Image

Image(
    painter = painterResource(R.drawable.header),
    contentDescription = null,
    modifier = Modifier
        .height(180.dp)
        .fillMaxWidth(),
    contentScale = ContentScale.Crop
)

Column / Row

Column 表示纵向排列,Row 表示横行排列。以 Column 为例代码如下:

Column(modifier = Modifier.background(Color.LightGray).height(400.dp).width(200.dp)) {
    Text(text = "FIRST LINE", fontSize = 26.sp)
    Divider()
    Text(text = "SECOND LINE", fontSize = 26.sp)
    Divider()
    Text(text = "THIRD LINE", fontSize = 26.sp)
}

Box

Box(Modifier.background(Color.Yellow).size(width = 150.dp, height = 70.dp)) {
    Text(
        "Modifier sample",
        Modifier.offset(x = 25.dp, y = 30.dp)
    )
    Text(
        "Layout offset",
        Modifier.offset(x = 0.dp, y = 0.dp)
    )
}

LazyColumn / LazyRow

LazyColumn(modifier = modifier) {
  items(items = names) { name ->
     Greeting(name = name)
        Divider(color = Color.Black)
    }
 }

ConstraintLayout

详细介绍可参考:medium.com/android-dev…

需要额外引入依赖:

implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-beta01"
@Composable
fun ConstraintLayoutContent() {    
   ConstraintLayout {
    val (button1, button2, text) = createRefs()
    Button(
        onClick = { /* Do something */ } ,
        modifier = Modifier.constrainAs(button1) {
 top.linkTo(parent.top, margin = 16.dp)
        }
 ) {
       Text("Button 1")
    }

    Text("Text", Modifier.constrainAs(text) {
        top.linkTo(button1.bottom, margin = 16.dp)
        centerAround(button1.end)
    } )

    val barrier = createEndBarrier(button1, text)
    Button(
        onClick = { /* Do something */ } ,
        modifier = Modifier.constrainAs(button2) {
            top.linkTo(parent.top, margin = 16.dp)
            start.linkTo(barrier)
        }) {
           Text("Button 2")
        }
    }
 }

在声明式 UI 中是无法获取一个 “View” 的 id,但是在 ConstraintLayout 中似乎有所例外,因为需要 id 来描述相对位置。

Scroll(滚动)

详见:developer.android.com/jetpack/com…

在 Compose 中只需加入 Modifier.scroll() / Modifier.verticalScroll() / Modifier.horizontalScroll() 即可实现。

Column(     
   modifier = Modifier            
      .background(Color.LightGray)            
      .size(100.dp)            
      .verticalScroll(rememberScrollState())    
 ) {     
   repeat(10) {       
       Text("Item $it", modifier = Modifier.padding(2.dp))        
   }    
}

NestedScroll

详见:developer.android.com/jetpack/com…

简单的嵌套滚动只需要加上了 Modifier.scroll() 即可实现

val gradient = Brush.verticalGradient(0f to Color.Gray, 1000f to Color.White)
Box( 
    modifier = Modifier
        .background(Color.LightGray)
        .verticalScroll(rememberScrollState())
        .padding(32.dp)
) {
 Column {
    repeat(6) {
        Box(
                modifier = Modifier    
                    .height(128.dp)
                    .verticalScroll(rememberScrollState())
            ) {
                 Text(     
                    "Scroll here",
                    modifier = Modifier
                        .border(12.dp, Color.DarkGray)
                        .background(brush = gradient)
                        .padding(24.dp)
                        .height(150.dp)
                )
            }
       }
    }
 }

如果是复杂的嵌套滚动需要使用 Modifier.nestedScroll()

val toolbarHeight = 48.dp
val toolbarHeightPx = with(LocalDensity.current) { toolbarHeight.roundToPx().toFloat() }
val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }
val nestedScrollConnection = remember {
    object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                val delta = available.y
                val newOffset = toolbarOffsetHeightPx.value + delta
                toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
                return Offset.Zero
            }
        }
    }

  Box(
        Modifier
            .fillMaxSize()
            .nestedScroll(nestedScrollConnection)
    ) {
 // our list with build in nested scroll support that will notify us about its scroll
        LazyColumn(contentPadding = PaddingValues(top = toolbarHeight)) {
            items(100) { index ->
                Text("I'm item $index", modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp))
            }
 }

  TopAppBar(
            modifier = Modifier
                .height(toolbarHeight)
                .offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) } ,
            title = { Text("toolbar offset is ${toolbarOffsetHeightPx.value}") }
        )
    }

声明式 UI

假设要修改一个 View 的颜色,如果使用命令式的开发方式首先需要借助 findViewById 等获得此 View 的句柄,然后通过调用其方法实现UI的变化。

// Imperative style
View b = findViewById(...)
b.setColor(red)

命令式UI需要持有全功能UI句柄,然后通过调用相关方法对UI进行变更

如果使用声明式的方式完成同样效果,只需要在声明 View 的同时将 state 关联到对应的 UI 属性,然后通过 state 的变化,驱动 View 的重新绘制

// Declarative style
View {
    color: state.color,
}

声明式UI仅仅描述当前的UI状态,当状态变化时框架自动更新UI

声明式UI中无法通过获取View的句柄对其属性进行修改,这间接保证了UI属性的不可变性(immutable),state作为唯一的“Source of Truth”,任何时刻保持与UI状态的绝对一致。因此声明式UI不可绕开的是状态管理。

状态管理

参考:developer.android.com/jetpack/com…

如果使用过声明式框架,应该对状态管理并不陌生。Flutter 中有 StatelessWidget 和 StatefulWidget,StatefulWidget 用于管理状态。而 Compose 使用 State 管理状态(大部分情况还需要结合 remember())。在下面的例子中,每当 text 值变化时,会触发重组,更新UI。

重组:在数据发生变化时重新运行某些函数以达到改变 UI

为什么修改 text 就会触发更新 UI 呢?这是 Compose 赋予 State 的能力(mutableStateOf()返回的是一个可变的 State),State 表示该变量是 Compose 需要关心的“状态”,当状态变化后需要被感知并触发重组。实际使用中,State 一般会配合 remember 函数使用,remember 是指当重组发生时可以记住上一次的 State。在下面例子中,当 Foo() 函数由于重组而被重新执行时,若没有 remember 该函数的局部变量 text 会被赋值成初始值(即空字符串),这显然与我们的预期不符,因此诞生了 remember。

@Composable
fun Foo() {
  var text: String by remember { mutableStateOf("") }
  Button(onClick = { text = "$text\n$text" }) {
    Text(text)
  }
}

上面的代码和 Flutter 中的 MobX 很像,但是相比于 MobX,Compose 的重组范围细粒度更精准,它会自动分析出最小的重组范围。例如上述代码只要重组的范围是 Button 的 lambda 部分

推荐架构

这些 State 应该放在哪里好呢?是分布在各个 @Composable 函数中吗?官方提供了一种推荐的架构:用 ViewModel 持有 LiveData,UI 界面观察 LiveData。

事件从下往上,状态从上往下(单向数据流)

其他

作为一个 UI 框架,还需要包括主题(Theme)、动画(Animation),这部分大家自行参考官方文档

实际使用呢?

实际使用 Compose 可能会关心这些数据

包大小

参考自:Jetpack compose before and after

结论:包大小有显著缩小

包大小
方法数
编译速度

性能

理论上由于没有了 XML -> Java 的转换所以对复杂布局有优势。

实际使用中,没有找到比较权威的数据,官方给的 Demo 刷着挺流畅的。

但是在 GitHub 上会也看到很多 issue 吐槽 Compose 的性能:github.com/android/com…

上手门槛&开发效率

优势:更容易写动画、自定义View、预览功能很强大。

劣势:需要适应声明式写法

android-developers.googleblog.com/2021/05/mer… 该文章声称 Compose 提高了 56% 的开发效率。如果有 Flutter 基础可以更快上手,大家可以尝试用 Compose 实现一个界面,用起来会挺顺。

经典 View 中写动画、自定义View、自定义 ViewGroup 都有一定门槛,但在 Compose 中使用系统提供的API,例如 animateXXXAsState、Canvas、Layout 实现相同功能会简单很多。

现有库的兼容性

实际使用时可能会遇到 Compose 基建不完善的情况。当然,很多三方库已经有了 Compose 的兼容版本,例如 Glide、ViewPager、SwipeRefreshRxJava等,但不得不承认更多库是没有 Compose 版本的,例如 Fresco、TTVideoPlayer、WebView、CoordinatorLayout 等都没有。官方也提供了一种解决方法 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
        factory = { 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
            view.coordinator.selectedItem = selectedItem.value
        }
   )
}

我尝试把 QrCodeView(一个二维码扫描 View) 用上述方式包装成 Compose ,遇到了一些问题。

  1. 混写命令式和声明式,很不舒服

  2. 不熟悉 Compose 导致的代码错误

    1. 例如:生命周期问题,composable 函数会在传统 View.onAttachToWindow() 时才会执行,所以在 Activity.onCreate/onStart/onResume 想要对此 AndroidView 做一些操作需要一些技巧。很多时候需要翻阅官方提供的 Sample 才能找到答案。
  3. 其他奇奇怪怪没有考虑 Compose 时的问题。很多三方库会有各种和 Compose 不兼容问题,需要一点点踩坑。

如果是一个无 Compose 版本的 ViewGroup,例如瀑布流布局,就别想着迁移了,还是用 Compose 重写个相同功能的 Composable 函数比较现实,Compose 提供了 Layout() 函数对自定义布局会比较简单的。

是否可以跨平台?

根据 Kotlin 跨平台特性,Compose 其实是有跨平台潜力的,官方也给了 Demo,当然都处于非常原始的状态。

Compose For Desktop:Compose for Desktop UI Framework

Compose For Web:Technology Preview: Jetpack Compose for Web | The Kotlin Blog

参考文档 (更多阅读)

  1. 【官方文档】Jetpack Compose 使用入门
  2. 【官方教程】Jetpack Compose: pathway
  3. Compose Academy
  4. 深入详解 Jetpack Compose | 实现原理
  5. 深入详解 Jetpack Compose | 优化 UI 构建
  6. Compose Preview 的 UX 设计之旅
  7. Jetpack Compose,不止是一个UI框架!
  8. juejin.cn/post/693751…
  9. mp.weixin.qq.com/s/VUe4JgdDd…
  10. intelligiblebabble.com/compose-fro…
  11. dev.to/zachklipp/s…
  12. 专栏:medium.com/mobile-app-…
  13. medium.com/mobile-app-…
  14. betterprogramming.pub/deep-dive-i…
  15. dev.to/zachklipp/s… (Compose 状态管理)