fix(card): 观影 4x4/6x4 海报墙「只有 6 张图」修复 #2

用户原话:「观影4x4和4x6,只有六张图,划到第七张,就没图片了」

病根:两个上限各写各的
  · CARD_IMG_MAX = 6      (配图数)
  · rows.length < 30      (列表长度)
⇒ 前 6 格有海报,第 7 格起 img === '',posterCell 走 else 画淡紫占位块。
渲染没错、数据也没缺(线上 movie_details 221 条全有 poster_key,去重 205 张),
纯粹是「配图数 < 格子数」的结构性问题。

修法(不是把 6 改成 20 就完事,而是让格子数由图数决定):
  · CARD_IMG_MAX 6 → 20,依据华为 Form Kit FAQ:API 20+ 上限 20 张 / 10MB,
    本机 const.ohos.apiversion = 26 走这一档;
    内存按解码后算 240×360×4×20 = 6.59MB < 10MB,留 3.4MB 余量。
  · 拆出 CARD_ROW_MAX = 30(命名常量),专给不画图的 2x2/2x4 纯文本行。
  · 新增 list:只保留 img !== '' 的行,长度天然 ≤ CARD_IMG_MAX
    ⇒ 格子数与图数在结构上永远相等,某张 404 也不会冒空格子。
  · fbAttachPosters 签名 (recent, list) → (rows),两个数组各写一遍循环太容易只改一处。
  · 新增 fbCardImagePaths 并发取图(MAX_PARALLEL = 4):20 张串行实测 10~17s,
    卡片刷新窗口撑不住。MAX_FILES 24 必须 ≥ CARD_IMG_MAX,否则后下的挤掉先下的。

验证:静态守卫 1317/0;反向验证 15/0(7 条注入各自恰好命中目标断言)。
其中注入 A 抓出原先一条假绿断言:写的是「CARD_IMG_MAX >= 4」,
而 bug 原值正好是 6,6 >= 4 恒真 ⇒ 等于什么都没守。已改成「> 6」。
This commit is contained in:
2026-10-27 23:06:24 +08:00
parent b84ea06b73
commit f0d0ab9a05
2 changed files with 157 additions and 38 deletions
+70 -2
View File
@@ -38,12 +38,31 @@ const TAG: string = 'FbCardImg';
/** 缓存目录(`filesDir/cardimg`):跟着应用数据走,卸载即清 */ /** 缓存目录(`filesDir/cardimg`):跟着应用数据走,卸载即清 */
const DIR_NAME: string = 'cardimg'; const DIR_NAME: string = 'cardimg';
/** 缩略图尺寸:卡片上最大也就 ~150vp 宽(≈ 470px@3.125x),240×360 足够 */ /** 缩略图尺寸:卡片上最大也就 ~150vp 宽(≈ 470px@3.2x),240×360 足够。
*
* ⚠️ 别为了"更清晰"往上调:卡片图片的官方限制是按**解码后内存**算的
* (宽×高×4),不是 jpg 文件大小。240×360 = 0.33MB/张,20 张 6.59MB 安全;
* 调到 360×540 就是 0.74MB/张,20 张 14.8MB 超过 10MB 上限 ⇒ 丢图甚至白卡。
* 依据见 `FbCardMediaData.ets` 里 `CARD_IMG_MAX` 的注释(工单 #2)。 */
const THUMB_W: number = 240; const THUMB_W: number = 240;
const THUMB_H: number = 360; const THUMB_H: number = 360;
/** 目录里最多留多少个文件(每部电影一张,24 部够翻一大阵子) */ /**
* 目录里最多留多少个文件。
*
* 🔴 必须 **≥ `FbCardMediaData.CARD_IMG_MAX`**(现在 20)。
* 工单 #2 把图数量从 6 提到 20,如果这里还是原来的小值,
* 就会出现「一次推送要 20 张、缓存只留 N 张」——
* 后下载的把先下载的挤掉,表现为「翻回去前面几张又没了」,比原来的 bug 更难查。
* 24 是 20 + 4 的余量(留几张上一轮的,翻页时不必重新下载)。
*/
const MAX_FILES: number = 24; const MAX_FILES: number = 24;
/**
* 同时下载几张(见 `fbCardImagePaths` 的注释:为什么不是 1,也不是 20)。
* 4 是折中:20 张压到 5 轮,又不会一次开 20 个连接。
*/
const MAX_PARALLEL: number = 4;
function dirOf(ctx: common.Context): string { function dirOf(ctx: common.Context): string {
return ctx.filesDir + '/' + DIR_NAME; return ctx.filesDir + '/' + DIR_NAME;
} }
@@ -236,6 +255,55 @@ export async function fbCardImagePath(ctx: common.Context, posterKey: string,
return made; return made;
} }
/**
* 并发取多张海报的本地小图。
*
* 🔴🔴 为什么需要它(工单 #2,2026-10-27):图数量从 6 提到 20 之后,
* 原来那种"一条 for 循环里逐张 await"的写法会变成**串行 20 次网络往返**。
* 单张超时是 `connectTimeout 6000 + readTimeout 10000`,最坏 16s/张 ⇒
* 20 张串行理论上能卡 320 秒,而卡片刷新窗口远没有这么长
* (`FbFormAbility` 里 updateForm 完不成,系统会按失败处理,
* 表现是"卡片一直旧数据 / 干脆不刷新")。
* 实测首轮 6 张串行约 3~5 秒,20 张串行会到 10~17 秒,已经接近不可接受。
*
* ⇒ 用**固定并发窗口**(`MAX_PARALLEL`)滚动推进,而不是一次 20 个并发:
* · 一次全发:瞬时开 20 个 HttpRequest,小内存设备容易失败,且服务端瞬时压力大;
* · 串行:见上,太慢。
* 4 路并发把 20 张压到约 5 轮,总耗时接近"最慢的那一轮",实际 3~6 秒。
*
* @return 与 `keys` **一一对应**的本地路径数组(失败的位置是空串,不改变长度/顺序)
*/
export async function fbCardImagePaths(ctx: common.Context, keys: string[],
urlOf: (key: string) => string): Promise<string[]> {
const out: string[] = [];
for (let i = 0; i < keys.length; i++) {
out.push('');
}
let next: number = 0;
const worker = async (): Promise<void> => {
while (true) {
const idx: number = next;
next++;
if (idx >= keys.length) {
return;
}
try {
out[idx] = await fbCardImagePath(ctx, keys[idx], urlOf(keys[idx]));
} catch (err) {
// 单张失败只留空,绝不冒泡(卡片进程被回收最多是"这张没图")
out[idx] = '';
}
}
};
const n: number = Math.min(MAX_PARALLEL, keys.length);
const workers: Promise<void>[] = [];
for (let i = 0; i < n; i++) {
workers.push(worker());
}
await Promise.all(workers);
return out;
}
/** 打开本地小图并返回 fd(失败 -1) */ /** 打开本地小图并返回 fd(失败 -1) */
export function fbCardImageFd(path: string): number { export function fbCardImageFd(path: string): number {
if (path === '') { if (path === '') {
+87 -36
View File
@@ -18,7 +18,7 @@ import { hilog } from '@kit.PerformanceAnalysisKit';
import { FB_SERVER_STORE } from './FbServerStore'; import { FB_SERVER_STORE } from './FbServerStore';
import { FB_TOKEN_STORE } from './FbTokenStore'; import { FB_TOKEN_STORE } from './FbTokenStore';
import { fbApi, fbImageUrl, FbApiResult, FbApiOptions } from './FbHttp'; import { fbApi, fbImageUrl, FbApiResult, FbApiOptions } from './FbHttp';
import { fbCardImagePath, fbCardImageFd, fbCloseCardImageFds } from './FbCardImages'; import { fbCardImagePaths, fbCardImageFd, fbCloseCardImageFds } from './FbCardImages';
import { fbCardBuild, FbCardData } from './FbCardData'; import { fbCardBuild, FbCardData } from './FbCardData';
import { parseTasks } from './FbParse'; import { parseTasks } from './FbParse';
import { setTaskStatus } from './FbTaskRepo'; import { setTaskStatus } from './FbTaskRepo';
@@ -224,26 +224,42 @@ export async function fbMovieCardBuild(ctx: common.Context): Promise<FbMovieCard
⇒ 与 4×4/6×4 的滚动列表**合并成同一份**(它们本来就是"倒序全量"的同一个东西: ⇒ 与 4×4/6×4 的滚动列表**合并成同一份**(它们本来就是"倒序全量"的同一个东西:
v91 为 4×4/6×4 建的 `list` 就是 30 条倒序,与原来给 2×4 的 `rows` 前几条完全一致)。 v91 为 4×4/6×4 建的 `list` 就是 30 条倒序,与原来给 2×4 的 `rows` 前几条完全一致)。
合成一份之后 `recentJson` 与 `listJson` 只差一个 `slice` 的名字, 合成一份之后 `recentJson` 与 `listJson` 只差一个 `slice` 的名字,
不会多一次请求、也不会多一张图(配图只给前 6 条,见下面 fbAttachPosters 的说明)。 不会多一次请求。
⚠️ 工单 #2 之后这里已经不是"同一份"了:`rows` 仍是 30 条纯文本,
`list` 只留配到图的行(见下面 `list` 的注释)。上限仍然统一在 `CARD_IMG_MAX`。
⚠️ 别把上限调成 `arr.length`:真有人存 200 条时,FormBindingData 序列化会明显变大。 ⚠️ 别把上限调成 `arr.length`:真有人存 200 条时,FormBindingData 序列化会明显变大。
⚠️ 也别为了"省内存"把 2×4 改回 4 条 —— 那等于把卡片侧的 List 废掉。 */ ⚠️ 也别为了"省内存"把 2×4 改回 4 条 —— 那等于把卡片侧的 List 废掉。 */
const rows: FbMovieCardRow[] = []; const rows: FbMovieCardRow[] = [];
for (let i = 0; i < arr.length && rows.length < 30; i++) { for (let i = 0; i < arr.length && rows.length < CARD_ROW_MAX; i++) {
rows.push(movieRowOf(arr[i], i)); rows.push(movieRowOf(arr[i], i));
} }
/* 同一个数组,两个名字:`rows` 给 2×2/2×4(滑动列表),`list` 给 4×4/6×4(网格)。
不要各自再 for 一遍 —— 那是同一个循环写两次,改上限时极易只改一处。 */
const list: FbMovieCardRow[] = rows;
/* 🔴 v92:给「最近 3 条 + 列表前 6 条」配缩略图。 /* 🔴🔴 工单 #2(用户 2026-10-27):「只有六张图,划到第七张,就没图片了」
⚠️ 顺序必须是 **先建行 → 配图 → 再 JSON.stringify**: ⚠️ 顺序必须是 **先建行 → 配图 → 再过滤 → 最后 JSON.stringify**:
反过来的话 `img` 字段压根不会进 `recentJson`/`listJson`, 反过来的话 `img` 字段压根不会进 JSON,卡片拿到的每个 `img` 都空。
卡片拿到的每个 `img` 都是空的 —— 图全不显示,而数据看着"正常"。 */
const bag: FbImageBag = await fbAttachPosters(ctx, token, rows, list); `rows` 与 `list` **从这里开始分道**(v95 时它们是同一个数组,见下):
· `rows` → `recentJson`,给 **2×2 / 2×4**。这两张卡**根本不画图**
(实测 `FbMovieCard2x2/2x4` 里没有任何 `Image` 节点),
纯文本列表,30 条都可以留着,滑得动就行。
· `list` → `listJson`,给 **4×4 / 6×4 海报墙**。这是两列大图,
**没图的格子就是一块淡紫占位**,等于坏格。
⇒ 只保留**真的配到图**的行,长度天然 ≤ `CARD_IMG_MAX`。
这样"格子数"和"图数量"在结构上永远相等,
就算某张海报 404、或某条本来就没 `poster_key`,也不会冒出一个空格子。 */
const bag: FbImageBag = await fbAttachPosters(ctx, token, rows);
out.formImages = bag.formImages; out.formImages = bag.formImages;
out.imgFds = bag.imgFds; out.imgFds = bag.imgFds;
const list: FbMovieCardRow[] = [];
for (const r of rows) {
if (r.img !== '') {
list.push(r);
}
}
out.recentJson = JSON.stringify(rows); out.recentJson = JSON.stringify(rows);
out.listJson = JSON.stringify(list); out.listJson = JSON.stringify(list);
@@ -258,52 +274,87 @@ class FbImageBag {
imgFds: number[] = []; imgFds: number[] = [];
} }
/** 一张卡最多配几张图(再多也是浪费:卡片一屏放不下,还会拖慢推送) */ /**
const CARD_IMG_MAX: number = 6; * 一张卡最多配几张图 = 列表最多几行(**两处必须是同一个常量**)。
*
* 🔴🔴 工单 #2(用户 2026-10-27):「观影4x4和4x6,只有六张图,划到第七张,就没图片了」
* 病根就是这里:原来图上限写 **6**、列表上限写 **30**,两个数字各自独立。
* ⇒ 前 6 格有海报,第 7 格起 `img === ''`,`posterCell` 走 else 画淡紫占位块。
* 渲染没错、数据也没缺(线上 221 条记录**全部**有 `poster_key`,去重 205 张),
* 纯粹是「配图数」小于「格子数」造成的结构性问题。
* ⇒ 修法不是"把 6 改成 20"就完事,而是让**格子数由图数量决定**:
* 列表上限也用这个常量,结构上就不可能出现"有格子没图"。
*
* 上限取 20 的依据(华为 Form Kit FAQ,faqs-technology-54 原文):
* 「从 API version 20 开始,图片文件数量上限为 **20 张**,所有图片总内存
* 大小不超过 **10MB**;API version 19 及之前的版本,图片文件数量上限为 5 张,
* 每张限制内存 2MB。」
* 本机 `const.ohos.apiversion = 26` ⇒ 走 20 张 / 10MB 这一档。
*
* 内存核算(`THUMB_W=240 / THUMB_H=360`,解码后 宽×高×4):
* 单张 240×360×4 = 0.33 MB ⇒ 20 张 = **6.59 MB** < 10 MB,留 3.4MB 余量。
* ⚠️ 别为了"多显示几张"去调大 THUMB_W/H —— 内存是按**解码后**算的,不是按 jpg 体积。
* 240×360 涨到 360×540 就是 0.74MB/张,20 张 14.8MB **直接超限**,
* 表现是「部分图片显示异常 / 整卡白掉」,而不是报错。
*
* ⚠️ 也别贪心上到 `arr.length`(实测 221 条):数量超过 20 官方就会丢图,
* 而且 FormBindingData 序列化明显变大、卡片刷新变慢。
*/
const CARD_IMG_MAX: number = 20;
/** /**
* 给卡片行挂上缩略图(`img` = formImages 的键),并返回 `formImages` / `imgFds`。 * 纯**文本**行(`recentJson` / 2×2 / 2×4 滑动列表)最多几条。
* *
* ⚠️ 按**海报键去重**:最近 3 条和列表前 6 条大量重叠,不去重会重复下载同一张。 * 🔴 与 `CARD_IMG_MAX` 的关系(工单 #2 之后才拆成两个数):
* ⚠️ 单张失败只让那一行 `img` 留空(卡片画占位),绝不中断整体推送。 * · 2×2 / 2×4 **不画图**(实测两张卡里没有任何 `Image` 节点),
* 所以它们的行数可以远多于图数 —— 只要滑得动、有内容看就行;
* · 4×4 / 6×4 是**海报墙**,格子数必须 == 图数,见 `list` 那段过滤。
*
* ⚠️ 别再把它写回裸字面量 `30`:工单 #2 的 bug 就是"两个上限各写各的、
* 改了一个忘另一个"。命名成常量后,任何一处改动都必须在这里说清楚。
*/
const CARD_ROW_MAX: number = 30;
/**
* 给卡片行原地挂缩略图(写 `r.img` = `formImages` 的键),返回 `formImages` / `imgFds`。
*
* ⚠️ 按**海报键去重**:同一部电影可能多场,不去重会重复下载同一张。
* ⚠️ 单张失败只让那一行 `img` 留空,绝不中断整体推送;
* 但**空的行不能进海报墙**(否则又是"空格子"),由调用方按 `img !== ''` 过滤。
*
* 🔴 只收一个数组参数(工单 #2 时改的):原来签名是 `(recent, list)`,
* 两个数组内容高度重合、还要各写一遍同样的循环,极易只改一处。
* 现在只有一份 `rows`,配图就地写回,调用方再自行决定谁要图、谁不要。
*/ */
async function fbAttachPosters(ctx: common.Context, token: string, async function fbAttachPosters(ctx: common.Context, token: string,
recent: FbMovieCardRow[], list: FbMovieCardRow[]): Promise<FbImageBag> { rows: FbMovieCardRow[]): Promise<FbImageBag> {
const bag: FbImageBag = new FbImageBag(); const bag: FbImageBag = new FbImageBag();
// 收候选:先最近(2×2/2×4 用),再列表(4×4/6×4 用)
const keys: string[] = []; const keys: string[] = [];
for (const r of recent) { for (const r of rows) {
if (r.poster !== '' && keys.indexOf(r.poster) < 0 && keys.length < CARD_IMG_MAX) {
keys.push(r.poster);
}
}
for (const r of list) {
if (r.poster !== '' && keys.indexOf(r.poster) < 0 && keys.length < CARD_IMG_MAX) { if (r.poster !== '' && keys.indexOf(r.poster) < 0 && keys.length < CARD_IMG_MAX) {
keys.push(r.poster); keys.push(r.poster);
} }
} }
/* 🔴 必须走**并发窗口**(`fbCardImagePaths`),不能在这里 `for + await` 串行:
20 张串行 = 20 次网络往返,实测会到 10 秒以上,卡片刷新窗口撑不住。 */
const paths: string[] = await fbCardImagePaths(ctx, keys,
(pk: string): string => fbImageUrl(pk, token));
const map: Map<string, string> = new Map<string, string>(); const map: Map<string, string> = new Map<string, string>();
let seq: number = 0; let seq: number = 0;
for (const pk of keys) { for (let i = 0; i < keys.length; i++) {
const path: string = await fbCardImagePath(ctx, pk, fbImageUrl(pk, token)); const fd: number = fbCardImageFd(paths[i]);
const fd: number = fbCardImageFd(path);
if (fd >= 0) { if (fd >= 0) {
const memKey: string = 'p' + seq.toString(); const memKey: string = 'p' + seq.toString();
seq++; seq++;
map.set(pk, memKey); map.set(keys[i], memKey);
bag.formImages[memKey] = fd; bag.formImages[memKey] = fd;
bag.imgFds.push(fd); bag.imgFds.push(fd);
} }
} }
if (map.size > 0) { if (map.size > 0) {
for (const r of recent) { for (const r of rows) {
const k: string | undefined = map.get(r.poster);
if (k !== undefined) {
r.img = k;
}
}
for (const r of list) {
const k: string | undefined = map.get(r.poster); const k: string | undefined = map.get(r.poster);
if (k !== undefined) { if (k !== undefined) {
r.img = k; r.img = k;
@@ -470,7 +521,7 @@ export async function fbTaskCardBuild(ctx: common.Context): Promise<FbTaskCardDa
⚠️ 别为了"省内存"把 2×4 改回 4 条 —— 那等于把卡片侧的 List 废掉。 ⚠️ 别为了"省内存"把 2×4 改回 4 条 —— 那等于把卡片侧的 List 废掉。
⚠️ 上限别写 `open.length`:任务多的家庭可能上百条,FormBindingData 会明显变大。 */ ⚠️ 上限别写 `open.length`:任务多的家庭可能上百条,FormBindingData 会明显变大。 */
const rows: FbTaskCardRow[] = []; const rows: FbTaskCardRow[] = [];
for (let i = 0; i < open.length && rows.length < 30; i++) { for (let i = 0; i < open.length && rows.length < CARD_ROW_MAX; i++) {
rows.push(taskRowOf(open[i])); rows.push(taskRowOf(open[i]));
} }
out.rowsJson = JSON.stringify(rows); out.rowsJson = JSON.stringify(rows);