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:
2026-10-03 12:11:25 +08:00
parent 4a01297dfe
commit 6a8e868d31
7 changed files with 452 additions and 28 deletions

View File

@ -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() 必须有调用点(登出)—— 否则这个方法只是摆设');
});

View File

@ -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`(含权限请求)就是把权限邮件混进收件箱,这条判据防的正是它');

View File

@ -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「快捷键打开发信页面 / 上下键切换发信目标 /
* 回车展开输入框」)。见该文件头部说明:为什么单开一个文件、为什么不端到端验。

View File

@ -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`:退出后不该还能"返回"到已登出的页。
*

View File

@ -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 {

View File

@ -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();
}
}

View File

@ -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` 里同名同形) */