引言
Jetpack Compose 为 Android 开发者带来了全新的声明式 UI 框架,但要写出优秀的 Compose 代码,却并没有想象中那么简单。如果缺乏对核心原则的理解,代码很容易变得难以维护,就像试图解开一团乱麻,费时又费力。这篇文章想和你聊聊 Compose 开发中两个至关重要的原则——“状态提升”和“单向数据流”。希望通过这些分享,能帮你把代码理清思路,写得更流畅、更优雅。
此文章可参考:
1.状态提升(State Hoisting)
先来聊聊“状态提升”,这听起来像是一种神秘的操作,但其实它并不复杂。想象你和一群朋友一起玩桌游,每个人都有一些信息需要共享,但如果有人偏偏把信息“藏”起来,那这游戏就玩不下去了,对吧?状态提升就像把那个藏私的朋友拽出来,让他共享信息,所有人才能顺利继续游戏。
在软件开发中,状态(State) 指的是程序在运行时存储的数据,它可以随着时间或用户交互而变化。这个概念在面向对象编程、函数式编程甚至各种 UI 框架中都广泛存在。在 Jetpack Compose 中,状态是驱动 UI 的关键所在,决定了界面内容如何展示。
什么是状态?有状态与无状态
在 Jetpack Compose 中,状态是通过 State
对象(如 MutableState
)管理的动态数据。当状态发生变化时,Compose 会触发重新组合(Recomposition),更新 UI。
根据是否直接管理状态,组件可以分为两类:
- 有状态组件(Stateful Composables)
-
- 定义:组件内部定义和管理自己的状态。
- 示例:
@Composable
fun StatefulCounter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
问题:当多个组件需要共享 count
时,这种管理方式会导致状态混乱且难以维护。
- 无状态组件(Stateless Composables)
- 定义:组件不直接管理状态,而是通过参数接收状态值和操作状态的回调函数。
- 示例:
@Composable
fun StatelessCounter(count: Int, onIncrement: () -> Unit) {
Button(onClick = onIncrement) {
Text("Count: $count")
}
}
通过这个区分,可以看出,无状态组件更具灵活性,因为它不依赖内部状态,可以根据不同的父组件提供的参数动态调整行为。
在 Jetpack Compose 中,有状态(stateful) 和 无状态(stateless) 的区别可以类比为是否持有并管理自己的状态。让我们用一个普通的函数示例来理解:
// stateful() 函数是有状态的
fun stateful() {
val name = "name" // name 是函数内部的状态
// 使用 name 更新 UI
nameView.text = name
// 执行一些操作
name.toUpperCase()
}
在这里,stateful
函数自己定义并管理了 name
状态。这样设计虽然简单,但当需要从外部修改 name
或共享其状态时,会变得难以扩展。
相对的,无状态函数则完全依赖外部传递的参数:
// stateless() 函数是无状态的
fun stateless(name: String) {
// 使用传入的 name 更新 UI
nameView.text = name
// 执行一些操作
name.toUpperCase()
}
在 stateless
函数中,name
是通过参数传递进来的,而函数自身并不管理任何状态。这样,stateless
函数变得更加灵活——它的行为完全取决于调用方传入的参数。
什么是状态提升?
状态提升,就是将有状态组件的状态移出其内部,由父组件集中管理。这相当于把 stateful()
写成 stateless()
,让状态变得透明并且易于共享。
为什么要状态提升?
- 共享状态更方便
状态提升后,多个组件可以轻松共享状态,而不需要为同步状态额外编写复杂逻辑。 - 组件更灵活
子组件变成无状态组件后,其行为完全由父组件决定,适应性更强。 - 简化调试和测试
状态逻辑被集中管理后,状态变化的追踪变得更加容易,无状态组件也更容易单独测试。
常见的错误示例
误区 1:子组件过多持有状态
@Composable
fun StatefulCounter() {
// 子组件内部管理状态
var count by remember { mutableStateOf(0) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "Count: $count")
Button(onClick = { count++ }) {
Text("Increment")
}
}
}
这种写法的问题在于:
count
被锁定在StatefulCounter
内部,其他组件无法访问或修改。- 如果需要添加一个“重置”按钮,就需要额外逻辑去同步状态。
误区 2:过度提升状态
状态提升是一把双刃剑。如果提升的层级过高(例如提升到根组件),会让父组件变得臃肿且难以维护。
状态提升的正确实践
@Composable
fun ParentWithReset() {
var count by remember { mutableStateOf(0) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
StatelessCounter(
count = count,
onIncrement = { count++ }
)
Button(onClick = { count = 0 }) {
Text("Reset")
}
}
}
通过将状态集中管理,父组件可以轻松扩展功能(如添加“重置”按钮),子组件依旧保持无状态,从而提高了代码的灵活性和可维护性。
2.单一信息源(Single Source of Truth)
在日常生活中,我们经常依赖一个权威信息来源。例如,如果想知道天气状况,我们通常只查看一个可靠的天气预报应用,而不会翻来覆去地对比多个来源。同样,在软件开发中,单一信息源(Single Source of Truth, SSOT) 是指状态或数据只存在于一个可信的地方,而不是分散在多个地方。
为什么需要单一信息源?
想象你和朋友一起玩卡牌游戏,每个人手上都有一套自己的得分记录。然而,如果有人记录错了,或者每个人的记录不一致,最后谁赢了就没人说得清了。为了解决这个问题,最简单的办法就是:所有人的得分都由一个人来记录,这个人的记录就是“单一信息源”。其他人只需要参考这个统一的记录,就不会出现混乱。
在编程中,单一信息源的作用也类似:我们希望所有的状态和数据只有一个可信的来源,这样可以:
- 避免数据冲突:确保所有地方读取到的状态是一致的。
- 简化调试:如果状态出现问题,你只需要检查这一个来源。
- 提高可维护性:减少重复代码,让逻辑更加清晰。
单一信息源在 Compose 中的意义
在 Jetpack Compose 中,单一信息源的核心理念是:
UI 应完全由一个统一的状态驱动,而这个状态应该存放在一个明确的地方(通常是 ViewModel 或 remember
状态对象中)。
例如,一个按钮的点击次数应该只存储在一个地方,而不是分别在按钮和显示组件中重复存储。
一个错误的例子:没有单一信息源
让我们看一个没有遵循单一信息源原则的示例:
@Composable
fun BrokenCounter() {
var count1 by remember { mutableStateOf(0) }
var count2 by remember { mutableStateOf(0) }
Column {
Button(onClick = { count1++ }) {
Text("Count1: $count1")
}
Button(onClick = { count2++ }) {
Text("Count2: $count2")
}
}
}
问题分析:
count1
和count2
分别存储在两个变量中,实际上它们表示的是同一个逻辑状态。- 如果你想增加某个按钮的点击次数,两者需要同步更新,这就会引入复杂且容易出错的逻辑。
一个改进的例子:使用单一信息源
我们可以将状态集中到一个地方,例如父组件中:
@Composable
fun CorrectCounter() {
var count by remember { mutableStateOf(0) }
Column {
Button(onClick = { count++ }) {
Text("Count: $count")
}
Button(onClick = { count = 0 }) {
Text("Reset Count")
}
}
}
为什么这个版本更好?
- 所有组件都从同一个状态变量
count
中读取。 - 状态的更新(如增加或重置)都操作同一个变量,不会出现数据不一致。
单一信息源的好处
- 数据一致性:
状态存储在一个地方,UI 和逻辑依赖同一个数据来源,避免了因重复存储导致的数据冲突。 - 更易调试:
如果状态有问题,只需要检查单一信息源,而不用逐一排查各个组件。 - 降低复杂性:
数据集中管理,避免在多个地方维护相同的状态逻辑。 - 增强可扩展性:
当需求增加时,你只需在单一信息源中修改或扩展逻辑,而不需要更新多个地方的状态代码。
3.单向数据流(Unidirectional Data Flow)
现在聊聊单向数据流。这一原则听起来像是个交通标志:数据只能沿一个方向流动。单向数据流的目的是让你的代码逻辑清晰,确保数据流动不混乱。想象一个城市的交通如果没有交通信号灯,结果会如何?一场无法收拾的混乱!
单向数据流(UDF) 是一种数据流动模式,强调 “数据向下流动,事件向上传递” 。在 Compose 中,这意味着状态由父组件管理并传递给子组件,而子组件通过回调通知父组件更新状态。
单向数据流的错误示例
为了更好地理解单向数据流的优点,我们先看看一个常见的错误示例。这种错误的方式经常被初学者误用,导致代码难以维护和调试。
在错误的例子中,状态在多个组件之间互相依赖,甚至直接被子组件修改。这种模式会破坏单向数据流的原则,增加代码的复杂性。
错误示例:状态由子组件直接管理
@Composable
fun ParentComponent() {
Column {
ChildComponent()
}
}
@Composable
fun ChildComponent() {
var text by remember { mutableStateOf("Initial Text") }
Column(
modifier = Modifier.padding(8.dp)
) {
TextField(
value = text,
onValueChange = {
text = it
},
label = { Text("Enter text") }
)
Button(onClick = { /* 使用 text 做一些逻辑操作 */ }) {
Text("Submit")
}
}
}
在上面的代码中,ChildComponent
直接管理自己的状态 text
。这看起来似乎没什么问题,但会在实际项目中引发多个问题:
- 状态不一致:当多个组件都试图管理相同的数据时,状态会变得难以追踪,难以确定哪个组件持有最终的正确状态。
- 难以维护:当应用规模变大,状态分散在多个子组件中时,理解和维护逻辑会变得非常困难。
- 可重用性降低:由于
ChildComponent
管理自己的状态,它就变得不那么容易被复用,除非所有场景都需要它管理相同的状态逻辑。
正确实现:状态提升的单向数据流
在 Compose 中,要遵循单向数据流的原则,我们应当将状态提升到公共的父组件中。这样,父组件负责管理状态,而子组件只是通过参数展示状态或触发事件。
修正后的示例:状态由父组件管理
@Composable
fun ParentComponent() {
var text by remember { mutableStateOf("Initial Text") }
Column {
ChildComponent(text = text, onTextChange = { newText -> text = newText })
Button(onClick = { /* 使用 text 做一些逻辑操作 */ }) {
Text("Submit")
}
}
}
@Composable
fun ChildComponent(text: String, onTextChange: (String) -> Unit) {
Column(
modifier = Modifier.padding(8.dp)
) {
TextField(
value = text,
onValueChange = onTextChange,
label = { Text("Enter text") }
)
}
}
在这个示例中,状态 text
被提升到 ParentComponent
,并通过参数 text
和回调 onTextChange
传递给 ChildComponent
。这种做法有以下几个好处:
- 单一来源的真相(Single Source of Truth) :所有状态由
ParentComponent
管理,确保数据的一致性。 - 逻辑清晰:数据流动方向明确,父组件 -> 子组件,事件传递方向为子组件 -> 父组件。
- 组件复用性高:
ChildComponent
不再持有状态,它只负责 UI 展示,因此更容易在其他上下文中复用。
单向数据流的好处
- 逻辑清晰:状态只从一个地方流向 UI,事件只回到状态来源,这样的数据流向使得应用的逻辑非常直观。
- 易于维护:所有状态更新都集中管理,减少了因状态散落在多个地方而带来的调试难度。
- 组件的复用性更高:子组件无需管理状态,只负责展示数据,因而可以在多个场景中重复使用,而不需要关心不同的状态逻辑。
更深入的比较与总结
- 在错误的示例中,子组件自持状态,这意味着状态逻辑在多个地方散落,这种分散式的状态管理会导致状态管理混乱、调试困难,特别是在复杂场景中,可能导致状态冲突或难以预测的行为。
- 而在正确的单向数据流实现中,所有状态均集中在父组件管理,这样可以将业务逻辑集中管理,便于在状态变更时找到唯一的来源,也利于测试和调试。
什么时候不适合使用状态提升?
尽管状态提升是一种良好的实践,但在某些情况下,它可能不是最优解。例如,当状态只与特定子组件有关且不会被其他组件使用时,可以考虑将状态保留在子组件中。
例外情况的示例
假如你有一个非常独立的 UI 元件,例如一个计时器,且其状态不会与其他组件共享。在这种情况下,状态可以保留在子组件内部,因为它不会影响到整个应用的其他部分。
@Composable
fun TimerComponent() {
var seconds by remember { mutableStateOf(0) }
// Timer logic here
Text("Time: $seconds seconds")
}
在此情况下,状态提升会增加不必要的复杂性,保持状态在组件内部反而更加简洁易懂。
4.结合使用
在真实项目中,使用 Jetpack Compose 与 Mavericks 框架结合,可以很好地实现单向数据流和状态提升的模式。以下是一个示例,展示如何将网络请求数据与 Compose UI 结合起来,以实践单向数据流和状态提升的概念。
案例背景
我们将实现一个展示用户列表的简单应用,应用通过网络请求获取用户信息,然后在 Compose UI 中显示这些数据。为了处理状态管理,我们将使用 Airbnb 提供的 Mavericks (小弟写的介绍)框架。
步骤 1:创建 ViewModel 并发起网络请求
Mavericks 框架的核心是创建一个扩展 MavericksViewModel 的 ViewModel 来管理 UI 状态。在这个示例中,我们将定义一个 UserState 用来存储用户数据,并在 ViewModel 中执行网络请求来获取数据。
// 定义用户数据类和 UI 状态类
data class User(val id: String, val name: String)
data class UserState(val users: List<User> = emptyList(), val isLoading: Boolean = false) : MavericksState
class UserViewModel(initialState: UserState) : MavericksViewModel<UserState>(initialState) {
private val client = OkHttpClient()
// 无意义代码
fun fetchUsers() {
setState { copy(isLoading = true) }
suspend {
// 执行网络请求获取用户数据
val request = Request.Builder().url("https://example.com/users").build()
val response = withContext(Dispatchers.IO) {
client.newCall(request).execute()
}
val moshi = Moshi.Builder().build()
val userListAdapter = moshi.adapter(List::class.java)
userListAdapter.fromJson(response.body?.string() ?: "") ?: emptyList<User>()
}.execute { result ->
setState { copy(users = result() ?: emptyList(), isLoading = false) }
}
}
}
步骤 2:在 Compose 中定义 UI
Compose 的 UI 部分通过观察 ViewModel 中的状态来进行更新。我们将创建一个简单的用户列表 UI,通过订阅 UserViewModel 的状态,动态显示用户数据。
@Composable
fun UserListScreen() {
// 获取 ViewModel
val viewModel: UserViewModel = mavericksViewModel()
val state = viewModel.collectAsState()
UserList(
users = state.users,
onUserClick = { user -> viewModel.onUserItemClick(user) }
)
}
@Composable
fun UserList(users: List<User>, onUserClick: (User) -> Unit) {
LazyColumn(modifier = Modifier.padding(16.dp)) {
items(users) { user ->
UserItem(user, onClick = { onUserClick(user) })
}
}
}
// 或者是 Item 内某个控件的点击事件。
@Composable
fun UserItem(user: User, onClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable { onClick() }
) {
Text(text = user.name, style = MaterialTheme.typography.h6)
}
}
// 遵循单向数据流和状态提升的原则后,拆分合理的 Composable 方便预览
@Preview
@Composable
fun PreviewUserList() {
val sampleUsers = listOf(
User(id = "1", name = "Alice"),
User(id = "2", name = "Bob"),
User(id = "3", name = "Charlie")
)
UserList(users = sampleUsers, onUserClick = {})
}
步骤 3:状态提升与单向数据流
在这个示例中,状态提升通过将 UserViewModel 中管理的状态传递给 Compose UI 层来实现。单向数据流意味着:
- 用户的交互行为(例如刷新用户列表)会触发 ViewModel 中的逻辑。
- ViewModel 处理逻辑并更新状态。
- Compose UI 通过收集 ViewModel 中的状态进行重组,呈现最新的数据。
状态提升在这里的作用是将 UI 状态的控制权集中在 UserViewModel 中,以保证状态的一致性和可追溯性,而不是将状态在多个组件之间自由传递,这样可以有效减少因状态不一致引起的 bug。
5.总结归纳
「状态提升」与「单项数据流」的重要性,类似于“积少成多”的思想,细节处理后整个项目都“立体”了。无论是从调试的角度,还是 Composable 函数的角度;遵循基本原则是强烈建议的。代码的质量往往从每一个细节点堆积在一起。以上便是对这两个原则的介绍。