Compose 入门:@Composable、组合与重组

本篇在讲什么 :用「首页计数」把 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.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 同屏即可。
相关推荐
洞见前行1 小时前
APK Signing Block V2 多渠道分包技术原理
android
DandelionR2 小时前
Android SDK安装
android
雪铃儿2 小时前
Flutter Android 热更新:我为什么没用 Shorebird 而是自己造了一个🚀
android·开源
angerdream3 小时前
Android手把手编写儿童手机远程监控App之通知栏消息
android
Junerver3 小时前
Kotlin - 约定contract
kotlin
Junerver4 小时前
使用datetime更加优雅地在kotlin中处理时间
kotlin
OCN_Yang4 小时前
能告诉我:你为什么用 MVI 吗?反正我不理解!
android·架构·前端框架
荣月灵的小梅花5 小时前
Android 给广播接收器增加权限(permission)或signature签名权限
android
装杯让你飞起来啊5 小时前
第 4 周 Unit 2:Jetpack Compose 状态、按钮、计数器与小费计算器
windows·microsoft·kotlin·安卓