fix(harmony)★★: MailStore 收/发共用一份快照 + 壁纸 PixelMap 泄漏 —— 两处 HIGH
★★ 前置事实修正:报告写的「本机无 hvigorw / 无 SDK / HarmonyOS 侧没有编译过」
**已不成立** —— `/opt/huawei/command-line-tools/bin/hvigorw` 6.26.2 可用,
`hvigorw assembleHap --no-daemon` 出 **BUILD SUCCESSFUL**(约 26 s)。
⇒ 本次两条 HIGH 都是**真编译过**的,不是静态推断。这是本次最大的认知变化:
「改 ArkTS 无法验证 ⇒ 只能推给下一个人」这个前提可以取消了。
## ① MailStore:收件箱与发件箱共用同一份快照(HIGH)
`loadInbox` 与 `loadSent` 此前**全部往同一个 `snapshot` 写**,而两者是两个独立
`@Component`(`InboxTab`/`SentTab`),各自 `await` 完才 `applyStoreSnapshot(...)`
⇒ 切页签 / SSE 交错时**发件箱那次把收件箱那份覆盖掉**:`unread = 0`、
`mails` 换成发件箱的、`loading` 互相关闭 ⇒ 症状是「收件箱未读被清零 /
列表短暂空白 / 转圈停了但列表是空的」。
★ 为什么此前没人动(旧报告标 CRITICAL 却长期未修):它当时的理由是
「Navigation 分栏下两个 pane 同时在屏」这个**必然场景**;`commTab` 后来改成
同一时刻只 mount 一个 ⇒ 那个"必然"没了 ⇒ 从"每次都坏"降级成"交错时坏"。
**这是判据缺失导致缺陷降级**——本条判据就是为了不让它再降级。
**修法(两份快照 + 代号守卫,各挡一半,都要有)**:
· 新增 `sentSnapshot`,`loadSent`/`paintSentFromCache` 只写它;
`SentTab` 读 `store.sentSnapshot`(收件箱侧零改动)。
· `inboxGen` / `sentGen` 代号:同一栏**自己**的两次 load 也可能乱序到达
(SSE 叫醒一次、切页签又触发一次)⇒ 旧的**后**到会盖掉新的。
⚠️ 守卫**只拦写快照**,`loading = false` 仍要执行 —— 否则新请求的 spinner 被挂住。
· `clear()` 清两份,**并把两个代号都 +1 作废**:否则**正在飞**的旧请求回来时
`myGen` 仍等于旧值 ⇒ 会把清空后的快照重新填上旧数据(登出/切账号正是此时)。
★★ 顺带修一个**我差点引入的回归**:拆开之前两份共用一份 ⇒ 归档一个会话会
同时抹掉两边的它。拆开后若只动收件箱,归档完**发件箱仍显示该会话**(而服务端已删)。
⇒ `dropSession` 拆成 `dropSessionFromInbox` / `dropSessionFromSent`,
**两份都要剔**;且发件箱那份**不能用** `splitByPermission(...).normal`
(发件箱不做权限分流,那一筛会抹掉「我发出的授权请求」——09-20 修过的
「发件箱一片空白」同一族)。
## ② AppearanceStore:壁纸 PixelMap 泄漏 + 全尺寸解码(HIGH)
`loadWallpaper` 此前既不设 `desiredSize`、也从不 `release()`:
· `PixelMap` 是**原生内存**,每次同步/每次切账号重新解码一张,全部不释放;
而它是**静态单例**、活过登出 ⇒ **换账号这条路必然泄漏**(无任何清理入口)。
· 不带 `desiredSize` ⇒ 按原图尺寸解。`BackgroundPicker` 只把上传边长卡在
`MAX_EDGE = 2560` ⇒ 一张 2560×2560 ARGB ≈ **26 MB** 常驻,
而它永远被合成器降采样着全屏画。
**修法**(照 `BackgroundPicker` 既有形状,不另创一套):
· `desiredSize` 取 `display.getDefaultDisplaySync()` 的 width/height,
不写死魔数(随屏变)。★ 它**会抛**(SDK 注释 1400001)⇒ 必须 catch,
取不到就退回"不限制尺寸",而不是把壁纸整条路断掉。
· 所有权:先 `this.wallpaper = next` 再释放**旧的**,并把局部变量置空 ——
★ 若在 `finally` 里释放 `this.wallpaper`,会把**刚装上的那张**释放掉,
这是最容易写反的一处。
· 新增 `releaseWallpaper()`,并在 `Logout.ets` 里与 `MailStore.clear()` 并排调用
—— 后者是该方法**唯一**的调用点,没有它它就只是"写好了没人用"。
## 判据(harmony-arkts 8 → 10;harmony-logic 补一格)
新两条按**括号配对**取方法体(`balanced()`,不用 `\{[\s\S]{0,N}` 窗口)。
**变异测试 5 个全部抓住**:loadSent 写回 snapshot / SentTab 读错快照 /
归档不剔发件箱 / 释放的是新图而非旧的 / 去掉 desiredSize。
★ 顺带修一条**HEAD 上就在红的判据**(不是本次引入):`harmony-logic` 那条
「先分家、后折叠」只认字面量 `groupMailsBySession(split.normal)`,
而源码**本来就是** `const inboxMails: MailLike[] = split.normal;` 后
`groupMailsBySession(inboxMails)` ⇒ 恒红。
⇒ 补上"经中间变量"这一支;变异验证:把 `inboxMails` 换成 `mergedMails`
(权限邮件混进收件箱)仍**照红** ✓ —— 放宽的是写法假设,不是判定。
## 编译期硬规则(ArkTS 不接受对象字面量当类型)
`targetSize()` 第一版返回 `{ width: number, height: number }` ⇒ 编译报
`arkts-no-obj-literals-as-types` / `arkts-no-untyped-obj-literals`(连
**返回的对象字面量**也要能对应到**具名**类型)。⇒ 必须先声明 interface,
且每个字面量先赋给**显式类型的局部变量**再 return。
★ 这条**编译期硬规则**在本仓无判据覆盖(harmony-arkts 判的是 import 位置那一类)
⇒ 只能靠真编译抓;而它能抓,再次证明「ArkTS 本机无法验证」已不成立。
## 边界 / 未做
· **本条判据证明不了运行时症状**:切页签交错那条要设备才能造,本仓无那种探针。
静态层只把住「两栏各写各的」。设备那半**仍未验**。
· `releaseWallpaper` 的**实际内存回收**未在真机验证(memory profiler)。
· 离屏解码(`@Concurrent`/taskpool)**本轮不做** —— 全仓 0 先例,
单独引入会扩大风险面。
This commit is contained in:
@ -57,6 +57,26 @@ function allEts(dir = ETS_ROOT) {
|
||||
|
||||
const rel = (p) => p.slice(ETS_ROOT.length + 1);
|
||||
|
||||
/**
|
||||
* 从 `openIdx`(某个 `{` 的位置)开始按**大括号配对**取出一个方法体。
|
||||
*
|
||||
* ★ 为什么不用 `\{[\s\S]{0,N}` 那种固定宽度窗口:`CRITERIA.md` §1 记着
|
||||
* 那个形状被"往规则里加一行注释"绕过(本仓已是第三次露头)。
|
||||
* 而下面两条判据要的正是"**这个方法体里**有没有某个调用" ——
|
||||
* 用窗口就会把**别的方法**里的话算进来(判据的匹配范围 > 它声称的语义范围)。
|
||||
*/
|
||||
function balanced(src, openIdx) {
|
||||
let depth = 0;
|
||||
for (let i = openIdx; i < src.length; i += 1) {
|
||||
if (src[i] === '{') depth += 1;
|
||||
else if (src[i] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return src.slice(openIdx + 1, i);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐行扫"最后一个 import"与"第一个非 import 语句"的位置。
|
||||
*
|
||||
@ -658,3 +678,163 @@ test('★ 不许用已废弃的全局 API(`router` / `promptAction` / `animate
|
||||
assert.equal(hit('animateTo({}, () => {});', 2), true, '要抓得住全局 animateTo');
|
||||
assert.equal(hit('const x = this.getUIContext().vp2px(24);', 3), false, '不许误伤 ui.vp2px');
|
||||
});
|
||||
|
||||
/*
|
||||
* ══════════════ ★★ 收件箱/发件箱**必须是两份快照**(2026-10-03)══════════════
|
||||
*
|
||||
* ## 缺陷是什么
|
||||
*
|
||||
* `MailStore.loadInbox` 与 `loadSent` 此前**全部往同一个 `snapshot` 写**,
|
||||
* 而两者是两个独立 `@Component`(`InboxTab` / `SentTab`),各自 `await` 完
|
||||
* 自己的 load 之后才 `applyStoreSnapshot(store.snapshot)`。
|
||||
* ⇒ 切页签 / SSE 事件与两者交错时,**发件箱那次会把收件箱那份覆盖掉**:
|
||||
* `snap.unread = 0`、`snap.mails` 换成发件箱的、`loading` 互相关闭。
|
||||
* 症状:收件箱未读被清零 / 列表短暂空白 / 「转圈停了但列表是空的」。
|
||||
*
|
||||
* ★ 旧报告(`harmony-state-review.md` #1)把它标成 CRITICAL,理由是
|
||||
* 「Navigation 分栏下两个 pane 同时在屏」。但 `commTab` 后来改成
|
||||
* 同一时刻只 mount 一个 ⇒ 那个"必然"没了,症状变罕见 ⇒ 没人动。
|
||||
* **这就是「判据缺失导致缺陷降级」**:从"每次都坏"降到"交错时坏"。
|
||||
*
|
||||
* ## 为什么是**结构**判据而不是设备判据
|
||||
*
|
||||
* 本条断言的是「两栏各写各的那份快照」—— 这是**源码结构**,静态可判。
|
||||
* 真正的运行时症状(切页签交错)要设备才能造,而本仓**没有**那种探针。
|
||||
* ⇒ 静态层把住结构,运行时那半记在下面那条的说明里(别假装已验)。
|
||||
*
|
||||
* ## 变异测试(两个方向都实测过)
|
||||
*
|
||||
* ① 把 loadSent 的 `this.sentSnapshot` 改回 `this.snapshot` ⇒ 本条红
|
||||
* ② 把 SentTab 的 `store.sentSnapshot` 改回 `store.snapshot` ⇒ 本条红
|
||||
*/
|
||||
test('★ 收件箱与发件箱各写各的快照(共用一份 ⇒ 切页签/SSE 交错时互相覆写)', () => {
|
||||
const storePath = join(ETS_ROOT, 'common', 'MailStore.ets');
|
||||
const pagePath = join(ETS_ROOT, 'pages', 'MainPage.ets');
|
||||
const store = code(storePath);
|
||||
const page = code(pagePath);
|
||||
|
||||
// 两份快照字段都在
|
||||
assert.match(store, /^\s*snapshot:\s*MailSnapshot\s*=\s*new MailSnapshot\(\);/m,
|
||||
'MailStore 要有收件箱那份 `snapshot`');
|
||||
assert.match(store, /^\s*sentSnapshot:\s*MailSnapshot\s*=\s*new MailSnapshot\(\);/m,
|
||||
'★ MailStore 要**另外**有 `sentSnapshot` —— 只有一份就是那个缺陷本身');
|
||||
|
||||
// 取 loadSent 的方法体(按大括号配对,不用固定宽度窗口 —— CRITERIA.md §1)
|
||||
const loadSent = /async loadSent\([^)]*\)[^{]*\{/.exec(store);
|
||||
assert.ok(loadSent, '未找到 loadSent');
|
||||
const body = balanced(store, loadSent.index + loadSent[0].length - 1);
|
||||
assert.ok(!/\bthis\.snapshot\b/.test(body),
|
||||
'★ loadSent 里出现 `this.snapshot` ⇒ 它仍在写收件箱那份 ⇒ 两者会互相覆写。' +
|
||||
'应当写 `this.sentSnapshot`。');
|
||||
|
||||
// clear() 要清两份
|
||||
const clearAt = /clear\(\)\s*:\s*void\s*\{/.exec(store);
|
||||
assert.ok(clearAt, '未找到 clear()');
|
||||
const clearBody = balanced(store, clearAt.index + clearAt[0].length - 1);
|
||||
assert.match(clearBody, /this\.snapshot\s*=\s*new MailSnapshot\(\)/,
|
||||
'clear() 要清收件箱那份');
|
||||
assert.match(clearBody, /this\.sentSnapshot\s*=\s*new MailSnapshot\(\)/,
|
||||
'★ clear() 也要清发件箱那份 —— 它同样是跨账号可见的(`SentTab` 直接读它)');
|
||||
|
||||
// SentTab 的读取点
|
||||
assert.match(page, /applyStoreSnapshot\(store\.sentSnapshot\)/,
|
||||
'★ SentTab 必须读 `store.sentSnapshot`;读 `store.snapshot` 就是取错了那一份');
|
||||
|
||||
/*
|
||||
* ★ `dropSession` 也要两份都剔 —— 这是**我差点引入的回归**:
|
||||
* 拆开之前两份共用一份快照 ⇒ 归档一个会话会同时抹掉两边的它。
|
||||
* 拆开之后若只动收件箱,归档完**发件箱仍显示该会话**(而服务端已经没了)。
|
||||
* ⇒ 这条是拆分的**必要配套**,不是额外功能。
|
||||
*
|
||||
* ★ 判据要判**两个 helper 各自拿的是哪一份**,而不是判 `dropSession` 的体里
|
||||
* 出现过 `this.snapshot` —— 实现把两份分给了两个私有方法(各自的类型标注
|
||||
* 要显式写,ArkTS 不给推断),主方法只做派发。
|
||||
* 第一版我判 `dropSession` 的体 ⇒ **假红**(它当然不含 `this.snapshot`)。
|
||||
*/
|
||||
const dropAt = /dropSession\(sessionId[^{]*\{/.exec(store);
|
||||
assert.ok(dropAt, '未找到 dropSession');
|
||||
const dropBody = balanced(store, dropAt.index + dropAt[0].length - 1);
|
||||
assert.match(dropBody, /dropSessionFromInbox\(/,
|
||||
'dropSession 要剔收件箱那份');
|
||||
assert.match(dropBody, /dropSessionFromSent\(/,
|
||||
'★ dropSession 也要剔发件箱那份 —— 否则归档后发件箱仍显示它(而服务端已删)');
|
||||
|
||||
const inboxAt = /private dropSessionFromInbox\([^)]*\)[^{]*\{/.exec(store);
|
||||
assert.ok(inboxAt, '未找到 dropSessionFromInbox');
|
||||
const inboxBody = balanced(store, inboxAt.index + inboxAt[0].length - 1);
|
||||
assert.match(inboxBody, /this\.snapshot/,
|
||||
'dropSessionFromInbox 要动收件箱那份');
|
||||
assert.ok(!/this\.sentSnapshot/.test(inboxBody),
|
||||
'dropSessionFromInbox 不该动发件箱那份(那是 Sent 的活)');
|
||||
|
||||
const sentAt = /private dropSessionFromSent\([^)]*\)[^{]*\{/.exec(store);
|
||||
assert.ok(sentAt, '未找到 dropSessionFromSent');
|
||||
const sentBody = balanced(store, sentAt.index + sentAt[0].length - 1);
|
||||
assert.match(sentBody, /this\.sentSnapshot/,
|
||||
'★ dropSessionFromSent 要动发件箱那份');
|
||||
/*
|
||||
* ★★ 而发件箱那份**不能**复用收件箱的 `splitByPermission(...).normal`:
|
||||
* 发件箱不做权限分流,而那一筛会把「我发出的授权请求」挑出去
|
||||
* ⇒ 对它们就会**抹掉**(09-20 修过的"发件箱一片空白"同一族)。
|
||||
*/
|
||||
assert.ok(!/splitByPermission/.test(sentBody),
|
||||
'★ dropSessionFromSent 不得用 splitByPermission —— 发件箱不做权限分流(见 loadSent 注释)');
|
||||
assert.match(sentBody, /groupMailsBySession\(kept\)/,
|
||||
'发件箱那份直接对全部 kept 分组(与 loadSent 同口径)');
|
||||
});
|
||||
|
||||
/*
|
||||
* ★★ 壁纸的 `PixelMap` 必须**有释放路径**(2026-10-03)
|
||||
*
|
||||
* ## 缺陷是什么
|
||||
*
|
||||
* `AppearanceStore.loadWallpaper` 此前既不设 `desiredSize`、也从不 `release()`:
|
||||
* ① `PixelMap` 是**原生内存**,每次同步/每次切账号都重新解码一张,全部不释放;
|
||||
* 而 `AppearanceStore` 是**静态单例**、活过登出 ⇒ **换账号这条路必然泄漏**;
|
||||
* ② 不带 `desiredSize` ⇒ 按原图尺寸解。`BackgroundPicker` 只把上传边长卡在
|
||||
* `MAX_EDGE = 2560` ⇒ 一张 2560×2560 ARGB ≈ **26 MB** 常驻。
|
||||
*
|
||||
* ## 为什么判"有 release 调用"而不是"内存没涨"
|
||||
*
|
||||
* 真机上不好直接观测 native 内存用量,而**释放调用是否存在**是静态可判的。
|
||||
* 这与 `harmony-appearance` 里那些设备判据是分工:那边看**观感**,
|
||||
* 这边看**资源纪律**。
|
||||
*
|
||||
* ⚠️ 边界:这**证明不了**「真的没有泄漏」—— 只证明"释放的调用写下来了"。
|
||||
* 泄漏的实证仍需真机(memory profiler)。
|
||||
*/
|
||||
test('★ 壁纸 PixelMap 必须释放,且解码要有目标尺寸(不释放 ⇒ 换账号必泄漏)', () => {
|
||||
const path = join(ETS_ROOT, 'common', 'AppearanceStore.ets');
|
||||
const src = code(path);
|
||||
|
||||
const at = /async loadWallpaper\([^)]*\)[^{]*\{/.exec(src);
|
||||
assert.ok(at, '未找到 loadWallpaper');
|
||||
const body = balanced(src, at.index + at[0].length - 1);
|
||||
|
||||
assert.match(body, /desiredSize/,
|
||||
'★ loadWallpaper 必须给 `desiredSize` —— 不给就按原图尺寸解码' +
|
||||
'(BackgroundPicker 的 MAX_EDGE=2560 ⇒ 最坏一张 ≈26 MB 常驻)');
|
||||
|
||||
assert.match(body, /\.release\(\)/,
|
||||
'★ loadWallpaper 必须 release —— PixelMap/ImageSource 是原生内存,不释放不回收');
|
||||
|
||||
assert.match(body, /finally/,
|
||||
'释放要放在 `finally` 里(该路径有多个出口,逐处 release 必漏一处或重一处)');
|
||||
|
||||
/*
|
||||
* ★ 所有权:必须是「换引用 → 释放**旧的**」,而不是释放 `this.wallpaper`。
|
||||
* 若在 `finally` 里 `this.wallpaper.release()`,会把**刚装上的那张**释放掉
|
||||
* —— 那正是最容易写反的一处,也是判据要单独钉它的理由。
|
||||
*/
|
||||
assert.match(body, /this\.wallpaper\s*=\s*next/,
|
||||
'先把新图挂上(转移所有权),再释放旧的那张');
|
||||
assert.match(body, /old[^\n]*\.release\(\)|old\.release\(\)/,
|
||||
'★ 要释放的是**旧的**那张(`old`),不是 `this.wallpaper`');
|
||||
|
||||
// 清理入口必须存在且被调用(否则方法只是"写好了没人用")
|
||||
assert.match(src, /releaseWallpaper\(\)\s*:\s*void/,
|
||||
'要有 releaseWallpaper() 清理入口(AppearanceStore 是静态单例、活过登出)');
|
||||
const logout = code(join(ETS_ROOT, 'api', 'Logout.ets'));
|
||||
assert.match(logout, /releaseWallpaper\(\)/,
|
||||
'★ releaseWallpaper() 必须有调用点(登出)—— 否则这个方法只是摆设');
|
||||
});
|
||||
|
||||
@ -698,8 +698,22 @@ test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 +
|
||||
const splitAt = inboxBody.indexOf('splitByPermission(mergedMails)');
|
||||
/* 折叠的对象必须是 `split.normal`(允许经中间变量转发) */
|
||||
const directAt = inboxBody.indexOf('groupMailsBySession(split.normal)');
|
||||
/*
|
||||
* ★ 2026-10-03 补一个真实的合法写法:`const inboxMails: MailLike[] = split.normal;`
|
||||
* 然后 `groupMailsBySession(inboxMails)`。
|
||||
* 源码里**本来就是**这么写的(`MailStore.loadInbox:595/609`,HEAD 上也是),
|
||||
* 而本判据只认 `split.normal` 字面量 ⇒ **恒红**。
|
||||
* ⚠️ 这不是代码回归,是判据的**写法假设**比实现的自由度窄 ——
|
||||
* 两条断言(分家在前、后折叠)本意都在,只是"后折叠"那半没覆盖中间变量。
|
||||
* 形状上与上面的 `viaVar` 同族,统一按「`split.normal` 赋给一个变量,
|
||||
* 后面拿那个变量折叠」来判。
|
||||
*/
|
||||
const aliasDecl = /const\s+(\w+)\s*:\s*MailLike\[\]\s*=\s*split\.normal/.exec(inboxBody);
|
||||
const aliasAt = aliasDecl
|
||||
? inboxBody.indexOf(`groupMailsBySession(${aliasDecl[1]})`)
|
||||
: -1;
|
||||
const viaVar = /const\s+\w+:\s*MailLike\[\]\s*=\s*split\.normal[\s\S]{0,120}?groupMailsBySession\(\w+\)/.exec(inboxBody);
|
||||
const groupAt = directAt >= 0 ? directAt : (viaVar ? inboxBody.indexOf(viaVar[0]) : -1);
|
||||
const groupAt = directAt >= 0 ? directAt : (aliasAt >= 0 ? aliasAt : (viaVar ? inboxBody.indexOf(viaVar[0]) : -1));
|
||||
assert.ok(splitAt >= 0 && groupAt >= 0,
|
||||
'收件箱要“先分家(`split.normal`)、后折叠” —— ' +
|
||||
'喂 `mergedMails`(含权限请求)就是把权限邮件混进收件箱,这条判据防的正是它');
|
||||
|
||||
@ -163,7 +163,7 @@ const SUITE = [
|
||||
['test/harmony-imageprep.test.mjs', ['--experimental-strip-types', '--no-warnings'], 31],
|
||||
// ArkTS **编译期**硬规则(纯文本可判、不需要设备)。这一条是构建撞出来的:
|
||||
// 我把常量表插在了既有 import 之前 ⇒ arkts-no-misplaced-imports,而当时没有任何判据会跑它。
|
||||
['test/harmony-arkts.test.mjs', [], 8],
|
||||
['test/harmony-arkts.test.mjs', [], 10],
|
||||
/*
|
||||
* 2in1 键盘可达(用户 2026-09-21「快捷键打开发信页面 / 上下键切换发信目标 /
|
||||
* 回车展开输入框」)。见该文件头部说明:为什么单开一个文件、为什么不端到端验。
|
||||
|
||||
Reference in New Issue
Block a user