From 549836193dd5bfdda21f5f2da97d17a5c147ab1d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 17 Sep 2026 19:08:27 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20fix(=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF)=20=E7=83=AD=E5=90=AF=E7=82=B9?= =?UTF-8?q?=E9=80=9A=E7=9F=A5**=E4=B8=8D=E8=B7=B3=E8=BD=AC**=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E7=9C=9F=E6=9C=BA=E5=AE=9E=E6=B5=8B=E9=80=BC?= =?UTF-8?q?=E5=87=BA=E6=9D=A5=E7=9A=84=E9=82=A3=E5=8D=8A=EF=BC=88=E5=8F=AA?= =?UTF-8?q?=E5=86=99=E6=A0=BC=E5=AD=90=E6=B2=A1=E4=BA=BA=E8=AF=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这是"收尾"里**静态判据看不出来**的那一类,只有设备能抓住。 ## 实测经过(模拟器,可复核) 只写 `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 完全满足③,而热启路径是死的。 --- client/electron/test/harmony-push.test.mjs | 49 +++++++++++++++++-- client/electron/test/run-all.mjs | 10 ++-- .../entry/src/main/ets/api/PushService.ets | 40 +++++++++++++++ .../main/ets/entryability/EntryAbility.ets | 14 +++++- .../entry/src/main/ets/pages/MainPage.ets | 48 +++++++++++++----- 5 files changed, 139 insertions(+), 22 deletions(-) diff --git a/client/electron/test/harmony-push.test.mjs b/client/electron/test/harmony-push.test.mjs index 142dfc9..3b984a4 100644 --- a/client/electron/test/harmony-push.test.mjs +++ b/client/electron/test/harmony-push.test.mjs @@ -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'); +}); + + diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 2ae392b..185a7e1 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -80,10 +80,12 @@ const SUITE = [ ['test/packaging.test.mjs', [], 5], ['test/align-refs.test.mjs', [], 3], ['test/harmony-deviceprobe.test.mjs', ['--experimental-strip-types', '--no-warnings'], 8], - // 契约/形状 15 条 + ★接线 3 条(2026-09-17 收尾:登录后补报 / 换账号补报 / pendingRoute 消费端)。 - // 前 15 条钉的是"点"(逻辑对不对),后 3 条钉的是"边"(谁调用、谁消费)—— - // 而"写好了但没人调用/没人读"编译一样通过(ArkTS 只编译可达模块,反向对照已复现过)。 - ['test/harmony-push.test.mjs', ['--experimental-strip-types', '--no-warnings'], 18], + // 契约/形状 15 条 + ★接线 4 条(2026-09-17 收尾:登录后补报 / 换账号补报 / + // pendingRoute 消费端 / **热启**也要能跳)。前 15 条钉的是"点"(逻辑对不对), + // 后 4 条钉的是"边"(谁调用、谁消费)—— "写好了但没人调用/没人读"编译一样通过 + // (ArkTS 只编译可达模块,反向对照已复现过)。接线④是真机实测逼出来的: + // 只写 pendingRoute 的版本冷启能跳、热启一次都不跳。 + ['test/harmony-push.test.mjs', ['--experimental-strip-types', '--no-warnings'], 19], // 服务器地址(apiBase):补 /api/v1 / 去尾斜杠不吃协议 // / 校验自带修法 / // 明文只对公网告警 / 404 说清“少了 /api/v1” / 网络错误码分类 / 归一化只有一份实现。 // 值判据跑真逻辑(model/ApiBase.ts);`.ets` 那几条是**静态**接线判据(无设备)。 diff --git a/client/harmony/entry/src/main/ets/api/PushService.ets b/client/harmony/entry/src/main/ets/api/PushService.ets index 0eadcf3..b1531b0 100644 --- a/client/harmony/entry/src/main/ets/api/PushService.ets +++ b/client/harmony/entry/src/main/ets/api/PushService.ets @@ -66,6 +66,46 @@ export class PushService { * 用一个静态格子而不是 AppStorage:ability 阶段拿不到页面状态,而静态格子两边都够得着。 */ static pendingRoute: PushRoute | undefined = undefined; + /* + * ★ 2026-09-17 真机实测补的**第二半**:光有"待处理格子"只够**冷启**。 + * + * 实测(模拟器,`aa start --ps data '{…open_mail…}'`):冷启能跳(页面刚挂载, + * `aboutToAppear` 会读 pendingRoute);但**应用已在运行时点通知**(`onNewWant`) + * 只把格子写上了,**没有任何东西会再读它** —— 那几个页面早就挂载完了、 + * `aboutToAppear` 不会重跑,用户看到的是"点了通知,App 弹到前台,停在列表"。 + * + * 为什么不用页面生命周期兜(`onPageShow` 之类):这里要的是"**事件发生的那一刻**" + * 而不是"页面每次可见时"—— 后者会在用户手动返回列表时反复触发跳转。 + * 所以:注册一个回调,`onNewWant` 里直接叫醒正在监听的页面。 + */ + private static routeListener: ((route: PushRoute) => void) | undefined = undefined; + + /** 页面注册"点通知要跳转"的回调(同一时刻只留一个:栈只有一处)。 */ + static setRouteListener(fn: (route: PushRoute) => void): void { + PushService.routeListener = fn; + } + + /** 页面退出时摘掉回调(否则会叫醒一个已销毁的页面)。 */ + static clearRouteListener(): void { + PushService.routeListener = undefined; + } + + /** + * 通知目标落地:**有人监听就立刻交出去,没人监听就留在格子里等页面来读**。 + * + * 两条路都要留着,因为两种启动方式各走一条: + * · 冷启(进程不在):页面还没挂载 ⇒ 没人监听 ⇒ 留在格子里,`aboutToAppear` 取走; + * · 热启(进程在、页面已挂载):有人监听 ⇒ 直接调,用户立刻看到那封信。 + */ + static deliverRoute(route: PushRoute): void { + const fn: ((route: PushRoute) => void) | undefined = PushService.routeListener; + if (fn !== undefined) { + fn(route); + return; + } + PushService.pendingRoute = route; + } + private context: common.UIAbilityContext; private account: AccountManager; diff --git a/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets b/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets index c622ac9..ea7ee64 100644 --- a/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets +++ b/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets @@ -45,7 +45,12 @@ export default class EntryAbility extends UIAbility { : want.parameters as Record; const route = PushService.routeFromWant(params, this.ledger); if (route !== undefined) { - PushService.pendingRoute = route; + /* + * 冷启:页面还没挂载 ⇒ 没人监听 ⇒ 落进格子里,页面起来后由 aboutToAppear 取走。 + * 走 deliverRoute 而不是直接写字段,是为了让"两种启动方式"共用一条路径 —— + * 见 PushService.deliverRoute 的说明。 + */ + PushService.deliverRoute(route); } } catch (err) { hilog.info(DOMAIN, 'testTag', '冷启通知跳转解析失败(静默):%{public}s', JSON.stringify(err)); @@ -76,7 +81,12 @@ export default class EntryAbility extends UIAbility { : want.parameters as Record; const route = PushService.routeFromWant(params, this.ledger); if (route !== undefined) { - PushService.pendingRoute = route; // 页面起来后读它 + /* + * 热启(应用已在运行):页面**早就挂载完**了,`aboutToAppear` 不会重跑 —— + * 所以这里必须**主动叫醒**正在监听的页面,否则"点通知"只会把 App 弹到前台。 + * 这是 2026-09-17 模拟器实测出来的:只写字段时,热启路径**完全没有跳转**。 + */ + PushService.deliverRoute(route); } } catch (err) { hilog.info(DOMAIN, 'testTag', '通知跳转解析失败(静默):%{public}s', JSON.stringify(err)); diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 270a1a0..0924665 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -1143,11 +1143,44 @@ struct CommPage { aboutToAppear(): void { this.refreshCounts(); + /* + * ★ 注册"点通知要跳转"的回调(2026-09-17 真机实测补的**另一半**)。 + * + * 冷启靠下面的 consumePendingRoute 就够了;但**应用已在运行时点通知**时, + * 本页早就挂载完了、`aboutToAppear` 不会重跑 ⇒ 只写 `pendingRoute` 是**没人读**的。 + * 实测(模拟器,`aa start --ps data '{…open_mail…}'` 两次):冷启进了详情页, + * 热启**一次都没进** —— 用户看到的是"点了通知,App 弹到前台,停在列表"。 + * 所以这里登记回调,由 `EntryAbility.onNewWant` → `PushService.deliverRoute` 直接叫醒。 + */ + PushService.setRouteListener((route: PushRoute): void => { + this.navigateToRoute(route); + }); this.consumePendingRoute(); } + aboutToDisappear(): void { + // 必须摘掉:留着会叫醒一个已销毁的页面(跳转落空,还容易误判成"推送坏了") + PushService.clearRouteListener(); + } + + /** 跳转到通知指定的那封信(冷启与热启**共用这一处落点**,两条路不许各写一遍) */ + private navigateToRoute(route: PushRoute): void { + /* + * 账号:通知的 `data` 里只有 mail_id/session_id(服务端契约如此),没有 account_id。 + * 所以用**当前活跃账号** —— 点通知的人就是本机正在用的那个人。 + */ + const ctx = this.getUIContext().getHostContext(); + let accountId: string = ''; + if (ctx !== undefined) { + accountId = AccountManager.getInstance(ctx).getActiveId(); + } + // 通知落的就是收件箱里那封信 ⇒ 先把栏切回收件箱,再压详情页 + this.commTab = 'inbox'; + this.openMail(route.mailId, accountId); + } + /** - * 消费「点通知进来」的待处理跳转(收尾项,pi 邮件 `66bbd929` §三)。 + * 消费「点通知**冷启**」留下的待处理跳转(收尾项,pi 邮件 `66bbd929` §三)。 * * 断链在哪:`EntryAbility` 已经把通知的 `data` 解析成 `PushService.pendingRoute` * (冷启在 `onCreate`、热启在 `onNewWant`),**但没有任何东西读它** —— @@ -1171,18 +1204,7 @@ struct CommPage { return; } PushService.pendingRoute = undefined; - /* - * 账号:通知的 `data` 里只有 mail_id/session_id(服务端契约如此),没有 account_id。 - * 所以用**当前活跃账号** —— 点通知的人就是本机正在用的那个人。 - */ - const ctx = this.getUIContext().getHostContext(); - let accountId: string = ''; - if (ctx !== undefined) { - accountId = AccountManager.getInstance(ctx).getActiveId(); - } - // 通知落的就是收件箱里那封信 ⇒ 先把栏切回收件箱,再压详情页 - this.commTab = 'inbox'; - this.openMail(route.mailId, accountId); + this.navigateToRoute(route); } /** 徽标数字:未读(收件箱里**要读的**那些)+ 待决策(授权栏) */