Compose学习笔记1-compose、state、flow、remember

新建一个 compose 项目

开始前,请下载最新版本的 Android Studio Arctic Fox,然后使用 Empty Compose Activity 模板创建应用。

我们先看看在 app/build.gradle 中是如何配置使用 compose 的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
android{
buildFeatures {
// viewbinding 之类的功能也需要在此开启
compose true
}
composeOptions {
kotlinCompilerExtensionVersion compose_version
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.7.0'
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.activity:activity-compose:1.3.1'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
}

观察第一个不同之处

默认项目创建的这个 Activity 继承自 ComponentActivity 这个类,而不是我们熟悉的 AppcompatActivity ,这两个类的层级关系如下:

image.png

普通的用法:

1
2
3
4
5
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_webview);
}

compose的用法:

1
2
3
4
5
6
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
}
}

activity 里的 setContent{} 函数来自androidx.activity:activity-compose

在这个函数中还调用了另一个 setContent{} 函数来自androidx.compose.ui:ui

在 compose 中使用 viewmodel

viewmodel很好用,这毋庸置疑,在 compose 中 我们使用 viewmodel 更一步的简化了,只需要在需要时调用 viewModel<>() 函数即可

这一扩展来自于:androidx.lifecycle:lifecycle-viewmodel-compose

1
2
3
4
5
6
7
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
val appViewModel = viewModel<AppViewModel>()
}
}

插播一个Flow的玩法

flow 是 kotlin 推出的用于在协程中处理多个异步数据返回的工具(异步数据流),地位等同于 Rxjava 但是比 RX 容易很多也简洁很多。

看一段示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*


//sampleStart
fun simple(): Flow<Int> = flow { // 流构建器
for (i in 1..3) {
delay(100) // 假装我们在这里做了一些有用的事情
emit(i) // 发送下一个值
}
}


fun main() = runBlocking<Unit> {
// 启动并发的协程以验证主线程并未阻塞
launch {
for (k in 1..3) {
println("I'm not blocked $k")
delay(100)
}
}
// 收集这个流
simple().collect { value -> println(value) }
}
//sampleEnd

flow函数可以构建一个 Flow,注意 flow 函数是一个 suspend 挂起函数;

  • 名为 flow 的 Flow 类型构建器函数。
  • flow { ... } 构建块中的代码可以挂起。
  • 函数 simple 不再标有 suspend 修饰符。
  • 流使用 emit 函数 发射 值。
  • 流使用 collect 函数 收集 值。

流是冷的

冷流只是一个概念,等同于 rx 中,创建的 Observable,他在被subscribe之前是不会执行的
Flow 也一样,调用构建函数 flow{} 并不会执行异步流,lambda中的发射函数 emit 也不会执行
直到 flow 函数被 collect() 收集,此时流才开始工作,这也就是所谓冷流的概念。

看一段我以前项目的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
private fun syncToDb() {
lifecycleScope.launch {
//sync to db
flow {
val map = mapOf("userObjId" to SpUtils.decodeString(Constants.SP_USER_ID))
val resp = BmobMethods.INSTANCE.getAllNoteByUserId(map.toJson())
emit(resp)
}.catch { e ->
XLog.e(e)
}.flatMapConcat {
val allNote = it.toBean<GetAllNoteResp>()
allNote.results.asFlow()
}.onEach { note ->
//云端的每一条笔记
val objId = note.objectId
//根据bmob的objid查表
val dbBean = NoteUtils.queryNoteById(objId)
if (dbBean == null) {
//本地没有次数据 新建本地数据并保存数据库
val entity = note.toEntity()
entity.save()
XLog.d("本地没有该数据 新建\n$entity")
}
//存在本地对象 对比是否跟新 or 删除
dbBean?.let {
if (it.isLocalDel) {
//本地删除
val resp = BmobMethods.INSTANCE.delNoteById(it.objId)
if (resp.contains("ok")) {
//远端删除成功 本地删除
it.delete()
XLog.d("远端删除成功 本地删除\n$resp")
}
return@let
}
//未本地删除对比数据相互更新
when {
note.updatedTime > it.updatedTime -> {
//云端内容更新更新本地数据
it.update(note)
XLog.d("使用云端数据更新本地数据库\n$it\n$note")
}
note.updatedTime < it.updatedTime -> {
//云端数据小于本地 更新云端
note.update(it)
val resp = BmobMethods.INSTANCE.putNoteById(
objId,
note.toJson(excludeFields = Constants.DEFAULT_EXCLUDE_FIELDS)
.createJsonRequestBody()
)
val putResp = resp.toBean<PutResp>()
XLog.d("使用本地数据更新云端数据 \n$it\n$note")
}
else -> {
//数据相同 do nothing
XLog.d("本地数据与云端数据相同\n$it\n$note")
return@let
}
}
it.isSync = true
it.save()
}
}.flowOn(Dispatchers.IO).onCompletion {
//完成时调用与末端流操作符处于同一个协程上下文范围
XLog.d("Bmob☁️同步执行完毕,开始同步本地数据到云端")
syncToBmob()
}.collect {
}
}
}
//同步本地其他数据到云端
private suspend fun syncToBmob() {
//未同步的即本地有而云端无
NoteUtils.listNotSync().asFlow()
.onEach {
XLog.d(it)
if (it.objId.isEmpty()) {
//本地有云端无 本地无objId 直接上传
val note = it.toBmob()
val resp = BmobMethods.INSTANCE.postNote(
note.toJson(excludeFields = Constants.DEFAULT_EXCLUDE_FIELDS)
.createJsonRequestBody()
)
val postResp = resp.toBean<PostResp>()
//保存objectId
it.objId = postResp.objectId
XLog.d("本地有云端无 新建数据")
} else {
//云端同步后 本地不可能出现本地有记录,且存在云端objid,但是没有和云端同步
// 这种情况只可能是云端手动删除了记录,但是本地没有同步,
// 即一个账号登录了两个客户端,但是在一个客户端中对该记录进行了删除,在另一个客户端中还存在本地记录
//此情况可以加入特殊标记 isCloudDel
it.isCloudDel = true
XLog.d("不太可能出现的一种情况\n$it")
}
it.isSync = true
it.save()
}.flowOn(Dispatchers.IO).onCompletion {
//完成时调用与末端流操作符处于同一个协程上下文范围
listAllFromDb()
}.collect {
XLog.d(it)
}
}

这是我以前写的一个示例demo,其中用到了Flow,感兴趣的可以去看一下源码:junerver/CloudNote
注意上面使用到的关键函数:

flow 之前我们说过了,用于构造Flow流,通过 emit 发射数据

catch 捕获异常没啥好说的

flatMapConcat 等同于 RxJava中的 flatMap,展平,他的最后一行是返回值,因为我的返回值是一个List需要通过 asFlow 转换成 flow 流,完成展平

onEach 对上游发射过来的每一条数据进行处理,该函数的返回值是被我门处理后的数据,不需要我们特别处理

flowOn 约等于 subscribeOn(Schedulers.io()) 表示流执行的协程

collect 之前说过 约等于 subscribe() 函数,此事流开始执行,注意 collect 执行在哪个协程就在哪个协程,不像RxJava需要使用 observeOn(AndroidSchedulers.mainThread()) 来切换到主线程这样的操作

最简单的流操作就大概如此了,除此以外还有比如 SharedFlowStateFlow 等进阶内容,以后用到再说

可以参考的文章:
协程进阶技巧 - StateFlow和SharedFlow
Kotlin Flow】 一眼看全——Flow操作符大全
不做跟风党,LiveData,StateFlow,SharedFlow 使用场景对比

为什么要在 Compose 中说 Flow

Flow 很好用,比如 StateFlow 甚至可以代替 LiveData,在 Compose 中这一点被放大了

在包 androidx.compose.ui:ui-tooling 中依赖了 androidx.compose.runtime:runtime
它为 StateFlow 提供了一个扩展函数 collectAsState()

1
2
3
4
5
@Suppress("StateFlowValueCalledInComposition")
@Composable
fun <T> StateFlow<T>.collectAsState(
context: CoroutineContext = EmptyCoroutineContext
): State<T> = collectAsState(value, context)

这里看起来函数返回了 一个State类型的封装对象,其实不然

1
2
3
4
5
6
7
8
@Stable
interface State<out T> {
val value: T
}

//注意这是by关键字实现
@Suppress("NOTHING_TO_INLINE")
inline operator fun <T> State<T>.getValue(thisObj: Any?, property: KProperty<*>): T = value

这是一个非常屌的包装
返回的这个 State 我们使用的时候无需任何解包装,直接就可以使用,而且他似乎还是 Stateful 的。

当然使用的时候不能直接使用这个对象,需要通过 by 来实现获取到value的效果,这样就不需要调用 state.value 这样的写法了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var count = 0
val testStateFlow = MutableStateFlow(count)
@Composable
fun Greeting(name: String) {
//这里要转型为 MutableState,这样state就是可读写的了
var state by testStateFlow.collectAsState() as MutableState
Column {
Text(text = "Hello $name!${state}")
Button(onClick = {
//模拟流数据变化
testStateFlow.value = ++count
//因为是可变的state,这里我们也可以直接写
state += 1
},
modifier = Modifier
.height(50.dp)
.width(100.dp)
){
Text(text = "点一下")
}
}
}

需要注意的是 collectAsState() 函数的返回值是 State,通过 by 关键字只能获得到只读属性,我们需要进行一次转型 as MutableState,之所以能转型为 MutableState,我们看源码的 produceState 函数源码可以发现实际返回的是一个可变State;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun <T> produceState(
initialValue: T,
key1: Any?,
key2: Any?,
@BuilderInference producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
// 这里可以看到实际生成的state是可变state
val result = remember { mutableStateOf(initialValue) }
LaunchedEffect(key1, key2) {
ProduceStateScopeImpl(result, coroutineContext).producer()
}
return result
}

这里多谢 墨丘比丘 指出collectAsState()获取的值使用 by 后可以通过转型变成可读写状态。

但是我还要指出的一点就是,flow流我们应该视作数据来源,或者状态store,他不应该被直接修改,这里我们只是展示一种读写方式。

对于状态与状态管理可以参考我写的另一篇文章:# Compose学习笔记2 - LaunchedEffect、状态与 状态管理,或者从前端工程对状态管理中中触类旁通:# 从零开始学习React-5:状态与状态管理

这里其实我们可以还可以有别的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var count = 0
val testState = mutableStateOf(count)
@Composable
fun Greeting(name: String) {
Column {
//直接使用State
Text(text = "Hello $name!${testState.value}")
Button(onClick = { testState.value = ++count},
modifier = Modifier
.height(50.dp)
.width(100.dp)
){
Text(text = "点一下")
}
}
}

就是不使用 Flow 的写法,直接使用 compose 提供的 State。

但这些都不是正确的写法!

为什么说他是错误的写法,因为 compose 是函数式的UI构建方式,每一个composable 函数自身应该是不依赖外部变量的,这种写法其实严重的违背了这一原则。
这时候就要介绍我们的 remember 函数了!

1
2
3
4
5
6
7
/**
* Remember the value produced by [calculation]. [calculation] will only be evaluated during the composition.
* Recomposition will always return the value produced by composition.
*/
@Composable
inline fun <T> remember(calculation: @DisallowComposableCalls () -> T): T =
currentComposer.cache(false, calculation)

机翻:记住由calculation产生的value。calculation只会在组成过程中进行评估。compose 重构时将始终返回由 composition 产生的值。
大致意思就是:该函数可以将块中的值保存起来,当compose视图重构时,可以读取到这个保存的值。

这种用法类似于在 Flutter 中使用StatefulWidget 类来包装控件(或者使用GetX),而在 Kotlin Compose 中,将 FP 风格贯彻的很好,没有那么多类,而是一个个的函数。需要实现这种 Stateful 时也更为简单。

这种写法乍一看来很奇怪,把变量保存在一个函数里,但其实有很大的优势,比如可以 UI 与逻辑混合使用,这一点比起 Flutter 具有巨大的优势。

例如下面这样的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Composable
fun Greeting(name: String) {
val counter = remember {
mutableStateOf(0)
}
Column {
if (counter.value % 2 == 1) {
Text(text = "Hello $name!${counter.value}")
}
Button(
onClick = {
counter.value += 1
},
modifier = Modifier
.height(50.dp)
.width(100.dp)
) {
Text(text = "点一下")
}
}
}

你可以试一下上面的例子,就能看出在 compose 中 UI 配合逻辑是多么简单。

小tips:如果你需要频繁的用到这个state ,翻来覆去的写 state.value 确实让人很烦,这时通过 by 关键字来获取确实很轻松。

在 MutableState 与 MutableStateFlow中如何选择?

上面我们这两个用于保存状态的类型我们都使用了一编,仅他俩作为 State 使用时看起来区别不大,如何选择其实完全看使用场景:
个人认为,在一些简单的场景,我们只是想要这个值用于 UI 的响应式变更刷新,那么直接使用 State 就可以了;如果数据来源是一个流,并且需要做一系列处理(比如 mapflatMap 等),最终结果以 State 形式反应在 UI 上,那就用 Flow 流更方便。