v95 服务卡片四项:日程卡「今」按钮放大、观影/任务 2x4 列表可滑动、观影 4x4/6x4 补列间距且 6x4 改两列
#1 用户提四项(2026-10-26): 「日程的右上角那个今天的今,特别不好点,一不小心就到日程里面去了…… 是不是可以把按钮弄的大一点。」 「观影的卡片,2x4,右侧观影的列表,不能上下滑动,第三个被截断,很难看, 如果显示多个,就要能滑动,如果不能滑动,就显示两个。」 「任务的 2x4 卡片,也是一样的。」 「观影的 4x4 卡片,和 4x6 卡片,两列图片挤在一起,中间没有空隙,应该缩小一点, 这个大小,我感觉应该根据屏幕的大小来。4x6,宽度都是一样的,为啥要弄成三列?」 ① 日程卡「今」按钮 22x22 → 32x32 病根:22 的圆比最小建议点击区小一半,手指偏一点就落到卡片本体的 `.onClick(openApp)` 上 ⇒ 「想回今天」变成「进了日程详情」。 面积 484 → 1024(2.1 倍);同一份 sideBtn() 里的「+」一并放大, 翻页键 ‹ › 本来 32,现在四个按钮手感一致。 为什么是 32 不是 40:4x4 卡高度紧,顶栏与内容区竖向堆叠 (内容区 layoutWeight(1)),顶栏长高多少内容区就少多少。 必须改 width/height 本身 —— 卡片里这是 Button,可点区就是自己的宽高。 ②③ 观影/任务 2x4 右半 Column → List(可滚动) 病根不在条数而在容器:Column 根本不滚动 + 硬截 3 条, 3x(38+10=48) + 2x6 = 156vp 而可用仅 ≈130vp ⇒ 第三条被压扁/裁掉。 条数上限 3 → MR_2X4_MAX=12 / TK_2X4_MAX=30;滚动条 BarState.Auto (条目少时不露空滚动条)。数据侧 FbCardMediaData 两处 `rows.length < 4` 同步放到 30,否则卡上拿到的还是 4 条,改了容器也滑不出东西。 ④ 观影 4x4 / 6x4 补列间距 + 6x4 三列改两列 病根:`List({ space: 8 })` 的 space 在 lanes 多列模式下只管行间距, 列与列之间不会自动留缝;海报格 .width('100%') + .aspectRatio(0.68) 又贴着 lane 边排 ⇒ 两列严丝合缝贴死。 改法:.lanes(2) → .lanes(2, 8),第二个位置参数才是列间距 gutter。 ⚠️ gutter 是位置参数(SDK list.d.ts: lanes(value, gutter?: Dimension), Dimension = PX|VP|FP|LPX|Percentage|Resource)⇒ 传数字, 写成 { gutter: 8 } 是类型错误(中途踩到,编译期才炸)。 6x4 同时从 .lanes(3) 改成两列,与 4x4 一致;文件头那句 「这块卡更宽、一排放三张」已过时,已注明别再照着它改回去。 同批带上 v92 未提交的观影海报墙实现(此前只在工作树里,从未提交过): - 新增 data/FbCardImages.ets(下载 + desiredSize 缩图 + fd 管理) - FbCardMediaData / FbFormAbility / FbCardForms 里的 formImages 管道 - string.json 卡片名带尺寸(「家书 · 日程 4×4」/「家书 · 观影 4×4」) ⚠️ FbCardImages.ets 已被 FbCardMediaData import,不一起提交就编译不过。 验证: - 静态守卫 _fb_arkts_guard.js:1303 通过 / 0 失败 (新增 groupV95() 共 28 条,钉住按钮尺寸、List 容器、条数上限、 两列 + gutter>0、且不许写成 { gutter: N } 对象) - 反向验证 _fb_v59d_falsify.py:289 通过 / 0 失败 (新增 9 条注入,逐条证明上述断言真的会红) - 真编译 hvigorw assembleHap:BUILD SUCCESSFUL,HAP 产物已生成 ⚠️ 真机验收未做:hdc list targets 为空(设备未连/未解锁)。 这四项全是观感与手感,静态断言证明不了「点得着、滑得动、看着舒服」, 待设备连上后补真机探针(重点:真的点「今」、真的滑列表、量两列实际间距)。
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 家书 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user