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「快捷键打开发信页面 / 上下键切换发信目标 /
|
||||
* 回车展开输入框」)。见该文件头部说明:为什么单开一个文件、为什么不端到端验。
|
||||
|
||||
@ -22,6 +22,7 @@ import { ApiClient } from './ApiClient';
|
||||
import { AuthApi } from './AuthApi';
|
||||
import { AccountManager } from './AccountManager';
|
||||
import { MailStore } from '../common/MailStore';
|
||||
import { AppearanceStore } from '../common/AppearanceStore';
|
||||
import { SseService } from './SseService';
|
||||
import { PushService } from './PushService';
|
||||
|
||||
@ -110,6 +111,19 @@ export async function performLogout(
|
||||
hilog.info(0x0001, 'Logout', 'MailStore.clear 失败:%{public}s', JSON.stringify(e));
|
||||
}
|
||||
|
||||
/*
|
||||
* ★ 2026-10-03:壁纸也要清(与 MailStore 同一形状、同一理由)。
|
||||
* `AppearanceStore` 是**静态单例**、活过登出,而 `wallpaper` 是**原生内存**
|
||||
* ⇒ 不释放就是真泄漏(旧的那张不会 GC)。
|
||||
* 同时这也是 `releaseWallpaper()` 的**唯一调用点** ——
|
||||
* 没有它,这个方法就只是"写好了没人用"。
|
||||
*/
|
||||
try {
|
||||
AppearanceStore.getInstance().releaseWallpaper();
|
||||
} catch (e) {
|
||||
hilog.info(0x0001, 'Logout', 'releaseWallpaper 失败:%{public}s', JSON.stringify(e));
|
||||
}
|
||||
|
||||
/*
|
||||
* ④ `replaceUrl` 而不是 `pushUrl`:退出后不该还能"返回"到已登出的页。
|
||||
*
|
||||
|
||||
@ -15,6 +15,7 @@ import { common } from '@kit.AbilityKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { image } from '@kit.ImageKit';
|
||||
import { display } from '@kit.ArkUI';
|
||||
import { preferences } from '@kit.ArkData';
|
||||
import { ApiClient } from '../api/ApiClient';
|
||||
import { AccountManager, AccountInfo } from '../api/AccountManager';
|
||||
@ -34,6 +35,18 @@ const PREF_STORE: string = 'agentmail_appearance';
|
||||
/** 本地缓存的键**必须带账号**:WebUI 侧的教训是多账号共用一份(键是全局常量) */
|
||||
const KEY_PREFIX: string = 'appearance.';
|
||||
|
||||
/**
|
||||
* 壁纸的**目标解码尺寸**(px)。
|
||||
*
|
||||
* ★ 为什么必须是**具名 interface**:ArkTS 不接受对象字面量当类型
|
||||
* (`arkts-no-obj-literals-as-types` / `arkts-no-untyped-obj-literals`)——
|
||||
* 2026-10-03 真编译实测,不是猜的。详见 `targetSize()` 上方那段。
|
||||
*/
|
||||
interface WallpaperTargetSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export class AppearanceStore {
|
||||
private static instance: AppearanceStore | null = null;
|
||||
|
||||
@ -182,19 +195,111 @@ export class AppearanceStore {
|
||||
await this.loadWallpaper(api);
|
||||
}
|
||||
this.applyTheme(ctx, this.snapshot.theme);
|
||||
}
|
||||
}
|
||||
|
||||
/** 取壁纸本体:**带认证**取回来(不用 `Image('http://…')`,也不用 `?token=`) */
|
||||
async loadWallpaper(api: AppearanceApi): Promise<void> {
|
||||
try {
|
||||
const bytes: ArrayBuffer = await api.fetchImageBytes();
|
||||
const src: image.ImageSource = image.createImageSource(bytes);
|
||||
this.wallpaper = await src.createPixelMap();
|
||||
} catch (e) {
|
||||
// 取不到就按"没有壁纸"渲染:不显示一块空的占位
|
||||
this.wallpaper = null;
|
||||
/*
|
||||
* ⚠️⚠️ 2026-10-03 编译实测记在这里:**ArkTS 不接受对象字面量当类型**。
|
||||
* 我第一版把返回类型写成 `{ width: number, height: number }` ⇒ 编译直接报
|
||||
* 10605040 arkts-no-obj-literals-as-types
|
||||
* 10605038 arkts-no-untyped-obj-literals(连**返回的对象字面量**也要能
|
||||
* 对应到**具名**类型)
|
||||
* ⇒ 必须先声明一个具名 interface,并把每个字面量先赋给一个**显式类型的局部变量**
|
||||
* 再 `return` 那个变量(直接 `return {…}` 同样不过)。
|
||||
* 这条**编译期硬规则**在本仓无判据覆盖(`harmony-arkts` 判的是 import 位置那一类)
|
||||
* ⇒ 只能靠真编译抓 —— 而它能抓,证明**「本机无法验证 ArkTS」这个前提不成立**。
|
||||
*/
|
||||
/**
|
||||
* 目标解码尺寸(px)。
|
||||
*
|
||||
* 为什么要它:不带 `desiredSize` 时,`createPixelMap()` 按**原图**尺寸解,
|
||||
* 而 `BackgroundPicker` 只把上传边长卡在 `MAX_EDGE = 2560`
|
||||
* ⇒ 一张 2560×2560 ARGB 常驻 ≈ **26 MB**,而它永远被合成器降采样着全屏画。
|
||||
*
|
||||
* ★ 用 `display.getDefaultDisplaySync()` 而不是写死魔数:
|
||||
* 尺寸随屏幕/分辨率变,写死就是在小屏上多占、在大屏上不够。
|
||||
* ★ 它**会抛**(SDK 注释:`1400001 Invalid display or screen`)⇒ 必须 catch,
|
||||
* 否则取不到尺寸会把整个壁纸流程带崩(而"没有壁纸"是能接受的状态)。
|
||||
*/
|
||||
private targetSize(): WallpaperTargetSize {
|
||||
try {
|
||||
const d = display.getDefaultDisplaySync();
|
||||
if (d.width > 0 && d.height > 0) {
|
||||
const sz: WallpaperTargetSize = { width: d.width, height: d.height };
|
||||
return sz;
|
||||
}
|
||||
} catch (e) {
|
||||
// 取不到屏幕就**不限制尺寸**(回到旧行为),而不是把壁纸整条路断掉
|
||||
}
|
||||
const none: WallpaperTargetSize = { width: 0, height: 0 };
|
||||
return none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取壁纸本体:**带认证**取回来(不用 `Image('http://…')`,也不用 `?token=`)。
|
||||
*
|
||||
* ★★ 2026-10-03 修三件事(此前一个都没有):
|
||||
* ① **不 release**:旧 `PixelMap` 与 `ImageSource` 每次同步都新建、原生内存
|
||||
* 从不释放。而 `syncFromServer` 每次同步/每次切账号都会跑一遍
|
||||
* ⇒ **换账号这条路必然泄漏**(`wallpaper` 不在任何清理路径里)。
|
||||
* ② **不设 desiredSize**:见 `targetSize()` 的注释(最坏 26 MB 常驻)。
|
||||
* ③ **所有权**:先换引用再释放**旧的**——若在 `finally` 里释放 `this.wallpaper`,
|
||||
* 会把**刚装上的那张**释放掉(那正是最容易写错的一处)。
|
||||
*/
|
||||
async loadWallpaper(api: AppearanceApi): Promise<void> {
|
||||
let src: image.ImageSource | null = null;
|
||||
let next: image.PixelMap | null = null;
|
||||
try {
|
||||
const bytes: ArrayBuffer = await api.fetchImageBytes();
|
||||
src = image.createImageSource(bytes);
|
||||
const target = this.targetSize();
|
||||
if (target.width > 0 && target.height > 0) {
|
||||
const decodeOptions: image.DecodingOptions = {
|
||||
desiredSize: { width: target.width, height: target.height }
|
||||
};
|
||||
next = await src.createPixelMap(decodeOptions);
|
||||
} else {
|
||||
next = await src.createPixelMap();
|
||||
}
|
||||
/*
|
||||
* 所有权转移:先把局部变量置空,`finally` 就不会把**新的**这个释放掉。
|
||||
* 下面是本方法里最容易写反的一行。
|
||||
*/
|
||||
const old: image.PixelMap | null = this.wallpaper;
|
||||
this.wallpaper = next;
|
||||
next = null;
|
||||
if (old !== null) {
|
||||
await old.release();
|
||||
}
|
||||
// `src` 已在 `finally` 释放;成功路径上它同样必须释放(旧实现这里就漏了)
|
||||
} catch (e) {
|
||||
// 取不到就按"没有壁纸"渲染:不显示一块空的占位
|
||||
this.wallpaper = null;
|
||||
} finally {
|
||||
if (next !== null) {
|
||||
await next.release();
|
||||
}
|
||||
if (src !== null) {
|
||||
await src.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 2026-10-03 新增:退出登录/换账号时清壁纸。
|
||||
*
|
||||
* 与 `MailStore.clear()` 同一形状、同一理由(登出后至少一帧不能看到上一个人的东西)。
|
||||
* ⚠️ 它**也必须 release**:这里是真的 native 内存,不是显示状态。
|
||||
*/
|
||||
releaseWallpaper(): void {
|
||||
const old: image.PixelMap | null = this.wallpaper;
|
||||
this.wallpaper = null;
|
||||
if (old !== null) {
|
||||
// 不能 await(同步接口):release 是 Promise,fire-and-forget + 失败静默
|
||||
old.release().catch(() => {
|
||||
// 释放失败不该阻断登出流程
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 供界面读的当前值(ArkTS 的 @State 观察不到类内部变化,所以页面自己复制一份) */
|
||||
current(): AppearanceSnapshot {
|
||||
|
||||
@ -156,6 +156,32 @@ export class MailStore {
|
||||
|
||||
/** 当前快照。界面读它渲染 */
|
||||
snapshot: MailSnapshot = new MailSnapshot();
|
||||
|
||||
/**
|
||||
* ★★ 2026-10-03 新增:**发件箱自己那份快照**。
|
||||
*
|
||||
* 为什么要拆:此前 `loadInbox` 与 `loadSent` **全部往同一个 `snapshot` 写**,
|
||||
* 而两者是两个独立 `@Component`(`InboxTab` / `SentTab`),各自
|
||||
* `await` 完自己的 load 之后才 `applyStoreSnapshot(store.snapshot)`。
|
||||
* ⇒ 切页签/SSE 事件与两者交错时,**发件箱的 `loadSent` 会把收件箱那份覆盖掉**:
|
||||
* `snap.unread = 0`、`snap.mails = merged`、`snap.loading` 互相关闭。
|
||||
* 后果:收件箱未读被清零 / 列表短暂空白 / “转圈停了但列表是空的”。
|
||||
*
|
||||
* ★ 为什么**只拆字段不够、还要 generation 守卫**:
|
||||
* 拆字段解决的是「两栏互相覆写」,
|
||||
* 但同一栏**自己**的两次 load 仍可能乱序到达(快切页签时旧的 `loadSent`
|
||||
* 后到、覆盖新的)⇒ 那要靠 generation 丢弃过期响应。
|
||||
* 两件事**各挡一半**,都要。
|
||||
*
|
||||
* ⚠️ 调用点只需改 `SentTab` 那一处(`MainPage.ets` 的 `store.snapshot` →
|
||||
* `store.sentSnapshot`);收件箱侧零改动。
|
||||
*/
|
||||
sentSnapshot: MailSnapshot = new MailSnapshot();
|
||||
|
||||
/** 收件箱这一栏的请求代号:只有**最新**那次 load 的结果才允许写快照 */
|
||||
private inboxGen: number = 0;
|
||||
/** 发件箱这一栏的请求代号(同上) */
|
||||
private sentGen: number = 0;
|
||||
/**
|
||||
* 版本号 —— 每次内容变化自增。
|
||||
*
|
||||
@ -292,9 +318,10 @@ export class MailStore {
|
||||
if (merged.length === 0) {
|
||||
return false;
|
||||
}
|
||||
this.snapshot.mails = merged;
|
||||
this.snapshot.groups = groupMailsBySession(merged);
|
||||
this.snapshot.loaded = merged.length;
|
||||
// ★ 写**发件箱那份**快照(此前误写 `this.snapshot`,与收件箱互相覆写)
|
||||
this.sentSnapshot.mails = merged;
|
||||
this.sentSnapshot.groups = groupMailsBySession(merged);
|
||||
this.sentSnapshot.loaded = merged.length;
|
||||
this.bump();
|
||||
return true;
|
||||
} catch (e) {
|
||||
@ -444,6 +471,7 @@ export class MailStore {
|
||||
* @param accountFilter `'all'` = 聚合;否则是单个账号 id
|
||||
*/
|
||||
async loadInbox(ctx: common.Context, accountFilter: string): Promise<void> {
|
||||
const myGen = ++this.inboxGen;
|
||||
const snap = this.snapshot;
|
||||
/*
|
||||
* ★★ 2026-09-24 新增:**先读缓存**(用户:「鸿蒙是 app 啊,缓存邮件多正常」)。
|
||||
@ -566,6 +594,17 @@ export class MailStore {
|
||||
const split: MailSplit = splitByPermission(mergedMails);
|
||||
const inboxMails: MailLike[] = split.normal;
|
||||
|
||||
/*
|
||||
* ★★ 2026-10-03:代号守卫。同一栏**自己**的两次 load 也可能乱序到达
|
||||
* (SSE 叫醒一次、用户又切页签触发一次)⇒ 旧的**后**到会盖掉新的。
|
||||
* 判据:代号不是最新就**不碰快照**。
|
||||
* ⚠️ 但 `loading = false` 仍要执行 —— 否则新那次请求的 spinner 被挂住
|
||||
* (“转圈停了但列表是空的”那个症状,守卫加上之后反而更容易出现)。
|
||||
*/
|
||||
if (this.inboxGen !== myGen) {
|
||||
this.bump();
|
||||
return;
|
||||
}
|
||||
snap.mails = mergedMails;
|
||||
snap.groups = groupMailsBySession(inboxMails);
|
||||
snap.loaded = inboxMails.length;
|
||||
@ -591,7 +630,9 @@ export class MailStore {
|
||||
snap.notice = partialLoadNotice(maxFetched, INBOX_PAGE_SIZE);
|
||||
} catch (e) {
|
||||
const ae = e as ApiError;
|
||||
snap.error = ae.message.length > 0 ? ae.message : '加载失败';
|
||||
if (this.inboxGen === myGen) {
|
||||
snap.error = ae.message.length > 0 ? ae.message : '加载失败';
|
||||
}
|
||||
} finally {
|
||||
snap.loading = false;
|
||||
this.bump();
|
||||
@ -605,7 +646,8 @@ export class MailStore {
|
||||
* 只是不做权限分流、不分组(发件箱按时间平铺,与 WebUI `mailStore.sent` 一致)。
|
||||
*/
|
||||
async loadSent(ctx: common.Context, accountFilter: string): Promise<void> {
|
||||
const snap = this.snapshot;
|
||||
const myGen = ++this.sentGen;
|
||||
const snap = this.sentSnapshot;
|
||||
snap.loading = true;
|
||||
snap.error = '';
|
||||
/*
|
||||
@ -673,6 +715,15 @@ export class MailStore {
|
||||
* 发件箱不显示那个栏位。所以这里直接对全部 merged 分组 ——
|
||||
* 与 WebUI `mailStore.fetchSent` 同口径(那边也是直接 set 全量)。
|
||||
*/
|
||||
/*
|
||||
* ★★ 2026-10-03:代号守卫(与 `loadInbox` 同一形状、同一理由)。
|
||||
* 同栏两次 load 乱序到达时,旧的**后**到会盖掉新的。
|
||||
*/
|
||||
if (this.sentGen !== myGen) {
|
||||
this.bump();
|
||||
return;
|
||||
}
|
||||
snap.mails = merged;
|
||||
snap.groups = groupMailsBySession(merged);
|
||||
snap.loaded = merged.length;
|
||||
snap.unread = 0;
|
||||
@ -682,7 +733,9 @@ export class MailStore {
|
||||
this.persistByAccount(ctx, KEY_SENT, accountFilter, allAccounts, merged);
|
||||
} catch (e) {
|
||||
const ae = e as ApiError;
|
||||
snap.error = ae.message.length > 0 ? ae.message : '加载失败';
|
||||
if (this.sentGen === myGen) {
|
||||
snap.error = ae.message.length > 0 ? ae.message : '加载失败';
|
||||
}
|
||||
} finally {
|
||||
snap.loading = false;
|
||||
this.bump();
|
||||
@ -697,24 +750,76 @@ export class MailStore {
|
||||
* 就地剔除则立即反映,且与列表滚动位置无关。
|
||||
*/
|
||||
dropSession(sessionId: string): void {
|
||||
const snap = this.snapshot;
|
||||
const kept: MailLike[] = [];
|
||||
for (let i = 0; i < snap.mails.length; i++) {
|
||||
const m = snap.mails[i];
|
||||
if (m.session_id !== sessionId) {
|
||||
kept.push(m);
|
||||
}
|
||||
}
|
||||
/*
|
||||
* ★★ 2026-10-03 修一个**我差点引入的回归**:拆开 `sentSnapshot` 之前,
|
||||
* 收件箱与发件箱共用一份 `snapshot` ⇒ 归档一个会话会**同时**把两边的
|
||||
* 那一会话抹掉。拆开之后若只动收件箱那份,归档完**发件箱仍显示该会话**
|
||||
* (而服务端已经没了)⇒ 用户会看到“归档了但它还在”。
|
||||
* ⇒ 两份都要剔,**保持归档前后的可见性一致**。
|
||||
*/
|
||||
this.dropSessionFromInbox(sessionId);
|
||||
this.dropSessionFromSent(sessionId);
|
||||
this.bump();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从**收件箱**快照剔掉某个会话。
|
||||
*
|
||||
* 分组口径:按权限分流后取 `normal`(与 `loadInbox` 同口径 —— 授权邮件要挑出去单独成栏)。
|
||||
*/
|
||||
private dropSessionFromInbox(sessionId: string): void {
|
||||
const snap: MailSnapshot = this.snapshot;
|
||||
const kept: MailLike[] = this.withoutSession(snap.mails, sessionId);
|
||||
snap.mails = kept;
|
||||
const split: MailSplit = splitByPermission(kept);
|
||||
snap.groups = groupMailsBySession(split.normal);
|
||||
snap.loaded = split.normal.length;
|
||||
this.bump();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从**发件箱**快照剔掉某个会话。
|
||||
*
|
||||
* ★ 这里**不能**复用收件箱那句 `splitByPermission(kept).normal`:
|
||||
* 发件箱**不做权限分流**(见 `loadSent` 的注释),而 `splitByPermission`
|
||||
* 会把 `permission_request` 挑出去 ⇒ 对“我发出的授权请求”就会把它们**抹掉**。
|
||||
* 那正是 2026-09-20 修过一次的真 bug(发件箱一片空白)的同一族。
|
||||
* ⇒ 这里直接对全部 kept 分组,与 `loadSent` 逐字同口径。
|
||||
*/
|
||||
private dropSessionFromSent(sessionId: string): void {
|
||||
const snap: MailSnapshot = this.sentSnapshot;
|
||||
const kept: MailLike[] = this.withoutSession(snap.mails, sessionId);
|
||||
snap.mails = kept;
|
||||
snap.groups = groupMailsBySession(kept);
|
||||
snap.loaded = kept.length;
|
||||
}
|
||||
|
||||
/** 滤掉某个会话的全部邮件(两份快照共用这一份实现) */
|
||||
private withoutSession(mails: MailLike[], sessionId: string): MailLike[] {
|
||||
const kept: MailLike[] = [];
|
||||
for (let i = 0; i < mails.length; i++) {
|
||||
const m: MailLike = mails[i];
|
||||
if (m.session_id !== sessionId) {
|
||||
kept.push(m);
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** 退出登录/换账号时清空 —— 否则下一个账号会看到上一个人的邮件(哪怕只有一帧)。 */
|
||||
clear(): void {
|
||||
this.snapshot = new MailSnapshot();
|
||||
/*
|
||||
* ★ 发件箱那份也要清:它同样是**跨账号可见的快照**(`SentTab` 直接读它),
|
||||
* 只清收件箱那份会留下“上一个账号的发件箱”。
|
||||
*/
|
||||
this.sentSnapshot = new MailSnapshot();
|
||||
/*
|
||||
* ★ 同时把两个代号**作废**:否则一个**正在飞**的旧请求回来时
|
||||
* `myGen === this.inboxGen` 仍成立(旧值没变)⇒ 会把清空后的快照重新填上旧数据。
|
||||
* (清空发生在请求飞行途中:登出/切账号时正是这样。)
|
||||
*/
|
||||
this.inboxGen += 1;
|
||||
this.sentGen += 1;
|
||||
this.bump();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1407,7 +1407,13 @@ struct SentTab {
|
||||
}
|
||||
const store: MailStore = MailStore.getInstance();
|
||||
await store.loadSent(ctx, 'all');
|
||||
this.applyStoreSnapshot(store.snapshot);
|
||||
/*
|
||||
* ★ 2026-10-03:读**发件箱自己那份**快照(`sentSnapshot`)。
|
||||
* 此前读的是 `store.snapshot` —— 与收件箱共用 ⇒ 切页签/SSE 交错时
|
||||
* `loadSent` 会把收件箱那份覆盖掉(`unread = 0`、`mails` 换成发件箱的)。
|
||||
* 与 `InboxTab.loadData` 的 `store.snapshot` 现在是**两个不同对象**。
|
||||
*/
|
||||
this.applyStoreSnapshot(store.sentSnapshot);
|
||||
}
|
||||
|
||||
/** 把 store 快照搬到本组件的 `@State`(`InboxTab` 里同名同形) */
|
||||
|
||||
Reference in New Issue
Block a user