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:
@@ -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 === '') {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user