/** * 家书 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 { 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 { 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 { 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 { const out: string[] = []; for (let i = 0; i < keys.length; i++) { out.push(''); } let next: number = 0; const worker = async (): Promise => { 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[] = []; 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); } } }