Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbCardImages.ets
T

339 lines
12 KiB
Plaintext
Raw Normal View History

/**
* 家书 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);
}
}
}