Android Jetpack Compose使用及性能优化小结

在一次项目开发中接触到了jetpack Compose,并且还项目中在逻辑简单的页面,使用了compose去实现。当时觉得很新颖,实践中也感觉到,这种响应式的,与当时的Vue/微信小程序/Flutter中思想大同小异,可能是未来的一种原生写UI的趋势。在现在的每记和脚印项目中,新实现的页面,都会优先考虑用Compose去实现。然而,Compose的一些性能优化点及注意点,也是做为开发人员需要熟悉的,今天将做一个小的总结。

一、声明式 vs 指令式编程

1、定义

无论是官网文档还是介绍Compose的优点时,都会说到Compose是声明式的。我们来回顾下,在wiki上有着如下定义:

声明式编程(英语:Declarative programming)或译为声明式编程,是对与命令式编程不同的编程范型的一种合称。它们建造计算机程序的结构和元素,表达计算的逻辑而不用描述它的控制流程。

指令式编程(英语:Imperative programming);是一种描述电脑所需作出的行为的编程范型。几乎所有电脑的硬件都是指令式工作;几乎所有电脑的硬件都是能执行机器语言,而机器代码是使用指令式的风格来写的。

通俗的来说就是:声明式编程是一种把程序写成描述结果的形式,而不是如何获得结果的形式。它主要关注结果,而不是实现细节。声明式编程的代码通常更简洁,更容易理解和维护。

命令式编程则是一种把程序写成指令的形式,告诉计算机如何实现结果。它更加关注细节,如何实现任务。命令式编程的代码通常更长,更难理解和维护。

2、个人理解

Compose其实就是UI框架,它最主要的功能就是让开发人员更加快速的实现 页面逻辑&交互效果 这是目的。

对于传统的XML来说,我们通过请求去服务器获取数据,请求成功后,我们需要findViewById找到页面元素View,再设置View的属性,更新页面展示状态。整个过程是按 http请求 -> 响应 -> 寻找对应View -> 更新对应View按部就班就地执行,这种思想就是命令式编程。

但是Compose描述为 http请求 -> 响应 -> 更新mutableData -> 引用对应数据的View自动重组,整个过程不需要我们开发去写更新UI的代码(发出命令),而是数据发生改变,UI界面自动更新,可以理解为声明式。

二、Compose优势

目前对于我的体验感受来说,Compose的优势体现在以下几个点:

  • 页面架构清晰。对比以前mvp,mvvm或结合viewbinding,少去了很多接口及编写填充数据相关的代码

  • 动画API简单好用。强大的动画支持,使得写动画非常简单。

  • 开发效率高,写UI速度快,style、shape等样式使用简单。

  • 另外、还有一些官方优势介绍

三、Compose 的重组作用域

虽然Compose 编译器在背后做了大量工作来保证 recomposition 范围尽可能小,我们还是需要对哪些情况发生了重组以及重组的范围有一定的了解 。

假设有如下代码:

@Composable
fun Foo() {
    var text by remember { mutableStateOf("") }
    Log.d(TAG, "Foo")
    Button(onClick = {
        text = "$text $text"
    }.also { Log.d(TAG, "Button") }) {
        Log.d(TAG, "Button content lambda")
        Text(text).also { Log.d(TAG, "Text") }
    }
}

其打印结果为:

D/Compose: Button content lambda
D/Compose: Text

按照开发经验,第一感觉会是,text变量只被Text控件用到了。

分析一下,Button控件的定义为:

参数 text 作为表达式执行的调用处是 Button 的尾lambda,而后才作为参数传入 Text()。 所以此时最小重组范围是 Button 的 尾lambda 而非 Text()

另外还有两点需要关注:

  • Compose 关心的是代码块中是否有对 state 的 read,而不是 write。

  • text 指向的 MutableState 实例是永远不会变的,变的只是内部的 value

重组中的 Inline 陷阱!

非inline函数 才有资格成为重组的最小范围,理解这点特别重要!

我们将代码稍作改动,为 Text() 包裹一个 Box{...}

@Composable
fun Foo() {

    var text by remember { mutableStateOf("") }

    Button(onClick = { text = "$text $text" }) {
        Log.d(TAG, "Button content lambda")
        Box {
            Log.d(TAG, "Box")
            Text(text).also { Log.d(TAG, "Text") }
        }
    }
}

日志如下:

D/Compose: Button content lambda
D/Compose: Box
D/Compose: Text

要点

  • ColumnRowBox 乃至 Layout 这种容器类 Composable 都是 inline 函数,因此它们只能共享调用方的重组范围,也就是 Button 的 尾lambda

如果你希望通过缩小重组范围提高性能怎么办?

@Composable
fun Foo() {

    var text by remember { mutableStateOf("") }

    Button(onClick = { text = "$text $text" }) {
        Log.d(TAG, "Button content lambda")
        Wrapper {
            Text(text).also { Log.d(TAG, "Text") }
        }
    }
}

@Composable
fun Wrapper(content: @Composable () -> Unit) {
    Log.d(TAG, "Wrapper recomposing")
    Box {
        Log.d(TAG, "Box")
        content()
    }
}
  • 自定义非 inline 函数,使之满足 Compose 重组范围最小化条件。

四、Compose开发时,提高性能的关注点

当 Compose 更新重组时,它会经历三个阶段(跟传统View比较类似):

  • 组合:Compose 确定要显示的内容 - 运行可组合函数并构建界面树。

  • 布局:Compose 确定界面树中每个元素的尺寸和位置

  • 绘图:Compose 实际渲染各个界面元素。

基于这3个阶段, 尽可能从可组合函数中移除计算。每当界面发生变化时,都可能需要重新运行可组合函数;可能对于动画的每一帧,都会重新执行您在可组合函数中放置的所有代码。

1、合理使用 remember

它的作用是:

  • 保存重组时的状态,并可以有重组后取出之前的状态

引用官方的栗子🍭:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // DON’T DO THIS
        items(contacts.sortedWith(comparator)) { contact ->
            // ...
        }
    }
}
  • LazyColumn在滑动时,会使自身状态发生改变导致ContactList重组,从而contacts.sortedWith(comparator)也会重复执行。而排序是一个占用CPU算力的函数,对性能产生了较大的影响。

正确做法:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    val sortedContacts = remember(contacts, sortComparator) {
        contacts.sortedWith(sortComparator)
    }

    LazyColumn(modifier) {
        items(sortedContacts) {
          // ...
        }
    }
}
  • 使用remember会对排序的结果进行保存,使得下次重组时,只要contacts不发生变化 ,其值可以重复使用。

  • 也就是说,它只进行了一次排序操作,避免了每次重组时都进行了计算。

提示:

  • 更优的做法是将这类计算的操作移出Compose方法,放到ViewModel中,再使用collectAsStateLanchEffect等方式进行观测自动重组。
2、使用LazyColumn、LazyRow等列表组件时,指定key

如下一段代码,是一个很常见的需求(from官网):

🍔NoteRow记录每项记录的简要信息,当我们进入编辑页进行修改后,需要将最近修改的一条按修改时间放到列表最前面。这时,假若不指定每项Item的Key,其中一项发生了位置变化,都会导致其他的NoteRow发生重组,然而我们修改的只是其中一项,进行了不必要的渲染。

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes
        ) { note ->
            NoteRow(note)
        }
    }
}

正确的做法:

  • 为每项Item提供 项键,就可避免其他未修改的NoteRow只需挪动位置,避免发生重组
@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes,
             key = { note ->
                // 为每项Item提供稳定的、不会发生改变的唯一值(通常为项ID)
                note.id
            }
        ) { note ->
            NoteRow(note)
        }
    }
}
3、使用 derivedStateOf 限制重组

🍗假设我们需要根据列表的第一项是否可见来决定划到顶部的按钮是否可见,代码如下:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}
  • 由于列表的滑动会使listState状态改变,而使用showButtonAnimatedVisibility会不断重组,导致性能下降。

🍟解决方案是使用派生状态。如下 :

val listState = rememberLazyListState()

LazyColumn(state = listState) {
  // ...
  }

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}
  • 派生状态,可以这样理解,只有在derivedStateOf里的状态发生改变时,只关注和派发对UI界面产生了影响的状态。这样AnimatedVisibility只会在改变时发生重组。对应的应用场景是,状态发生了改变,但是我们只关注对界面产生了影响的状态进行分发,这种情况下,就可以考虑使用。
4、尽可能延迟State的读行为

之前我们提到,对于一个Compose页面来说,它会经历以下步骤:

  • 第一步,Composition,这其实就代表了我们的Composable函数执行的过程。

  • 第二步,Layout,这跟我们View体系的Layout类似,但总体的分发流程是存在一些差异的。

  • 第三步,Draw,也就是绘制,Compose的UI元素最终会绘制在Android的Canvas上。由此可见,Jetpack Compose虽然是全新的UI框架,但它的底层并没有脱离Android的范畴。

  • 最后,Recomposition,也就是重组,并且重复1、2、3步骤。

尽可能推迟状态读取的原因,其实还是希望我们可以在某些场景下直接跳过Recomposition的阶段、甚至Layout的阶段,只影响到Draw。

🍿分析如下代码:

@Composable
fun SnackDetail() {
    // Recomposition Scope
    // ...
    Box(Modifier.fillMaxSize()) {  Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value) // 1,状态读取
        // ...
    } 
// Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    // ...
    val offset = with(LocalDensity.current) { scroll.toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset) // 2,状态使用
    ) {
        // ...
    }
}

上面的代码有两个注释,注释1,代表了状态的读取;注释2,代表了状态的使用。这种“状态读取与使用位置不一致”的现象,其实就为Compose提供了性能优化的空间。

那么,具体我们该如何优化呢?简单来说,就是让:“状态读取与使用位置一致”

改为如下 :

// 代码段12

@Composable
fun SnackDetail() {
    // Recomposition Scope 
    // ...

    Box(Modifier.fillMaxSize()) {
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) { scroll.value } // 1,Laziness
        // ...
    } 
    // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    val offset = with(LocalDensity.current) { scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset) // 2,状态读取+使用
    ) {
    // ...
    }
}

理解: 由于我们将scroll.value变成了Lambda,所以,它并不会在composition期间产生状态读取行为,这样,当scroll.value发生变化的时候,就不会触发「重组」,这就是 延迟 的意义。

五、小结

其实以上案例优化的点在本质上,都是在践行:状态读取与使用位置一致的原则。但是需要我们对Compose的底层原理,快照系统,还有ScopeUpdateScope有一定的了解。这样才会让我们有着深刻的理解,代码为什么要这么写。

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

推荐阅读更多精彩内容