用户原话:「观影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」。
339 lines
12 KiB
Plaintext
339 lines
12 KiB
Plaintext
/**
|
||
* 家书 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);
|
||
}
|
||
}
|
||
}
|