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

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