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

字体回退与测量缓存

字体申请不等于字体就绪

示例继续使用“春日花草茶”“净含量 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 })。字体版本属于设计环境,输入变化只失效检查,不必清空所有自然尺寸;区域变化也是重新比较边界,不必废弃相同字体的自然尺寸。页面销毁时其缓存随组件对象释放,重新打开页面重新建立上下文与缓存,避免沿用旧窗口的单位换算结果。

资源回退状态的标签预览

排查缓存身份而非猜测字宽

调试可以从四类变化进入:同文案重复检查应得到相同键;更改文字或字号应形成不同键;字体环境变化应清空旧条目;缩小可用宽度应重新计算容纳结论。不要把缓存条目增加直接解释为测量正确,仍需核对实际传入的字体族、字重、字号单位及当前界面上下文。

对资源就绪表的写入应设置单一入口。资源流程发生失败时,保留通用字体与回退标记;资源成功加入后,再一次性更新选择结果、环境版本和缓存。不要先把界面标记改成资源就绪,再等待字体注册完成,否则用户会在这个间隙得到一个无法追溯字体身份的检查结果。这里的版本是应用内部的设计状态,不是操作系统版本号。

若一次替换多份字体,应先完成资源层的串行处理,再发布一次完整就绪集合。就绪集合与预览组件不宜共用一个可任意修改的数组;可以在刷新入口复制集合,避免资源层增删元素时悄悄改变页面判断。当前样例集合为空,但选择函数接受明确输入,后续接入资源时仍能保持同样的决策路径。

缓存还需要区分自然尺寸与行框高度。文字自然高度来自字体测量,行框高度来自排版方案,缓存前者不意味着后者也被锁定。改变行框高度而不改变字号时,可以复用自然尺寸,但总高度和逐行容纳关系必须重算。把最终成功状态直接缓存下来,就会遗漏这一类设计变化。

容量检查宜覆盖第一百二十八条之后插入新键、覆盖已有键以及清空后再次写入。淘汰应影响复用机会,不能影响结果正确性;被淘汰的条目下次重新测量即可。调用方拿到尺寸后也不应修改它来表示预览缩放,预览缩放是观察层参数,测量尺寸始终保留在设计坐标中。

异常排查不要立刻重试整段文案。可以先检查失败行的参数和字体环境版本,再决定是等待资源更新、修复输入,还是重新创建页面测量上下文。短时间反复测量相同失败输入会增加无效工作,而把失败写成零尺寸又会造成错误的可容纳结论。缓存只承接成功尺寸,是这一层最重要的数据约束。

字体回退还可能发生在单个字形层面:所选字体存在,并不意味着覆盖全部字符。应用保存的是字体选择方案,底层字形选择仍由字体系统完成。因此资源内容变化时即使字体名称相同,也应推进环境版本。对中文、数字、温度符号和补充字符,应分别观察自然尺寸与行框关系,字符计数规则继续独立运行。

本篇把资源回退状态和缓存失效连接起来,缓存只复用可解释的自然尺寸,预览与测量始终共享字体方案。后续批量模板可以继续使用这条测量出口,并把具体问题定位到相应文案行。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐