Jetpack Compose 核心概念(一)

1. 命令式 UI 和声明式 UI

1.1 命令式 UI

在传统的 XML UI 系统中,创建一个 UI 的逻辑往往分为以下几步:

  1. 通过 xml 控件完成 UI 布局
  2. 运行期将 xml 中的各控件转换为 java 对象,对象中的每个会直接或间接改变控件显示效果的属性,都被称为控件的内部状态
  3. 通过 findViewById 拿到对应的控件对象,并调用其 getXXXsetXXX 方法来手动维护其内部状态的更新

这种由控件对象提供 setXXX 方法来由外部手动维护控件内部状态更新的操作,就是命令式编程。

1.2 声明式 UI

在 Jetpack Compose 声明式编程范式中,每个控件都是无状态的(控件内部并不保存相应的属性),也不会提供对应的 getXXX()setXXX() 方法,而是将控件的状态抽象到了控件的外部,由专门的 State 状态对象来维护其控件的属性,且控件与 State 对象的绑定是在声明的过程中完成的。

运行过程中,只要 State 状态的值发生了变化,与之绑定的控件就会被刷新。刷新过程完全是自动完成的,不需要任何的手动干预。这种只需声明一次,就能自动完成后续控件刷新操作的编程范式,就是声明式编程。

Jetpack Compose 应用

只需要把界面声明出来,而不需要手动更新。界面的更新完全由数据驱动。UI 会自动根据数据的变化而更新。

2. Composition 和 Recomposition

2.1 Composition

Jetpack Compose 通过调用 composable 树结构来完成页面 UI 显示的过程被称为一次 compositionComposition 分为:initial composition 和 recomposition 两个过程。

Initial composition 指的是首次运行 composable 树结构来完成页面显示的过程,控件与 state 状态对象的关系绑定主要是在这一过程中完成的。(部分控件并没有在 initial composition 过程中得到执行,则其与 state 的绑定关系,是在 recomposition 过程中,控件被第一次执行的时候完成的)

2.2 Recomposition

Recomposition 是指当 Jetpack Compose 在执行完 initial composition 过程并完成了绝大部分控件与 state 状态对象的绑定之后,由于某一个或多个 State 状态对象发生变化后,Jetpack Compose 更新 UI 的方式。Recomposition 在执行过程中,只会调用 State 状态发生变化所对应的 composable function 或 lambda 进行 执行,其它未发生变化的部分会尽可能的跳过,通过这种方式来提高更新 UI 的执行效率。

3. Compose 的执行特点

  1. Composable function 可按任何顺序执行
  2. Composable function 可以并发执行
  3. Recomposition 会跳过尽可能多的内容
  4. Recomposition 是乐观的操作
  5. Recomposition 可能执行的非常频繁

3.1 Composable function 可按任何顺序执行

@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}

这里 MyFancyNavigation 函数中调用的三个 composable function 可以按照任何顺序执行。这三个 composable function 中不应该有任何的执行依赖关系(如:在 StartScreen 中改变一个全局变量的值,而在 MiddleScreen 中使用这个改变后的全局变量的值),并保证其相互独立。

3.2 Composable function 可以并发执行

Composable function 的执行可能会在后台线程执行。当在 composable 方法之内调用 Effect 附带效应(如:调用 viewModel 中的某个函数),可能会出现多线程并发问题。所以,Effect 附带效应应该运行在 composable 范围之外执行。

并发执行的局部变量问题

@Composable
fun ListWithBug(myList: List<String>) {
    var items = 0

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
                items++ // Avoid! Side-effect of the column recomposing.
            }
        }
        Text("Count: $items")
    }
}

由于 composable 执行的最小单位为 composable 或者 lambda 代码块,上面代码中 Column 和 Text 可能会在不同的线程同时执行,这样,items 显示的值就是错误的。

3.3 Recomposition 会跳过尽可能多的内容

Jetpack Compose 只会在某一个或者多个 composeable 所绑定的 state 状态发生变化的时候,进行 recomposition 的更新操作。Recomposition 的过程中,以引用了 state 的 composable 为起点,根据该 composable 调用的子 composable 参数是否变化,来判断是否需要对该子 composable 进行刷新,并依此向下递归。以达到尽量只更新状态发生改变所对应的 composable 的目的。

@Composable
fun NamePicker(
    header: String,
    names: List<String>,
    onNameClicked: (String) -> Unit
) {
    Column {
        // 当 header 值改变时,会引发 Text 的 recompose,而 names 的改变不会引起 Text 的 recompose
        Text(header, style = MaterialTheme.typography.h5)
        Divider()

        LazyColumn {
            items(names) { name ->
                // 当 names 中的某个 name 的值发生改变时,对应的 NamePickerItem 执行 recompose。header值的改变并不会引发 NamePickerItem 的 recompose。
                NamePickerItem(name, onNameClicked)
            }
        }
    }
}

3.4 Recomposition 是乐观的操作

当 recomposition 还未完成时,由于新的状态变化导致新的 recomposition 的发生,旧的 recomposition 会被取消(也就是丢弃 recomposition 过程中所生成的界面树),新的 recomposition 会得到执行。

3.5 Recomposition 可能执行的非常频繁

Recomposition 的执行可能会非常的频繁,像一些 side-effect(附带效应)的操作,推荐在其它线程执行,并通过 state 对象将其结果通过 recomposition 的方式返回。

4. State

4.1 State 是什么

对于应用来说,state 就是会引起页面或逻辑发生变化的值。

对于控件来说,state 就是那些会直接或间接引起控件展示效果发生变化的值。比如:TextFild 的属性 text 所对应的值就是一个 State。

在 Jetpack Compose 中 state 指的是实现了 state 接口的对象,它会与对应的 composable 进行绑定,并在值发生变化时,通知对应的 composable 进行刷新。

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

4.2 Stateful(有状态)与 Stateless(无状态)

说明

Stateful 表示控件内部持有外部设置的属性值。只要用户针对控件的某个属性设置过一次值之后,接下去的页面刷新导致控件的重新执行,对应的值都是会显示出来的,不需要再次设置。

Stateful 的控件通常会返回控件对象本身给业务来进行值的设置,就像传统的 XML 控件。

Stateless 表示控件内部并不持有任何属性对应的值,每次控件被刷新了,都需要调用当前控件并将对应的属性值通过参数的形式传给控件来显示,否则不会显示对应的属性值。

Stateless 的控件通常不会返回控件对象本身,而是会提供参数让业务来传值,Composable 类型的控件就是这样的控件。

Composable 应用及优缺点

在某个 composable 中,如果内部创建并持有了一个 state 状态对象,那么这个 composable 就是 stateful(有状态的),反之,则是 stateless 的。

Stateful composable 的好处:调用者无需管理状态就可以直接使用,使用起来比较方便。

Stateful composable 的坏处:由于其持有了一个特定的 state 对象,降低了可重用性和可测试性。

Stateless composable 的好处:降低了 composable 的复杂度的同时,增加了其灵活性。

如果你是一个开发通用 composable 的开发者,一般情况下,需要针对同一个 composable 分别开发 stateful 或者 stateless 的的版本,供调用者选择使用。

为什么说 Composable 是无状态的?

这里的状态主要指的是 composable 控件中的属性。如:TextView 中的 text 属性就是 TextView 中的一个状态,可以通过 TextView 实例拿到这个属性(状态)的值。

而 Composable 中的无状态所说的是:Composable 的 UI 控件是没有属性的,所有需要显示的值都是被当作 Composable 函数参数进行执行,然后显示出来,Composable UI 控件并没有保存这些值,也就是我们无法再次通过 UI 控件实例获取到设置的这些值,因为无法拿到 Composable UI 控件的实例。

无状态不是一个功能或者优点,无状态是 Compose 在实现声明式 UI 控件过程中,所自带的特点。

Composables should be relatively Stateless — meaning their display state should be driven by arguments passed into the Composable function itself.

如果无法通过 Composable UI 控件获取到其对应的属性,那么,如果在实际开发过程中,就是需要获取某个 Composable UI 控件所使用的值的话,那又该如何实现呢?

上面所说的状态,其实是指的某个控件的内部状态,而如果 Composable UI 控件内部是无状态的,所有的状态(带来控件改变的参数)都是通过外部传递进来的,那么我们就只需要将外部状态,也就是控件外部的值在两个 Composable UI 控件之间进行共享,就相当于是一个 Composable UI 控件获取到了另外一个 Composable UI 控件的状态(值)了,只不过这里的状态是外部状态,而非传统 View System 中,状态是在控件的内部存储,并通过控件提供的方法来进行访问的。

4.3 State hoisting(状态提升)

State hoisting 的概念主要说的是将一个 stateful 的 composable 通过将其 state 对象向上转移来将其转换为 stateless 状态。

Stateful composable 转换为 Stateless composable 的方法

一个 State 对象需要使用 2 个函数参数来进行替换:

  1. value: T:由原 state 对象所持有并需要被显示的值。
  2. onValueChange: (T) -> Unit:由原 stateful composable 中会改变原 state 状态变化的代码,以回调方式将改变后的值,同步到持有 state 的 composable 去更新。如果改变状态的回调函数较多,这里也可以接收一个带多个函数的接口作为参数。

Stateful composable:

@Composable
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       var name by remember { mutableStateOf("") }
       if (name.isNotEmpty()) {
           Text(
               text = "Hello, $name!",
               modifier = Modifier.padding(bottom = 8.dp),
               style = MaterialTheme.typography.h5
           )
       }
       OutlinedTextField(
           value = name,
           onValueChange = { name = it },
           label = { Text("Name") }
       )
   }
}

Stateless composable:

@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }

    HelloContent(name = name, onNameChange = { name = it })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}

State hoisting(状态提升)的好处

  1. 唯一性。当多个 composable 都需要持有同一个 state 对象时,将这个 state 提升到共同最近一级的父类,可以减少维护的成本及降低出现 bug 的概率。
  2. 封装性。仅持有 state 对象的 composable 才需要维护其状态。
  3. 共享性。多个 composable 可以共享同一个 state 实例。
  4. 可拦截性。在修改 state 对象前,可以对事件进行忽略或者修改。
  5. 解藕性。将 composable 状态与 composable 本身进行解藕,增加了灵活性、可重用性和可测试性。

State hoisting(状态提升)原理 - 单向数据流

通过将 state 状态提升后,持有 state 状态的 composable 与 stateless composable 之间的关系就变成了单向数据流,即 stateless composable 触发了事件后向上传递给 stateful composable 对象,stateful composable 接收到 Event 事件后,改变其持有的 state 状态对象,并将 state 状态对象所持有的值向下传递个 stateless composable 来完成 UI 上的展示。

image

State Hoisting(状态提升)原则

  1. 读取 state 最低层级父类。state 状态对象应该被提升到离所有使用(读取)这个 state 状态对象最近的父类上。
  2. 修改 state 最高层级。state 状态对象应该被提升到可能会修改此 state 的最高一级的 composable。
  3. 合并 state 对象。如果两个 state 状态对象维护的是同一个 Event 事件的话。应该将两个 state 合并为同一个。

4.4 非 Composable 可观察者对象转换成 State 的方法

  1. LiveData
  2. Flow
  3. RxJava

上面 3 个常见的可观察者对象都可以通过 xxx.observeAsState 来将其转化为 state 对象。

4.5 Event 的类型

  1. 用户主动触发有事件,主要是由人与应用的交互中产生的,如:点击事件、触摸事件等等。
  2. 被动触发的事件,如:登录信息 token 过期后触发的事件。

4.6 State 状态类型

  1. 界面元素的状态,即:界面元素的展示状态。如:Snackbar 的 SnackbarHoststate 用来表示其本身显示或者隐藏的状态。
val snackbarHostState = remember { SnackbarHostState() }

val result = snackbarHostState.showSnackbar(
            message = "Snackbar # $index",
            actionLabel = "Action on $index"
        )
        when (result) {
            SnackbarResult.ActionPerformed -> {
                /* action has been performed */
            }
            SnackbarResult.Dismissed -> {
                /* dismissed, no action needed */
            }
        }
  1. 业务逻辑的状态。比如:CartUiState 能同时包含 CartItem 内容、加载失败的内容以及加载中需要显示的内容等。
// 业务逻辑状态对象
data class ExampleUiState(
    dataToDisplayOnScreen: List<Example> = emptyList(),
    userMessages: List<Message> = emptyList(),
    loading: Boolean = false
)

// 如何使用 viewModel 管理业务逻辑状态
class ExampleViewModel(
    private val repository: MyRepository,
    private val savedState: SavedStateHandle
) : ViewModel() {

    var uiState by mutableStateOf<ExampleUiState>(...)
        private set

    // Business logic
    fun somethingRelatedToBusinessLogic() { ... }
}

// 如何在 Composable 中应用业务逻辑状态对象
@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {

    val uiState = viewModel.uiState
    ...

    Button(onClick = { viewModel.somethingRelatedToBusinessLogic() }) {
        Text("Do something")
    }
}

4.7 在 Compose 中如何存储 State

如果在 composable function 中直接创建 state 并将其绑定到 composable 控件的话,会存在一个问题:每次 recompositon 都会导致 state 被新建并将默认值绑定到对应 composable 控件来展示。这显然达不到我们想要的效果。

remember

remember 也是一个 @composable 对象,它的作用是在 composable 中保存单个对象到内存中。

保存时机:默认是当 composable function 初次 composition 的时候。同时会在每一次的 composition 将保存的值进行返回(包括 initial composition)。

移除时机:当调用 remember 的 composable 在 composition 过程中被移除的时候。

重建时机:当应用的配置发生改变(如:屏幕旋转)的时候,会导致 remember 所保存的对象被重建并重新保存。

多次保存:remember 支持传递一个或者多个 key 来控制 remember 是否需要重新执行保存操作。如果传递的 key 值中有一个值发生了变化都会导致 remember 再次执行保存的操作。

inline fun <T> remember(
    key1: Any?,
    key2: Any?,
    key3: Any?,
    calculation: @DisallowComposableCalls () -> T
): T

rememberSaveable

rememberSaveable 的作用也是保存对象值,只要能被 bundle 保存的值,都可以使用 rememberSaveable 来保存。与 remember 不同的是,rememberSaveable 是将数据保存到 bundle 中并序列化到本地进行持久化存储,所以,当 activity 或者 process 销毁并重建了之后,也是可以获取到之前保存了的对象值。

4.8 持久化存储非 Parcelizable 对象的方式

  1. MapSaver
data class City(val name: String, val country: String)

val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, countryKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}
  1. 为对象中的每个值设置一个 key
  2. 提供 save 和 restore 方法

本质上就是将对象中的每个值都使用 key-value 的方式存储,并在获取的时候,将 key-value 值重新组织成相应的对象进行返回。mapSaver 同样也是存储在 bundle 中。

  1. ListSaver
data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
    save = { listOf(it.name, it.country) },
    restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

listSaver 是针对 mapSaver 的简化,直接以 list 的下标作为对象中值的 key 来存储数据;并通过下标取出对象相应属性的值来完成对象的组装工作。最终数据同样是存储在 bundle 中。

4.9 正确声明 State 的三种方式

  1. val mutableState = remember { mutableStateOf(default) }
  2. var value by remember { mutableStateOf(default) }
  3. val (value, setValue) = remember { mutableStateOf(default) }

4.10 如何管理 state

State Holders

当业务逻辑越来越复杂,使用的 state 状态对象越来越多的时候,state 状态对象及其业务逻辑的的维护成本越来越高。此时,可以使用一个或多个单独的 state holder 对象来统一管理这些 state 状态对象及其业务逻辑。

State Holder 例子:

// 通过 StateHolder 来管理 UI 逻辑及 State 状态对象
class MyAppState(
    val scaffoldState: ScaffoldState,
    val navController: NavHostController,
    private val resources: Resources,
    /* ... */
) {
    val bottomBarTabs = /* State */

    // Logic to decide when to show the bottom bar
    val shouldShowBottomBar: Boolean
        get() = /* ... */

    // Navigation logic, which is a type of UI logic
    fun navigateToBottomBarRoute(route: String) { /* ... */ }

    // Show snackbar using Resources
    fun showSnackbar(message: String) { /* ... */ }
}

@Composable
fun rememberMyAppState(
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    navController: NavHostController = rememberNavController(),
    resources: Resources = LocalContext.current.resources,
    /* ... */
) = remember(scaffoldState, navController, resources, /* ... */) {
    MyAppState(scaffoldState, navController, resources, /* ... */)
}

// 使用 StateHodler 对象
@Composable
fun MyApp() {
    MyTheme {
        val myAppState = rememberMyAppState()
        Scaffold(
            scaffoldState = myAppState.scaffoldState,
            bottomBar = {
                if (myAppState.shouldShowBottomBar) {
                    BottomBar(
                        tabs = myAppState.bottomBarTabs,
                        navigateToRoute = {
                            myAppState.navigateToBottomBarRoute(it)
                        }
                    )
                }
            }
        ) {
            NavHost(navController = myAppState.navController, "initial") { /* ... */ }
        }
    }
}

管理 State 的几种方式

  1. Composable 管理。当 composable UI 较简单,state 状态对象不多时, 可以直接放在 composable 中进行管理。
  2. State Holder 管理。当 composable UI 较复杂时,可以单独创建一个 state holder 来管理其 state 状态对象及其逻辑。
  3. Viewmodel 管理。可以直接使用 ViewModel 来管理其 state 状态对象。

ViewModel 相比于 StateHolder 的好处

  1. ViewModel 不受屏幕配置变化的影响。
  2. ViewModel 与 Navigation 集成,当页面位于回退栈中时,Navigation 会缓存 ViewModel,这样做的好处是:可以在返回到当前页面时,立即显示之前加载过的数据。而 StateHolder 由于屏幕旋转等会导致 state 对象的重建而丢失之前的数据。同时,当页面从返回栈退出时,ViewModel 会自动被清除,而对于 StateHodler 来说,state 状态会被一直保存。
  3. ViewModel 与一些其它库集成(如:LiveData、Hilt),扩展性更强。

ViewModel 与 StateHolder 协同工作

虽然 ViewModel 相比于 StateHodler 来说,有诸多好处,但两者的定位还是有一定的差距。

  1. StateHodler 主要是用于管理 UI 逻辑及界面元素的状态。
  2. ViewModel 主要用于处理业务逻辑及返回待展示的数据。

总体来说,在管理 state 状态对象的时候,两者都能胜任,而在处理 UI 逻辑时,StateHodler 更加适合;而在处理业务逻辑时,ViewModel 更加适合。

private class ExampleState(
    val lazyListState: LazyListState,
    private val resources: Resources,
    private val expandedItems: List<Item> = emptyList()
) { ... }

@Composable
private fun rememberExampleState(...) { ... }

@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {

    // ViewModel 处理业务逻辑状态
    val uiState = viewModel.uiState
    // StateHodler 处理界面元素状态
    val exampleState = rememberExampleState()

    LazyColumn(state = exampleState.lazyListState) {
        items(uiState.dataToDisplayOnScreen) { item ->
            if (exampleState.isExpandedItem(item) {
                ...
            }
            ...
        }
    }
}

State 与 Reomposition 的关系

当 UI 完成 initial composition 的加载后,Compose 也完成了对 state 状态的追踪。接下来,UI 的 recompostion(重新组合) 通常是通过 state 的改变成触发的。当对应的 state 发生变化后,会触发引用了发生改变 state 状态对象的 composable 及其被直接或间接调用的子 composable 的重新执行。当然,由于 Compose 对这种刷新做了优化,只会对那些输入发生改变的 composable 进行更新。

如何理解单向数据流(undirectional data flow)

单向数据流描述的是事件与状态(数据)的一种逻辑关系,即事件触发状态的改变,状态改变后,触发 UI 的更新,整个过程是单向的。

5. Composable 的生命周期

[图片上传失败...(image-1d35d9-1641520488295)]

Composable 的生命周期与 Compose 的 composition 绑定在了一起。主要分为三个部分:

  1. 进入 composition,也就是当前 composable 得到了 Compose 的执行;
  2. Recompose 0 到多次,composable 在进入 composition 后,又被重新执行了 0 到多次;
  3. 退出 composition,composable 在 Compose 进行 composition 过程中,没有得到执行(非 composable 由于其状态未发生变化而跳过)。

当然,这个 composable 的生命周期并没有那么严格的执行顺序,通常会多次进入 composition 后,运行 0 到多次后,又退出 composition。

5.1 Composable 实例及唯一性确认

每一个 composable 在初次被调用的时候会生成一个 composable 实例,同一个 composable 被不同的地方调用,会生成多个实例。也就是同一个 composable 在输入(参数)不变的情况下,是否会创建新的实例是以调用点来判断的。如果同一 composable 在同一调用点(如:for 循环创建 list item 对象)被调用多次,则以执行顺序来区分不同的 composable 实例(默认情况下会将同一调用点不同的执行顺序与当前 composable 进行关联并唯一的标识当前 composable 实例)。

调用点(源代码调用处)所对应的 composable 已经被创建后,默认情况下会将同一调用点不同的执行顺序与当前 composable 进行关联并唯一的标识当前 composable 实例。重复调用,如果其输入没有变化的话,不会重新去创建,而会重用之前创建的实例;如果输入(参数)发生变化,则会重新执行相应的 composable function,并创建新的 composable 实例。

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            // 同一调用点多次调用同一 Composable 对象,以执行顺序来区分不同的 Composalbe 实例。
            MovieOverview(movie)
        }
    }
}

5.2 按执行顺序区分不同实例的几种情况

  1. 列表队尾添加新的 Composable 实例
image

由于 recomposition 之前已经创建的 composable 实例的执行顺序与 recomposition 时的执行顺序及输入都未发生变化,所以,recomposition 之前就已经创建的 composable 对象会被 recomposition 重用。

  1. 列表队头添加新的 Composable 实例
image

由于 recomposition 之前已经创建的 composable 实例的执行顺序已经与 recomposition 时的执行顺序不同,所以,recomposition 会为对应的 composable 创建新的实例,而不会重用 recomposition 之前已经创建好的实例。

  1. 列表队中添加新的 Composable 实例

插入点之上的 composable 实例在 recomposition 过程中会被重用;插入点之后的 composable 实例都会被重新创建。

5.3 Compose 默认根据什么规则来跳过 Recomposition,如何是用关键字(如:key @state)来避免不必要的 Recomposition

默认情况下,当 composable 的输入是稳定的类型且没有改变时,recomposition 会跳过这些输入稳定且没有改变的 composable。

这里的稳定类型说的是输入类型对象本身是否是稳定类型,这个是前提,如果不是稳定类型,就算对象本身没有变化,也会被重建,而不会复用之前的 composable。

Stable 类型需要满足的条件

  1. 对于相同的两个对象实例,其 equals 必须相等
  2. 如果一个类型中的公共属性发生了改变,composation 的时候需要被通知
  3. 所有公共属性的类型都必须是稳定的

默认被认为是 Stable 的类型

  1. 基础数据类型
  2. 字符串类型
  3. 所有的 lambda 类型
  4. 被 State 状态对象所持有的值

为什么是这些类型?

因为这些类型都是 immutable(不可变)的类型,这些类型本身是不可能发生改变的。如果发生了改变,那都是不同的对象了,Compose 能识别这种变化而触发 recomposition 的更新。

State 状态对象的实例是 MutableState,它虽然是一个 mutable 可变类型,但是由于其所持有的属性 value 在发生变化后,会通知给 composition 进行相应的更新,所以,state 状态对象,也是稳定的。

State 类型对象是否改变的默认认定方式

当作为参数传递给 composable 的所有类型都是 stable 时,Compose 会使用 equals 来对各参数进行比对,如果都相等,则认为数据没有发生变化,会跳过当次的 composition。

使用 @Stable 注解将非 Stable 类型对象改为 Stable 类型

如果手动为某个类或接口使用 @Stable 修饰后,其所有对象或实现都会被 Compose 认定为 stable 状态的类型。如果某个 composable 接收的参数类型使用了 @Stable 修饰,则会直接使用 equals 来判断当前参数是否改变,进而判断是否需要在 recomposition 时,重建当前的 composable。

6. Side-effect

6.1 什么是 Side-effect(附带效应)

Side-effect:指在可组合函数范围之外发生的应用状态变化。

这里如何理解什么是可组合函数范围?

可组合函数范围指的就是 composable 中只要其接收的 lambda 是一个 composable 对象,被其所包含的内容被称为 可组合函数范围,如果有多个可组合函数存在包含关系,那这里就是一个递归关系。只要 composable lambda 直接包含的区域中的代码就被认为是在 可组合函数范围 之外。下面代码中的 content 和 label 所对应的 lambda 中直接包含的内容就是在 可组合函数范围 之内。除此之外的其它部分(如:Text 中的 click 点击事件所对应的 lambda),都被称为 可组合函数范围 之外的区域。

简而言之,被 composable function 或 composable lambda 直接包含的区域就被称为 可组合函数之内,其他地方都被称为 可组合函数范围之外

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(Modifier.padding(15.dp), content = {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp)
                .clickable { Log.d("TAG", "onClick") },
            style = MaterialTheme.typography.h5
        )

        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text(text = "Name") })
    })
}

可组合函数范围的概念理解了,那么什么叫做可组合函数范围之外发生的应用状态变化呢?

Button(
    onClick = {
            // Create a new coroutine in the event handler
            // to show a snackbar
            scope.launch {
                scaffoldState.snackbarHostState
                    .showSnackbar("Something happened!")
            }
        }
    ) {
        Text("Press me")
    }
}

这里的 onclick 中启动协程并修改 snackbar 状态的操作就被称为 可组合函数范围之外发生的应用状态变化

6.2 常见的 Side-effects

LaunchedEffect

fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    // implementation
}

LaunchedEffect 是一个 composable function,其作用为:在 composable 作用域启动一个协程来执行传递给它的包含相应逻辑的挂起函数。每一次执行挂起函数的时候,都会启动一个新的协程,并取消上一次启动的协程。同时,当 LaunchedEffect 所绑定的 composable 在某次 composition 过程中,没有被包括在内时,之前启动的协程也会被取消。

block 执行的条件:

  1. 初次调用 LaunchedEffect 函数时,block 会被执行;
  2. 再次调用 LaunchedEffect 函数时,只有所接收的一到多个 key 值中至少有一个值发生了变化后,block 都会执行。

Note:每一次 block 的执行都是在新的协程中运行。

@Composable
fun c() {
    var refreshState by remember { mutableStateOf(false) }

    MyScreen(refresh = refreshState) {
        refreshState = !refreshState
    }
}

@Composable
fun MyScreen(refresh: Boolean, value: () -> Unit) {
    Button(onClick = { value.invoke() }) {
        Text(text = "refresh the page")
    }

    LaunchedEffect(key1 = refresh) {
        Log.d("launchedEffect", "launchedEffect launched, refresh = $refresh")
    }

    LaunchedEffect(true) {
        Log.d("launchedEffect", "launchedEffect launched, key never changed")
    }
}

上面的代码中,launchedEffect launched, key never changed 只会被打印一次,而 launchedEffect launched, refresh = $refresh 在每一次 MyScreen 被调用时,都会被打印。

协程取消的时机:

  1. 当 LaunchedEffect 在连续两次 composition 过程中,其绑定的 composable 都被调用时,前一次启动的协程会被取消。
  2. 当 LaunchedEffect 被调用后的下一次 composition 过程中,其绑定的 composable 没有再被调用时,会取消其启动的协程。

rememberCoroutineScope

rememberCoroutineScope 是一个 composable 方法,其作用是:创建一个绑定了 composition 的协程作用域,该协程作用域可以在可组合函数范围之外启动一个绑定了 composable 生命周期的协程。当创建该协程作用域的 composable 在 composition 过程从显示中被移除时,其通过 rememberCoroutineScope 启动的协程就会被取消。

@Composable
@Composable
fun rememberCoroutineScopeClick(scaffoldState: ScaffoldState = rememberScaffoldState()) {
    var showState by remember { mutableStateOf(true) }

    Scaffold(scaffoldState = scaffoldState) {
        Column {

            Button(onClick = {
                showState = !showState
            }) {
                Text("show or not show")
            }

            if (showState) {
                Log.d("sideEffect", "showState = $showState")
                rememberCoroutineScopeExample()
            }
        }
    }
}

@Composable
fun rememberCoroutineScopeExample() {
    val scope = rememberCoroutineScope()


    Button(onClick = {
        scope.launch {
            delay(6000)
            Log.d("sideEffect", "coroutine launch")
        }
    }) {
        Text("Press me")
    }

}

协程被取消的时机:

  1. 当调用 rememberCoroutineScope 的 composable 在 composition 过程中,没有被显示到页面上,会取消使用 rememberCoroutineScope 启动的所有协程。

rememberUpdatedState

作用:保存某个参数或者状态的最新值,当被调用的时候,返回已保存的最新值。

@Composable
fun rememberUpdateStateExample() {
    var count by remember { mutableStateOf(0) }

    Column {

        Button(onClick = {
            count++
        }) {
            Text("Change the onTime $count")
        }

        LandingScreen {
            Log.d("sideEffect", "count = $count")
        }
    }
}

@Composable
fun LandingScreen(onTime: () -> Unit) {
    Log.d("sideEffect", "LandingScreen")
    val currentOnTimeout by rememberUpdatedState(onTime)
    var executeState by remember { mutableStateOf(false) }


    if (executeState) {
        LaunchedEffect(true) {
            delay(2000)
            currentOnTimeout()
        }
    }

    Button(onClick = { executeState = !executeState }) {
        Text(text = "loading updated state")
    }
}

DisposableEffect

DisposableEffect 作用:启动一个提供了回收方法的 LaunchedEffect(启动了一个协程),当 DisposableEffect 在某次 composition 过程中没有被执行,则会取消之前启动的协程,并会在取消协程前调用其回收方法进行资源回收相关的操作。

@Composable
fun DisposableEffectExample() {
    var showState by remember { mutableStateOf(false) }

    Column {
        Button(onClick = { showState = !showState }) {
            Text(text = "showing or not Showing")
        }

        if (showState) {
            HomeScreen(
                onStart = { Log.d("sideEffect", "onStart") },
                onStop = { Log.d("sideEffect", "onStop") })
        }
    }
}

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // Safely update the current lambdas when a new one is provided
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    // If `lifecycleOwner` changes, dispose and reset the effect
    DisposableEffect(lifecycleOwner) {
        // Create an observer that triggers our remembered callbacks
        // for sending analytics events
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                currentOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                currentOnStop()
            }
        }

        // Add the observer to the lifecycle
        lifecycleOwner.lifecycle.addObserver(observer)

        // When the effect leaves the Composition, remove the observer
        onDispose {
            Log.d("sideEffect", "onDispose")
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

SideEffect

通过将非 Compose 代码与 composable 绑定,当绑定的 composable 在 recomposition 过程中被更新时,使用 SideEffect 来更新非 Compose 代码。SideEffect 并未接收任何 key 值,所以,其只要被调用,就会执行其 block。

@Composable
fun rememberAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        /* ... */
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

produceState

将非 Compose 状态的对象,通过 produceState 的包装后,转化为 Compose state 状态对象,便于在 Compose 中直接与 composable 进行绑定。

@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository
): State<Result<Image>> {

    // Creates a State<T> with Result.Loading as initial value
    // If either `url` or `imageRepository` changes, the running producer
    // will cancel and will be re-launched with the new inputs.
    return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {

        // In a coroutine, can make suspend calls
        val image = imageRepository.load(url)

        // Update State with either an Error or Success result.
        // This will trigger a recomposition where this State is read
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

将网络请求回来的 Result 普通对象转换为 Compose state 对象。

derivedStateOf

将一个或多个 Compose state 状态对象转化成一个新的 Compose state 对象,并且当旧的 state 状态或者 derivedStateOf 所引用的变量发生改变时,都会引起新 state 对象的联动更新。

@Composable
fun TodoList(highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")) {

    val todoTasks = remember { mutableStateListOf<String>() }

    // Calculate high priority tasks only when the todoTasks or highPriorityKeywords
    // change, not on every recomposition
    val highPriorityTasks by remember {
        derivedStateOf { todoTasks.filter { it.containsWord(highPriorityKeywords) } }
    }

    Box(Modifier.fillMaxSize()) {
        LazyColumn {
            items(highPriorityTasks) { /* ... */ }
            items(todoTasks) { /* ... */ }
        }
        /* Rest of the UI where users can add elements to the list */
    }
}

上面的代码将 todoTask state 经过 highPriorityKeywords 过滤后,转换成了新的 highPriorityTask state 对象。当 todo state 或者 highPriorityKeywords 的状态发生变化时,都会引起 highPriorityTask state 的更新。

snapshotFlow

在 Compose 中创建一个 flow,并与 state 状态对象绑定。当 state 对象发生改变时,会通过 flow 发送出去。

val listState = rememberLazyListState()

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

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

上面的代码在 Effect 创建一个 flow 并绑定了 listState 状态对象,当 listState 状态发生变化时,都会通过该 flow 把变化后的值发送出去。

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

推荐阅读更多精彩内容