Compose 的秘诀:单向数据流与状态提升

1,081 阅读11分钟

引言

Jetpack Compose 为 Android 开发者带来了全新的声明式 UI 框架,但要写出优秀的 Compose 代码,却并没有想象中那么简单。如果缺乏对核心原则的理解,代码很容易变得难以维护,就像试图解开一团乱麻,费时又费力。这篇文章想和你聊聊 Compose 开发中两个至关重要的原则——“状态提升”和“单向数据流”。希望通过这些分享,能帮你把代码理清思路,写得更流畅、更优雅。

此文章可参考:

1.状态提升(State Hoisting)

先来聊聊“状态提升”,这听起来像是一种神秘的操作,但其实它并不复杂。想象你和一群朋友一起玩桌游,每个人都有一些信息需要共享,但如果有人偏偏把信息“藏”起来,那这游戏就玩不下去了,对吧?状态提升就像把那个藏私的朋友拽出来,让他共享信息,所有人才能顺利继续游戏。

在软件开发中,状态(State) 指的是程序在运行时存储的数据,它可以随着时间或用户交互而变化。这个概念在面向对象编程、函数式编程甚至各种 UI 框架中都广泛存在。在 Jetpack Compose 中,状态是驱动 UI 的关键所在,决定了界面内容如何展示。

什么是状态?有状态与无状态

在 Jetpack Compose 中,状态是通过 State 对象(如 MutableState)管理的动态数据。当状态发生变化时,Compose 会触发重新组合(Recomposition),更新 UI。

根据是否直接管理状态,组件可以分为两类:

  1. 有状态组件(Stateful Composables)
    • 定义:组件内部定义和管理自己的状态。
    • 示例
@Composable
fun StatefulCounter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Count: $count")
    }
}

问题:当多个组件需要共享 count 时,这种管理方式会导致状态混乱且难以维护。

  1. 无状态组件(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")
        }
    }
}

问题分析

  • count1count2 分别存储在两个变量中,实际上它们表示的是同一个逻辑状态。
  • 如果你想增加某个按钮的点击次数,两者需要同步更新,这就会引入复杂且容易出错的逻辑。

一个改进的例子:使用单一信息源

我们可以将状态集中到一个地方,例如父组件中:

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

    Column {
        Button(onClick = { count++ }) {
            Text("Count: $count")
        }
        Button(onClick = { count = 0 }) {
            Text("Reset Count")
        }
    }
}

为什么这个版本更好?

  1. 所有组件都从同一个状态变量 count 中读取。
  2. 状态的更新(如增加或重置)都操作同一个变量,不会出现数据不一致。

单一信息源的好处

  1. 数据一致性
    状态存储在一个地方,UI 和逻辑依赖同一个数据来源,避免了因重复存储导致的数据冲突。
  2. 更易调试
    如果状态有问题,只需要检查单一信息源,而不用逐一排查各个组件。
  3. 降低复杂性
    数据集中管理,避免在多个地方维护相同的状态逻辑。
  4. 增强可扩展性
    当需求增加时,你只需在单一信息源中修改或扩展逻辑,而不需要更新多个地方的状态代码。

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。这看起来似乎没什么问题,但会在实际项目中引发多个问题:

  1. 状态不一致:当多个组件都试图管理相同的数据时,状态会变得难以追踪,难以确定哪个组件持有最终的正确状态。
  2. 难以维护:当应用规模变大,状态分散在多个子组件中时,理解和维护逻辑会变得非常困难。
  3. 可重用性降低:由于 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。这种做法有以下几个好处:

  1. 单一来源的真相(Single Source of Truth) :所有状态由 ParentComponent 管理,确保数据的一致性。
  2. 逻辑清晰:数据流动方向明确,父组件 -> 子组件,事件传递方向为子组件 -> 父组件。
  3. 组件复用性高ChildComponent 不再持有状态,它只负责 UI 展示,因此更容易在其他上下文中复用。

单向数据流的好处

  1. 逻辑清晰:状态只从一个地方流向 UI,事件只回到状态来源,这样的数据流向使得应用的逻辑非常直观。
  2. 易于维护:所有状态更新都集中管理,减少了因状态散落在多个地方而带来的调试难度。
  3. 组件的复用性更高:子组件无需管理状态,只负责展示数据,因而可以在多个场景中重复使用,而不需要关心不同的状态逻辑。

更深入的比较与总结

  • 在错误的示例中,子组件自持状态,这意味着状态逻辑在多个地方散落,这种分散式的状态管理会导致状态管理混乱调试困难,特别是在复杂场景中,可能导致状态冲突或难以预测的行为。
  • 而在正确的单向数据流实现中,所有状态均集中在父组件管理,这样可以将业务逻辑集中管理,便于在状态变更时找到唯一的来源,也利于测试和调试。

什么时候不适合使用状态提升?

尽管状态提升是一种良好的实践,但在某些情况下,它可能不是最优解。例如,当状态只与特定子组件有关且不会被其他组件使用时,可以考虑将状态保留在子组件中。

例外情况的示例

假如你有一个非常独立的 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 层来实现。单向数据流意味着:

  1. 用户的交互行为(例如刷新用户列表)会触发 ViewModel 中的逻辑。
  2. ViewModel 处理逻辑并更新状态。
  3. Compose UI 通过收集 ViewModel 中的状态进行重组,呈现最新的数据。

状态提升在这里的作用是将 UI 状态的控制权集中在 UserViewModel 中,以保证状态的一致性和可追溯性,而不是将状态在多个组件之间自由传递,这样可以有效减少因状态不一致引起的 bug。

5.总结归纳

「状态提升」与「单项数据流」的重要性,类似于“积少成多”的思想,细节处理后整个项目都“立体”了。无论是从调试的角度,还是 Composable 函数的角度;遵循基本原则是强烈建议的。代码的质量往往从每一个细节点堆积在一起。以上便是对这两个原则的介绍。