Skip to content

Commit c97ed41

Browse files
committed
feat: implement retention policy for background apps
- Introduced RetentionPolicy interface and RetentionManager class to manage background app retention. - Updated JSCore to ensure proper handling of lifecycle and termination APIs. - Enhanced AppManager to configure and observe memory pressure for retained apps. - Implemented background scheduler to manage timer callbacks during app suspension. - Added tests for background lifecycle and retention behavior. - Updated iOS DMPApp and DMPAppManager to support retention functionality.
1 parent 4b324bc commit c97ed41

30 files changed

Lines changed: 966 additions & 14 deletions

File tree

android/dimina/src/main/kotlin/com/didi/dimina/Dimina.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ class Dimina private constructor(context: Context) {
9494
return BuildConfig.DEBUG && config.debugMode
9595
}
9696

97+
@MainThread
98+
fun configureRetention(policy: com.didi.dimina.core.RetentionPolicy) {
99+
MiniApp.getInstance().configureRetention(policy)
100+
}
101+
102+
/** Hosts may forward their own pressure source here as well. */
103+
fun notifyMemoryPressure() { MiniApp.getInstance().notifyMemoryPressure() }
104+
97105
fun getApiNamespaces(): List<String> = config.apiNamespaces
98106

99107
private val appContext: Context = context
@@ -108,6 +116,17 @@ class Dimina private constructor(context: Context) {
108116
// 初始化核心组件
109117
private fun setupCoreComponents() {
110118
StoreUtils.initialize(context = appContext)
119+
appContext.registerComponentCallbacks(object : android.content.ComponentCallbacks2 {
120+
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {}
121+
override fun onLowMemory() { notifyMemoryPressure() }
122+
override fun onTrimMemory(level: Int) {
123+
if (level == android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW ||
124+
level == android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL ||
125+
level >= android.content.ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
126+
notifyMemoryPressure()
127+
}
128+
}
129+
})
111130
}
112131

113132
// 应用配置

android/dimina/src/main/kotlin/com/didi/dimina/core/MiniApp.kt

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,49 @@ import java.io.File
5656
*/
5757
class MiniApp private constructor() {
5858
private val tag = "MiniApp"
59+
private val retention = BackgroundRetention()
60+
private val retentionHandler by lazy { android.os.Handler(android.os.Looper.getMainLooper()) }
61+
private var retentionPressure = false
62+
private val retentionTask = Runnable { collectRetainedApps() }
63+
64+
@androidx.annotation.MainThread
65+
fun configureRetention(policy: RetentionPolicy) {
66+
retention.policy = policy
67+
scheduleRetention()
68+
}
69+
70+
fun notifyMemoryPressure() {
71+
retentionHandler.post {
72+
retentionPressure = true
73+
scheduleRetention()
74+
}
75+
}
76+
77+
internal fun retentionVisibility(appId: String, visible: Boolean) {
78+
if (visible) retention.forget(appId)
79+
else retention.hide(appId, android.os.SystemClock.elapsedRealtime())
80+
scheduleRetention()
81+
}
82+
83+
private fun scheduleRetention() {
84+
retentionHandler.removeCallbacks(retentionTask)
85+
retentionHandler.post(retentionTask)
86+
}
87+
88+
private fun collectRetainedApps() {
89+
retentionHandler.removeCallbacks(retentionTask)
90+
val now = android.os.SystemClock.elapsedRealtime()
91+
val canEvict: (String) -> Boolean = { DiminaActivity.canEvictRetainedApp(it) }
92+
val victims = retention.collect(now, retentionPressure, canEvict)
93+
retentionPressure = false
94+
victims.forEach { appId ->
95+
// No opener restoration or foreground task movement during cache eviction.
96+
DiminaActivity.closeForUninstall(appId)
97+
clear(appId)
98+
}
99+
retention.nextDelay(now, canEvict)?.let { retentionHandler.postDelayed(retentionTask, it) }
100+
}
101+
59102

60103
private val apiRegistry = ApiRegistry()
61104
private val bluetoothApi = BluetoothApi()
@@ -93,6 +136,7 @@ class MiniApp private constructor() {
93136
* @param miniProgram The MiniProgram to open
94137
*/
95138
fun openApp(context: Activity, miniProgram: MiniProgram) {
139+
collectRetainedApps()
96140
// Initialize or get JsCore for this MiniProgram
97141
val alreadyRunning = isRunning(miniProgram.appId)
98142
getOrCreateJsCore(miniProgram.appId, context)
@@ -467,6 +511,7 @@ class MiniApp private constructor() {
467511
*/
468512
@androidx.annotation.MainThread
469513
fun clear(appId: String) {
514+
retention.forget(appId)
470515
updateCheckRegistry.reset(appId)
471516
synchronized(this) {
472517
pendingAppShowOptions.remove(appId)
@@ -503,6 +548,8 @@ class MiniApp private constructor() {
503548
*/
504549
@androidx.annotation.MainThread
505550
fun clearAll() {
551+
retention.clear()
552+
retentionHandler.removeCallbacks(retentionTask)
506553
updateCheckRegistry.resetAll()
507554
synchronized(this) {
508555
pendingAppShowOptions.clear()
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.didi.dimina.core
2+
3+
/** SDK defaults, not a promise about another client's cache capacity. */
4+
data class RetentionPolicy(
5+
val maxBackgroundApps: Int = 3,
6+
val backgroundTimeoutMs: Long = 300_000,
7+
) {
8+
init {
9+
require(maxBackgroundApps >= 0) { "maxBackgroundApps must be non-negative" }
10+
require(backgroundTimeoutMs >= 0) { "backgroundTimeoutMs must be non-negative" }
11+
}
12+
}
13+
14+
/** Pure policy: timestamps are monotonic, duplicate hide never extends the lease. */
15+
internal class BackgroundRetention {
16+
var policy = RetentionPolicy()
17+
private val hidden = linkedMapOf<String, Long>()
18+
fun hide(id: String, now: Long) { hidden.putIfAbsent(id, now) }
19+
fun forget(id: String) { hidden.remove(id) }
20+
fun clear() { hidden.clear() }
21+
fun collect(now: Long, pressure: Boolean, canEvict: (String) -> Boolean): List<String> {
22+
val candidates = hidden.entries.filter { canEvict(it.key) }.sortedBy { it.value }
23+
val victims = mutableListOf<String>()
24+
for ((id, since) in candidates) {
25+
if (pressure || candidates.size - victims.size > policy.maxBackgroundApps ||
26+
(policy.backgroundTimeoutMs > 0 && now - since >= policy.backgroundTimeoutMs)) {
27+
victims.add(id)
28+
hidden.remove(id)
29+
}
30+
}
31+
return victims
32+
}
33+
fun nextDelay(now: Long, canEvict: (String) -> Boolean): Long? {
34+
if (policy.backgroundTimeoutMs == 0L) return null
35+
val since = hidden.filterKeys(canEvict).values.minOrNull() ?: return null
36+
return (policy.backgroundTimeoutMs - (now - since)).coerceAtLeast(1)
37+
}
38+
}

android/dimina/src/main/kotlin/com/didi/dimina/ui/container/DiminaActivity.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1906,6 +1906,7 @@ class DiminaActivity : ComponentActivity() {
19061906

19071907
fun hideMiniProgram() {
19081908
if (!isMiniProgramForeground() || isFinishing) return
1909+
activityRegistry.snapshot(miniProgram.appId).forEach { it.retainedByHost = true }
19091910
window.decorView.clearFocus()
19101911
// Queue options before another task can receive onStart.
19111912
queueOpenerReturn(null)
@@ -1981,7 +1982,11 @@ class DiminaActivity : ComponentActivity() {
19811982
* bootstrap lands - the pending intent is not lost, it is left for
19821983
* [reconcileAppVisibilityWithCore] to replay once a real JsCore exists.
19831984
*/
1985+
private var retainedByHost = false
1986+
19841987
private fun dispatchMiniProgramShow() {
1988+
retainedByHost = false
1989+
miniApp.retentionVisibility(miniProgram.appId, true)
19851990
val jsCore = miniApp.peekJsCore(miniProgram.appId)
19861991
if (jsCore != null) {
19871992
val showOptions = miniApp.consumePendingAppShowOptions(miniProgram.appId)?.apply {
@@ -1996,6 +2001,7 @@ class DiminaActivity : ComponentActivity() {
19962001
}
19972002

19982003
private fun dispatchMiniProgramHide() {
2004+
miniApp.retentionVisibility(miniProgram.appId, false)
19992005
miniApp.peekJsCore(miniProgram.appId)?.appHide()
20002006
com.didi.dimina.api.network.WebSocketManager.shared.setBackgrounded(miniProgram.appId, true)
20012007
}
@@ -2590,6 +2596,11 @@ class DiminaActivity : ComponentActivity() {
25902596
return true
25912597
}
25922598

2599+
internal fun canEvictRetainedApp(appId: String): Boolean {
2600+
val activity = activityRegistry.lastRegistered(appId) ?: return true
2601+
return activity.retainedByHost && !visibilityTracker.isForeground(appId)
2602+
}
2603+
25932604
internal fun closeForUninstall(appId: String) {
25942605
activityRegistry.closeAll(appId) { activity ->
25952606
activity.prepareForColdRestart()
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.didi.dimina.core
2+
3+
import org.junit.Assert.*
4+
import org.junit.Test
5+
6+
class BackgroundRetentionTest {
7+
@Test fun capacityUsesHideRecencyAndDoesNotRefreshDuplicateHide() {
8+
val state = BackgroundRetention()
9+
state.policy = RetentionPolicy(2, 0)
10+
state.hide("a", 0); state.hide("b", 1); state.hide("a", 2); state.hide("c", 3)
11+
assertEquals(listOf("a"), state.collect(3, false) { true })
12+
state.forget("b"); state.hide("b", 4); state.hide("d", 5)
13+
assertEquals(listOf("c"), state.collect(5, false) { true })
14+
}
15+
16+
@Test fun expiresAtDeadlineAndCancelsLastLease() {
17+
val state = BackgroundRetention()
18+
state.policy = RetentionPolicy(3, 100)
19+
state.hide("a", 0); state.hide("b", 50)
20+
assertEquals(1L, state.nextDelay(99) { true })
21+
assertEquals(emptyList<String>(), state.collect(99, false) { true })
22+
assertEquals(listOf("a"), state.collect(100, false) { true })
23+
state.forget("b")
24+
assertNull(state.nextDelay(100) { true })
25+
}
26+
27+
@Test fun pressureProtectsPinnedAppsAndZeroCapacityDisablesRetention() {
28+
val state = BackgroundRetention()
29+
state.hide("pinned", 0); state.hide("a", 1); state.hide("b", 2)
30+
assertEquals(listOf("a", "b"), state.collect(2, true) { it != "pinned" })
31+
state.policy = RetentionPolicy(0, 0)
32+
assertEquals(listOf("pinned"), state.collect(2, false) { true })
33+
}
34+
35+
@Test(expected = IllegalArgumentException::class)
36+
fun rejectsNegativeCapacity() { RetentionPolicy(-1) }
37+
}

docs/MiniProgram-Retention.md

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 小程序多实例与后台留存
22

3-
不同 appId 可以同时保留运行状态;同一个 appId 从宿主入口再次打开时复用已有实例,恢复当前页面栈,不重新执行 `App.onLaunch``Page.onLoad`
3+
不同 appId 可以同时保留运行状态;同一个 appId 从宿主入口再次打开时优先复用仍在缓存中的实例,恢复当前页面栈。实例已被回收时走冷启动,重新执行 `App.onLaunch``Page.onLoad`
44

55
## 关闭界面与销毁实例
66

@@ -40,7 +40,73 @@ Android、iOS 和 Harmony 的后台实例不能操作前台导航栈。销毁后
4040

4141
每个保留实例仍占用 JS 和 WebView 内存,宿主可通过主动销毁入口释放不再需要的实例。Harmony 隐藏期间的 TabBar 更新在恢复时应用;待处理更新最多 128 条,超过上限会失败,避免无限积压。
4242

43-
当前没有统一的运行时挂起、留存超时或按内存压力自动淘汰策略。宿主保持前台、某个小程序处于后台时,其 JS 仍可能继续执行;需要控制实例数量时,应通过宿主销毁接口释放实例。
43+
四端共用逻辑层的协作式挂起:`App.onHide` 执行后暂停 `setTimeout``setInterval` 和普通业务消息回调,包括网络、扩展事件与 Canvas 动画回调。`App.onShow` 时恢复,定时器继续剩余等待时间,周期定时器不补跑隐藏期间错过的次数。业务消息按接收顺序分批处理,每批最多 64 条,避免为每条积压消息创建原生定时器。
44+
45+
资源初始化、页面生命周期、销毁屏障和跨小程序导航的成功/失败/完成回调继续分发,避免隐藏后的退出或重启操作互相等待。挂起不抢占正在执行的 JS 或微任务,不冻结整个 Worker、WebView、原生网络或媒体任务;已到达但尚未分发的业务消息仍占用内存。原生能力继续遵循各自的隐藏与销毁规则。
46+
47+
原生宿主需要同步更新共享 JSSDK 中的 `service.js`,才能启用上述挂起逻辑;只升级原生 SDK、继续使用旧 JSSDK 时,只会生效原生实例回收策略。
48+
49+
## 宿主留存策略
50+
51+
| 配置 | 默认值 | 含义 |
52+
| --- | --- | --- |
53+
| `maxBackgroundApps` | `3` | 可回收后台缓存实例的数量上限;`0` 表示界面关闭后不留存 |
54+
| `backgroundTimeoutMs` | `300000` | 自最近一次隐藏起的超时,单位毫秒;`0` 关闭超时回收 |
55+
56+
配置必须是非负整数。这些默认值属于 Dimina,不是微信客户端固定数量或时长的承诺。配置更新后会重新检查已有缓存。
57+
58+
超限时按最近使用顺序回收:最早隐藏且此后未重新显示的实例最先释放。重复隐藏不会延长超时;显示后再次隐藏才开始新的留存周期。每个管理器只维护最近一个到期计时器,不轮询、不为每个实例创建回收计时器。宿主进程被系统挂起期间不能保证准点执行;启动入口会在复用前再次检查期限。
59+
60+
当前展示的实例,以及跨小程序导航中尚未解除的来源链,属于受保护的展示关系,不计入可回收缓存上限。宿主整体进入系统后台也不会立即解除该关系。因此总运行实例数可以大于 `maxBackgroundApps`,该配置不是进程总实例数的硬上限。实例脱离展示关系后才允许自动销毁,避免破坏返回页面与来源关系。
61+
62+
内存压力会释放所有可回收后台实例,保留受保护的展示关系;之后再次打开被回收实例走冷启动。回收释放运行时、页面和实例资源,不清除 Storage 或用户文件,也不自动打开其他小程序。
63+
64+
### Android
65+
66+
在主线程配置;调用 `Dimina.init` 后即可设置:
67+
68+
```kotlin
69+
import com.didi.dimina.core.RetentionPolicy
70+
71+
dimina.configureRetention(RetentionPolicy(maxBackgroundApps = 3, backgroundTimeoutMs = 300_000))
72+
```
73+
74+
SDK 自动监听应用的 `onLowMemory` 及低内存级别的 `onTrimMemory`,单纯的 `TRIM_MEMORY_UI_HIDDEN` 不当作内存告警。宿主也可主动调用 `dimina.notifyMemoryPressure()`
75+
76+
### iOS
77+
78+
在主线程配置:
79+
80+
```swift
81+
DMPAppManager.sharedInstance().configureRetention(
82+
DMPRetentionPolicy(maxBackgroundApps: 3, backgroundTimeoutMs: 300_000)
83+
)
84+
```
85+
86+
SDK 自动监听 `UIApplication.didReceiveMemoryWarningNotification`。宿主可在主线程主动调用 `DMPAppManager.sharedInstance().notifyMemoryPressure()`
87+
88+
### Harmony
89+
90+
```typescript
91+
import { DMPAppManager, DMPRetentionPolicy } from 'dimina'
92+
93+
DMPAppManager.sharedInstance().configureRetention(new DMPRetentionPolicy(3, 300000))
94+
```
95+
96+
启动时 SDK 向应用上下文注册一次环境监听,在 `onMemoryLevel` 中发起回收。宿主也可主动调用 `DMPAppManager.sharedInstance().notifyMemoryPressure()`
97+
98+
### Web
99+
100+
```typescript
101+
const container = createContainer({
102+
mount,
103+
retention: { maxBackgroundApps: 3, backgroundTimeoutMs: 300000 },
104+
})
105+
container.configureRetention({ maxBackgroundApps: 1, backgroundTimeoutMs: 60000 })
106+
container.notifyMemoryPressure()
107+
```
108+
109+
配置和实例池按容器隔离。浏览器没有通用且可靠的内存告警事件,因此 Web 内存压力入口由宿主触发,不依据不可靠的堆大小估算主动淘汰。
44110

45111
Harmony 的 `customLaunchPageCallBack` 自定义挂载页面没有框架路由入口,由宿主负责隐藏和恢复,不适用默认路由隐藏方法。
46112

@@ -54,9 +120,15 @@ Harmony 的 `customLaunchPageCallBack` 自定义挂载页面没有框架路由
54120
6. TabBar 场景重复上述操作,检查选中项、已加载 Tab、徽标及页面状态。
55121
7. 从不同场景重新打开同一实例,检查进入参数更新、旧来源关系清除,以及系统前后台切换后参数不回退。
56122

123+
8. 把后台上限设为 1,依次关闭 A、B:A 应被回收;重新打开 A 应冷启动。
124+
9. 缩短超时,检查到期回收、显示后取消旧期限,以及回收时其他前台小程序不受影响。
125+
10. 主动发起内存压力,检查后台缓存释放;当前展示实例与返回来源链仍可使用。
126+
11. 隐藏期间检查业务定时器和回调不执行,恢复后顺序正确;隐藏后的退出/重启回调与销毁屏障仍能完成。
127+
57128
回归入口:
58129

59-
- Android:`:dimina:testDebugUnitTest`以及示例应用中的任务栈切换
130+
- Android:`:dimina:testDebugUnitTest`其中 `BackgroundRetentionTest` 覆盖容量、期限与内存压力;界面仍需示例应用中的任务栈切换验证
60131
- iOS:`DMPRetainedMiniProgramTests``DMPNavigatorCapsuleTests`
61-
- Web:`retained-mini-program.spec.ts` 与容器 SDK 现有生命周期、并发打开测试。
132+
- Web:`retention-policy.spec.ts``retained-mini-program.spec.ts` 与容器 SDK 现有生命周期、并发打开测试。
133+
- 共享逻辑层:`background-scheduler.spec.js``background-lifecycle.spec.js`,验证挂起、恢复、定时器剩余时间和终止性回调的顺序。
62134
- Harmony:安装前端依赖后执行 `node --test harmony/scripts/retained-pages.test.mjs`;ArkUI 编译通过 `dimina:assembleHar` 验证,实际界面复用仍需设备验证。

docs/Multi-Mini-Program.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ B 调用 `navigateBackMiniProgram` 或 `exitMiniProgram` 后:
3434
3. 恢复的仍是原来的 A 实例,不重新触发 `App.onLaunch`
3535
4. A 以场景值 `1038` 触发 App Show 和当前 Page Show;`navigateBackMiniProgram``extraData` 放在 `referrerInfo.extraData` 中返回。
3636

37-
这条“隐藏不等于销毁”的边界也适用于宿主直接缓存的 Web `MiniApp``closeApp()` 只从呈现栈摘除实例,后续再次 `openApp()` 同一 `appId` 会前置缓存实例;传入 `destroy: true` 才会销毁其它实例
37+
这条“隐藏不等于销毁”的边界也适用于宿主直接缓存的 Web `MiniApp``closeApp()` 只从呈现栈摘除实例,后续再次 `openApp()` 同一 `appId` 会前置缓存实例;传入 `destroy: true` 会主动销毁其它实例;已脱离呈现栈的缓存还会按宿主留存策略自动回收
3838

3939
## 平台实现
4040

@@ -52,7 +52,7 @@ B 调用 `navigateBackMiniProgram` 或 `exitMiniProgram` 后:
5252
- `restartMiniProgram` 会替换当前实例的完整运行时,不属于后台恢复。
5353
- `exitMiniProgram` 会销毁当前目标实例;它不会销毁仍在呈现栈中的来源实例。
5454
- 后台保留是进程内能力,不是系统级持久化。宿主进程被系统终止后,需要按冷启动或宿主保存的恢复数据重新创建。
55-
- JavaScript 定时器、WebSocket、蓝牙和局域网等能力仍受各能力自身的后台限制及操作系统策略约束;例如 WebSocket 会按既有后台宽限策略中断。框架尚未实现统一的 JS 挂起或实例自动淘汰,详见[资源边界](./MiniProgram-Retention.md#生命周期与资源边界)
55+
- JavaScript 定时器、WebSocket、蓝牙和局域网等能力仍受各能力自身的后台限制及操作系统策略约束;例如 WebSocket 会按既有后台宽限策略中断。框架在逻辑层协作式暂停定时器与普通业务回调,并为脱离展示链的后台缓存提供数量、超时和内存压力回收,详见[资源边界](./MiniProgram-Retention.md#生命周期与资源边界)[宿主留存策略](./MiniProgram-Retention.md#宿主留存策略)
5656

5757
## 验证建议
5858

fe/packages/common/src/core/callback.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ class Callback {
1919
return evtId
2020
}
2121

22+
/** Lifecycle transactions must finish even after their initiating app hides. */
23+
allowInBackground(evtId) {
24+
if (this.callbacks[evtId]) this.callbacks[evtId].backgroundControl = true
25+
}
26+
27+
isAllowedInBackground(evtId) {
28+
return this.callbacks[evtId]?.backgroundControl === true
29+
}
30+
2231
/**
2332
* [Container] triggerCallback -> [Service] invoke
2433
* @param {*} evtId

fe/packages/container-sdk/__tests__/retained-mini-program.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,22 @@ it('refreshes host entry options without reviving an old mini-program opener', a
6666
mount.remove()
6767
}
6868
})
69+
70+
71+
it('reclaims a hidden runtime after capacity reduction and rebuilds it on next entry', async () => {
72+
const mount = document.createElement('div')
73+
document.body.appendChild(mount)
74+
const container = createContainer({ mount, retention: { maxBackgroundApps: 1, backgroundTimeoutMs: 0 } })
75+
const first = await container.openApp({ appId: 'evict-a' })
76+
await container.application.dismissView(first, { destroy: false })
77+
const second = await container.openApp({ appId: 'evict-b' })
78+
container.configureRetention({ maxBackgroundApps: 0, backgroundTimeoutMs: 0 })
79+
await vi.waitFor(() => expect(container.application.appManager.getAppById(first.appId)).toBeNull())
80+
expect(FakeWorker.instances[0].terminate).toHaveBeenCalledTimes(1)
81+
expect(container.application.views.at(-1)).toBe(second)
82+
const restored = await container.openApp({ appId: 'evict-a' })
83+
expect(restored).not.toBe(first)
84+
await container.application.destroyRootView(restored)
85+
await container.application.destroyRootView(second)
86+
mount.remove()
87+
})

0 commit comments

Comments
 (0)