Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
44 changes: 37 additions & 7 deletions compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
*/
Expand All @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[建议] page、host、propsJson 或 sourceMaps 在同一导航条目内变化时,会生成新的 retain 值;旧的 RetainedPage 只是退出组合,仍被该条目的 store 持有,因此直到整个条目被 clearChild 前都不会触发 111/112 行的 close()。这在 UpdatesPage 的 E2/E6 回退中会发生:切换 bundle 后重新加载出的 page.module 会替换旧页面,失败页的引擎却继续占用资源。应为一次重新挂载显式释放旧的 retain child/PageHost,或约束并实现每个 retained navigation entry 内页面配置不可变,避免把重挂载产生的旧实例当作可返回页面保留。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

核实过 runtime-retain 1.12.1 源码,不成立:ManagedRetainedValuesStore.saveExitingValue 只在 isRetainingExitedValues(= isEnabled && !isContentComposed)时保留离开的值。条目在屏幕上(内容已组合)时,key 变化产生的旧值走 else 分支立即 onRetired → PageHost.close();只有整个条目离开组合期间离开的值才会被保留。条目重新进入组合时 onContentEnteredComposition 还会 purgeUnusedExitedValues,把没被取回的旧值关掉。所以 UpdatesPage 的 E2/E6 回退换包时,失败页的引擎当场关闭。

Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
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
Expand All @@ -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
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
}
}
DisposableEffect(pageHost, lifecycle) {
val observer = LifecycleEventObserver { _, event ->
Expand All @@ -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) {
Expand Down
33 changes: 23 additions & 10 deletions compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -102,15 +103,15 @@ fun ComponentRegistry.registerBuiltins(): ComponentRegistry = apply {
register(BuiltinSchemas.Column) { scope ->
val scroll = scope.get<Boolean>("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<String>("align")) { "center" -> Alignment.CenterHorizontally; "end" -> Alignment.End; else -> Alignment.Start },
) { scope.Children { child -> if (scroll) Modifier else weightOf(child)?.let { Modifier.weight(it) } ?: Modifier } }
}
register(BuiltinSchemas.Row) { scope ->
val scroll = scope.get<Boolean>("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<String>("align")) { "center" -> Alignment.CenterVertically; "end" -> Alignment.Bottom; else -> Alignment.Top },
) { scope.Children { child -> if (scroll) Modifier else weightOf(child)?.let { Modifier.weight(it) } ?: Modifier } }
Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -223,7 +232,7 @@ private fun boxAlignment(name: String?): Alignment = when (name) {
private fun TextFieldComponent(scope: NodeScope) {
val maxLength = scope.get<Double>("maxLength")?.toInt()?.takeIf { it > 0 }
fun clip(t: String) = clipTo(t, maxLength)
var text by remember { mutableStateOf(clip(scope.get<String>("initialText") ?: "")) }
var text by retain { mutableStateOf(clip(scope.get<String>("initialText") ?: "")) }
var focused by remember { mutableStateOf(false) }
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
Expand Down Expand Up @@ -289,7 +298,7 @@ private fun TextFieldComponent(scope: NodeScope) {
@Composable
private fun CodeFieldComponent(scope: NodeScope) {
val length = (scope.get<Double>("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) }
Expand Down Expand Up @@ -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() }
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
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") }
}
}
}
Expand All @@ -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)
Expand Down
36 changes: 34 additions & 2 deletions docs/app-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T : Any> rememberRetainedValuesNavEntryDecorator(): NavEntryDecorator<T> {
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. 对构建链的影响

Expand Down
3 changes: 2 additions & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 出现第一次真实的轮换诉求,或托管实例接入外部租户 |
Expand Down
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions sample/shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 12 additions & 6 deletions sample/shared/src/commonMain/kotlin/app/tinyui/sample/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,29 @@ 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
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")
Expand Down Expand Up @@ -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()) {
Expand All @@ -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()) } }
}
}
}
Expand Down
Loading
Loading