엄마! Jetpack Compose는 배우기가 너무 어렵습니다. 두려워하지 마세요. 문제를 해결하는 데 도움이 되는 몇 가지 개념이 있습니다.

"너겟·스타팅 플랜"에 참여하고 있습니다.

Jetpack Compose 시작하기

최근 몇 년 동안 声明式布局개발 방법은 웹 페이지에서 휴대폰으로 점차 확장되었으며 결국 선언적 스타일이 너무 향기롭고 코드가 더 명확하고 간결하며 자연어 표현에 더 가깝습니다. 따라서 코드를 쉽게 이해하고 유지 관리할 수 있으므로 개발자의 정신적 부담이 줄어듭니다.

Google과 Apple은 지구상에서 가장 큰 두 개의 모바일 운영 체제인 Android와 IOS를 각각 유지 관리합니다. 휴대폰은 오랫동안 프로그래밍 언어의 특성상 명령형 레이아웃(객체지향 개발 방식에 치우침)을 사용하여 UI를 개발해 왔지만, 휴대폰 UI가 점점 복잡해지고 역동적으로 변해감에 따라 , 원래 방법은 병목 현상을 드러내며 점점 더 많은 휴대폰 엔지니어가 제품에서 요구하는 효과를 달성하기가 점점 더 어려워지고 있다고 불평합니다. 따라서 시장의 요구 사항을 충족시키기 위해 Google과 Apple은 각각 기본 声明式布局개발 프레임 워크를 출시했으며 Apple 측은 SwiftUi, Android 측은 이번 문제의 주인공입니다 Jetpack Compose.

들어 본 적이 없다면 Jetpack Compose저자는 공식 소개 및 개발 문서를 먼저 읽을 것을 권장합니다. 왜냐하면 이 프로그램은 0부터 이 프레임워크를 소개하는 것이 아니라 초보자가 가장 어려운 진입 임계값을 극복하도록 이끌기 때문입니다.

위의 두 문서를 대충 읽었는데도 여전히 이 문서로 돌아오면 작성자가 처음에 직면한 문제에 직면했음을 의미합니다. 문서가 정말 잘못 작성되고 기계 번역이 가득하며 상대적으로 어렵습니다 Jetpack Compose. 개념은 그냥 지나칠 뿐입니다.

이 장에서는 Jetpack Compose 초보자가 이해하기 가장 어려운 몇 가지 사항을 단계별로 안내합니다.

1. 구성 가능한 기능 및 부대 효과

이미지-20230304195344511.png

可组合函数Compose에서 UI를 기술하는 함수로 HTML과 비교할 수 있으며, Composable 함수는 kotlin을 개발 언어로 사용합니다.

구성 가능한 함수와 관련하여 다음과 같은 특성이 있어야 합니다.

  • 이 기능에는 @Composable주석이 달려 . 구성 가능한 모든 함수에는 이 주석이 있어야 합니다. 이 주석은 Compose 컴파일러에 이 함수가 데이터를 인터페이스로 변환하기 위한 것임을 알려줍니다.

  • 이 함수는 데이터를 받아들입니다. 구성 가능한 함수는 애플리케이션 로직이 인터페이스를 설명할 수 있도록 하는 매개변수를 수락할 수 있습니다. 이 경우 위젯은 하나를 허용하므로 String이름으로 사용자를 맞이할 수 있습니다.

  • 이 함수는 인터페이스에 텍스트를 표시할 수 있습니다. 이를 위해 실제로 텍스트 인터페이스 요소를 생성하는 구성 가능한 함수를 Text()호출합니다 . 구성 가능한 함수는 다른 구성 가능한 함수를 호출하여 인터페이스 계층을 내보냅니다.

  • 이 함수는 아무 것도 반환하지 않습니다. 화면을 방출하는 Compose 함수는 화면 위젯을 구성하는 대신 원하는 화면 상태를 설명하기 때문에 아무 것도 반환할 필요가 없습니다.

  • 구성 가능한 함수는 빠르고 멱등적이며 부작용이 없습니다 .

    • 이 함수는 동일한 인수로 여러 번 호출될 때 동일한 방식으로 동작하며 전역 변수 또는 random()호출 .
    • 이 함수는 속성이나 전역 변수 수정과 같은 부작용 없이 인터페이스를 설명합니다.

여기에는 개념이 있습니다. 멱등성, 누군가 이 단어를 처음 접했을 수도 있습니다. 이 단어에 대해 설명하겠습니다.

프로그래밍에서 "멱등성(idempotent)"은 몇 번을 수행해도 결과가 동일한 연산 또는 함수를 의미합니다. 즉, 주어진 입력에 대해 동일한 작업 또는 기능을 여러 번 실행해도 추가적인 영향이나 부작용이 없습니다.

함수가 외부 변수를 변경하거나 외부 변수에 액세스한다고 가정하면 다음 함수와 같이 멱등성이 아닙니다.

var a:Int=1

fun nonIdempotent(){
    a++
}

분명히 nonIdempotent() 메서드를 호출한 결과는 매번 다른데, 이것이 바로 不幂等" "의 기능이다.

幂等마찬가지로 함수를 다시 살펴보겠습니다 .

fun idempotent(a:Int):Int{
    return a+1
}

I가 전달하는 a가 다르고 매번 반환되는 결과가 다르면 여전히 멱등적입니까? 물론 멱등성의 전제는 매개변수를 일관되게 유지하는 것이라고 하는데, 매개변수가 일관되면 결과는 항상 a+1이므로 이 방법은 멱등적입니다.

다른 함수를 살펴보겠습니다.

fun idempotentOrNonIdempotent(a:Int):Int{
    print("${a+1}")
    return a+1
}

也许你会觉得这是幂等的,因为结果是一致的,但是这个函数却是不幂等的,因为print会对控制台输出日志,这属于对函数外部产生了影响,而对外部产生影响属于附带效应,因此也是不幂等的。

附带效应是指发生在可组合函数作用域之外的应用状态的变化。由于可组合项的生命周期和属性(例如不可预测的重组、以不同顺序执行可组合项的重组或可以舍弃的重组),可组合项在理想情况下应该是无附带效应的。

回到Compose中来,为什么Compose的可组合函数要强调幂等且没有附带效应呢?因为Compose是没有对象这一个概念的,它是用纯粹的函数来表达UI,因此UI的刷新就是重新调用一次可组合函数,刷新过程由Compose的智能重组机制自动完成,关于这个机制我们接下来才会提到,你只需要了解一个概念:即Compose的UI刷新就是重新调用一次可组合函数,而且调用的次数和时机是不确定的即可。

基于这个因素,如果我们的可组合函数里面出现了附带效应的情况,就会导致附带效应在不恰当的时机出现,例如下面的代码:

@Composable
fun MyScreen(
    title:String
){

    Log.d("UI日志","MyScreen")
    Column{
        Text(title)
    }

}

也许你的本意只是想输出一个日志,查看MyScreen的出现时机,但是这样在Compose中属于经典的错误。这样写的结果是每当MyScreen刷新的时候,都会输出一遍日志,如何解决附带效应的问题我们接下来再讲,现在只需要读者留一个概念:千万要注意附带效应

二、重组与智能重组

在命令式界面模型中,如需更改某个 widget,您可以在该 widget 上调用 setter 以更改其内部状态。在 Compose 中,您可以使用新数据再次调用可组合函数。

这样做会导致函数进行重组,系统会根据需要使用新数据重新绘制函数发出的 widget。Compose 框架可以智能地仅重组已更改的组件。

例如,假设有以下可组合函数,它用于显示一个按钮:

@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("I've been clicked $clicks times")
    }
}

每次点击该按钮时,调用方都会更新 clicks 的值。Compose 会再次调用 lambda 与 Text 函数以显示新值;此过程称为“重组”。不依赖于该值的其他函数不会进行重组。

如前文所述,重组整个界面树在计算上成本高昂,因为会消耗计算能力并缩短电池续航时间。Compose 使用智能重组来解决此问题。

所谓的智能重组就是:Compose根据可组合函数的参数来决定是否进行重组。

也就是说,每一次可组合函数被调用的时候,他会检查所有传入的参数,如果本次传入的参数和上一次传入的参数都是相同的话(这里指的相同是指结构性相等,在kotlin中指的是==,在java中指的是调用对象的equals()方法) ,那么Compose就会略过调用这个可组合函数,以达到最快的重组效率。

让我们回到这个可组合函数,如果他的父级可组合函数由于某种原因触发了重组,那么Compose就会尝试调用MyScreen()来完成刷新,如果title参数没有发生变化的话,Compose实际上就会略过MyScreen的刷新。

@Composable
fun MyScreen(
    title:String
){
    Column{
        Text(title)
    }
}

三、Compose的生命周期

구성 가능한 항목의 수명 주기를 보여주는 다이어그램

组合中可组合项的生命周期。 进入组合,执行 0 次或多次重组,然后退出组合。

每一次composable(重组)就是调用一次可组合函数

四、remember与状态

由于 Compose 是声明式工具集,因此更新它的唯一方法是通过新参数调用同一可组合项。这些参数是界面状态的表现形式。每当状态更新时,都会发生重组。

1.remember

remember 会将对象存储在组合中,当调用 remember 的可组合项从组合中移除后,它会忘记该对象

为什么需要remember,是因为Compose使用了纯函数的形式表达UI(与flutter等框架使用对象不同),可组合函数本身可能会被多次调用,如果我们直接在方法体中声明属性,这个属性就会因为方法本身被多次调用从而丢失,因此我们需要一种让变量“持久化”的能力,remember就提供了这种能力,让某个变量从“Enter the Compotision”阶段一直保存到“leave the Composition”阶段。

被remember包裹住的变量,每一次组合的时候,取的都是同一个变量。

이미지-20230305103730994.png

이미지-20230305103842072.png

有时候,我们希望某个remember变量在恰当的时候发生变化,例如int类型的变量num变化的时候,自动生成对应的字符串,我们可以使用remember的key,当key发生变化的时候,remember的变量会重新生成。

var num by remember { mutableStateOf(0) }
val numString = remember(key1=num) {"我是数字$num"}

上述案例中,numString是受num影响的,如果num不变的情况下,numString取的值永远都是上一次生成的值,一旦num发生了变化,即remember中的key值变化,那么remember的lambda会重新执行来获取新值。

2.MutableState

mutableStateOf 会创建可观察的 MutableState,后者是与 Compose 运行时集成的可观察类型

interface MutableState<T> : State<T> {
    override var value: T
}

如果 value 有任何变化,系统就会为用于读取 value 的所有可组合函数安排重组。

在可组合项中声明 MutableState 对象的方法有三种:

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }(实际中最多使用)
  • val (value, setValue) = remember { mutableStateOf(default) }

这些声明是等效的,以语法糖的形式针对状态的不同用法提供。您选择的声明应该能够在您编写的可组合项中生成可读性最高的代码。

简单来说,MutableState对象的作用就是一种可以被Compose观察其变化的对象,当一个MutableState变化的时候,这个对象所在的所有重组作用域都会进入重组。

*关于重组作用域的概念此处不展开,你可以大致理解为MutableState所在的那个可组合函数

通常MutableState是和remember一起出现的,下面演示一个组件:

@Composable
fun MyButton(){

    var num:Int by remember{ mutableStateOf(0) }

    Column{
        Button(onClick = { num++ }) {
            Text("点我加一")
        }
        Text("当前点击次数:$num")
    }
    

}

很容易看出来,这是一个竖向的布局,上面是一个按钮,点击之后,会让num变量+1,然后触发重组,导致其下面的Text的显示内容也+1。

可能很多初学者看到num的类型是Int很奇怪,会奇怪为什么Int的类型变化会导致重组,不是说只有MutableState变化才会触发重组吗,这是由于使用了by这个操作符对MutableState进行了委托,num的get和set方法本质上是修改了MutableState的内部的value

我们可以去除掉by操作符,代码会变成这样,本质是一样的:

@Composable
fun MyButton(){

    val num: MutableState<Int> = remember{ mutableStateOf(0) }

    Column{
        Button(onClick = { num.value++ }) {
            Text("点我加一")
        }
        Text("当前点击次数:${num.value}")
    }


}

可以看出来,num的类型变成了MutableState,不能再对num修改,而是修改起内部的value,这样会导致Compose进行重组(也许你会好奇为什么会进行重组,这里大致的原理是每个重组作用域都会监听它内部所有的MutableState的value的变化,一旦他们发生了变化就会触发重组,是一个观察者模式的设计)。

实际开发中基本都是使用by的方式委托调用MutableState,因为不需要额外写.value。

五、解决附带效应

附带效应是指发生在可组合函数作用域之外的应用状态的变化,例如当进入页面的时候更新一下当前的位置,亦或者修改一下全局变量,如果直接在可组合函数里面引入这些附带效应的话,会让逻辑出现严重的问题,下面是两种常见的错误的附带效应的使用案例:


/**
 * 手机位置更新服务
 */
object PhoneLocationUpdateService{
    //...
    fun updateMyLocation(){
        // TODO: 更新当前的位置
    }
    //...
}

/**
 * 全局变量、可组合函数以外的变量
 */
var globalParams:Int=0

@Composable
fun TestScreen(){

    //❎的做法一
    PhoneLocationUpdateService.updateMyLocation()
    //❎错误的做法二
    globalParams++

    Column{
        //...
    }

}

为什么附带效应在Compose中存在问题?

这要结合Compose的生命周期来说,回归到生命周期的这张图中

구성 가능한 항목의 수명 주기를 보여주는 다이어그램

如果我们希望可组合函数显示的时候,都让某个全局变量增加1的话,确实很容易直接在可组合函数的开头几句中直接让全局变量+1,但是需要重视的是,可组合函数会在生命周期期间多次重组的,也就是自身会被多次调用,这样就和我们需要的业务相违背了。

相似地,如果我们在AndroidView中的onLayout中插入某些访问全局变量的代码的话,可想而知会出现多大的问题(因为onLayout会频繁被调用而且次数未知)。

var globalParams:Int=0

class CrazyView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        super.onLayout(changed, left, top, right, bottom)
        //让人疯狂!!!
        globalParams++
    }
    
}

解决附带效应利器——SideEffect Api

Compose 编程思想文档中所述,可组合项应该没有附带效应。如果您需要更改应用的状态(如管理状态文档中所述),您应该使用 Effect API,以便以可预测的方式执行这些附带效应

换句话说,我们的可组合函数确实有些情况需要带一点附带效应,但是我们希望以"可预测的方式"的执行。

注:SideEffect较多,属于最难上手的部分,但是只要你搞懂了他们的使用场景和解决的问题,就可以大胆使用了。

1.LaunchedEffect:在某个可组合项的作用域内运行挂起函数

使用场景:希望在一个组合内进行异步操作

LaunchedEffect会提供一个协程作用域,这个作用域不会随着重组消失,它只会在该组合销毁的时候停止。

LaunchedEffect和remember一样,使用key作为是否重启的标志,当key发生变化的时候,会重新启动运行挂起函数

下图展示了一个组件显示3秒之后会弹出一个"我显示了"的文字的可组合函数

이미지-20230306103032112.png

图中的key1传入了Unit,也就是LaunchedEffect不会重启,你可以通过改变key的方式让它重启,具体得看业务需要。

2.rememberCoroutineScope:获取组合感知作用域,以便在可组合项外启动协程

使用场景:希望在非重组作用域启动协程任务

Compose中并不都是重组作用域,有一些诸如点击回调的地方,我们也希望启动协程任务,这样LaunchedEffect就无法满足我们的需求了,因为LaunchedEffect是一个可组合函数,他无法在重组作用域以外的地方调用。

下面看看案例,我们在重组作用域使用rememberCoroutineScope()方法生成一个scope,这个scope的生命周期和组合的生命周期也是一致的,从组合出现到销毁,中间的重组并不会影响它,同时我们根据名字也可以知道,这个scope内部是被remember处理过,我们不用担心重组之后又生成一个Scope。

接着我们就可以在非重组作用域(图中是onClick回调)中使用协程来完成异步操作。

이미지-20230306103530739.png

你看懂了吗,点击按钮的3秒后,Text就会显示一段文字。

3.rememberUpdatedState:在效应中引用某个值,该效应在值改变时不应重启

使用场景:LaunchedEffect中执行了一段异步操作之后,希望取到最新的方法参数的值

假设我们拥有这样一个可组合函数,他的逻辑希望是:3秒后显示传入的num。

이미지-20230306103919180.png

实际上,当你在3秒内传入了不同的num,在3秒后显示的结果是第一次传入的num。

这是什么情况呢,还记得LaunchedEffect的设计吗,它的设计就是避免异步逻辑遭受重组的干扰,因此只有第一次传入的num会真正被LaunchedEffect的lambda拿走,其余的num都被LaunchedEffect自身的设计忽视了。

这个时候会有人想起,LaunchedEffect的key是可以让它重启的,于是会改造成这样:

2317f0bf37e98a1d4b34b9276fe829bf.png

每次num发生变化的时候,都重启LaunchedEffect,这样不就可以在3秒倒计时之后,取到的都是最新的num吗,最终结果来说这是没问题的,显示的也是最新的值,但是问题是:倒计时也重启了。

在这种场景下,就需要使用rememberUpdatedState()了,它本质上非常简单,让我们看看源码:

@Composable
fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
    mutableStateOf(newValue)
}.apply { value = newValue }

实际上就是把一个值缓存在一个MutableState里面而已,这样有什么用呢,我们看看改造后的代码:

image-20230306104333079.png

我们继续看代码,使用rememberUpdatedState()num缓存在一个MutableState中,当LaunchedEffect内部的delay结束时,通过MutableState访问到了最新的值。

等等,为什么这个时候获取到的是最新的值呢,不是说LaunchedEffect不是不会受到重组影响吗,当然不会,还记得MutableStateby使用方式吗,我们访问rememberUpDatedNum实际上是访问了MutableState内部的value变量,MutableState自始至终都没发生过变化,而是它内部的value发生了变化,因此我们可以取到最新的值。

4.DisposableEffect:需要清理的效应

使用场景:当前可组合函数去订阅某些信息,而且可组合函数销毁的时候取消订阅

假设我们有一个这样的天气服务,可以通知所有的订阅者当前的天气。

interface WeatherListener{

    fun onUpdate(weather:String)

}

object WeatherService{

    private val observerList=mutableListOf<WeatherListener>()

    fun addObserver(observer:WeatherListener)= observerList.add(observer)

    fun remove(observer: WeatherListener)=observerList.remove(observer)

    fun update(){
        observerList.forEach {
            it.onUpdate("下雨了")
        }
    }

}

我们希望在一个组合中订阅实时的天气,可以这样做:

@Composable
fun Weather(){

    var weatherString by remember{ mutableStateOf("") }

    DisposableEffect(Unit){
        val listener=object:WeatherListener{
            override fun onUpdate(weather: String) {
                weatherString=weather
            }
        }
        WeatherService.addObserver(listener)
        onDispose {
            WeatherService.remove(listener)
        }
    }
    
    Text("当前的天气:${weatherString}")

}

DisposableEffectLaunchedEffect很类似,都有key作为重启的标识,只是必须调用onDispose方法结尾,在onDispose中进行解绑操作。

5.derivedStateOf:将一个或多个状态对象转换为其他状态

使用场景:订阅可观察的列表变化、观察多个状态的变化等

有时候我们希望某个状态发生改变的时候,会改变另外一个状态的值,通常可以使用rememberkey来完成这个业务,例如下图,showText的值会在num改变的时候重新生成。

image-20230306104813605.png

但是有些可观察的状态我们是无法使用为rememberkey的,因为变化并不是发生在它自身的值的变化,而是其内部的值发生了变化,例如常见的mutableListOf()生成的列表。

image-20230306105028782.png

为什么会没用呢,因为remember比较的额是对象自身,而不是对象内部的内容,对于list来说,它从来没有改变为其他引用,我们只改变它内部的元素,因此remember是无法感知到list的变化的,这时候我们就需要使用derivedStateOf来感知。

image-20230306105201317.png

derivedStateOf传入的lambda里面的任意一个MutableState发生变化的时候,就会重新生成一个新值。

다른 상태를 포함하는 다른 클래스도 이러한 방식으로 변경 사항을 관찰할 수 있습니다.

요약하다

저자는 Compose의 기본 개념과 시작의 어려움에 대해 간략하게 설명했습니다. Compose를 시작하는 과정에서 우회하지 않고 적절한 시나리오에서 다양한 API의 역할을 이해하기를 바랍니다. Compose 및 기타에 대한 더 많은 기사 안드로이드 개발은 추후 공개될 예정이니 구독과 좋아요 부탁드립니다.

추천

출처juejin.im/post/7244420350753144891