本篇在讲什么:用「首页计数」把 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>快照状态 时,会建立 观察关系:状态变了 → 相关作用域重组。

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 为准):

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.ktHomeViewModel.kt 一起读(路径见第 6 节)。若你在博客里只贴这两个文件,请至少再贴上 HomeContract.kt 里的 HomeUiStateHomeEvent 两段定义,否则类型从哪来会断档;更省事的做法是 仓库 链接,正文只保留关键片段;需要自洽长文时可直接用文末 附录 中的粘贴块。

阅读顺序:谁保存数据 → 谁画界面 → 点按钮发生什么。各小节末尾的 「自检 · QA」 用问答收束,方便对照吸收。


4.1 HomeViewModel:计数存在哪里?

  1. _uiState: MutableStateFlow<HomeUiState>(…)

    • 这是 唯一可写的状态容器(私有)。初始里带了 headlinecounter
    • 业务上「屏幕长什么样」的快照,用不可变 data class HomeUiState 表达:改计数不是改字段,而是 copy(counter = …) 换一个新对象,避免多线程/重组下半成品状态。
  2. val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

    • 对外只给 只读的 StateFlow:界面层只能 collect,不能直接 _uiState.value = …,防止绕过 onEvent 乱改。
  3. fun onEvent(event: HomeEvent)

    • 所有「用户意图」从 UI 进来都走这里(本例只有 Increment)。
    • _uiState.update { it.copy(counter = it.counter + 1) }:在 Flow 内部 原子地 用旧状态算新状态,比手写「先读 value 再写」更安全。

自检 · 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?

  1. viewModel: HomeViewModel = viewModel()

    • Composable 作用域 里拿到与当前 NavBackStackEntry(或 Activity)绑定的 ViewModel 实例;旋转屏幕后仍是同一份逻辑层(系统配置变更时由框架处理,细节见官方 ViewModel 文档)。
  2. val state by viewModel.uiState.collectAsStateWithLifecycle()

    • 把冷/热的 StateFlow 转成 Compose 能 by 委托 读的 Statestate 一变,HomeRoute 及其子树中 读了 state 的 Composable 会进入 重组
    • collectAsStateWithLifecycle:只在界面至少 STARTED 时收集,避免 Activity 在后台仍白跑 collect、浪费电或与生命周期打架(比手写 LaunchedEffect { flow.collect } 更省心,也更符合界面代码习惯)。
  3. 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 的字段。

  • HomeRoutestate 更新后,HomeScreen 会以新的 state 为入参被再次调用(重组):Compose 比较参数,发现 state 变了,就会重新执行函数体里依赖该参数的 UI 描述。

  • Button(onClick = { onEvent(HomeEvent.Increment) })

    • 点击 不直接改计数,只发一个 意图 HomeEvent.Increment;真正改 _uiState 的是 ViewModel。
    • 这样即使以后在 onEvent 里加校验、埋点、防抖,也 不必改 HomeScreen 的布局代码

把「重组」落到本例一句话:
countern 变成 n+1 时,至少 HomeRoute(因 state 变了)HomeScreen(因入参 state 变了) 会再走一遍组合逻辑;MaterialTheme 等未变且未读变化状态的祖先,可能被跳过(由编译器/运行时优化)。只需记住:不是整棵树从零重画

自检 · QA

Q:counter 变了以后,一定是「整棵 HomeScreen 里的每个子 Composable 都重跑一遍」吗?
A:不一定细到「每一个」,由运行时/编译器做 跳过优化;但 HomeScreen 作为收到新 state 的函数会再执行,其内部依赖 state.counterText 一定会拿到新值。记牢一点:读变化状态的地方会更新,不必手算每一次跳过。


4.4 从点击到数字变:一条因果链

可以当成「小剧本」记:

  1. 用户点击 「+1」 → 执行 onEvent(HomeEvent.Increment)
  2. HomeViewModel.onEvent_uiState.update { … counter+1 … }
  3. StateFlow 发射新 HomeUiStatecollectAsStateWithLifecycle 提供的 state 更新。
  4. 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(当前工程):保持 counterHomeViewModelHomeUiState 里。

做法 B(对比实验,做完可删):在 HomeRoute 里增加
var localHeadline by remember { mutableStateOf("本地标题") },并让某处 TextlocalHeadline 而不是 state.headline(或临时改 HomeScreen 多传一个参数)。然后试着回答下面三问:

  1. 旋转屏幕后,localHeadline 还在吗?ViewModel 里的 counter 还在吗?
  2. 若要从 「设置页」 回来刷新首页标题,改 哪一处 最自然?
  3. 若要给 counter单元测试,测 HomeScreen 还是测 HomeViewModel 更靠谱?

参考答案 · QA

Q1:旋转后 localHeadlineViewModel.counter 各自怎样?
A1:

  • localHeadline:若在 HomeRoute 里用 remember { mutableStateOf(…) }(未用 rememberSaveable),一般 配置变更导致 Activity 重建组合后,会回到初始值(除非你再做 save/restore)。
  • ViewModel 里的 counter:在 默认 ViewModel 作用域 下,通常仍在(进程未被杀时);所以你会看到「计数还在、本地标题没了」这类 分裂,这就是把业务态与临时态混放的危险。

Q2:从设置页回来要刷新首页标题,改哪最自然?
A2:ViewModelHomeUiState 的生成/更新逻辑(或设置页通过共享 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 里 MainActivityHomeScreen 同屏即可。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐