Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbCardImages.ets
T
laoyang f0d0ab9a05 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」。
2026-10-27 23:06:24 +08:00

339 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 FamilyBook · **卡片图片**(鸿蒙端)—— v92
*
* 用户 2026-10-25:「观影应该显示图片,左侧应该显示图片。实际上应该用大图的形式显示,
* 就做成和观影模式一样,不行吗?」
*
* 🔴🔴 为什么不能直接在卡片里写 `Image('https://…')`:
* `Image` 组件的说明里写着「**ArkTS 卡片上不支持 http:// 等网络相关路径前缀
* 和 file:// 路径前缀的字符串**」,`PixelMap` 也不支持直传。
* ⇒ 卡片要显示网络图,**只能**由 FormExtensionAbility 把图取下来,
* 以 `formImages`(键 → fd)交给卡片,卡片再用 `Image('memory://<键>')` 取。
* 这是官方示例与多份文档一致的口径,别想着绕。
*
* 🔴 为什么还要在本机做一次「缩小 + 转 jpg」,而不是直接把原文件 fd 塞进去:
* 线上海报实测 **110 KB ~ 1.1 MB**,解码成位图是 `宽×高×4` 字节
* (600×900 就是 2.1 MB/张)。一张卡放 6 张 ⇒ 十几 MB 常驻卡片进程,
* 卡片渲染进程被拖垮的表现是**整张卡白掉**,而且极难联想到图片。
* ⇒ 只解码一次、缩到 240×360(约 0.33 MB/张)、存成小 jpg,
* 以后每次刷新只开 fd、不再解码也不再联网。
*
* ⚠️ 三条铁律(与卡片其它数据侧同口径):
* ① **永不抛错**:卡片进程被回收最多"这次没图",绝不能让卡片白掉;
* ② 缓存目录自己管:文件名 = 海报键 basename,超过 24 个删最旧的
* (不清理会无限长,用户的手机上这是实打实的垃圾);
* ③ fd 必须**用完就关**:`formProvider.updateForm` 完成后由调用方
* `fbCloseCardImageFds()` 统一关闭(与官方 FAQ 示例一致)。
*/
import { common } from '@kit.AbilityKit';
import { fileIo } from '@kit.CoreFileKit';
import { image } from '@kit.ImageKit';
import { http } from '@kit.NetworkKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbCardImg';
/** 缓存目录(`filesDir/cardimg`):跟着应用数据走,卸载即清 */
const DIR_NAME: string = 'cardimg';
/** 缩略图尺寸:卡片上最大也就 ~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_H: number = 360;
/**
* 目录里最多留多少个文件。
*
* 🔴 必须 **≥ `FbCardMediaData.CARD_IMG_MAX`**(现在 20)。
* 工单 #2 把图数量从 6 提到 20,如果这里还是原来的小值,
* 就会出现「一次推送要 20 张、缓存只留 N 张」——
* 后下载的把先下载的挤掉,表现为「翻回去前面几张又没了」,比原来的 bug 更难查。
* 24 是 20 + 4 的余量(留几张上一轮的,翻页时不必重新下载)。
*/
const MAX_FILES: number = 24;
/**
* 同时下载几张(见 `fbCardImagePaths` 的注释:为什么不是 1,也不是 20)。
* 4 是折中:20 张压到 5 轮,又不会一次开 20 个连接。
*/
const MAX_PARALLEL: number = 4;
function dirOf(ctx: common.Context): string {
return ctx.filesDir + '/' + DIR_NAME;
}
/** 海报键 `posters/xxx_suffix.webp` → 缓存文件名 `xxx_suffix.jpg` */
function cacheName(posterKey: string): string {
const slash: number = posterKey.lastIndexOf('/');
let base: string = slash >= 0 ? posterKey.substring(slash + 1) : posterKey;
const dot: number = base.lastIndexOf('.');
if (dot > 0) {
base = base.substring(0, dot);
}
// 只留安全字符:文件名直接来自服务端,不做过滤的话 "/"、".." 会很麻烦
let out: string = '';
for (let i = 0; i < base.length; i++) {
const c: string = base.charAt(i);
const ok: boolean = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') || c === '_' || c === '-';
out += ok ? c : '_';
}
return out === '' ? '' : out + '.jpg';
}
/** 确保目录存在 */
function ensureDir(dir: string): void {
try {
if (!fileIo.accessSync(dir)) {
fileIo.mkdirSync(dir);
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '建卡片图片目录失败:%{public}s', JSON.stringify(err));
}
}
/** 文件存在且非空 */
function hasFile(path: string): boolean {
try {
if (!fileIo.accessSync(path)) {
return false;
}
return fileIo.statSync(path).size > 0;
} catch (err) {
return false;
}
}
/** 目录里超过 MAX_FILES 就删最旧的几个(按 mtime) */
function pruneDir(dir: string): void {
try {
const names: string[] = fileIo.listFileSync(dir);
if (names.length <= MAX_FILES) {
return;
}
const rows: string[][] = [];
for (const n of names) {
const p: string = dir + '/' + n;
let mt: number = 0;
try {
mt = fileIo.statSync(p).mtime * 1000;
} catch (err) {
mt = 0;
}
rows.push([p, mt.toString()]);
}
rows.sort((a: string[], b: string[]): number => {
return parseInt(a[1], 10) - parseInt(b[1], 10);
});
const drop: number = names.length - MAX_FILES;
for (let i = 0; i < drop && i < rows.length; i++) {
try {
fileIo.unlinkSync(rows[i][0]);
} catch (err) {
// 单个删不掉就算了
}
}
} catch (err) {
hilog.warn(DOMAIN, TAG, '清理卡片图片目录失败:%{public}s', JSON.stringify(err));
}
}
/** 下载一张图(失败返回 null) */
async function download(url: string): Promise<ArrayBuffer | null> {
const req: http.HttpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await req.request(url, {
method: http.RequestMethod.GET,
expectDataType: http.HttpDataType.ARRAY_BUFFER,
connectTimeout: 6000,
readTimeout: 10000
});
if (resp.responseCode !== 200) {
return null;
}
const body: string | Object | ArrayBuffer = resp.result;
if (body instanceof ArrayBuffer) {
return body as ArrayBuffer;
}
return null;
} catch (err) {
hilog.warn(DOMAIN, TAG, '下载海报失败:%{public}s', JSON.stringify(err));
return null;
} finally {
// ⚠️ 必须销毁:每次 createHttp 都占一个连接对象
req.destroy();
}
}
/**
* 把原图缩成小 jpg 落盘(只做一次,之后命中缓存直接返回)。
*
* @return 落盘后的本地路径;失败返回空串(卡片就只显示文字,不会白)
*/
async function makeThumb(buf: ArrayBuffer, outPath: string): Promise<string> {
const tmp: string = outPath + '.tmp';
let src: image.ImageSource | null = null;
let pm: image.PixelMap | null = null;
try {
src = image.createImageSource(buf);
/* ⚠️ 必须给 desiredSize:不给就是把原图整张解出来(600×900×4 ≈ 2.1 MB),
卡片进程同时解 6 张直接十几 MB —— 表现是卡片白掉。 */
pm = await src.createPixelMap({
desiredSize: { width: THUMB_W, height: THUMB_H }
});
const fdObj: fileIo.File = fileIo.openSync(tmp,
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
try {
const packer: image.ImagePacker = image.createImagePacker();
await packer.packToFile(pm, fdObj.fd, { format: 'image/jpeg', quality: 80 });
} finally {
fileIo.closeSync(fdObj);
}
// 先写 .tmp 再改名:中途被杀不会留下半张图(半张图 = 卡片上永远一个灰块)
fileIo.renameSync(tmp, outPath);
return outPath;
} catch (err) {
hilog.warn(DOMAIN, TAG, '缩图失败:%{public}s', JSON.stringify(err));
try {
if (fileIo.accessSync(tmp)) {
fileIo.unlinkSync(tmp);
}
} catch (e) { /* ignore */ }
return '';
} finally {
if (pm !== null) {
try {
await pm.release();
} catch (err) { /* ignore */ }
}
if (src !== null) {
try {
await src.release();
} catch (err) { /* ignore */ }
}
}
}
/**
* 取一张海报的**本地小图**(没有就下载并缩一次)。
*
* @param posterKey 服务端海报键(`posters/xxx.webp`)
* @param url 带令牌的可下载地址;空串表示不下载(只查缓存)
* @return 本地路径,失败空串
*/
export async function fbCardImagePath(ctx: common.Context, posterKey: string,
url: string): Promise<string> {
if (posterKey === '') {
return '';
}
const name: string = cacheName(posterKey);
if (name === '') {
return '';
}
const dir: string = dirOf(ctx);
const path: string = dir + '/' + name;
if (hasFile(path)) {
return path;
}
if (url === '') {
return '';
}
ensureDir(dir);
const buf: ArrayBuffer | null = await download(url);
if (buf === null) {
return '';
}
const made: string = await makeThumb(buf, path);
if (made !== '') {
pruneDir(dir);
}
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) */
export function fbCardImageFd(path: string): number {
if (path === '') {
return -1;
}
try {
return fileIo.openSync(path, fileIo.OpenMode.READ_ONLY).fd;
} catch (err) {
hilog.warn(DOMAIN, TAG, '打开卡片图片失败:%{public}s', JSON.stringify(err));
return -1;
}
}
/**
* 关闭本次推送用的所有 fd —— `updateForm` 之后由调用方调用。
*
* ⚠️ 卡片渲染侧会自己 dup 这些 fd(官方 FAQ 示例同样是"推完就 close"),
* 不关的话:`pushAll` 可能在**应用主进程**里跑,fd 会一路攒着。
*/
export function fbCloseCardImageFds(fds: number[]): void {
for (const fd of fds) {
if (fd < 0) {
continue;
}
try {
fileIo.closeSync(fd);
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '关闭卡片图片 fd 失败:%{public}s', e.message);
}
}
}