本篇在讲什么 :用「首页计数」把 Composable、重组、以及状态放在哪一层 说清楚。系列其余篇目会接着讲状态、Modifier、副作用等;治理类话题偶尔在文末带一句。
源码仓库 :ComposeDemo @ GitHub(分支main)。下文路径均相对仓库根目录。
1. 核心概念(必须建立的心智模型)
1.1 @Composable 是什么?
带 @Composable 注解的函数告诉 Compose 编译器 :这里描述的是 UI 树(组合),而不是普通 Kotlin 函数。
- 编译器会插入对
Composer的调用,用于记录「上次组合长什么样」。 - 禁止 在
@Composable里做「假设只调用一次」的随机副作用(例如UUID.randomUUID()直接决定业务 id);需要副作用时用专门 API(见第 03 篇)。
1.2 组合(Composition)与重组(Recomposition)
- 首次组合 :从
setContent { ... }开始,自上而下执行 Composable,建立 UI 树。 - 重组 :当 State 变化 或 输入参数变化 时,框架可能 再次调用部分
@Composable,用新数据刷新界面。 - 重要 :重组不保证 调用次数、顺序与「整个函数体重新跑一遍」的直觉一致;编译器会做 跳过(skipping) 优化。
1.3 读状态就会「订阅」重组
在 Composable 体内读取 mutableStateOf / State<T> 等 快照状态 时,会建立 观察关系:状态变了 → 相关作用域重组。
kotlin
var count by remember { mutableIntStateOf(0) }
Text(text = "$count") // 读 count → count 变 → 这里重组
2. remember { ... } 在技术上解决什么?
remember 把 计算结果存进组合 里,键默认是当前调用位置(调用点 identity)。
- 典型用途 :
mutableStateOf的宿主、LazyListState()、SnackbarHostState()等「与这条 UI 分支同生命周期」的对象。 - 常见错误 :把 应随业务变化重新计算 的东西放进无 key 的
remember { },导致永远不更新;或把 应随配置重建 的东西rememberSaveable过度保存。
3. 入口代码:从 Activity 到第一棵树
本仓库入口(与线上一致,以 MainActivity.kt 为准):
kotlin
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposeDemoTheme {
Surface(modifier = Modifier.fillMaxSize()) {
val navController = rememberNavController()
AppNavHost(navController = navController)
}
}
}
}
}
技术点:
setContent:由androidx.activity:activity-compose提供,把 Compose 接到 Activity 窗口。rememberNavController():必须在 Composable 作用域 内调用,保证与组合生命周期一致。ComposeDemoTheme:提供MaterialTheme(颜色、字体、形状),见第 06 篇。
4. 对照练习:首页计数器

下面结合仓库里的 HomeScreen.kt 、HomeViewModel.kt 一起读(路径见第 6 节)。若你在博客里只贴这两个文件 ,请至少再贴上 HomeContract.kt 里的 HomeUiState 与 HomeEvent 两段定义,否则类型从哪来会断档;更省事的做法是 给 仓库 链接 ,正文只保留关键片段;需要自洽长文时可直接用文末 附录 中的粘贴块。
阅读顺序:谁保存数据 → 谁画界面 → 点按钮发生什么 。各小节末尾的 「自检 · QA」 用问答收束,方便对照吸收。
4.1 HomeViewModel:计数存在哪里?
-
_uiState: MutableStateFlow<HomeUiState>(...)- 这是 唯一可写的状态容器 (私有)。初始里带了
headline与counter。 - 业务上「屏幕长什么样」的快照,用不可变
data class HomeUiState表达:改计数不是改字段,而是copy(counter = ...)换一个新对象,避免多线程/重组下半成品状态。
- 这是 唯一可写的状态容器 (私有)。初始里带了
-
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()- 对外只给 只读的
StateFlow:界面层只能collect,不能直接_uiState.value = ...,防止绕过onEvent乱改。
- 对外只给 只读的
-
fun onEvent(event: HomeEvent)- 所有「用户意图」从 UI 进来都走这里(本例只有
Increment)。 _uiState.update { it.copy(counter = it.counter + 1) }:在 Flow 内部 原子地 用旧状态算新状态,比手写「先读 value 再写」更安全。
- 所有「用户意图」从 UI 进来都走这里(本例只有
自检 · QA
Q1:每点一次「+1」,HomeUiState 是「改字段」还是「换了一个新对象」?
A1:换了一个新对象。 copy(counter = it.counter + 1) 每次返回 新的 HomeUiState 实例;旧实例若没有别的引用,可被 GC。这是「不可变快照」的典型写法。
Q2:新实例里,headline 对用户来说「变没变」?
A2:在本次只有 Increment 的前提下,标题文字不变。 因为 copy 没写 headline = ...,会从 it 把原来的 headline 原样抄进新实例 ;变的是 counter。不要和「整个对象换了」混成「每个字段展示都变了」。
Q3:一句话总结快照,怎么说?
A3: 对象身份常换,字段只在本次事件里该变的才变------整体仍是「一帧 UI 一份只读数据」。
4.2 HomeRoute:界面怎么「订阅」ViewModel?
-
viewModel: HomeViewModel = viewModel()- 在 Composable 作用域 里拿到与当前
NavBackStackEntry(或 Activity)绑定的ViewModel实例;旋转屏幕后仍是同一份逻辑层(系统配置变更时由框架处理,细节见官方 ViewModel 文档)。
- 在 Composable 作用域 里拿到与当前
-
val state by viewModel.uiState.collectAsStateWithLifecycle()- 把冷/热的
StateFlow转成 Compose 能by委托 读的 State :state一变,HomeRoute及其子树中 读了state的 Composable 会进入 重组。 collectAsStateWithLifecycle:只在界面至少STARTED时收集,避免 Activity 在后台仍白跑 collect、浪费电或与生命周期打架(比手写LaunchedEffect { flow.collect }更省心,也更符合界面代码习惯)。
- 把冷/热的
-
HomeScreen(state = state, onEvent = viewModel::onEvent, ...)- 把 数据 和 事件回调 往下传。注意:
HomeScreen本身 不持有ViewModel,只拿「当前快照 + 发事件的 lambda」------这叫 单向数据流(UDF) 的常见写法,也方便 Preview(见文件末尾HomeScreenPreview)。
- 把 数据 和 事件回调 往下传。注意:
自检 · QA
Q:若把 collectAsStateWithLifecycle 挪到 HomeScreen 里,让 HomeScreen 直接 collect ViewModel,还能叫「纯展示」吗?
A:严格说就不再「纯」了。
- 纯展示 通常指:只依赖 入参
state+ 回调onEvent,不依赖ViewModel类型、不写viewModel()。 - 一旦在
HomeScreen里 collect,HomeScreen就 绑定了数据来源 (要知道 Flow、生命周期),Preview / 单测 时也要伪造 Flow 或 ViewModel,成本上去。 - 推荐 :在
HomeRoute(或更薄的...Screen父层) 完成 collect,子组件只收state------和本仓库写法一致。
4.3 HomeScreen:重组时谁在「读状态」?
HomeScreen 的参数是 state: HomeUiState ,函数体内 没有 viewModel.xxx。
-
Text(text = state.headline, ...)、Text(text = stringResource(R.string.home_counter, state.counter), ...)这两处都在 读
state的字段。 -
当
HomeRoute里state更新后,HomeScreen会以新的state为入参被再次调用 (重组):Compose 比较参数,发现state变了,就会重新执行函数体里依赖该参数的 UI 描述。 -
Button(onClick = { onEvent(HomeEvent.Increment) })- 点击 不直接改计数 ,只发一个 意图
HomeEvent.Increment;真正改_uiState的是 ViewModel。 - 这样即使以后在
onEvent里加校验、埋点、防抖,也 不必改HomeScreen的布局代码。
- 点击 不直接改计数 ,只发一个 意图
把「重组」落到本例一句话:
counter 从 n 变成 n+1 时,至少 HomeRoute(因 state 变了) 与 HomeScreen(因入参 state 变了) 会再走一遍组合逻辑;MaterialTheme 等未变且未读变化状态的祖先,可能被跳过(由编译器/运行时优化)。只需记住:不是整棵树从零重画。
自检 · QA
Q:counter 变了以后,一定是「整棵 HomeScreen 里的每个子 Composable 都重跑一遍」吗?
A:不一定细到「每一个」 ,由运行时/编译器做 跳过优化 ;但 HomeScreen 作为收到新 state 的函数会再执行 ,其内部依赖 state.counter 的 Text 一定会拿到新值 。记牢一点:读变化状态的地方会更新,不必手算每一次跳过。
4.4 从点击到数字变:一条因果链
可以当成「小剧本」记:
- 用户点击 「+1」 → 执行
onEvent(HomeEvent.Increment)。 HomeViewModel.onEvent→_uiState.update { ... counter+1 ... }。StateFlow发射新HomeUiState→collectAsStateWithLifecycle提供的state更新。HomeRoute/HomeScreen重组 →stringResource(R.string.home_counter, state.counter)读到新数字 → 界面上的计数文案更新。
自检 · QA
Q:为什么不用 mutableStateOf 写在 Button 旁边,直接改计数?
A:可以跑 demo,但不利于放大。
- Composable 里的
mutableStateOf:配置变更 / 进程恢复 时是否还在、是否与别的屏幕同步,都要自己操心。 ViewModel + StateFlow:生命周期与 单真相源 更清晰,也方便 单测onEvent(本系列 01 篇展开)。
4.5 动手练习
目的 :体会 「状态真相源」放在 Composable 里 vs 放在 ViewModel 里 的差别。
做法 A(当前工程) :保持 counter 在 HomeViewModel 的 HomeUiState 里。
做法 B(对比实验,做完可删) :在 HomeRoute 里增加
var localHeadline by remember { mutableStateOf("本地标题") },并让某处 Text 读 localHeadline 而不是 state.headline(或临时改 HomeScreen 多传一个参数)。然后试着回答下面三问:
- 旋转屏幕后,
localHeadline还在吗?ViewModel里的counter还在吗? - 若要从 「设置页」 回来刷新首页标题,改 哪一处 最自然?
- 若要给
counter写 单元测试 ,测HomeScreen还是测HomeViewModel更靠谱?
参考答案 · QA
Q1:旋转后 localHeadline 与 ViewModel.counter 各自怎样?
A1:
localHeadline:若在HomeRoute里用remember { mutableStateOf(...) }(未用rememberSaveable),一般 配置变更导致 Activity 重建组合后,会回到初始值(除非你再做 save/restore)。ViewModel里的counter:在 默认ViewModel作用域 下,通常仍在 (进程未被杀时);所以你会看到「计数还在、本地标题没了」这类 分裂,这就是把业务态与临时态混放的危险。
Q2:从设置页回来要刷新首页标题,改哪最自然?
A2: 改 ViewModel 里 HomeUiState 的生成/更新逻辑 (或设置页通过共享 ViewModel/Repository 发事件),让 state.headline 来自真相源 ;不要指望在 HomeScreen 里「偷偷读 remember 本地标题」能长期维护。
Q3:单测 counter 测谁?
A3: 优先测 HomeViewModel.onEvent (纯 Kotlin、断言 _uiState.value.counter);HomeScreen 更适合 Compose UI 测 / 截图测 或靠 Preview 目视。把业务规则塞进 Composable,@Test 很难写。
小结 :业务展示数据优先 ViewModel 快照 + 单向事件 ;remember { mutableStateOf } 更适合 纯 UI 局部态 (展开/收起等),不要与业务真相源混在同一层。
5. 风险与误区(技术向)
| 误区 | 后果 |
|---|---|
在 Composable 随机读 Random / 当前时间决定结构 |
重组后结果漂移、闪烁 |
| 以为「Composable 只执行一次」 | 把初始化网络请求直接写在函数体顶层 |
到处 remember 缓存大对象 |
内存与逻辑陈旧 |
在 Composable 写 lateinit var 业务状态 |
生命周期与线程难以推理 |
6. 仓库路径与动手验证
- 在线浏览(GitHub
main) - 本地路径 :
app/src/main/java/com/kuen/composedemo/home/下上述三文件;入口见MainActivity.kt。 - 本地运行 :打开工程 → Run → 首页 点「+1」看计数变化;需要配图时,IDE 里
MainActivity与HomeScreen同屏即可。