Android Compose 介绍与实践

简介

Jetpack Compose 是 Google 官方 2019 年推出的UI框架,它可简化并加快 Android 的 UI 开发工作。使用更少的代码、强大的工具和直观的 Kotlin API,快速构建 App 的 UI。2021年马上就将迎来 Compose 的正式版,是时候来了解一下这个官方强推的,布局机制、渲染机制、具体写法等可以说是全新的UI框架了。

先来看一段简单的 Compose 代码:

Column {
    Text("Hello world")
    Image()
}

OK这就是一个完整的UI界面了,对比原来定义在 xml 文件中的方式有着天壤之别,展现一个UI不再是去创建一个 TextView 之类的控件,而是变成了一次函数调用。虽然 Text 以大写开头,但它其实就是一个普通函数,严格说是个带 @Composable 注解的 Compose 函数:

@Composable
fun Text(...) {
    ...
}

来看一段完善一些的 Compose 代码:

@Composable
fun NewsStory() {
    MaterialTheme {
        val typography = MaterialTheme.typography
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Image(
                painter = painterResource(R.drawable.header),
                contentDescription = null,
                modifier = Modifier
                    .height(180.dp)
                    .fillMaxWidth()
                    .clip(shape = RoundedCornerShape(4.dp)),
                contentScale = ContentScale.Crop
            )
            Spacer(Modifier.height(16.dp))

            Text(
                "A day wandering through the sandhills " +
                     "in Shark Fin Cove, and a few of the " +
                     "sights I saw",
                style = typography.h6,
                maxLines = 2,
                overflow = TextOverflow.Ellipsis)
            Text("Davenport, California", style = typography.body2)
            Text("December 2018", style = typography.body2)
        }
    }
}

以 Column、Row 代替 LinearLayout 等布局,以 Text、Image 等代替 TextView、ImageView 等控件,以 Modifier 等用作细节和修饰,所以其实 Compose 就是这样由多个函数调用组合起来,形成一个完整的 UI 界面。

Compose 改变了原有的基于 xml 和 View 的体系,纯在代码中实现页面UI,那么它比起老的方式有什么优势呢?

Compose 的特点

Jetpack Compose is Android’s modern toolkit for building native UI.

这是官方对 Compose 的定义,比起旧有体系,Compose 更加 “现代”。

现有的 Android 视图体系从 2010年以来没有发生太大变化,10年间无论从硬件规格还是APP复杂度都发生了极大变化,这套已经跑了10年的技术体系也已经显得有些落伍。

声明式 vs 命令式

说起 Compose 最大的特点,就是它是声明式的,而现有体系是命令式的

  • 命令式:现有视图体系要先将UI定义在 xml 文件中,当需要刷新时,需要在代码中先 findViewById 获取控件的引用,再下达如 setTextsetVisibility 等命令,主动要求更新状态、刷新UI。

    随着界面越来越复杂,控件越来越多,各控件状态难以保持同步,UI显示不一致的Bug频发。我们的很多精力花费在了如何能准确且不遗漏地更新所有该更新的控件上。

  • 声明式:声明式UI以一个“纯函数”的方式运行,当 State 变化时函数根据传入参数重新执行刷新UI。

    Compose 会对界面中用到的数据自动进行订阅——不管是字符串还是图像还是别的什么,Compose 全部能够自动订阅——这样当数据改变的时候,Compose 会直接把新的数据更新到界面。

    var text by mutableStateOf("Hello")
    

    个人理解就是,只需要把界面给提前“声明”出来,先定义好在各种 state 时 UI 应该是个什么样子,当数据产生变化时,就不再需要去主动下达 setVisibility 等各种命令,界面会自动更新。

    现有的 Data Binding 其实就是声明式的,但它通过数据更新的只能是界面元素的值,而 Compose 可以更新界面中的任何内容,包括界面的结构。

    比如以下根据数据变化整个UI结构,Data Binding 就无法做到:

    @Composable
    fun MessageList(messages: List<String>) {
        Column {
            if (message.size == 0) {
                Text("No messages")
            } else {
                message.forEach { message ->
                    Text(text=messag)
                }
            }
        }
    }
    

高性能的重组(重绘)

在上面的例子里,当 message 发生变化时,MessageList 重新执行,这个过程叫重组(recomposition)。Composee 的 UI 正是通过不断重组来实现刷新。

但如果数据变化时会触发重组,大面积的重组是否会影响性能呢?

Compose 会通过在 Gap Buffer 这样的线性结构上进行 diff 实现局部刷新。 Gap Buffer 可以理解为一个树形结构经 DFS 处理后的数组,数组单元通过 key 标记其在树上的位置信息。Compose 在编译期为 Composable 生成带有位置信息的 key,存入到 Gap Buffer 数组的对应位置。运行时可以根据 key 来识别 Composable 节点是否发生了位置变化,以决定是否参与重组。同时,Gap Buffer 还会记录 Composable 对象关联的状态(State 或 Parameters),仅仅当关联状态变化时,Composable 才会参与重组,函数才会重新执行。

布局层级嵌套

做 Android 开发的都知道一个规矩:布局文件的界面层级要尽量地少,因为层级的增加会大幅拖慢界面的加载。这种拖慢的主要原因就在于各种 Layout 的重复测量。虽然重复测量对于布局过程是必不可少的,但这也确实让界面层级的数量对加载时间的影响变成了指数级。

而 Compose 是不怕层级嵌套的,因为它从根源上解决了这种问题。它解决的方式也非常巧妙而简单——它不许重复测量。

Compose 通过一种叫做 Intrinsic Measurement(固有特性测量)的机制,避免了随着层级增多,重复测量导致绘制时间指数式增加的性能陷阱,也就是说,使用 Compose 时疯狂嵌套,和把所有组件写在同一层级里,性能上是一样的!这是比起原体系的一大进步。

配合其他 Jetpack 组件

@Composable
fun ConversationScreen() {
    val viewModel: ConversatioinViewModel = viewModel()
    val message by viewModel.messages.observeAsState()
    MessageLit(messages)
}

@Composable
fun MessageList(message: List<String>){
    ...
}

Compose 可以配合现有 Jetpack 组件的使用,例如 ViewModel、LiveData 等,对于一个标准的 Jetpack MVVM项目,将很容易将 UI 部分替换成 Compose。

Composalbe 中调用 viewModel() 可以获取当前 Context 的 ViewModel, observeAsState() 将 LiveData 转换为 Compose State 并建立绑定。当 LiveData 变化时,ConversationScreen 会发生重组,内部的 MessageLit 、MessageItem 由于依赖了参数 messages,都会参与重组。

功能完备的UI系统

Compose目前的 UI 系统功能完备,可以完全覆盖 Android 现有视图系统的所有能力。

  • 各种UI组件:所有常见的UI组件在 Compose 中都能找到对应实现,甚至Card、Fab、AppBar等 Material Designe 的控件也一应俱全、开箱即用 。

  • 列表 List:Compose 的列表非常简单,无需再写烦人的 Adapter。

    @Composable
    fun MessageList(list: List<Message>) {
        Column {  
            LazyList { // this :LazyListScope
                items(list) { item ->
                    when(item.type) {
                        Unread -> UnreadItem(message)
                        Readed -> ReadedItem(message)
                    }
                }
            }
        }
    }
    
  • 布局 Layout:Compose 提供了多种容器类Composalbe,可以对子组件进行布局,简单易用且功能强大。

    • Row ≈ Horizontal LinearLayout
    • Column ≈ Vertical LinearLayout
    • Box ≈ FragmeLayout
  • 自定义布局:通过简单的函数调用完成 measure 和 layout 过程。

    Layout(
        content = content,
        modifier = modifier
    ) { measurables, constraints ->
        // Measure the composables
        val placeables = measurable.measure(constraints)
        // Layout the comopsables
        layout(width, height) {
            placeables.forEach { placeable ->
                placeable.place(x, y)
            }
        }
    }
    
  • Modifier 操作符:Compose 通过一系列链式调用的 Modifier 操作符来装饰 Composable 的外观。操作符种类繁多,例如 size、backgrounds、padding 的设置以及 click 事件的添加等。

  • 动画 Animatioin:Compose 动画也是基于 State 驱动不断重组完成的。

    @Composable
    fun AnimateAsStateDemo() {
        var isHighLight by remember { mutableStateOf(false) }
        val color by animateColorAsState (
            if (isHighLight) Red else Blue,
        )    
        val size by animateDpAsState (
            if (isHighLight) LargeSize else SizeSize,
        )
        Box(Modifier.size(size).background(color))
    }
    

开发中预览

目前的基于 xml 的预览效果很鸡肋,导致很多开发者都习惯于实机运行查看UI。Compose 预览机制可以做到与真机无异,真正的所见所即得。

预览时只需创建一个无参的 Composalbe,并添加 @Preview 注解即可。

@Preview
@Composable
fun PreviewGreeting() {
    Greeting("Android")
}

与现有体系良好的互操作性

Compose 能够与现有 View 体系能一起使用,比如在现有布局中使用 Compose,或在 Compose 布局中使用旧视图体系。所以迁移到 Compose 很方便,可以为一个已有项目先引入 Compose,再逐渐切换,不要求一次性将旧UI全替换为新的,有很大的缓冲空间。

实践

现在来尝试动手写一个简单的 Compose 界面。

配置 Kotlin
plugins {
    id("org.jetbrains.kotlin.android") version "1.4.32"
}
配置 Gradle
android {
    defaultConfig {
        ...
        minSdkVersion(21)
    }

    buildFeatures {
        // Enables Jetpack Compose for this module
        compose = true
    }
    ...

    // Set both the Java and Kotlin compilers to target Java 8.

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }

    kotlinOptions {
        jvmTarget = "1.8"
        useIR = true
    }

    composeOptions {
        kotlinCompilerVersion = "1.4.32"
        kotlinCompilerExtensionVersion = "1.0.0-beta07"
    }
}
添加 Jetpack Compose 工具包依赖项
dependencies {
    implementation("androidx.compose.ui:ui:1.0.0-beta07")
    // Tooling support (Previews, etc.)
    implementation("androidx.compose.ui:ui-tooling:1.0.0-beta07")
    // Foundation (Border, Background, Box, Image, Scroll, shapes, animations, etc.)
    implementation("androidx.compose.foundation:foundation:1.0.0-beta07")
    // Material Design
    implementation("androidx.compose.material:material:1.0.0-beta07")
    // Material design icons
    implementation("androidx.compose.material:material-icons-core:1.0.0-beta07")
    implementation("androidx.compose.material:material-icons-extended:1.0.0-beta07")
    // Integration with observables
    implementation("androidx.compose.runtime:runtime-livedata:1.0.0-beta07")
    implementation("androidx.compose.runtime:runtime-rxjava2:1.0.0-beta07")

    // UI Tests
    androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.0.0-beta07")
}
用 Compose 来重新写一下 账号登录 页面:
class MainActivity : ComponentActivity() {

    private val isLogInning = mutableStateOf(false)

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

    private fun login() {
        isLogInning.value = true
        Toast.makeText(this, "登录中", Toast.LENGTH_SHORT).show()
    }

    @Composable
    fun LoginScreen(isLogInning: MutableState<Boolean> = mutableStateOf(false)) {
        Column {
            Image(
                painter = painterResource(R.drawable.titlebar_back_light),
                contentDescription = null,
                modifier = Modifier
                    .height(40.dp)
                    .width(15.dp)
                    .absoluteOffset(10.dp)
            )
            Text(
                text = "登录TP-LINK ID",
                fontSize = 30.sp,
                fontWeight = FontWeight.Bold,
                modifier = Modifier.padding(10.dp)
            )
            TextField(
                value = TextFieldValue(),
                onValueChange = {},
                placeholder = { Text(text = "TP-LINK ID") },
                colors = TextFieldDefaults.textFieldColors(
                    backgroundColor = Color.White,
                    placeholderColor = Color.LightGray
                ),
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 10.dp)
            )
            TextField(
                value = TextFieldValue(),
                onValueChange = {},
                placeholder = { Text(text = "密码") },
                colors = TextFieldDefaults.textFieldColors(
                    backgroundColor = Color.White,
                    placeholderColor = Color.LightGray
                ),
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 10.dp)
            )
            Text(
                text = "忘记密码",
                fontSize = 16.sp,
                color = Color.Gray,
                textAlign = TextAlign.End,
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 10.dp, vertical = 20.dp)
            )
            Button(
                content = {
                    if (isLogInning.value)
                        Text("登录中...")
                    else
                        Text("登录")
                },
                onClick = { login() },
                colors = ButtonDefaults.buttonColors(
                    backgroundColor =
                        if (isLogInning.value)
                            Color(0xFFA6B7F7)
                        else
                            Color(0xFF3C65FC),
                    contentColor = Color.White
                ),
                modifier = Modifier
                    .height(65.dp)
                    .fillMaxWidth()
                    .padding(10.dp)
            )
            Row {
                Text(
                    text = "新用户注册",
                    fontSize = 16.sp,
                    color = Color(0xFF3C65FC),
                    modifier = Modifier.padding(10.dp)
                )
                Text(
                    text = "暂不登录",
                    fontSize = 16.sp,
                    color = Color.Gray,
                    modifier = Modifier
                        .padding(10.dp)
                        .offset(205.dp)
                )
            }
        }
    }

    @Preview(showBackground = true)
    @Composable
    fun DefaultPreview() {
        LoginScreen()
    }
}
  • 简单使用 Column 做一个垂直线性布局

  • .dp 是 Int 的扩展函数,方便在代码中直接定义以 dp 为单位的数值

  • 运用 ModifieroffSetpaddingfillMaxWidth 等调整组件细节

  • 定义一个 MutableState 类型的 isLogInning 作为参数传入 Composable 函数 LoginScreen,让 Compose 自动订阅,自动根据这个值的变化而重组 LoginScreen 方法,更新UI显示

  • 当登录按钮点击时回调 onClick 方法,改变 isLogInning 的值

  • isLogInning 的值改变时,能看到“登录”按钮自动改变了颜色,并变为“登录中...”,而我们并没有主动去命令它 setTextsetBackground。这和 Data Binding 类似,但更加简单和灵活

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容

  • 我是黑夜里大雨纷飞的人啊 1 “又到一年六月,有人笑有人哭,有人欢乐有人忧愁,有人惊喜有人失落,有的觉得收获满满有...
    陌忘宇阅读 8,520评论 28 53
  • 信任包括信任自己和信任他人 很多时候,很多事情,失败、遗憾、错过,源于不自信,不信任他人 觉得自己做不成,别人做不...
    吴氵晃阅读 6,178评论 4 8
  • 步骤:发微博01-导航栏内容 -> 发微博02-自定义TextView -> 发微博03-完善TextView和...
    dibadalu阅读 3,125评论 1 3
  • 回这一趟老家,心里多了两个疙瘩。第一是堂姐现在谈了一个有妇之夫,在她的语言中感觉,她不打算跟他有太长远的计划,这让...
    安九阅读 3,494评论 2 4