271 lines
9.1 KiB
Plaintext
271 lines
9.1 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.125x),240×360 足够 */
|
|||
|
|
const THUMB_W: number = 240;
|
|||
|
|
const THUMB_H: number = 360;
|
|||
|
|
/** 目录里最多留多少个文件(每部电影一张,24 部够翻一大阵子) */
|
|||
|
|
const MAX_FILES: number = 24;
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 打开本地小图并返回 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);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|