跨端: fix(推送客户端) 热启点通知**不跳转** —— 真机实测逼出来的那半(只写格子没人读)

这是"收尾"里**静态判据看不出来**的那一类,只有设备能抓住。

## 实测经过(模拟器,可复核)

只写 `PendingRoute` 的那版(上一个提交 423ff9f):

    aa force-stop → aa start --ps data '{…open_mail…}'   ⇒ render MailDetailDestination ✓ 冷启能跳
    (应用已在运行)aa start --ps data '{…open_mail…}'   ⇒ **0 次**              ✗ 热启不跳

根因:冷启时页面**刚挂载**,`aboutToAppear` 会读那个静态格子;
热启时页面**早就挂载完**了、`aboutToAppear` 不会重跑 ⇒ 格子写得进去、**没人读**。
症状正是"点了通知,App 弹到前台,停在列表页" —— 而这恰恰是推送**最常见**的用法
(App 在后台,用户点通知回来看那封信)。

## 修法:补"事件发生时就交出去"的那条路

`PushService.deliverRoute(route)`:**有人监听就当场交出去,没人监听才留在格子里**。
两条路都要留着,因为两种启动各走一条(冷启没人监听、热启有人监听):

- `EntryAbility.onCreate` / `onNewWant` 都走 `deliverRoute`(两条启动方式共用一条投递路径);
- `CommPage.aboutToAppear` 注册监听、`aboutToDisappear` 摘除
  (不摘会叫醒已销毁的页面);
- 冷启与热启**共用同一个落点** `navigateToRoute`(各写一遍必然漏改一处)。

不用页面生命周期兜(`onPageShow` 之类):那会在"用户手动返回列表"时反复触发跳转,
而这里要的是"事件发生的那一刻"。

## 验证

真机(模拟器,装新包后实测):
- 热启 ⇒ `render current custom node: MailDetailDestination` ✓(修之前 0 次)
- 冷启回归 ⇒ 仍然 1 次 ✓(没被这次改动破坏)
- 截图硬证:详情页(返回箭头 + 详情窗格);"加载失败"是我塞的假 mail_id
  (`WARM456`)的**正确**后果 —— 说明确实带着那个 id 去取了

判据:`harmony-push.test.mjs` 18 → 19 条,新增「接线④:热启要能跳」,
**变异验证过两个方向**(deliverRoute 退回"只写格子" ⇒ 红;删掉监听器注册 ⇒ 红)。
它单独存在的理由:接线③那种"有人读"的检查**会放它过去** ——
`aboutToAppear` 里读 pendingRoute 完全满足③,而热启路径是死的。
This commit is contained in:
2026-09-17 19:08:27 +08:00
parent 423ff9fbb2
commit 549836193d
5 changed files with 139 additions and 22 deletions

View File

@ -237,9 +237,10 @@ test('★ 接线②:换账号后必须补报(同一 token 换账号是"转
test('★ 接线③:pendingRoute 必须有人**消费**(EntryAbility 只写不读 ⇒ 点通知停列表页)', () => {
const main = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'pages', 'MainPage.ets'));
const ent = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'entryability', 'EntryAbility.ets'));
// 写侧:ability 解析 want(冷启 onCreate + 热启 onNewWant)
const writes = (ent.match(/PushService\.pendingRoute = route/g) || []).length;
assert.equal(writes, 2, 'EntryAbility 的 onCreate 与 onNewWant 都要写 pendingRoute(冷启漏了就只有热启能跳)');
// 写侧:ability 解析 want(冷启 onCreate + 热启 onNewWant),两条都要交出去
const writes = (ent.match(/PushService\.deliverRoute\(route\)/g) || []).length;
assert.equal(writes, 2,
'EntryAbility 的 onCreate 与 onNewWant 都要 deliverRoute(冷启漏了就只有热启能跳,反之亦然)');
// 读侧:必须有页面真的读它,并**清空**它
assert.match(main, /PushService\.pendingRoute/,
'MainPage 必须读 pendingRoute —— 只有 EntryAbility 写、没人读,等于"点通知只拉起 App"');
@ -250,3 +251,45 @@ test('★ 接线③:pendingRoute 必须有人**消费**(EntryAbility 只写
'消费 pendingRoute 要落到邮件详情(openMail 往 navPathStack 压详情路由)');
});
/*
* ★★ 接线④:**热启**(应用已在运行)必须也能跳 —— 这一条是真机实测逼出来的。
*
* 实测经过(2026-09-17,模拟器):只写 `pendingRoute` 的版本,
* 冷启能跳(页面刚挂载 ⇒ `aboutToAppear` 会读),
* **热启一次都不跳**(页面早挂载完、`aboutToAppear` 不会重跑,格子没人读)。
* 症状:点了通知,App 弹到前台,停在列表 —— 而这**恰恰是推送最常见的使用场景**
* (App 在后台,用户点通知回来看那封信)。
*
* 静态判据单独钉这一条,是因为接线③那种"有人读"的检查**会放它过去**:
* `aboutToAppear` 里读 pendingRoute 完全满足③,但热启路径是死的。
* ⇒ 必须要有"**事件发生时就交出去**"的那条路(监听器),而不只是"页面起来时读一次"。
*/
test('★ 接线④:热启要能跳(页面已挂载 ⇒ 只写格子没人读 ⇒ 必须有人被"叫醒")', () => {
const svc = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'api', 'PushService.ets'));
const main = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'pages', 'MainPage.ets'));
// ① deliverRoute:有人监听就**当场交出去**,没人监听才留在格子里
const start = svc.indexOf('static deliverRoute(');
assert.ok(start > 0, 'PushService 要有 deliverRoute(两条启动方式共用一条投递路径)');
const body = svc.slice(start, start + 420);
assert.match(body, /routeListener/,
'deliverRoute 必须先看有没有监听者 —— 没有这一半,热启就是死的');
assert.match(body, /fn\(route\)|routeListener\(route\)/,
'有人监听时要**当场调用**它(这才是"叫醒")');
assert.match(body, /PushService\.pendingRoute = route/,
'没人监听(冷启)时才落进格子 —— 两半都要在,少一半就废掉一种启动方式');
// ② 消费侧:注册 + 摘除
assert.match(main, /PushService\.setRouteListener\(/,
'MainPage 要注册监听器(只在 aboutToAppear 读一次是不够的)');
assert.match(main, /PushService\.clearRouteListener\(\)/,
'aboutToDisappear 要摘掉监听器 —— 否则会叫醒已销毁的页面');
// ③ 两条路必须**共用同一个落点**,不许各写一遍(写两遍必然漏改一处)
// 调用点恰好 2 处:热启的监听器回调 + 冷启的 consumePendingRoute。
const navCalls = (main.match(/this\.navigateToRoute\(/g) || []).length;
assert.equal(navCalls, 2,
`冷启消费与热启监听都该走 navigateToRoute(实得 ${navCalls} 处调用),` +
'两条启动路径各写一遍跳转逻辑时,改一处必漏另一处');
assert.match(main, /private navigateToRoute\(route: PushRoute\): void \{/,
'共用落点要实现成 navigateToRoute');
});