查看原文
其他

JetPack Compose从初探到实战

搜狐焦点 满鹏飞 搜狐技术产品 2021-11-19


  

本文字数:4210

预计阅读时间:29分钟


前言

Jetpack Compose是Google近几年在Android的UI方面变化最大的一次改变,而且表示后续的UI工作重心会放在Compose上。前些日子推出了beta版本,API已经基本稳定,正是了解Compose的最佳时机。

是什么

Jetpack Compose是Google开发的用于构建原生应用的UI工具包,它使用kotlin语言进行开发,基于声明式编程描述UI。

为什么

长久以来Android开发页面是使用XML的形式描述UI,操作系统通过解析XML文件生成view,然后使用Java/Kotlin在Activity中findViewById找到对应的view,通过调用view提供的方法对view进行诸如设置显隐、背景色等操作。这种通过XML描述UI然后通过Java/Kotlin进行命令式改变view样式的方式有几个缺点:

  1. 不优雅,每个view都需要通过findViewById获取对象,复杂页面这种样板代码能达到几十上百行。
  2. 不安全,由于view类型强转的存在和id在某些配置中可能不存在导致可能发生类型安全和空安全问题
  3. 不解耦,数据变化后每次view的样式更新都需要手动set,同一个数据多处发生变化就需要在每个地方都记得更新UI,否则就会出现错误。
  4. 不联动,XML和Java/Kotlin天生的割裂让其在重构过程中无法有效互动,一个UI设计重构需要改写两处。
  5. 不灵活,XML的语法限制了其动态性,比如分支循环等语法都很难在XML布局中实现。

虽然Google和社区一直致力于解决上面问题推出了多个库,比如ButterKnife、Kotlin Android Extensions、DataBinding、ViewBinding,但是无一不是只能解决部分问题,甚至为了解决上述问题还会引入更多其他问题。如上问题有的是因为XML和Java/Kotlin的割裂造成的,有的是因为命令式的复杂造成的。Compose一次可以解决这两个问题:抛弃XML使用Kotlin进行描述UI,采用声明式编程不再详细命令每一步的UI变化,而是描述数据和UI的关系,数据的改变自然而然就改变了UI。

初探

前置环境

由于Compose处于beta版,所以需要下载Canary版的Android Studio进行体验。尝试Compose有两种方式:创建全新的Compose项目和在现有项目中加入Compose。如果全新创建只需要在项目创建导航中选择Empty Compose Activity即可,如果是引入现有项目,需要按如下配置开发环境。

配置Gradle

需要将应用最低支持API设置为21及以上,并在gradle中启用Compose支持。

buildscript {
    ext {
        compose_version = '1.0.0-beta02'
    }
}

android {
    defaultConfig {
        ...
        minSdkVersion 21
    }

    buildFeatures {
        // 启用Compose支持
        compose true
    }
    ...

    // 设置为Java8
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }

    kotlinOptions {
        jvmTarget = "1.8"
        useIR = true
    }

    composeOptions {
        kotlinCompilerExtensionVersion compose_version
        kotlinCompilerVersion '1.4.31'
    }
}

引入依赖

在gradle中引入相关依赖项。

dependencies {
    implementation 'androidx.compose.ui:ui:$compose_version'
    // 工具支持(比如预览)
    implementation 'androidx.compose.ui:ui-tooling:$compose_version'
    // 基础组件(比如Box、Image、Scroll)
    implementation 'androidx.compose.foundation:foundation:$compose_version'
    // Material Design
    implementation 'androidx.compose.material:material:$compose_version'
    // Compose的Activity依赖
    implementation 'androidx.activity:activity-compose:1.3.0-alpha04'
    // Compose的AppCompat依赖
    implementation 'androidx.appcompat:appcompat:1.3.0-beta01'
    // Compose的ViewModel依赖
    implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha03'
    // Compose的Livedata依赖
    implementation 'androidx.compose.runtime:runtime-livedata:$compose_version'
}

接入Compose

setContent方式

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Text(text = "Hello Compose!")
        }
    }
}

这是Google推荐的默认方式,在我们熟悉的onCreate中没有使用以前的setContentView方法,而是使用了一个专门为Compose扩展的setContent方法。其中Text便是Composable函数,它由@Composable注解标识。

new方式

可以看下上述方式中的关键方法的源码实现:

public fun ComponentActivity.setContent(
    parent: CompositionContext? = null,
    content: @Composable () -> Unit
)
 {
    val existingComposeView = window.decorView
        .findViewById<ViewGroup>(android.R.id.content)
        .getChildAt(0as? ComposeView

    if (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 attach
        setParentCompositionContext(parent)
        setContent(content)
        setContentView(this, DefaultActivityContentLayoutParams)
    }
}

通过查看setContent源码可以发现该方法是ComponentActivity的扩展函数,本质是创建了一个ComposeView然后调用setContentView方法,而Composable函数则传递给ComposeViewsetContent中调用,由此可以得到使用new创建ComposeView的Compose接入方式:

class MainActivity : AppCompatActivity() {
    override fun onCreate(...) {
        ...
        setContentView(ComposeView(this).apply {
            setContent {
                Text(text = "Hello New ComposeView!")
            }
        })
    }
}

这种方式可以用于Fragment等地方完全使用Compose时接入。不过如果同一个布局中具有多个ComposeView元素,每个元素必须设置唯一ID才能使savedInstanceState发挥作用,示例如下:

class MainFragment : 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>

XML方式

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout ...>

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Hello XML!" />


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


</LinearLayout>

和常见的使用XML描述布局相同,使用id获取ComposeView,然后调用setContent()使用Compose。

class MainActivity : AppCompatActivity() {
    override fun onCreate(...) {
        ...
        setContentView(R.layout.activity_main)
        findViewById<ComposeView>(R.id.compose_view).setContent {
            Text("Hello XML ComposeView!")
        }
    }
}

此方式适合接入已经成熟的项目,但是由于Compose的编程模式改变,本就是为了抛弃XML的方式,推荐使用第一种方式直接接入。

探究Composable函数

如上所述Text是一个由@Composable注解标识的Composable函数,Compose是围绕Composable函数构建的,这些函数用来描述UI和数据的关系,每当数据发生变化时系统就会重新调用Composable函数进行重组,所以和Composable函数普通函数有所不同:

  • 所有Composable函数必须带有@Composable注解。
  • 没有返回值,因为它是用于描述UI状态,所以也不需要有返回值。
  • 幂等性:使用同一参数调用函数N次和调用1次得到的行为完全相同,不会使用全局变量和随机函数;没有副作用,即不能用于修改全局变量等对函数外部造成变动的行为。

由于其特性可以使得Compose框架在实现和优化中对其进行了充分的利用,其表现为:乱序执行、并行执行、局部跳过、乐观操作、频繁执行。

乱序执行

Composable函数的执行顺序是任意的,由Compose框架判断哪个函数先执行。比如有如下操作:

@Composable
fun HomePage() {
    Header()
    Body()
    Footer()
}

不能保证HeaderBodyFooter按任何顺序调用,所以每个函数都应该是独立的,不能试图依赖其他函数的调用,即满足交换律。

并行执行

Compose为了充分利用多核处理,可以并行运行Composable函数来优化重组,并且可能在后台线程池中运行,另外因为多线程并行调用,应避免在Composable函数中修改外部变量。比如如下代码可能因为Column的重组而造成num的错误。

@Composable
fun MemberList(nameList: List<String>) {
    var num = 0
    Row {
        Column {
            for (name in nameList) {
                Text(text = "姓名: $name")
                num++
            }
        }
        Text(text = "共${num}人")
    }
}

可以改成无写入操作的线程安全代码:

@Composable
fun MemberList(nameList: List<String>) {
    Row {
        Column {
            for (name in nameList) {
                Text(text = "姓名: $name")
            }
        }
        Text(text = "共${nameList.size}人")
    }
}

局部跳过

Compose优化了执行效率,对界面中不变的部分在重组时可能会直接跳过,只执行状态发生改变的Composable函数。

@Composable
fun HomePage(title: String, content: String) {
    Header(title)
    Body(content)
    Footer()
}

如上代码,当title发生变化时只会执行Header函数,而不会执行BodyFooter函数,同样的当titlecontent发生变化时Footer依然会被跳过。

乐观操作

只要Compose认为某个可组合项的参数可能已更改,就会开始重组。重组是乐观的操作,也就是说,Compose预计会在参数再次更改之前完成重组。如果某个参数在重组完成之前发生更改,Compose可能会取消重组,并使用新参数重新开始。

取消重组后,Compose会从重组中舍弃界面树。如有任何附带效应依赖于显示的界面,则即使取消了组成操作,也会应用该附带效应。这可能会导致应用状态不一致。

确保所有可组合函数和Lambda都幂等且没有附带效应,以处理乐观的重组。

频繁执行

Composable函数在某些情况下可能会被频繁执行,比如进行动画可能每一帧运行一次某个Composable函数,如果该函数执行成本高的操作,一秒上百次的运行会造成灾难性的性能问题。所以如果有读取磁盘、密集运算等高成本操作可以在其他线程处理完成后通过mutableStateOfLiveData将相应的数据传递给Compose。

实战

了解了上述基础内容之后,我们选择做一个最典型的feed流界面来感受一下Compose的实际开发体验。最终效果如下图所示:

布局

首先先实现列表项,仔细观察可得列表项是一个卡片,卡片内左侧有竖排两行文字,右侧一张图片构成。这种布局在xml中可以用CardViewLinearLayout实现,在Compose中提供了类似CardViewLinearLayout的Composable函数:CardRowColumn

垂直排列

左侧标题和新闻概要是两个Text垂直排列,如果直接写两个Text()会发现文字重叠在一起了,所以需要使用Column将其垂直排列,这里的Column就类似XML布局中的LinearLayout设置android:orientation=vertical的效果。

Column{
    Text(
        data.projName,
        style = typography.h5
    )
    Text(data.marketingInfo)
}

加载网络图片

以往Android开发中加载网络图片很简单,使用Glide或者Fresco即可,由于这两个图片加载库没有适配Compose,幸而Google写了一个兼容库来使用以往的图片加载框架:com.google.accompanist,这个兼容库可以在Compose中使用Coil和Glide。以最熟悉的Glide为例:

dependencies {
    implementation 'com.google.accompanist:accompanist-glide:0.7.1'
}

然后就可以直接使用Compose形式的Glide了,同样可以直接设置填充模式:

GlideImage(
    data = data.projPhotoPath,
    contentDescription = null,
    contentScale = ContentScale.Crop
)

水平排列和Card样式

Column类似,Row类似XML布局中的LinearLayout设置android:orientation=horizontal的效果。如下可以将文字和图片水平排列:

Row {
    Column {...}
    GlideImage(...)
}

最后由Card包裹Row即可得到列表项,阴影高度和圆角也可以直接设置:

Card(
    shape = RoundedCornerShape(8.dp),
    elevation = 5.dp
) {
    Column{...}
    GlideImage(...)
}

Modifier修饰

虽然基本的布局框架搭建完毕,但是边距,大小,外观和点击事件都没有,所以Compose引入了Modifier来进行处理。通过调用不同的Modifier函数可以达到对Composable函数进行修饰以达到对布局外观和交互的调整。

比如对于图片我们想设置为100*100dp,圆角为8dp:

GlideImage(
    ...
    modifier = Modifier
        .width(100.dp)
        .height(100.dp)
        .clip(shape = RoundedCornerShape(8.dp)),
)

对Card添加padding和点击事件:

Card(
    modifier = Modifier
        .clickable {
            Log.d("Compose""NewsItem: " + data.pid)
        }
        .padding(5.dp),
)

这里需要注意的是,Modifier有序的链式调用,每一个调用都会基于前一个调用的结果发挥作用,也就是说,如果调换padding()padding()的调用顺序,点击范围将不包含5dp的padding区域。

列表

实现

当列表项完成后,在传统开发模式里使用RecyclerView加载列表项,同时要自定义Adapter和ViewHolder来告知RecyclerView加载ItemView和重用逻辑。即使最简单的列表也要写很多样板代码,但是在Compose中完全不需要写这些样板代码,使用LazyColumn可以很容易加载上述列表项。

为了方便演示,可以先将上述列表项封成一个Composable函数:

@Composable
fun NewsItem(data: ItemBean) {
    Card {
        Row {
            Column {
                Text()
                Text()
            }
            GlideImage()
        }
    }
}

然后放入LazyColumn中:

@Composable
fun HomePage(dataList: List<ItemBean>) {
    LazyColumn {
        items(dataList) {
            NewsItem(it)
        }
    }
}

是的,这就已经完成来列表的加载了,没有一坨坨的样板代码,逻辑一目了然。

智能重组

但是有一个问题,如前文所述,Compose是会局部跳过没有改变的Composable函数,所以如果执行增删改操作,是否会引起重组。

首先Compose为了在标识相同项,引入了key的概念,只要key在调用点处的Composable函数作用域内唯一即可标识而不会重组。例如使用Column时加入key

Column {
    dataList.forEach {
        key(it.pid) {
            NewsItem(it)
        }
    }
}

只要pid不同,对列表项的增删改都不会引发其他项的重组。LazyColumn中也是默认支持key的,items中的一个参数就是key。我们之前实现中并没有设置key会发生什么,通过查看源码可知:

override fun getKey(index: Int): Any {
    val interval = intervals.intervalForIndex(index)
    val localIntervalIndex = index - interval.startIndex
    val key = interval.content.key?.invoke(localIntervalIndex)
    return key ?: getDefaultLazyKeyFor(index)
}

internal actual fun getDefaultLazyKeyFor(index: Int): Any = DefaultLazyKey(index)

private data class DefaultLazyKey(private val index: Int) : Parcelable {
    override fun writeToParcel(parcel: Parcel, flags: Int) {
        parcel.writeInt(index)
    }
    ...
}

key存在时,使用传入的key,当key不存在时使用当前项的索引。当增删时有可能改变其他项的索引而导致重组,所以最好将其完善为:

LazyColumn {
    items(dataList, key = { it.pid }) {
        NewsItem(it)
    }
}

状态管理

Compose是声明式风格的UI编程,所谓声明式就是状态改变则UI自动根据新的状态声明出新的UI,所以所有声明式UI都面临一个基本问题:状态管理。Compose使用MutableState<T>作为可观察类型,使用mutableStateOf方法将普通值转换成MutableState<T>,一旦MutableState<T>的成员value发生改变则会根据该值发生重组。但是悖论是一旦重组,MutableState<T>的值又会重新初始化,所以Compose引入了一个remember的Composable函数来记忆单个对象,三者配合即可完成对于状态的管理:

var value by remember { mutableStateOf(default) }

注意此处通过属性委托将MutableState<T>value成员取出以便使用。虽然使用起来并不复杂,但是多个Composable函数之间同步状态势必繁杂易错造成很多问题。其实Compose的前缀Jetpack就说明了它和其他Jetpack工具包可以搭配使用。在传统开发模式中我们使用的UI数据管理的工具ViewModel和LiveData,这两个广受好评的工具在Compose中也可以无缝使用,甚至更加容易。首先访问ViewModel:

val viewModel: MainViewModel = viewModel()

viewModel()可以创建一个新的ViewModel或者返回一个已有的ViewModel,其实例生命周期和以往使用ViewModel一样,然后通过ViewModel获取LiveData:

val liveNews by viewModel.news.observeAsState(ArrayList())

observeAsState()会返回一个State<T>,同样通过属性委托取出了value,然后使用该值构建UI:

HomePage(liveNews)

自此只要在任意地方更新该LiveData的值即可直接改变UI,无需类似命令式编程还需要手动通知Adapter数据已经发生改变。例如示例增加一条内容:

val addItemExample = ArrayList<ItemBean>()
addItemExample.addAll(liveNews)
addItemExample.add(ItemBean(...))
viewModel.news.value = addItemExample

注意:LiveData无法观察ArrayList内部项的变化,必须创建新的对象才能被观察到。

感想

随着React、Flutter、SwiftUI、Compose的推出和流行,声明式UI编程的时代已经不可抵挡。其实通过对比几个开发框架可以明显感觉到思想的一致性,可能具体写法有所区别但是思想是一致的。

比起同样是Google推出的Flutter来Compose有着其独特的优势:完全复用Jetpack工具包,熟悉的Kotlin语法和无缝的原生Android开发支持,因此Compose是在Android开发领域了解声明式UI编程的最佳切入点。

当然Compose有千般好,但是在我看来依然有一个比较大的问题:Composable函数会并行执行,这就要求所有的Composable函数必须线程安全,每一个局部变量的使用都要小心谨慎,一旦出现错误既难查又难解。由于Compose全部都是Composable函数构建的UI,因此需要处处小心,但是目前Android Studio并没有针对这种问题做代码检查,所以对开发者的要求非常高,如果后面不解决这个问题,可能会造成难以预料的问题。


上期获奖名单公布

恭喜“阿策~”、“smile...”、“木雨夕”、“_”、“萌瑶”!以上读者请及时添加小编微信:sohu-tech20兑书~




也许你还想看

(▼点击文章标题或封面查看)

Kotlin学习之设计模式篇

2021-05-06

Kotlin攻略(深度解读版)

2021-04-22

干货:在Flutter项目下安卓flavor打包配置实践

2021-03-25

一文带你了解适配Android 11分区存储

2021-02-04

点我一下,你将获得排查性能问题的超能力~

2021-01-28



: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存