diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts index 2893343..37019df 100644 --- a/compose/build.gradle.kts +++ b/compose/build.gradle.kts @@ -33,6 +33,8 @@ kotlin { commonMain.dependencies { api(libs.quickjs.kmp) implementation(compose.runtime) + // hosts give each navigation entry a store from it (docs/app-model.md §5) + api(libs.compose.runtime.retain) implementation(compose.foundation) implementation(libs.compose.material3) implementation(libs.kotlinx.coroutines.core) diff --git a/compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt b/compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt index 15d3f07..6a4c00d 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt @@ -11,7 +11,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.key -import androidx.compose.runtime.remember +import androidx.compose.runtime.retain.RetainObserver +import androidx.compose.runtime.retain.retain import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboard @@ -25,7 +26,13 @@ import androidx.compose.ui.unit.dp /** * Renders one TinyUI page; the host owns navigation and passes the page's bytecode (docs/app-model.md). * To deliver a navigation result to this page, keep the [PageHost] (see [onHost]) and call [PageHost.emit]. - * The page is visible (`pageVisible()`) while the lifecycle is at least STARTED; its toasts and dialogs draw over it. + * The page is visible (`pageVisible()`) while it is composed and the lifecycle is at least STARTED; its toasts and + * dialogs draw over it. + * + * The page is [retain]ed: by default it closes when it leaves composition, as if remembered. Under a + * [androidx.compose.runtime.retain.LocalRetainedValuesStore] that keeps exited values (one per navigation entry), it + * lives on off screen, engine and all, and comes back as the user left it; it closes when that store lets it go + * (docs/app-model.md §5). * * @param locals this mount's objects for `host.call` capabilities (docs/native-api.md §13). */ @@ -40,9 +47,10 @@ fun TinyUIPage( error: @Composable (PageFailure) -> Unit = { PageFailureScreen(it) }, onHost: (PageHost) -> Unit = {}, ) { - val pageHost = remember(page, host, propsJson, sourceMaps) { - PageHost(page, host, propsJson, locals, sourceMaps = sourceMaps) + val retained = retain(page, host, propsJson, sourceMaps) { + RetainedPage(PageHost(page, host, propsJson, locals, sourceMaps = sourceMaps)) } + val pageHost = retained.host val uriHandler = LocalUriHandler.current val clipboard = LocalClipboard.current // a new list of the same objects each recomposition must not remount the page: the context just follows it @@ -52,10 +60,15 @@ fun TinyUIPage( pageHost.context.locals = locals.associate { it.local to it.value } } val lifecycle = LocalLifecycleOwner.current.lifecycle - DisposableEffect(pageHost) { + DisposableEffect(retained) { onHost(pageHost) - pageHost.start() - onDispose { pageHost.close() } + retained.startOnce() + onDispose { + // off screen but maybe kept: hidden, and nothing to open links or copy with until it is back + pageHost.visible(false) + pageHost.context.uriHandler = null + pageHost.context.clipboard = null + } } DisposableEffect(pageHost, lifecycle) { val observer = LifecycleEventObserver { _, event -> @@ -82,6 +95,23 @@ fun TinyUIPage( } +/** Closes the page when the retaining store lets it go, not when it merely leaves composition. */ +private class RetainedPage(val host: PageHost) : RetainObserver { + private var started = false + + fun startOnce() { + if (started) return + started = true + host.start() + } + + override fun onRetained() {} + override fun onEnteredComposition() {} + override fun onExitedComposition() {} + override fun onRetired() = host.close() + override fun onUnused() = host.close() +} + /** The default failure screen: kind and message, plus the stack when [TinyUI.debug] is on. */ @Composable fun PageFailureScreen(failure: PageFailure) { diff --git a/compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt b/compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt index 7571d38..3bec050 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt @@ -15,8 +15,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -53,6 +53,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.retain.retain import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -102,7 +103,7 @@ fun ComponentRegistry.registerBuiltins(): ComponentRegistry = apply { register(BuiltinSchemas.Column) { scope -> val scroll = scope.get("scroll") == true Column( - modifier = scope.modifier().then(if (scroll) Modifier.verticalScroll(rememberScrollState()) else Modifier), + modifier = scope.modifier().then(if (scroll) Modifier.verticalScroll(retainScrollState()) else Modifier), verticalArrangement = verticalArrangement(scope["justify"], scope["gap"]), horizontalAlignment = when (scope.get("align")) { "center" -> Alignment.CenterHorizontally; "end" -> Alignment.End; else -> Alignment.Start }, ) { scope.Children { child -> if (scroll) Modifier else weightOf(child)?.let { Modifier.weight(it) } ?: Modifier } } @@ -110,7 +111,7 @@ fun ComponentRegistry.registerBuiltins(): ComponentRegistry = apply { register(BuiltinSchemas.Row) { scope -> val scroll = scope.get("scroll") == true Row( - modifier = scope.modifier().then(if (scroll) Modifier.horizontalScroll(rememberScrollState()) else Modifier), + modifier = scope.modifier().then(if (scroll) Modifier.horizontalScroll(retainScrollState()) else Modifier), horizontalArrangement = horizontalArrangement(scope["justify"], scope["gap"]), verticalAlignment = when (scope.get("align")) { "center" -> Alignment.CenterVertically; "end" -> Alignment.Bottom; else -> Alignment.Top }, ) { scope.Children { child -> if (scroll) Modifier else weightOf(child)?.let { Modifier.weight(it) } ?: Modifier } } @@ -185,6 +186,14 @@ fun ComponentRegistry.registerBuiltins(): ComponentRegistry = apply { } } +/** + * What a user did to a component that a retained page must come back with (docs/app-model.md §5): typed text, code + * digits and scroll positions are `retain`ed, so they live as long as the page; focus and an open menu are + * `remember`ed and start over. Under the default store `retain` is `remember`. + */ +@Composable +private fun retainScrollState(): ScrollState = retain { ScrollState(0) } + // a scrolling axis is unbounded, so there is no remaining space to share: weight is ignored there rather than collapsing the child to 0 private fun weightOf(child: UINode): Float? = (child.props["weight"] as? Double)?.toFloat()?.takeIf { it > 0f } @@ -223,7 +232,7 @@ private fun boxAlignment(name: String?): Alignment = when (name) { private fun TextFieldComponent(scope: NodeScope) { val maxLength = scope.get("maxLength")?.toInt()?.takeIf { it > 0 } fun clip(t: String) = clipTo(t, maxLength) - var text by remember { mutableStateOf(clip(scope.get("initialText") ?: "")) } + var text by retain { mutableStateOf(clip(scope.get("initialText") ?: "")) } var focused by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current @@ -289,7 +298,7 @@ private fun TextFieldComponent(scope: NodeScope) { @Composable private fun CodeFieldComponent(scope: NodeScope) { val length = (scope.get("length")?.toInt() ?: 6).coerceIn(1, MAX_CODE_LENGTH) - var value by remember { mutableStateOf(TextFieldValue("")) } + var value by retain { mutableStateOf(TextFieldValue("")) } val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current var focused by remember { mutableStateOf(false) } @@ -408,21 +417,22 @@ internal fun linkedText(text: String, color: Color, onClick: (String) -> Unit): /** Rows are the node's children; virtualisation only in composition (docs/adr-003 §3.6). */ @Composable private fun LazyColumnComponent(scope: NodeScope) { - val state = rememberLazyListState() + val state = retain { LazyListState() } val node = scope.node scope.Commands { command -> if (command.name == "scrollTo") state.animateScrollToItem(((command.args["index"] as? Double) ?: 0.0).toInt().coerceIn(0, maxOf(0, node.children.size - 1))) } + // with the scroll position: a list coming back still at its end has already reported that length + val reachedEnd = retain { ReachedEnd() } if (scope.has("onReachEnd")) { // fires on each arrival at the last row and when the list grows while there; re-arms once the user scrolls away LaunchedEffect(state, node) { - var firedFor = -1 snapshotFlow { (state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1) to node.children.size } .distinctUntilChanged() .collect { (last, size) -> val atEnd = size > 0 && last >= size - 1 - if (!atEnd) firedFor = -1 - else if (firedFor != size) { firedFor = size; scope.dispatch("onReachEnd") } + if (!atEnd) reachedEnd.size = -1 + else if (reachedEnd.size != size) { reachedEnd.size = size; scope.dispatch("onReachEnd") } } } } @@ -441,6 +451,9 @@ private fun LazyColumnComponent(scope: NodeScope) { } } +/** The list length `onReachEnd` last fired for; -1 once the user is away from the end. */ +private class ReachedEnd(var size: Int = -1) + private fun payload(key: String, value: String) = buildJsonObject { put(key, value) }.toString() @OptIn(ExperimentalMaterial3ExpressiveApi::class) diff --git a/docs/app-model.md b/docs/app-model.md index 9a57ca1..716b27e 100644 --- a/docs/app-model.md +++ b/docs/app-model.md @@ -47,9 +47,41 @@ JS 侧不存在长生命周期的"应用对象":没有 `app.tsx`、没有 `onL - **顺序只在单页内保证**:每个引擎串行,同一页收到的 K5 严格有序;跨页无顺序承诺 - **同名页面多实例**:detail 跳 detail 是两个引擎,Kotlin 以页面实例 id 寻址而不是页面名;回传目标、订阅登记都挂在实例上 -## 5. 返回栈回收 +## 5. 页面随导航条目存活与返回栈回收 -ADR-002 遗留的"栈深页引擎回收策略"归 Kotlin 侧路由器:栈深超过 N 关引擎,返回时用保存的 props 重新 `__mount`。推论是页面必须能从 props + store 重建,页内临时状态丢失由业务经 store 自救。N 等 roadmap B 组真机内存数据出来后定。 +2026-10-28 定(PingPong 注册流程触发:从第二步返回第一步,填好的邮箱密码不能没了)。**页面实例跟着导航条目活,和原生页一样**:条目在栈里,引擎、节点树、组件里用户输入的东西都在;条目出栈才关。不让业务把表单草稿塞进 `store` 自救——那是业务替框架补课。 + +机制是 Compose 官方的 `retain`(`androidx.compose.runtime:runtime-retain`,就是为"在返回栈里、此刻没被组合的导航页"设计的): + +| 层 | 做什么 | +|---|---| +| `TinyUIPage` | `PageHost` 用 `retain` 持有;离开组合只算不可见(`pageVisible()` 为 false,`uriHandler` / 剪贴板摘掉,期间开链接、写剪贴板答 `E_UNSUPPORTED`),回来按生命周期恢复可见;保留它的存储放手时才 `close()` | +| 内置组件 | 用户做出来的状态 `retain`:`TextField` 文本、`CodeField` 数字、`Column` / `Row` / `LazyColumn` 滚动位置;焦点、展开中的下拉菜单 `remember`,回来重新开始 | +| `UpdatesPage` | 当前包与已加载的页面也 `retain`:回来的是用户离开时那一页、打开时那个包,期间装了新包也不换;第一帧就组合出被保留的页面(否则存储会当它没被取回而放手) | +| 宿主 | 给每个导航条目一个保留离开值的存储,条目出栈时清掉;不给就是 Compose 的缺省存储(离开组合即丢),行为与以前完全一样 | + +宿主用 Navigation 3 时是一个 decorator(框架不依赖任何导航库,写法放文档,不进库): + +```kotlin +@Composable +fun rememberRetainedValuesNavEntryDecorator(): NavEntryDecorator { + val registry = retainRetainedValuesStoreRegistry() + return remember(registry) { + NavEntryDecorator( + onPop = { key -> registry.clearChild(key) }, // 出栈:放手,页面 close() + decorate = { entry -> registry.LocalRetainedValuesStoreProvider(entry.contentKey) { entry.Content() } }, + ) + } +} +``` + +被保留的页面**照常在后台运行**,和原生页留在返回栈里时它的 ViewModel 协程照跑一样:计时器、`http`、`host.call` 都不停,`pageVisible()` 为 false,页面据此自己决定停什么(轮询、动画)。挂载时交给 `host.call` 能力的 `locals` 随页面保留,所以它们应与导航条目同寿(条目级 ViewModel 之类),不要传组合级的东西。宿主的保留存储由 `tinyui-compose` 以 `api` 依赖带出,宿主不用另加。 + +sample 的四个 tab 各给一个存储(`App.kt`),切走再切回,表单内容与列表滚动位置都在。 + +**栈深回收**仍归宿主:栈深超过 N 时对最深的条目 `clearChild`,那一页关引擎,返回时用保存的 props 重新 `__mount`——只有被回收的页才需要能从 props + store 重建。N 等 roadmap B 组真机内存数据出来后定;注册流程最多 4 层,不触发。被保留的页面一直占着引擎内存,这正是回收要管的。 + +与"页面预载"的关系:预载(导航前先 `start()` 到挂载完成)同样需要 `PageHost` 独立于组合,`retain` 已经把它从 `remember` 里拿出来了;等首屏实测超预算再加,不另起机制。 ## 6. 对构建链的影响 diff --git a/docs/roadmap.md b/docs/roadmap.md index c7480c6..5feb1e1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -54,7 +54,8 @@ | 二进制编码路径 | ADR-002 | 真机 profiling 证明 Kotlin 解析占主导 | | 命令回执(`cmd()` 返回 Promise) | ADR-004 | 出现必须知道结果的命令 | | 窗口化 `For` | ADR-003 | 出现万行级列表需求 | -| 返回栈深页引擎回收策略 | ADR-002 | 真机内存数据出来后定,可能提前 | +| 返回栈深页引擎回收策略 | ADR-002 | 真机内存数据出来后定,可能提前。页面随导航条目存活已完成(2026-10-28,`retain`,[app-model.md](./app-model.md) §5):回收即宿主对最深条目 `clearChild` | +| 页面预载(导航前 `start()` 到挂载完成) | app-model.md §5 | 首屏实测超预算(PingPong 注册首屏 ≤ 300 ms 门槛) | | 应用级服务 Runtime(不挂 UI、随 App 生命周期的引擎) | app-model.md | 业务出现不属于任何页面的常驻 JS 逻辑 | | 手势组合内置组件 | ADR-004 | 业务需求出现 | | 公钥轮换不中断旧 App:预置备用钥(`publicKeys` 为一组,客户端任一内置钥验过即可,服务端登记一组并可吊销单把,manifest 记签名用的钥) | updates.md §7 | 出现第一次真实的轮换诉求,或托管实例接入外部租户 | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 70adeab..d07e925 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,8 @@ kotlin = "2.4.21" composeMultiplatform = "1.12.2" # 独立版本线,只发 alpha;须与 composeMultiplatform 同代,LoadingIndicator 也只在这里有 composeMaterial3 = "1.12.0-alpha03" +# composeMultiplatform 底下的 androidx runtime 版本(各端都指向它);runtime-retain 须同代 +androidxComposeRuntime = "1.12.1" android-minSdk = "24" android-compileSdk = "37" android-targetSdk = "37" @@ -31,6 +33,7 @@ ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "kto ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "composeMaterial3" } +compose-runtime-retain = { module = "androidx.compose.runtime:runtime-retain", version.ref = "androidxComposeRuntime" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivityCompose" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } # build-logic 用:把插件当普通依赖引进 convention plugin 的 classpath diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index d52afc0..e22a08e 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -24,6 +24,7 @@ kotlin { implementation(project(":updates")) implementation(libs.okio) implementation(compose.runtime) + implementation(libs.compose.runtime.retain) implementation(compose.foundation) implementation(libs.compose.material3) implementation(compose.ui) diff --git a/sample/shared/src/commonMain/kotlin/app/tinyui/sample/App.kt b/sample/shared/src/commonMain/kotlin/app/tinyui/sample/App.kt index ffd10dd..9f8aaf0 100644 --- a/sample/shared/src/commonMain/kotlin/app/tinyui/sample/App.kt +++ b/sample/shared/src/commonMain/kotlin/app/tinyui/sample/App.kt @@ -16,17 +16,14 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.retain.retainRetainedValuesStoreRegistry import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okio.Path -import org.jetbrains.compose.resources.ExperimentalResourceApi -import org.jetbrains.compose.resources.MissingResourceException import app.tinyui.Bundle import app.tinyui.TinyUI import app.tinyui.sample.res.Res @@ -34,9 +31,14 @@ import app.tinyui.updates.Updates import app.tinyui.updates.UpdatesPage import app.tinyui.updates.UpdatesScheduler import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okio.Path +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.MissingResourceException /** The two packages the App embeds, each under its own resource directory (docs/updates.md §3). */ private val packages = listOf("sample", "sample-extra") @@ -83,6 +85,9 @@ fun App(updatesDir: Path) { var page by remember { mutableStateOf(pages.first()) } val host = remember(updatesDir) { sampleHost(updatesDir.parent!! / "tinyui-data") } val locale by sampleLocale.collectAsState() + // one store per tab that keeps what leaves composition: a page switched away from stays alive, form input and + // all; a navigation host does the same per back stack entry and clears an entry's store when it is popped + val retained = retainRetainedValuesStoreRegistry() // pages name theme tokens only (docs/components.md §6); flipping the scheme here restyles them with no patch MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { Surface(Modifier.fillMaxSize()) { @@ -92,7 +97,8 @@ fun App(updatesDir: Path) { TextButton(onClick = { sampleLocale.value = if (locale == "en") "zh" else "en" }) { Text(if (locale == "en") "中文" else "EN") } } val u = updates - if (u == null) Text("loading…", Modifier.padding(16.dp)) else UpdatesPage(u, page, host, modifier = Modifier.fillMaxSize()) + if (u == null) Text("loading…", Modifier.padding(16.dp)) + else key(page) { retained.LocalRetainedValuesStoreProvider(page) { UpdatesPage(u, page, host, modifier = Modifier.fillMaxSize()) } } } } } diff --git a/updates/build.gradle.kts b/updates/build.gradle.kts index e5883fd..df35245 100644 --- a/updates/build.gradle.kts +++ b/updates/build.gradle.kts @@ -18,6 +18,7 @@ kotlin { implementation(libs.quickjs.kmp) api(libs.okio) implementation(compose.runtime) + implementation(libs.compose.runtime.retain) implementation(compose.foundation) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) diff --git a/updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt b/updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt index 49cc6a5..b5871e8 100644 --- a/updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt +++ b/updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState +import androidx.compose.runtime.retain.retain import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -22,6 +22,9 @@ import kotlinx.coroutines.CancellationException * [TinyUIPage] for a page of one of [updates]' packages: [name] is the module name, the part before the first * `/` names the package. A page from an installed package that fails with E2 / E6 is remounted from the * embedded package, which the whole package falls back to (docs/updates.md §4.5). + * + * The bundle and the loaded page are retained with the page (see [TinyUIPage]): a page coming back from the back + * stack is the one the user left, from the bundle it was opened with, even if a newer one was installed meanwhile. */ @Composable fun UpdatesPage( @@ -35,9 +38,13 @@ fun UpdatesPage( onHost: (PageHost) -> Unit = {}, ) { val pkg = name.substringBefore('/') - var bundle by remember(updates, name) { mutableStateOf(updates.current(pkg)) } - val loaded by produceState?>(null, bundle, name) { - value = try { + var bundle by retain(updates, name) { mutableStateOf(updates.current(pkg)) } + // already loaded when it comes back: the first frame must compose the retained page, or its store lets it go. + // A failure is not kept: coming back tries again, the read may well work now + val loadedState = retain(bundle, name) { mutableStateOf?>(null) } + LaunchedEffect(loadedState) { + if (loadedState.value?.isSuccess == true) return@LaunchedEffect + loadedState.value = try { Result.success(bundle.page(name)) } catch (e: CancellationException) { throw e @@ -45,7 +52,7 @@ fun UpdatesPage( Result.failure(t) } } - val result = loaded ?: return + val result = loadedState.value ?: return // a page the package cannot load (e.g. not in it) is not the package failing to run: no fallback val page = result.getOrElse { t -> val failure = remember(t) {