HarmonyOS包装文案预检连载06:字体资源回退与测量缓存【共创稿事节】
标签文案没有改变,字号也没有改变,换了一套字体资源以后,原来的版面检查却可能失效。更隐蔽的问题来自缓存:页面保存了上一次测量的宽度,字体加载状态已经变化,预览使用新字体,检查仍然读取旧尺寸。沿着上一章的标题层级,本篇把实际采用的字体方案纳入测量身份,并为页面建立有容量限制的自然尺寸缓存。

字体申请不等于字体就绪
示例继续使用“春日花草茶”“净含量 50g”“冲泡建议:85℃温水”三行文案。普通标题字号三十二、行框四十四,说明字号二十二、行框三十,间距六,内容框最大二百八十宽、一百二十高。字体改变不会自动改写这些设计值,必须重新检查它们是否仍能容纳文本。
字体资源层需要分清申请名称、资源就绪状态和当前字体。申请名称用于表达设计意图;就绪表来自应用资源管理流程;当前字体才是测量与绘制实际传入的参数。当前示例的就绪表为空,申请 LabelDisplay 时统一采用 sans-serif,界面显示资源回退。这样能够完整走通回退、检查和缓存失效流程,不需要在正文中留下额外字体附件的引用。
华为自定义字体的注册和使用说明了注册资源与设置字体族的关系,也指出注册操作需要串行安排。应用接入自有字体时,应在资源流程确认就绪后更新就绪表,并推动字体环境版本变化;把申请名称写入列表并不能完成字体注册。就绪表是应用状态,不是系统查询接口。
| 状态 | 申请名称 | 就绪表 | 测量与预览字体 |
|---|---|---|---|
| 当前样例 | LabelDisplay | 空 | sans-serif |
| 资源已就绪 | LabelDisplay | 包含同名资源 | LabelDisplay |
| 资源撤换 | LabelDisplay | 移除同名资源 | sans-serif |
新增 model/FontCache.ets 存放选择策略和缓存,service/CachedMeasure.ets 对接界面上下文。缓存只保存逐行自然尺寸,不保存最终是否容纳的结论。宽度变化可以复用自然尺寸,但仍要重新比较当前内容框;如果以后启用受约束的自动折行测量,约束宽度就必须加入缓存键。
// model/FontCache.ets
export interface SizeVp { width: number; height: number; }
export function fontPlan(requested: string, ready: string[]): string {
return ready.includes(requested) ? requested : 'sans-serif';
}
export function measureKey(text: string, family: string,
epoch: number, sizeVp: number): string {
return JSON.stringify([text, family, epoch.toString(), sizeVp.toString(), 'Normal']);
}
export class FontCache {
private values: Map<string, SizeVp> = new Map();
get(key: string): SizeVp | undefined {
const value = this.values.get(key);
return value === undefined ? undefined : { width: value.width, height: value.height };
}
put(key: string, value: SizeVp): void {
if (!Number.isFinite(value.width) || !Number.isFinite(value.height) ||
value.width < 0 || value.height < 0) {
throw new Error('文字尺寸无效');
}
if (!this.values.has(key) && this.values.size >= 128) {
const oldest = this.values.keys().next().value;
if (oldest !== undefined) { this.values.delete(oldest); }
}
this.values.set(key, { width: value.width, height: value.height });
}
clear(): void { this.values.clear(); }
count(): number { return this.values.size; }
}
键采用字符串数组序列化,文本中即使包含分隔符,也不会与另一个字段组合混淆。字体环境版本用于区分同名字体资源的不同内容;仅靠字体名称无法表达资源替换。字号和字重也属于自然尺寸的输入。当前字重固定为标准字重,因此键中保留明确身份,后续增加粗体时必须同时修改测量参数与键。
缓存采用先进先出淘汰,更新已有键不改变它最初的插入顺序。它不是按最近使用时间淘汰的策略,命中也不会重新排列条目。对于当前短标签页面,一百二十八条是容量策略,不是性能结论;项目可以依据实际文案规模调整,但应始终存在上限,避免编辑每个字符都留下永久条目。
缓存只承接成功的自然尺寸
华为文字测量示例展示了通过界面上下文获取测量工具、传入视口字号字符串,并把像素结果转换为视口单位的过程。本篇沿用支持该示例接口的二十版本及以上开发环境。界面测量和内存缓存无需额外权限,缓存对象绑定当前页面的界面上下文,不在不同窗口间共享。
// service/CachedMeasure.ets
import { UIContext } from '@kit.ArkUI';
import { FontCache, SizeVp, measureKey } from '../model/FontCache';
import { RoleRow, rowsHeight } from '../model/Typography';
export function cachedSize(context: UIContext, cache: FontCache,
text: string, family: string, epoch: number, sizeVp: number): SizeVp {
const key = measureKey(text, family, epoch, sizeVp);
const old = cache.get(key);
if (old !== undefined) { return old; }
const result = context.getMeasureUtils().measureTextSize({
textContent: text, fontFamily: family,
fontSize: sizeVp + 'vp', fontWeight: FontWeight.Normal
});
const size: SizeVp = {
width: context.px2vp(Number(result.width)),
height: context.px2vp(Number(result.height))
};
cache.put(key, size);
return size;
}
export function checkCached(context: UIContext, cache: FontCache,
rows: RoleRow[], family: string, epoch: number, widthVp: number): string {
if (!Number.isFinite(widthVp) || widthVp <= 0) { throw new Error('内容宽度无效'); }
if (rowsHeight(rows) > 120) { return '行框高度超出内容框'; }
for (let i = 0; i < rows.length; i++) {
if (rows[i].text.length === 0) { continue; }
const size = cachedSize(context, cache, rows[i].text, family, epoch, rows[i].sizeVp);
if (size.width > widthVp || size.height > rows[i].boxVp) {
return '第' + (i + 1) + '行超出行框';
}
}
return '版面可容纳';
}
只有尺寸通过有限数值与非负检查后才写入缓存。接口抛出异常时不写入失败占位,也不把异常转换成零宽度。缓存返回和存入都复制两个标量,调用方改动返回对象不会污染后续读取。内容框宽度不在这个自然尺寸键里,因此同一行可以在窄屏和宽屏复用尺寸,而最终容纳结论仍由各自区域决定。

将字体环境更新接到页面失效链
下面的面板替换上一章的角色预览区域,原文仍由父页面通过双向绑定提供。字体资源更新时调用统一的环境刷新入口;示例也提供同名按钮,方便检查失效链。按钮推进的是应用字体环境版本,不改变资源就绪事实。当前就绪表为空,所以刷新后仍为通用字体,缓存清空,状态回到待检查。
// pages/FontPanel.ets
import { FontCache, fontPlan } from '../model/FontCache';
import { makeRows, RoleRow } from '../model/Typography';
import { inspectUnicode } from '../model/UnicodeRules';
import { checkCached } from '../service/CachedMeasure';
@Component
export struct FontPanel {
@Link @Watch('invalidate') raw: string;
@Prop @Watch('invalidate') widthVp: number = 312;
@State family: string = 'sans-serif';
@State epoch: number = 0;
@State entries: number = 0;
@State status: string = '待检查';
private cache: FontCache = new FontCache();
private requested: string = 'LabelDisplay';
private ready: string[] = [];
invalidate(): void { this.status = '待检查'; }
refreshFont(): void {
this.family = fontPlan(this.requested, this.ready);
this.epoch++;
this.cache.clear();
this.entries = 0;
this.status = '待检查';
}
check(): void {
try {
const report = inspectUnicode(this.raw, 'codepoint-v2');
this.status = report.issues.length > 0 ? report.issues.join(';') :
checkCached(this.getUIContext(), this.cache, makeRows(this.raw, false),
this.family, this.epoch, Math.min(280, this.widthVp));
} catch { this.status = '检查失败,请重试'; }
this.entries = this.cache.count();
}
build() {
Column({ space: 12 }) {
Text('申请字体 ' + this.requested).fontSize('18fp')
Text('当前字体 ' + this.family).fontSize('18fp')
Text(this.family === this.requested ? '资源就绪' : '资源回退').fontSize('16fp')
Column({ space: 6 }) {
ForEach(makeRows(this.raw, false), (row: RoleRow) => {
Text(row.text).fontFamily(this.family).fontSize(row.sizeVp + 'vp')
.fontWeight(FontWeight.Normal).maxLines(1)
.width(Math.min(280, this.widthVp)).height(row.boxVp)
.textAlign(TextAlign.Start)
}, (row: RoleRow) => row.key + ':' + row.text + ':' + this.family + ':' + this.epoch)
}.width(Math.min(280, this.widthVp)).height(120).clip(true)
.alignItems(HorizontalAlign.Start).justifyContent(FlexAlign.Start)
.backgroundColor('#F1F6F0')
Text('缓存条目 ' + this.entries).fontSize('16fp')
Text(this.status).fontSize('18fp')
Button('检查版面').onClick(() => { this.check(); })
Button('刷新字体环境').onClick(() => { this.refreshFont(); })
Text('内容框 ' + Math.min(280, this.widthVp) + ' × 120vp').fontSize('16fp')
}.width('100%').alignItems(HorizontalAlign.Start)
}
}
父层继续保存原文与分栏计划,调用 FontPanel({ raw: $raw, widthVp: plan.previewVp })。字体版本属于设计环境,输入变化只失效检查,不必清空所有自然尺寸;区域变化也是重新比较边界,不必废弃相同字体的自然尺寸。页面销毁时其缓存随组件对象释放,重新打开页面重新建立上下文与缓存,避免沿用旧窗口的单位换算结果。

排查缓存身份而非猜测字宽
调试可以从四类变化进入:同文案重复检查应得到相同键;更改文字或字号应形成不同键;字体环境变化应清空旧条目;缩小可用宽度应重新计算容纳结论。不要把缓存条目增加直接解释为测量正确,仍需核对实际传入的字体族、字重、字号单位及当前界面上下文。
对资源就绪表的写入应设置单一入口。资源流程发生失败时,保留通用字体与回退标记;资源成功加入后,再一次性更新选择结果、环境版本和缓存。不要先把界面标记改成资源就绪,再等待字体注册完成,否则用户会在这个间隙得到一个无法追溯字体身份的检查结果。这里的版本是应用内部的设计状态,不是操作系统版本号。
若一次替换多份字体,应先完成资源层的串行处理,再发布一次完整就绪集合。就绪集合与预览组件不宜共用一个可任意修改的数组;可以在刷新入口复制集合,避免资源层增删元素时悄悄改变页面判断。当前样例集合为空,但选择函数接受明确输入,后续接入资源时仍能保持同样的决策路径。
缓存还需要区分自然尺寸与行框高度。文字自然高度来自字体测量,行框高度来自排版方案,缓存前者不意味着后者也被锁定。改变行框高度而不改变字号时,可以复用自然尺寸,但总高度和逐行容纳关系必须重算。把最终成功状态直接缓存下来,就会遗漏这一类设计变化。
容量检查宜覆盖第一百二十八条之后插入新键、覆盖已有键以及清空后再次写入。淘汰应影响复用机会,不能影响结果正确性;被淘汰的条目下次重新测量即可。调用方拿到尺寸后也不应修改它来表示预览缩放,预览缩放是观察层参数,测量尺寸始终保留在设计坐标中。
异常排查不要立刻重试整段文案。可以先检查失败行的参数和字体环境版本,再决定是等待资源更新、修复输入,还是重新创建页面测量上下文。短时间反复测量相同失败输入会增加无效工作,而把失败写成零尺寸又会造成错误的可容纳结论。缓存只承接成功尺寸,是这一层最重要的数据约束。
字体回退还可能发生在单个字形层面:所选字体存在,并不意味着覆盖全部字符。应用保存的是字体选择方案,底层字形选择仍由字体系统完成。因此资源内容变化时即使字体名称相同,也应推进环境版本。对中文、数字、温度符号和补充字符,应分别观察自然尺寸与行框关系,字符计数规则继续独立运行。
本篇把资源回退状态和缓存失效连接起来,缓存只复用可解释的自然尺寸,预览与测量始终共享字体方案。后续批量模板可以继续使用这条测量出口,并把具体问题定位到相应文案行。
更多推荐

所有评论(0)