状态
在Jetpack Compose中状态可以是随时间变化的任何值,可以是从数据库中的值到类的变量
Android 应用中的一些状态示例:
- 根据网络情况显示的信息提示控件
- 文章和相关评论
- 点击按钮时播放的涟漪动画
- 倒计时显示控件
主要学习内容
- 什么是单向数据流
- 如何看待界面中的状态和事件
- 如何在 Compose 中使用架构组件的
ViewModel和LiveData管理状态 - Compose 如何使用状态绘制界面
- 何时将状态移至调用方
- 如何在 Compose 中使用内部状态
- 如何使用
State<T>将状态与 Compose 集成
单向数据流
界面更新循环
在Android应用中状态会随着事件进行更新。事件是从应用外部生成的输入,如:用户点击按钮
事件用于通知程序的某些部分有情况发生

-
Event:由用户或程序的其他部分生成 -
Update State:事件处理通常会更改界面所使用的状态 -
Display State:界面会更新以显示新状态
当界面更新显示新状态时会等待直至下一个
Event的输入或直接触发一个新Event,由此进行循环
非结构化状态
在介绍 Compose 之前,我们先来了解一下 Android View 系统中的事件和状态

2834915029)]
要实现这种效果很显然是通过监听TextField的输入事件更新Text即可,当前项目采用ViewBinding,要采用ViewBinding需要在build.gradle文件进行配置
android {
...
buildFeatures {
compose true
viewBinding true
}
...
}
...
代码实现
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_vertical"
tools:context=".HelloActivity"
android:orientation="vertical">
<TextView
android:id="@+id/helloText"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<EditText
android:id="@+id/textInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
class HelloActivity : AppCompatActivity() {
private val binding by lazy {
ActivityHelloBinding.inflate(layoutInflater)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
binding.textInput.doAfterTextChanged { str ->
updateText(str?.toString())
}
}
private fun updateText(str: String) {
binding.helloText.text = "Hello , $str"
}
}
这种事件处理方式可以说是非常常见,是典型的非结构化状态。非结构化状态需要我们手动在所有涉及状态【此处为
textInput中的值】改变的位置去调用Update State操作
对于像这样的小示例来说,没有问题。但是,随着界面的扩大,越来越难管理。
当我们继续添加更多的事件和状态时,可能会出现几个问题:
-
测试 - 由于UI的状态是与
Views代码交织在一起的,因此很难测试此代码 - 部分状态更新 - 当界面中有更多事件时,很容易忘记更新部分状态以响应事件。会导致用户看到不一致或不正确的UI
- 部分界面更新 - 由于状态每次发生变化后,都需要我们手动更新UI,因此有时很容易忘记,更新的界面中可能会显示过时的数据
- 代码复杂性 - 如果以这种模式进行编码,则很难提取某些逻辑。因此,代码往往难以阅读和理解
单向数据流
为了解决这种非结构化状态导致的问题,我们可以引入了 ViewModel 和 LiveData
借助 ViewModel,我们可以从界面提取状态,并定义可供界面调用以更新对应状态的事件。下面我们来看一下使用 ViewModel 编写的同一activity
class HelloActivity : AppCompatActivity() {
private val binding by lazy {
ActivityHelloBinding.inflate(layoutInflater)
}
private val viewModel by viewModels<HelloViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
viewModel.name.observe(this) { name ->
binding.helloText.text = "Hello , $name"
}
binding.textInput.doAfterTextChanged { str->
viewModel.updateName(str.toString())
}
}
}
class HelloViewModel : ViewModel() {
private val _name = MutableLiveData("")
val name: LiveData<String> = _name
fun updateName(str: String) {
_name.value = str
}
}
private val _name = MutableLiveData("")
val name: LiveData<String> = _name
在此示例中,我们将状态从 Activity 移到了 ViewModel。在 ViewModel 中,状态由 LiveData 表示
LiveData 是一种可观察的状态容器,它可让任何人观察状态的变化。然后,我们在界面中使用 observe 方法,以便在状态变化时更新界面
可能对这里会有些疑惑,为什么教程中会这样去写?
通过查看源码可以知道
LiveData对于修改函数 (postValue和setValue) 的访问修饰符为protected,这意味着LiveData对于非子类的修改是关闭的,这样的写法可以防止数据在ViewModel外被修改,只有通过调用ViewModel内提供的函数才能修改public class MutableLiveData<T> extends LiveData<T> { ... @Override public void postValue(T value) { super.postValue(value); } @Override public void setValue(T value) { super.setValue(value); } } public abstract class LiveData<T> { ... protected void postValue(T value) { boolean postTask; synchronized (mDataLock) { postTask = mPendingData == NOT_SET; mPendingData = value; } if (!postTask) { return; } ArchTaskExecutor.getInstance().postToMainThread(mPostValueRunnable); } protected void setValue(T value) { assertMainThread("setValue"); mVersion++; mData = value; dispatchingValue(null); } ... }
ViewModel 是如何与事件和状态配合工作的
-
Event:当TextInput文本输入更改时由UI调用 -
Update State:在事件回调中通过updateName设置状态_name -
Display State:name的观察者被调用,通知UI状态变化

通过以这种方式构建代码,我们可以将Event“向上”流动到 ViewModel。然后,为了响应事件,ViewModel 将进行一些处理并更新状态。状态更新后,会“向下”流动到 Activity
这种模式称为单向数据流。单向数据流是一种状态向下流动而事件向上流动的设计。以这种方式构建代码有以下优点:
- 可测试性 - 通过将状态与显示状态的界面分开,您可以更轻松地测试 ViewModel 和 activity
-
状态封装 - 因为状态只能在一个位置 (
ViewModel) 更新,所以不容易出现部分状态更新错误 - 界面一致性:所有状态更新都通过观察可观察状态立即反映在UI中
单向数据流是一种事件向上流动而状态向下流动的设计
例如,在
ViewModel中,系统会使用方法调用从界面向上传递事件,而使用LiveData向下流动状态
准备工作
在状态的学习中我们将逐步完成一个有状态界面,其中会显示可修改的互动式 TODO 列表

可能是Example版本更新了,和官网教程上对应不上
不过在
Github中可以找到有对应的样例,示例下载当然你也可以跟着我的思路来一步一步实现,代码上会与示例大同小异
首先先引入所需依赖,在Compse项目默认依赖的基础上新增两个依赖
dependencies {
...
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1"
implementation "androidx.compose.material:material-icons-extended:$compose_version"
implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
}
创建数据类 - TodoItem
data class TodoItem(
val task: String,
val icon: TodoIcon = TodoIcon.values().random(),
val id: UUID = UUID.randomUUID()
)
//使用枚举类型限定使用图标类型
enum class TodoIcon(
val imageVector: ImageVector,
//官方示例使用 @StringRes 引用,为了方便这里直接使用String
val contentDescriptor: String
) {
Square(Icons.Default.CropSquare, "Crop"),
Done(Icons.Default.Done, "Done"),
Event(Icons.Default.Event, "Event"),
Privacy(Icons.Default.PrivacyTip, "Privacy"),
Trash(Icons.Default.RestoreFromTrash, "Restore");
companion object {
val Default = Square
}
}
//生成测试用数据
fun generateRandomTodoItemList(size:Int): List<TodoItem> {
return List(size){
generateRandomTodoItem()
}
}
fun generateRandomTodoItem(): TodoItem {
val message = listOf(
"Learn compose",
"Learn state",
"Build dynamic UIs",
"Learn Unidirectional Data Flow",
"Integrate LiveData",
"Integrate ViewModel",
"Remember to savedState!",
"Build stateless composables",
"Use state from stateless composables"
).random()
val icon = TodoIcon.values().random()
return TodoItem(message, icon)
}
然后编写可组合函数
@Composable
fun TodoScreen(
modifier: Modifier = Modifier,
items: List<TodoItem>
) {
Column(modifier = modifier) {
LazyColumn(modifier = Modifier.weight(1f)) {
items(items.size) { index ->
TodoListItem(item = items[index])
}
}
}
}
@Composable
fun TodoListItem(modifier: Modifier = Modifier, item: TodoItem) {
Row(modifier = modifier.padding(4.dp)) {
Text(
text = item.task, modifier = Modifier
.weight(1f)
.align(CenterVertically)
)
Icon(
imageVector = item.icon.imageVector,
contentDescription = item.icon.contentDescriptor
)
}
}

Compose与ViewModel
状态提升
之前我们使用了LiveData与ViewModel在Android View系统中实现单向数据流,那么在Compose中如何使用 ViewModel 在 Compose 中使用单向数据流
完成此部分的学习后,您将构建如下所示的界面:

通过底部的按钮实现添加一个随机列表项,点击列表项则删除改列表项
显然我们需要对点击事件进行处理,所以在TodoScreen中添加两个参数【点击事件回调函数】
@Composable
fun TodoScreen(
modifier: Modifier = Modifier,
items: List<TodoItem>,
onAddItem: (TodoItem) -> Unit,
onRemoveItem: (TodoItem) -> Unit
) {
Column(modifier = modifier) {
LazyColumn(modifier = Modifier.weight(1f)) {
items(items.size) { index ->
TodoListItem(modifier = Modifier.clickable {
onRemoveItem(items[index])
Log.e("TodoScreen", "do onRemoveItem" )
}, item = items[index])
}
}
Button(modifier=Modifier.fillMaxWidth(),onClick = {
onAddItem(generateRandomTodoItem())
Log.e("TodoScreen", "do onAddItem" )
}) {
Text(text = "添加一个随机列表项")
}
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ActivityScreen(generateRandomTodoItemList(4).toMutableList())
}
}
@Composable
fun ActivityScreen(list: MutableList<TodoItem>) {
TodoScreen(items = list, onAddItem = { item ->
list.add(item)
}, onRemoveItem = { item ->
list.remove(item)
})
}
}
但是运行之后会发现列表并不会添加或删除,但是可以看到有打印信息【回调函数被调用】
实际上,可组合项TodoScreen是无状态的。它只会显示传入的事项列表,而且无法直接修改该列表。而是通过传递两个请求更改的事件:onRemoveItem 和 onAddItem对列表进行修改
无状态可组合项是指无法直接更改任何状态的可组合项。无状态可组合项更容易测试、往往没有多少 bug,可重性高
如果可组合项是无状态的,那它如何才能显示可修改的列表?
为实现此目的,它会使用一种称为状态提升的技术。状态提升是一种将状态上移以使组件变为无状态的模式
即将组件的状态管理交给调用者,由调用者决定是否进行状态的修改。使得TodoScreen 与状态的管理方式是完全解耦的
此案例的界面更新循环:
-
Event:当用户请求添加或移除事件时,TodoScreen会调用onAddItem或onRemoveItem -
Update State:TodoScreen的调用方可以通过更新状态来响应这些事件 -
Display State:状态更新后,系统将使用新的items再次调用TodoScreen,而且TodoScreen可以在界面上显示这些新事项
状态提升是一种将状态上移以使组件变为无状态的模式
当应用于可组合项时,这通常意味着向可组合项引入以下两个参数:
value: T- 要显示的当前值onValueChange: (T) -> Unit:请求更改值的事件,其中T是建议的新值
定义ViewModel
class TodoViewModel : ViewModel() {
private val _todoList = MutableLiveData<List<TodoItem>>(listOf())
val todoList = _todoList
fun addItem(item: TodoItem) {
_todoList.value = _todoList.value!! + item
}
fun removeItem(item: TodoItem) {
_todoList.value = _todoList.value!!.toMutableList().also {
it.remove(item)
}
}
}
TodoViewModel中定义一个状态变量和两个事件。我们使用此 ViewModel 来提升 TodoScreen 中的状态,会创建如下所示的单向数据流:

kotlin中
!!操作符可以将可空类型强制转换为不可空类型,当转换对象为null时会报错
MutableLiveData中要通知观察者数据变化需要监听对象发生变化,若是单纯地新增或删除列表中的值,列表本身的值不会变化。所以需要列表对象发生变化才会通知
plus和toMutableList都会新建一个对象public operator fun <T> Collection<T>.plus(element: T): List<T> { val result = ArrayList<T>(size + 1) result.addAll(this) result.add(element) return result } public fun <T> Collection<T>.toMutableList(): MutableList<T> { return ArrayList(this) }
向上流动事件
在ActivityScreen可组合函数中,向ViewModel传递addItem和removeItem
@Composable
fun ActivityScreen(viewModel: TodoViewModel) {
val list:List<TodoItem> by viewModel.todoList.observeAsState(listOf())
TodoScreen(
items = list!!,
onAddItem = { item ->
viewModel.addItem(item)
},
onRemoveItem = viewModel::removeItem
)
}
当TodoScreen调用onAddItem或onRemoveItem时,可以将调用传递到ViewModel对应的事件
onRemoveItem = viewModel::removeItem,方法引用语法
向下传递状态
val list:List<TodoItem> by viewModel.todoList.observeAsState(listOf())
通过这行代码观察LiveData,并能让我们直接将当前值用作List<TodoItem>
-
val items: List<TodoItem>声明了类型为List<TodoItem>的变量items -
todoViewModel.todoList是来自ViewModel的LiveData<List<TodoItem> -
.observeAsState会观察LiveData<T>并将其转换为State<T>对象,让 Compose 可以响应值的变化 -
listOf()是一个初始值,用于避免在初始化LiveData之前可能出现null结果。如果未传递,items会是List<TodoItem>?,可为 null 性。 -
by是 Kotlin 中的属性委托语法,使我们可以自动将State<List<TodoItem>>从observeAsState解封为标准List<TodoItem>
observeAsState可观察LiveData并返回State对象,每当LiveData发生变化时,该对象都会更新其实
observeAsState将观察和创建状态合在一起了而已,效果上与下面的代码一致@Composable fun ActivityScreen(viewModel: TodoViewModel) { var list by remember { //创建State mutableStateOf(viewModel.todoList.value) } viewModel.todoList.observe(this) { //当todoList中值发生变化时,修改State中的值通知Compose进行重组 list = it } TodoScreen( items = list!!, onAddItem = { item -> viewModel.addItem(item) }, onRemoveItem = viewModel::removeItem ) }
Compose中的记忆功能
既然有无状态可组合项,那么自然就有有状态可组合项
有状态可组合项是一种具有可以随时间变化的状态的可组合项
在此部分中,我们将探讨如何向可组合函数添加记忆功能
我们使得TodoListItem中的图标都使用介于 0.3 到 0.9 之间的随机 Alpha 值调节色调
@Composable
fun TodoListItem(modifier: Modifier = Modifier, item: TodoItem) {
Row(modifier = modifier.padding(4.dp)) {
Text(
text = item.task,
modifier = Modifier
.weight(1f)
.align(CenterVertically)
)
val tintColor = item.icon.imageVector.tintColor
Icon(
imageVector = item.icon.imageVector,
contentDescription = item.icon.contentDescriptor,
tint = tintColor.copy(alpha = randomTintAlpha())
)
}
}
fun randomTintAlpha() = Random.nextFloat().coerceIn(.3f, .9f)
然而当我们运行应用时,在添加列表项或删除列表项时会发现列表项中的图标颜色在发生变化
重组
每当列表发生变化时,对应状态变化通知 Compose 开始重组,重组是使用新的输入重新调用可组合项以更新 Compose 树的过程,也就是在重新调用TodoListItem中又一次调用了randomTint导致alpha发生变化
重组过程中会再次运行相同的可组合项
Compose 会生成一个可组合项的树。我们可以直观呈现 TodoScreen,如下所示:

Compose 首次运行组合时,会为每个被调用的可组合项构建一个树。然后,在重组期间,它会使用调用的新可组合项更新树
每次 TodoListItem 重组时,图标都会更新,是因为 TodoListItem 具有一个隐藏的附带效应。附带效应是指在可组合函数运行范围之外发生的任何变化。
附带效应是指在可组合函数范围之外发生的任何变化
调用
Random.nextFloat()会更新伪随机数生成器中使用的内部随机变量。每次您请求随机数时,Random都会以这种方式返回不同的值
remember
我们不希望每次 TodoListItem 重组时色调都发生变化。为此,我们需要有一个变量来记住我们在上一次组合中使用的alpha
Compose 使我们可以将值存储在组合树中,因此我们可以在 TodoListItem中将 iconAlpha 存储在组合树中
remember为可组合函数提供了记忆功能。系统会将由
remember计算的值存储在组合树中,而且只有当remember的键发生变化时才会重新计算该值。您可以将
remember看作是为函数提供单个对象的存储空间,过程与private val属性在对象中执行的操作相同。
@Composable
fun TodoListItem(modifier: Modifier = Modifier, item: TodoItem) {
Row(modifier = modifier.padding(4.dp)) {
...
val tintColor = item.icon.imageVector.tintColor
val tintAlpha = remember(item.id) {
randomTintAlpha()
}
Icon(
imageVector = item.icon.imageVector,
contentDescription = item.icon.contentDescriptor,
tint = tintColor.copy(alpha = tintAlpha)
)
}
}
此时在Compose树中,会将tintAlpha添加进入树中

此时再次运行应用,添加或删除列表项时,图标颜色不会发生变化。在重组时,系统会返回 remember 存储的先前的值
此处 remember 调用包含两部分:
@Composable
inline fun <T> remember(
key1: Any?,
calculation: @DisallowComposableCalls () -> T
): T
-
key 参数:这次 remember 调用使用的“key”,即在圆括号中传递的那部分内容。在此示例中,我们传递
todo.id作为 key -
calculation 参数:一个 lambda,用于计算要记住的新值,传入尾随 lambda。在此示例中,我们使用
randomTint()计算一个随机值
第一次组合时,remember 会调用 randomTint 并记住结果。它还会保存已传递的 todo.id。然后,在重组过程中,除非有新的 todo.id 传递给 TodoRow,否则它会跳过调用 randomTint 并返回记住的值
一旦从树中移除发出调用的可组合项,系统会立即忘记组合中记住的值
幂等性可组合项始终会对相同的输入生成相同的结果,并且不会对重组产生任何附带效应
可组合项的重组必须具有幂等性。使用 remember 将对 randomTint 的调用括起来,便可在重组后跳过对随机值的调用,除非待办事项发生变化。因此,TodoRow 没有任何附带效应,每次重组时都使用相同的输入,始终生成相同的结果,具有幂等性
提高重用性
@Composable
fun TodoListItem(
modifier: Modifier = Modifier,
item: TodoItem,
tintAlpha: Float = remember(item.id,::randomTintAlpha)
) {
Row(modifier = modifier.padding(4.dp)) {
Text(
text = item.task,
modifier = Modifier
.weight(1f)
.align(CenterVertically)
)
val tintColor = item.icon.imageVector.tintColor
Icon(
imageVector = item.icon.imageVector,
contentDescription = item.icon.contentDescriptor,
tint = tintColor.copy(alpha = tintAlpha)
)
}
}
通过参数的方式设置tintAlpha值,可以让调用方控制此值
向可组合项添加记忆功能时,需要考虑:“某些调用方有理由想要控制此值吗?”
如果答案是肯定的,请改为构造参数。
如果答案是否定的,请将其保留为局部变量。
因为一旦从树中移除发出调用的可组合项,系统会立即忘记组合中记住的值,所以您不应依赖
remember将重要内容存储在用于添加和移除子项的可组合项中,比如:LazyColumn当
LazyColumn中列表项足够多到滚动屏幕,当一些列表项离开界面范围后,在滚动会去时图标的Alpha值会发生变化。此时需要使用rememberSaveable保存数据
组件中添加状态
接下来通过可组合函数的记忆功能在可组合函数中添加状态,使其成为有状态组合项
- 待办事项输入(状态:展开)

- 待办事项输入(状态:收起)

根据文本内容进行状态的切换,当文本内容不为空则显示展开状态,反之为收起状态
在界面中修改文本是有状态的。用户每次输入字符时(甚至在更改所选内容时),当前显示的文本都会更新。在 Android View 系统中,此状态是 EditText 的内置状态,并通过 onTextChanged 监听器公开
但由于 Compose 是专为单向数据流设计的,因此它并不适用上述的写法
Compose 中的 TextField 是一个无状态可组合项。与显示不断变化的待办事项列表的 TodoScreen 类似,TextField 仅显示您告知的内容,并且在用户输入内容时发布事件
TextField若没有在输入内容事件中更新状态,那么文本框就不会更新。也就是说没有设置状态TextField中输入文字不会有任何效果
内置的可组合项专为单向数据流而设计
大多数内置可组合项为每个 API 提供至少一个无状态版本。与 View 系统相比,内置可组合项提供的是不包含有状态界面内置状态的选项,例如可修改的文本。这有助于避免应用与组件之间出现重复状态
创建有状态的TextField可组合项
@Composable
fun TodoItemInput(modifier: Modifier = Modifier) {
Column(modifier) {
Row(
modifier = Modifier
.padding(top = 12.dp)
.padding(horizontal = 12.dp)
) {
TodoInputTextField(
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
)
TodoEditButton(modifier = Modifier.align(CenterVertically))
}
}
}
@Composable
fun TodoInputTextField(modifier: Modifier = Modifier) {
val (text, setText) = remember { mutableStateOf("") }
TextField(
value = text,
onValueChange = setText,
maxLines = 1,
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent),
modifier = modifier
)
}
@Composable
fun TodoEditButton(modifier: Modifier = Modifier) {
Button(
modifier = modifier,
onClick = {},
shape = CircleShape
) {
Text(text = "Add")
}
}
此函数使用 remember 向自身添加记忆功能,然后在内存中存储 mutableStateOf,以创建 MutableState<String>,这是一种提供可观察状态容器的内置 Compose 类型
mutableStateOf会创建MutableState<T>,后者是 Compose 中内置的可观察状态容器interface MutableState<T> : State<T> { override var value: T operator fun component1(): T operator fun component2(): (T) -> Unit }对
value做出的任何更改都会自动重组用于读取此状态的所有可组合函数可以通过3种方式声明可组合项中的
MutableState对象://获取到MutableState<T>对象 val state = remember { mutableStateOf(default) }//委托机制,此时value为T对象 var value by remember { mutableStateOf(default) }//解构,可以理解为(getter,setter) val (value, setValue) = remember { mutableStateOf(default) }在组合中创建
State<T>(或其他有状态对象)时,请一定要对其执行remember操作。否则,它会在每次组合时重新初始化
按钮点击事件
我们要将“Add”按钮设置为可实际添加 TodoItem。为此,我们需要从 TodoInputTextField 访问 text
但是我们在TodoInputTextField中存储text状态,TodoEditButton需要访问text的当前值才能进行添加。于是我们要进行状态提升,将状态从子级可组合项 TodoInputTextField 移到父级可组合项 TodoItemInput
单向数据流既适用于高级架构,也适用于使用 Jetpack Compose 的单个可组合项的设计
此时我们的做法相当于让状态从
TodoItemInput向下流动,而让事件向上流动。状态提升是在 Compose 中构建单向数据流设计的主要模式
根据之前对于状态提升的理解,我们可以将可组合项的内置状态 T 重构为 (value: T, onValueChange: (T) -> Unit) 参数
状态提升后代码
@Composable
fun TodoInputTextField(modifier: Modifier = Modifier, text: String, onTextChange: (String) -> Unit) {
TextField(
value = text,
onValueChange = onTextChange,
maxLines = 1,
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent),
modifier = modifier
)
}
@Composable
fun TodoEditButton(modifier: Modifier = Modifier, onClick: () -> Unit, enable: Boolean) {
Button(
modifier = modifier,
onClick = onClick,
enabled = enable,
shape = CircleShape
) {
Text(text = "Add")
}
}
@Composable
fun TodoItemInput(modifier: Modifier = Modifier, onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
Column(modifier) {
Row(
modifier = Modifier
.padding(top = 12.dp)
.padding(horizontal = 12.dp)
) {
TodoInputTextField(
modifier = Modifier
.weight(1f)
.padding(end = 6.dp),
text = text,
onTextChange = setText
)
TodoEditButton(
modifier = Modifier.align(CenterVertically),
//回调事件
onClick = {
onItemComplete(TodoItem(text))
setText("")
},
//仅当文本不为空时才启用 (enable) 按钮
enable = text.isNotBlank()
)
}
}
}
以这种方式提升的状态具有以下几个重要属性:
- 单一可信来源 - 通过移动状态而不是复制状态,确保文本只有一个可信来源。这有助于避免 bug。
-
封装 - 只有
TodoItemInput能够修改状态,而其他组件可以向TodoItemInput发送事件。以这种方式提升时,只有一个可组合项是有状态的,即使有多个可组合项使用状态也是如此 -
可共享 - 提升的状态可以作为不可变值与多个可组合项共享。我们可以同时在
TodoInputTextField和TodoEditButton中使用此状态 -
可拦截 -
TodoItemInput可以在更改状态之前决定是忽略还是修改事件。例如,TodoItemInput可以在用户输入内容时将 :emoji-codes: 格式转换为表情符号 -
解耦 -
TodoInputTextField的状态可存储在任何位置。例如,我们可以选择通过 Room 数据库支持此状态,每当输入字符时,该数据库都会更新,而不必修改TodoInputTextField中的代码
基于状态的动态界面
接下来,我们将探讨如何基于状态构建动态界面
要实现内容如下:
待办事项输入(状态:已展开 - 非空白文本)

待办事项输入(状态:已收起 - 空白文本)

首先先构建显示的控件
@Composable
fun IconRow(
modifier: Modifier = Modifier,
selectIcon: TodoIcon,
setIconChange: (TodoIcon) -> Unit
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceAround
) {
TodoIcon.values().forEach { todoIcon ->
SelectableIconButton(
icon = todoIcon.imageVector,
contentDescription = todoIcon.contentDescriptor,
isSelected = selectIcon == todoIcon,
onSelected = { setIconChange(todoIcon) }
)
}
}
}
@Composable
fun SelectableIconButton(
modifier: Modifier = Modifier,
icon: ImageVector,
contentDescription: String,
isSelected: Boolean,
onSelected:()->Unit
) {
val tintColor = if (isSelected) {
MaterialTheme.colors.primary
} else {
MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
}
Column(modifier = modifier) {
TextButton(onClick = onSelected, shape = CircleShape) {
Icon(imageVector = icon, contentDescription = contentDescription, tint = tintColor)
}
Divider(
modifier = Modifier
.width(icon.defaultWidth)
.align(CenterHorizontally)
.padding(top = 3.dp)
.height(1.dp),
color = if(isSelected)tintColor else Color.Transparent
)
}
}
从状态中派生iconsVisible
对于该控件我们需要在TodoItemInput新添加状态
fun TodoItemInput(modifier: Modifier = Modifier, onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default) }
val iconsVisible = text.isNotBlank()
...
}
很显然IconRow中需要一个状态对控件进行更新操作,但是在添加操作时也需要获取selectIcon的状态,所以需要进行状态提升。在TodoItemInput中添加了第二个状态 icon,用于存放当前选定的图标
值 iconsVisible 不会向 TodoItemInput 添加新状态。TodoItemInput 无法直接对其进行更改。相反,它完全基于 text 的值
我们已经知道了重组会重新调用可组合函数,所以
iconsVisible会基于text的值进行赋值当然也可以通过添加另一种状态,以控制图标何时可见,但仔细查看,便会发现可见性完全基于输入的文本,在构建一个状态就有些多余了
根据 iconsVisible 的值显示 IconRow。如果 iconsVisible 的值为 true,则显示 IconRow;如果为 false,则显示一个 16.dp 的分隔符
@Composable
fun TodoItemInput(modifier: Modifier = Modifier, onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default) }
val iconsVisible = text.isNotBlank()
Column(modifier) {
Row(
...
) {
TodoInputTextField(
...
)
TodoEditButton(
onClick = {
//TodoItem的构造函数中添加icon
onItemComplete(TodoItem(text,icon))
setText("")
},
...
)
}
if (iconsVisible) {
IconRow(selectIcon = icon,
setIconChange = {
setIcon(it)
})
} else {
Spacer(modifier = Modifier.height(4.dp))
}
}
}
我们基于 iconsVisible 的值动态更改组合树,这种条件式显示逻辑等同于 Android View 系统中的可见性

Compose 中没有“visibility”属性
由于 Compose 可以动态更改组合,因此您无需设置可见性,只需从组合中移除可组合项即可
官网案例中使用带有动画效果的
AnimatedIconRow,动画会在之后的笔记中记录,所以此处不进行解析,如果想要使用AnimatedIconRow其代码如下:@OptIn(ExperimentalAnimationApi::class) @Composable fun AnimatedIconRow( icon: TodoIcon, onIconChange: (TodoIcon) -> Unit, modifier: Modifier = Modifier, visible: Boolean = true, ) { val enter = remember { fadeIn(animationSpec = TweenSpec(300, easing = FastOutLinearInEasing)) } val exit = remember { fadeOut(animationSpec = TweenSpec(100, easing = FastOutSlowInEasing)) } Box(modifier.defaultMinSize(minHeight = 16.dp)) { AnimatedVisibility( visible = visible, enter = enter, exit = exit, ) { IconRow(selectIcon = icon, setIconChange = onIconChange) } } }
虚拟键盘确认 - imeAction
在进行输入时,Android会弹出虚拟键盘然用户进行输入,按照用户操作惯性来说:应用应该能通过键盘的 IME 操作提交添加事件,就是点击右下角的确认/换行按钮

Compose 中可以通过TextField的keyboardActions 中响应对应的键盘事件,所以我们需要为TodoInputTextField添加新参数onImeAction: () -> Unit响应键盘事件
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun TodoInputTextField(
modifier: Modifier = Modifier,
text: String,
onTextChange: (String) -> Unit,
onImeAction: () -> Unit = {}
) {
//获取当前KeyboardController
val keyboardController = LocalSoftwareKeyboardController.current
TextField(
value = text,
onValueChange = onTextChange,
maxLines = 1,
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent),
modifier = modifier,
//指定响应的事件类型
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
//事件触发执行的操作
keyboardActions = KeyboardActions(onDone = {
onImeAction()
//隐藏键盘
keyboardController?.hide()
}),
)
}
如果要使用键盘处理操作,可以使用
TextField提供的以下两个参数:
keyboardOptions- 用于设置支持监听的IME 操作keyboardActions- 用于指定在响应特定 IME 操作时触发的操作 - 在本例中,当用户按下“Done”后,我们希望调用submit并隐藏键盘为了控制软件键盘,我们需要使用
LocalSoftwareKeyboardController.current。由于这是一个实验性 API,因此必须使用@OptIn(ExperimentalComposeUiApi::class)为该函数添加注解
onImeAction 的行为与 TodoEditButton 完全相同。我们可以复制此代码,但这样很难对其进行长期维护,因为代码是复制的,若出现bug需要修改多个地方很容易遗漏
所以我们将事件提取到变量中,以便同时将其用于 TodoInputText 的 onImeAction 和 TodoEditButton 的 onClick
TodoItemInput代码
@Composable
fun TodoItemInput(modifier: Modifier = Modifier, onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default) }
val iconsVisible = text.isNotBlank()
//提取事件
val submit = {
onItemComplete(TodoItem(text, icon))
setIcon(TodoIcon.Default)
setText("")
}
Column(modifier) {
Row(
...
) {
TodoInputTextField(
modifier = Modifier
.weight(1f)
.padding(end = 6.dp),
text = text,
onTextChange = setText,
onImeAction = submit
)
TodoEditButton(
modifier = Modifier.align(CenterVertically),
onClick = submit,
enable = text.isNotBlank()
)
}
...
}
}
提取无状态可组合项
修改模式
接下来我们将构建如下所示的界面:

点击列表项,列表项会展开,其中会重复使用与TodoItemInput的部分界面,不过将按钮更改为"保存"符号和"删除"符号
虽然部分界面相同,但是,其中的状态是完全不相同
TodoItemInput中状态为添加的TodoItem的值列表项中的状态为对应的
TodoItem的值
所以我们需要从 TodoItemInput 提升状态。我们可以改为将可组合项拆分为两个,一个是有状态的,另一个是无状态的
我们将 TodoItemInput 拆分为两个可组合项,然后将有状态可组合项重命名为 TodoItemEntryInput,因为它只用于添加新输入的 TodoItems
从有状态可组合项中提取无状态可组合项更便于在不同位置重复使用界面
可以在 Android Studio 中使用 Refactor->Function (Extract Method) 命令执行此重构,而无需输入任何代码
- 选择
TodoItemInput的界面部分(Column及其子项)

- 选择“Refactor”->“Function”(快捷键:
Cmd/Ctl+Alt+M)

- 该新函数命名为
TodoItemInput

- 将参数
setText和setIcon分别重命名为onTextChange和onIconChange

- 点击ok
- 在新的函数调用上按
Alt+Enter,然后选择 Add names to call arguments - 重命名有状态函数为
TodoItemEntryInput
- 在新的函数调用上按
@Composable
fun TodoItemEntryInput(modifier: Modifier = Modifier, onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default) }
val iconsVisible = text.isNotBlank()
val submit = {
onItemComplete(TodoItem(text, icon))
setIcon(TodoIcon.Default)
setText("")
}
TodoItemInput(
modifier = modifier,
text = text,
onTextChange = setText,
submit = submit,
iconsVisible = iconsVisible,
icon = icon,
onIconChange = setIcon
)
}
@Composable
fun TodoItemInput(
modifier: Modifier,
text: String,
onTextChange: (String) -> Unit,
submit: () -> Unit,
iconsVisible: Boolean,
icon: TodoIcon,
onIconChange: (TodoIcon) -> Unit
) {
Column(modifier) {
Row(
modifier = Modifier
.padding(top = 12.dp)
.padding(horizontal = 12.dp)
) {
TodoInputTextField(
modifier = Modifier
.weight(1f)
.padding(end = 6.dp),
text = text,
onTextChange = onTextChange,
onImeAction = submit
)
TodoEditButton(
modifier = Modifier.align(CenterVertically),
onClick = submit,
enable = text.isNotBlank()
)
}
if (iconsVisible) {
IconRow(selectIcon = icon,
setIconChange = {
onIconChange(it)
})
} else {
Spacer(modifier = Modifier.height(4.dp))
}
}
}
我们使用了有状态可组合项 TodoItemInput,并将其拆分为两个可组合项:一个有状态 (TodoItemEntryInput),另一个无状态 (TodoItemInput)
无状态可组合项包含所有与界面相关的代码,有状态可组合项则不包含任何与界面相关的代码。这样一来,在需要以不同的状态更新界面时,我们就可以重复使用界面代码了
在ViewModel中使用状态
现在,我们需要确定在哪里添加该编辑器的状态。我们可以构建另一个有状态可组合项“TodoRowOrInlineEditor”,用于处理一个列表项的显示或修改操作,但一次只能显示一个编辑器

由于 TodoItemEntryInput 和 TodoInlineEditor 都需要了解当前的编辑器状态,TodoItemEntryInput需要决定是否隐藏,TodoInlineEditor 需要知道哪一个列表项在被编辑
我们需要将状态至少提升到 TodoScreen。界面是层次结构中最低级别的可组合项,也是需要知道修改操作的每个可组合项的通用父级
不过,由于TodoLInlineEditor派生自列表并将对其进行转变,因此它应实际位于列表的旁边。我们希望将状态提升到可以修改的级别,即 TodoViewModel 中
提升状态规则:
- 状态应至少提升到使用(或读取)该状态的所有可组合项的最低共同父项
- 状态应至少提升到它可以被更改(修改)的最高级别
- 如果两种状态发生变化以响应相同的事件,它们应一起提升
您可以将状态提升到高于这些规则要求的级别,但如果状态提升级别过低,会使得单向数据流变得困难,甚至于不可能完成
使用mutableStateListOf
mutableStateListOf 让我们可以创建可观察的 MutableList 实例。我们可以像使用 MutableList 一样使用 todoItems,这样可以消除使用 LiveData<List> 所产生的开销
class TodoViewModel : ViewModel() {
var todoList = mutableStateListOf<TodoItem>()
private set
fun addItem(item: TodoItem) {
todoList.add(item)
}
fun removeItem(item: TodoItem) {
todoList.remove(item)
}
}
todoItems 的声明较短,而且捕获的行为与 LiveData 版本时效果一致
通过指定 private set,可将对此状态对象的写入操作限制在 ViewModel 内提供的函数中
使用
mutableStateListOf和MutableState完成的工作适用于 Compose如果 View 系统也使用该
ViewModel,那么最好继续使用LiveData
创建MutableList的ViewModel的使用
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val list = viewModel.todoList
TodoScreen(
items = list,
onAddItem = { item ->
viewModel.addItem(item)
},
onRemoveItem = viewModel::removeItem
)
}
}
定义编辑器状态
我们在TodoViewModel中记录下当前编辑的列表项的索引
class TodoViewModel : ViewModel() {
var todoList = mutableStateListOf<TodoItem>()
private set
//当前正在修改的列表索引
private var currentEditPosition by mutableStateOf(-1)
//正在修改的列表
val currentEditItem: TodoItem?
get() = todoList.getOrNull(currentEditPosition)
fun addItem(item: TodoItem) {
todoList.add(item)
}
fun removeItem(item: TodoItem) {
todoList.remove(item)
}
...
}
每当可组合项调用 currentEditItem 时,它都会观察 todoItems 和 currentEditPosition 的变化。如果其中任何一项发生变化,该可组合项将再次调用 getter 来获取新值
Compose 将观察可组合项读取的任何
State<T>,即使读取发生在由可组合项调用的标准 Kotlin 函数中,也是如此。现在,我们将从currentEditPosition和todoItems读取,以生成currentEditItem。Compose会读取currentEditItem只要出现任何变化就会发生重组
为了使
State<T>转换发挥作用,必须从State<T>对象中读取状态如果您已将
currentEditPosition为-1,Compose 将无法观察到对currentEditItem所做的更改
定义编辑器事件
我们定义了编辑器状态,现在需要定义可组合项能够调用来控制修改的事件
onEditItemSelected 和 onEditDone 事件仅会更改 currentEditPosition。更改 currentEditPosition 后,Compose 将重组所有读取 currentEditItem 的可组合项
事件 onEditItemChange 会在 currentEditPosition 更新列表。这会同时更改 currentEditItem 和 todoItems 返回的值。在执行此操作之前,需要执行一些安全检查,以确保调用方没有尝试写入错误的事项。
class TodoViewModel : ViewModel() {
fun removeItem(item: TodoItem) {
todoList.remove(item)
onEditDone()
}
...
fun onEditItemSelected(item: TodoItem) {
currentEditPosition = todoList.indexOf(item)
}
fun onEditItemChange(item: TodoItem) {
require(currentEditItem?.id == item.id) {
"只能修改同一个id的item的值"
}
todoList[currentEditPosition] = item
}
fun onEditDone() {
currentEditPosition = -1
}
}
编写TodoInlineEditor
所有的状态和事件都准备的差不多,我们开始准备写TodoInlineEditor可组合项
@Composable
fun TodoInlineEditor(
item: TodoItem,
onEditItemChanged: (TodoItem) -> Unit,
onRemoveItem: (TodoItem) -> Unit,
onEditDone: () -> Unit,
) {
TodoItemInput(
text = item.task,
onTextChange = {
onEditItemChanged(item.copy(task = it))
},
submit = onEditDone,
iconsVisible = true,
icon = item.icon,
onIconChange = {
onEditItemChanged(item.copy(icon = it))
}
)
}
在TodoScreen中使用
@Composable
fun TodoScreen(
modifier: Modifier = Modifier,
items: List<TodoItem>,
onAddItem: (TodoItem) -> Unit,
onRemoveItem: (TodoItem) -> Unit,
currentItem: TodoItem?,
onEditItemChanged: (TodoItem) -> Unit,
onEditItemSelected: (TodoItem) -> Unit,
onEditDone: () -> Unit
) {
Column(modifier = modifier) {
//修改时替换顶部输入框
if(currentItem==null){
TodoItemEntryInput(onItemComplete = onAddItem)
}else{
Text(
text = "Editing",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.h5,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.05f))
.padding(6.dp)
)
}
LazyColumn(modifier = Modifier.weight(1f)) {
items(items.size) { index ->
val item = items[index]
//根据id确认是否被选中
if (item.id == currentItem?.id) {
TodoInlineEditor(
item = currentItem,
onEditItemChanged = onEditItemChanged,
onRemoveItem = onRemoveItem,
onEditDone = onEditDone
)
} else {
TodoListItem(modifier = Modifier.clickable {
onEditItemSelected(item)
}, item = item)
}
}
}
}
}
@Composable
fun ActivityScreen(viewModel: TodoViewModel) {
val list = viewModel.todoList
TodoScreen(
items = list,
onAddItem = { item ->
viewModel.addItem(item)
},
onRemoveItem = viewModel::removeItem,
currentItem = viewModel.currentEditItem,
onEditItemChanged = viewModel::onEditItemChange,
onEditItemSelected = viewModel::onEditItemSelected,
onEditDone = viewModel::onEditDone
)
}
好,现在我们之差最后在列表项中将添加按钮替换为"保存"和"删除"就完成了
官网案例中使用了动画效果使得替换更为平滑,其代码如下
@Composable fun TodoItemInputBackground( elevate: Boolean, modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit ) { val animatedElevation by animateDpAsState(if (elevate) 1.dp else 0.dp, TweenSpec(500)) Surface( color = MaterialTheme.colors.onSurface.copy(alpha = 0.05f), elevation = animatedElevation, shape = RectangleShape, ) { Row( modifier = modifier.animateContentSize(animationSpec = TweenSpec(300)), content = content ) } } @Composable fun TodoScreen( items: List<TodoItem>, currentlyEditing: TodoItem?, onAddItem: (TodoItem) -> Unit, onRemoveItem: (TodoItem) -> Unit, onStartEdit: (TodoItem) -> Unit, onEditItemChange: (TodoItem) -> Unit, onEditDone: () -> Unit ) { Column { val enableTopSection = currentlyEditing == null TodoItemInputBackground(elevate = enableTopSection) { if (enableTopSection) { TodoItemEntryInput(onAddItem) } else { Text( "Editing item", style = MaterialTheme.typography.h6, textAlign = TextAlign.Center, modifier = Modifier .align(Alignment.CenterVertically) .padding(16.dp) .fillMaxWidth() ) } } // .. } }
使用插槽传递界面

TodoItemEntryInput与TodoInlineEditor只有在按钮部分有差异,我们可以使用传递参数的方式来配置TodoItemInput的按钮部分
用于传递预配置部分的模式是插槽。插槽是可组合项的参数,可让调用方描述界面的某个部分
可以在内置的可组合 API 中找到插槽的示例。最常用的一个示例为
Scaffold
Scaffold是 Material Design 中用于描述整个界面(例如topBar、bottomBar和界面正文)的可组合项
Scaffold公开了可以填充您想要的任何可组合项的插槽,而不是提供数百个参数来配置界面的每个部分。这不仅减少了Scaffold的参数数量,而且提高了可重用性。如果您想构建自定义topBar,可以使用Scaffold来显示
插槽是可组合函数的参数,可让调用方描述界面的某个部分
请使用
@Composable () -> Unit类型的参数声明插槽
在无状态 TodoItemInput 上定义一个名为 buttonSlot 的新 @Composable RowScope.() -> Unit 参数,同时将对 TodoEditButton 的调用替换为插槽的内容
@Composable
fun TodoItemInput(
modifier: Modifier = Modifier,
text: String,
onTextChange: (String) -> Unit,
submit: () -> Unit,
iconsVisible: Boolean,
icon: TodoIcon,
onIconChange: (TodoIcon) -> Unit,
//插槽
buttonSlot:@Composable RowScope.()->Unit
) {
Column(modifier) {
Row(
modifier = Modifier
.padding(top = 12.dp)
.padding(horizontal = 12.dp)
) {
TodoInputTextField(
modifier = Modifier
.weight(1f)
.padding(end = 6.dp),
text = text,
onTextChange = onTextChange,
onImeAction = submit
)
//替换
buttonSlot()
}
if (iconsVisible) {
IconRow(selectIcon = icon,
setIconChange = {
onIconChange(it)
})
} else {
Spacer(modifier = Modifier.height(4.dp))
}
}
}
通过将插槽函数指定为
@Composable RowScope.() -> Unit类,可以让调用方自己决定在布局的位置
TodoItemEntryInput插槽
更新 TodoItemEntryInput:
@Composable
fun TodoItemEntryInput(modifier: Modifier = Modifier, onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default) }
val iconsVisible = text.isNotBlank()
val submit = {
onItemComplete(TodoItem(text, icon))
setIcon(TodoIcon.Default)
setText("")
}
TodoItemInput(
modifier = modifier,
text = text,
onTextChange = setText,
submit = submit,
iconsVisible = iconsVisible,
icon = icon,
onIconChange = setIcon,
//插槽
buttonSlot = {
TodoEditButton(
modifier = Modifier.align(CenterVertically),
onClick = submit,
enable = text.isNotBlank()
)
}
)
}
TodoInlineEditor插槽
更新 TodoInlineEditor:
@Composable
fun TodoInlineEditor(
item: TodoItem,
onEditItemChanged: (TodoItem) -> Unit,
onRemoveItem: (TodoItem) -> Unit,
onEditDone: () -> Unit,
) {
TodoItemInput(
text = item.task,
onTextChange = {
onEditItemChanged(item.copy(task = it))
},
submit = onEditDone,
iconsVisible = true,
icon = item.icon,
onIconChange = {
onEditItemChanged(item.copy(icon = it))
},
//插槽
buttonSlot = {
val shrinkModifier = Modifier.widthIn(20.dp)
val textWidthModifier = Modifier.width(30.dp)
Row (modifier = Modifier.align(CenterVertically)){
//保存按钮
TextButton(
onClick = onEditDone,
modifier = shrinkModifier
) {
Text(
text = "\uD83D\uDCBE",
textAlign = TextAlign.Center,
modifier = textWidthModifier
)
}
Spacer(modifier = Modifier.width(4.dp))
//删除按钮
TextButton(
onClick = { onRemoveItem(item) },
modifier = shrinkModifier
) {
Text(text = "❌", textAlign = TextAlign.Center, modifier = textWidthModifier)
}
}
}
)
}
在向无状态可组合项添加参数以自定义子项时,请评估插槽是否是更出色的设计。插槽会让可组合项更具可重用性,同时保持参数数量可控