31 已经验证冷流每次收集会重新执行上游,32 则区分状态与事件。把进度显示搬进 Android 界面后,还多出一个问题:用户按 Home,界面不再可见,收集者是否继续工作?如果旋转屏幕,按钮计数和进度又会怎样?这两个问题不能只靠 APK 编译结果回答。

一个界面里其实有两种状态

隔离工程 examples/kotlin-language/android-compose/ 的 TaskActivity 只做一个可观察的练习。按钮计数由界面本地维护;冷 flow 每秒发出一个递增的 tick,collectAsStateWithLifecycle 把它转换为 Compose 可读取的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var clicks by rememberSaveable { mutableIntStateOf(0) }
val progress = remember {
flow {
var tick = 0
try {
while (true) {
delay(1000)
emit(++tick)
Log.i("KotlinE01", "COLLECT tick=$tick")
}
} finally {
Log.i("KotlinE01", "COLLECTION_STOP tick=$tick")
}
}
}
val ticks by progress.collectAsStateWithLifecycle(initialValue = 0)

remember 留住当前组合中的 Flow 实例,不能代替生命周期管理;rememberSaveable 针对这里可保存的整数,在 Activity 重建时恢复点击计数。ticks 是当前收集结果,初值 0 仅供新收集启动前展示,不是持久化进度。Android 官方 Compose 状态指南 对 rememberSaveable 的范围限定在可写入保存状态的值;生命周期与协程指南说明 collectAsStateWithLifecycle 默认在 STARTED 及以上收集,生命周期进入 STOPPED 时停止。运行结果仍要由设备上的日志和界面树核对,而非把文档当实验输出。

Flow 定义放在 remember 内,不会因当前组合重组而重新构造;Activity 重建仍会创建新 Flow。SideEffect 将本次组合的点击数和 tick 写入日志,便于对照 UI。一次 COMMIT 不等于一次 Flow 发射,更不等于后台任务完成。

从真实界面读回按钮计数

实验使用 JDK 21、Gradle 8.14、AGP 8.9.2、Kotlin Android/Compose 插件 2.2.21、Compose BOM 2025.04.01、Lifecycle 2.9.0,针对 Android SDK 35 构建。宿主没有 /dev/kvm 和真机:API 35 两种镜像此前未能稳定启动;随后 API 29 x86_64 模拟器在单核软件模拟下启动,并报告 sys.boot_completed=1。这是一次特定的无加速设备实验,不是“Android 各版本已通过”。

安装新 APK、启动 Activity 后,设备 uiautomator 界面树先显示 Tap count 0,同时有 Task progress。两次 adb shell input tap 170 180 后再次读取,按钮文本成为 Tap count 2,进度继续增长。verification/E01/device-verified/ 中分别保存了安装输出、设备指纹、启动结果、两次界面树和 Logcat;早期模拟器未启动与 System UI 无响应弹窗的失败记录也保留,不能把它们改写成成功的设备运行。

这里点击的是 Compose Button,不是给 JVM 单测里一个 Int 赋值。界面树的前后差异能检验实际安装、启动、点击和文本更新;它不能证明屏幕的像素渲染在所有 GPU/设备上一致,也不证明保存状态跨进程死亡可恢复。

离页停止不是“任务执行完毕”

先让界面产生若干 tick,再发送 KEYCODE_HOME。设备日志记录 STOP,接着是 Flow 的 finally 中的 COLLECTION_STOP tick=118。两次间隔采集的后台日志,最后一个 COLLECT tick 都是 118。早期脚本直接比较两份滚动日志中的行数,一份是 74 行、另一份是 73 行,误报失败;那是 Logcat 窗口丢掉了最旧一行,不能据此说后台又发射了一次。修正后的断言比较最后的 tick 值,并要求存在停止日志。

这个 finally 只表示当前收集的上游因生命周期变化退出。它既不是外部任务完成回执,也没有在后台继续保存一个任务进度:本例压根没有数据库或独立后台 worker。将界面收集者的取消传播到真正的业务 Job,必须单独设计任务所有权;34 已经区分请求与任务的生命周期。

从后台再次打开 Activity,Logcat 出现新的 START,冷流重新从 COLLECT tick=1 开始;界面树仍读到 Tap count 2。这里的“重新开始”只适用于这个局部冷 Flow。若上游是共享状态流、服务端数据或者持久化任务,重新订阅时看到什么取决于各自的缓存与持久化契约,不能套用本例的 tick 归零。

旋转恢复了什么

设备设定为横屏后,TaskActivity.onCreate 记录 CREATE restored=true,新的界面树仍显示 Tap count 2,进度数字则已经变化。rememberSaveable 保存的是那个可保存的点击整数,不是“保活整个 Activity”、也不让 Flow 继续沿用上一次收集的局部变量。把按钮计数改成普通 remember 而不添加其他状态持有者,就不能从它推导旋转后的保存保证;本次没有把这个对照 APK 安装到设备,不能报告它的实测 UI 结果。

这组读回把三件事分开:后台 STOP 使 UI 收集退出,再进入界面时冷流重新收集,配置变更时仅保存被 rememberSaveable 管理的本地计数。进程被系统回收后的恢复、状态过大或不可保存类型、多个 Activity 的跨页面共享,以及真实任务继续执行均 NOT_RUN。它们需要 SavedStateHandle、持久化或独立工作单元的另外一组实验,不能由一次旋转替代。

复跑和证据边界

下载累计工程与 Android 探针后先校验同目录 SHA-256。解包的根目录 kotlin-language/ 可离线跑 JVM 主线回归,android-compose/ 在另有 SDK 与已缓存 AGP/Compose 依赖时构建 APK;设备上执行 verify-device.sh 要设置 ANDROID_HOME,且须先有已完成启动的模拟器或真机。它会保留前台、后台、返回和旋转的实际日志与界面树;不能把离线 Gradle 构建、HTML 页面生成或模拟器启动标志当成这些交互断言。

本机单核软件模拟器速度不稳定,先前失败、最终原始 UI/XML/Logcat、后台最后 tick 比较和旋转恢复记录分别在 writing-plans/kotlin/verification/E01/。其中成功的阶段性重跑见 device-verified/continuation.txt;设备整轮脚本的退出码以同目录 device-replay/run.txt 为准。对于没有 Android 设备的读者,assembleDebug 最多验证可构建,离页和旋转行为依然是 NOT_RUN,不能照搬本次设备结论。

参考资料