切换语言后按钮被截断,本地化审计怎样发现长度风险

把 QA 面板的语言切到 Deutsch,屏幕右侧那台模拟手机里,"继续浏览"按钮的德文 "Weiter durchsuchen" 顶出了按钮圆角,字被 TextOverflow.Ellipsis 截掉一半。旁边飘出一个红色小标签"文本被截断",下面那行 "Vor 2 Minuten bearbeitet" 也变成了红色。翻译明明是齐的,问题出在放不下------这是本地化里最容易漏的一类缺陷,因为它只有在特定语言、特定分辨率下才会现形。

我负责的这个面板叫"多语言遗漏检查器",四个语言:简中、English、日本語、Deutsch。右上角顶着 87% 的覆盖率,左下角有一张 356 键的文案键值矩阵,19 个 issue 被分成三色:缺失 7、超长 8、变量异常 4。今天只讲超长这条线,它是三者里最"软"的一个------缺失可以 grep,变量异常可以正则,唯独超长,得真的去量。

字符数和像素宽度是两码事

第一版我写得很省事:拿 string.length 数一下字符,超过 24 个就标黄。跑起来才发现完全不靠谱。"继续浏览"四个字是 4 个字符,"Weiter durchsuchen" 是 19 个字符,按 24 的阈值前者平安、后者也平安,可实际渲染出来德文那段已经溢出按钮了。反过来的例子也有,"おはよう、リン" 只有 8 个字符,短得很,但它每个假名都占一个全宽,实际宽度并不比 19 个字符的德文窄多少。

字符数在 CJK 和 Latin 之间根本不是一个度量。真要判断"放不放得下",得拿到每种语言渲染后的实际宽度。鸿蒙这边有 @ohos.measure,它能在不真正上屏的情况下算出文本的测量尺寸,单位是 vp。于是我把"长度"从字符数换成了像素宽度,以基准语言(简中)的宽度为参照,给每个 UI 槽位定一条预算线。

先落数据模型。一条审计项要覆盖四语言、一个键、一条宽度预算:

ts 复制代码
interface AuditEntry {
  key: string            // 文案键,如 'cart.checkout'
  maxWidthVp: number     // 这个槽位允许的最大渲染宽度
  refWidthVp: number     // 基准语言(简中)的实测宽度
  samples: Record<string, string>  // locale -> 翻译文案
}

enum Overflow {
  OK = 0,     // 预算内
  WARN = 1,   // 超出基准 1.3 倍,但还没顶到预算
  OVER = 2    // 超过预算,会被截断
}

interface AuditResult {
  key: string
  locale: string
  widthVp: number
  level: Overflow
  ratio: number        // widthVp / refWidthVp
}

预算不是拍脑袋定的。maxWidthVp 我直接从界面布局里抄:每个按钮、每个文本槽在 Index.ets 里都有固定的 width 或者 padding 包出来的可用宽度,扣掉左右留白就是它实际能装下多少。面板里那行 Text('Weiter durchsuchen').maxLines(1) 的按钮,layoutWeight(1) 分到的可用宽约 96vp,这条就记成 96。

测量函数和预算判定

测量本身是同步的,但一次全量审计要跑 356 个键 × 4 种语言,还得拿到字体配置,全塞在 UI 线程里会把界面卡住。所以我把测量丢进一个异步的 worker 里做,主线程只收结果。

ts 复制代码
import measure from '@ohos.measure'

const BUDGET: Record<string, number> = {
  'home.welcome': 180,
  'cart.checkout': 96,
  'profile.badge': 72,
  'settings.sync': 120,
  'error.network': 132,
}

async function measureOne(key: string, locale: string, text: string): Promise<number> {
  if (text.length === 0) {
    return -1   // 缺文案,走缺失分支,这里不参与宽度判定
  }
  const size = measure.measureTextSize({
    textContent: text,
    fontSize: 9,          // 面板里这些小字号文案统一 9vp
    fontFamily: 'HarmonyOS Sans'
  })
  return size.width
}

这里有个坑:fontSize 必须和真实界面上的一致。审计脚本里写 12vp、界面里却是 9vp,量出来的宽度完全没意义。我一开始就栽在这,量出来全是"安全",实际一跑德语该截还是截。后来把字号、字重、letterSpacing 都从界面里读出来一起传进去,数字才对上。

量出来的数字,跟谁比才算数?

判定逻辑放在主流程里,一条规则:先算相对基准的比例,再跟预算比较。

ts 复制代码
async function auditEntry(entry: AuditEntry): Promise<AuditResult[]> {
  const results: AuditResult[] = []
  const refText = entry.samples['zh-Hans'] ?? ''
  if (refText.length === 0) {
    return results   // 连基准语言都缺,这条直接跳过,避免无意义的告警
  }
  const refWidth = await measureOne(entry.key, 'zh-Hans', refText)
  if (refWidth <= 0) {
    return results
  }

  for (const locale of ['en', 'ja', 'de']) {
    const text = entry.samples[locale] ?? ''
    if (text.length === 0) {
      continue
    }
    const width = await measureOne(entry.key, locale, text)
    const ratio = width / refWidth
    let level = Overflow.OK
    if (width > entry.maxWidthVp) {
      level = Overflow.OVER      // 顶到预算,UI 上必然截断
    } else if (ratio > 1.3) {
      level = Overflow.WARN      // 没截断,但比基准宽了 30%,离危险不远
    }
    if (level !== Overflow.OK) {
      results.push({ key: entry.key, locale, widthVp: width, level, ratio })
    }
  }
  return results
}

比例阈值 1.3 是调出来的,不是算出来的。德语 "Weiter durchsuchen" 相对简中 "继续浏览" 差不多是 1.6 倍,直接进了 OVER;英语 "Continue browsing" 约 1.1 倍,安全;日语 "閲覧を続ける" 约 1.25 倍,落在 WARN 区。这套双层判定能把"已经炸了"和"快炸了"分开,问题清单里才能按高中低排优先级。

两层判定落到面板上,就是那三色标签的分色依据:

级别 判定条件 面板表现 处置建议
OK 宽度不超预算,比例不超 1.3 绿色,不进问题清单 无需处理
WARN 比例超 1.3,宽度仍在预算内 黄色 纳入下次翻译评审,先观察
OVER 宽度超出 maxWidthVp 红色,界面文本被截断 依次换短词、缩字号,ellipsis 兜底

比例阈值只管黄不黄,预算线才管红不红,两层分开的好处是翻译同学扫一眼黄区就能提前动手,红区则是必须动的。面板里 19 个 issue 分成缺失 7、超长 8、变量异常 4,超长那 8 条里有 3 条 OVER、5 条 WARN,OVER 全在德语列,这和德语名词普遍较长对得上。

异步全量跑批和失败分支

356 个键不能一个接一个 await,那样要等几百次测量往返。我用一个并发窗口把键切块,每批并发 16 个,批间等待。窗口大小是踩出来的------太小吞吐上不去,太大 worker 队列排队反而乱。

ts 复制代码
async function runFullAudit(entries: AuditEntry[], onProgress: (done: number, total: number) => void): Promise<AuditResult[]> {
  const WINDOW = 16
  const all: AuditResult[] = []
  let done = 0

  for (let i = 0; i < entries.length; i += WINDOW) {
    const chunk = entries.slice(i, i + WINDOW)
    const batch = await Promise.all(chunk.map(e => auditEntry(e)))
    for (const rs of batch) {
      all.push(...rs)
    }
    done += chunk.length
    onProgress(done, entries.length)
  }
  return all
}

崩在测量之前

失败分支不在测量里,而在测量之前的取数环节。键的翻译文案不是硬编码的,是从资源文件里读出来的。资源文件可能缺键、可能格式坏掉、可能某个 locale 根本没这个文件。这些都会在 loadEntries 里变成异常,得接住并降级:

ts 复制代码
import { resourceManager } from '@kit.LocalizationKit'
import preferences from '@ohos.data.preferences'

async function loadEntries(locales: string[]): Promise<AuditEntry[]> {
  const entries: AuditEntry[] = []
  try {
    const res = await resourceManager.getResourceManager()
    // 逐 locale 拉取 string.json,解析成 key -> text 的映射
    for (const locale of locales) {
      const raw = await res.getRawFileContent(`strings_${locale}.json`)
      const parsed: Record<string, string> = JSON.parse(String.fromCharCode(...raw))
      // ... 合并进 entries,略
    }
  } catch (e) {
    // 某个 locale 文件缺失或 JSON 损坏,这里不能把整个审计崩掉
    const store = await preferences.getPreferences(getContext(), 'locale_audit')
    await store.put('last_error', `${e}`)
    await store.flush()
  }
  return entries
}

这段是简化的,核心意思就一句:取数失败要落盘留痕,而不是让整个扫描静默返回空。第一次联调时 strings_de.json 的 JSON 多了一个逗号,JSON.parse 抛异常,我把 try 范围写大了,结果整个德语列全空,覆盖率从 87% 掉到 72% 我都没反应过来。后来把 try 收到单文件粒度,坏一个语言只影响一个语言。

有些情况,量了也白量

宽度审计跑得再勤,也有几类文案量出来是"安全"的,界面照样出问题。这些我在面板里单独归了一组,不给自动判定,只做人工提示:

  • 占位符文案 :像 %s、{name} 这种,测量时按字面量算,运行时被真实数据替换后可能宽得多。我的处理是把占位符替换成等长填充串再量,并且这类槽位的预算额外留 20% 余量,卡得太死等于没量。
  • emoji 和代理对 :一个 emoji 在 UTF-16 里占两个码元,TextOverflow.Ellipsis 截断时可能把代理对切成一半,屏幕上留半个方块。含 emoji 的文案我在预算上加一档,宁可误报也不要漏报。
  • 日语断词 :ja 没有词间空格,maxLines(1) 下截断点可能落在两个假名中间。宽度判定是绿的,显示出来却是个断词,所以 ja 的 WARN 条目我要求人工过一遍再关。
  • 字体回退:目标设备没装 HarmonyOS Sans、回退到系统默认字体时,测出来的宽度会有几个百分点的偏差。所以每条审计结论里都带字体版本,发布前仍然要四语言真机截图各看一眼。

这四条都指向同一件事:测量给的是参考值,不是判决。工具负责把风险指出来,最后那下确认永远是人按在真机上。面板里那个 87% 的覆盖率也一样,它只说明"键齐了",不说明"放得下"------覆盖率是给翻译同学看的进度条,宽度审计才是给界面上的保险。

目录树和落地

整个审计模块的物理结构很薄,就三层:

text 复制代码
entry/src/main/ets/
├── pages/
│   └── Index.ets              # QA 面板 UI,键值矩阵 + 设备预览 + 问题清单
├── service/
│   ├── LocaleAudit.ets        # 审计主流程:跑批、并发窗口、进度回调
│   ├── MeasureHelper.ets      # 封装 @ohos.measure 的宽度测量
│   └── EntryLoader.ets        # 从 string_xx.json 读文案 + 失败降级
└── model/
    └── AuditTypes.ets         # AuditEntry / Overflow / AuditResult

跑完的结果我按严重级别分组,写回 @ohos.data.preferences,这样下次打开面板不用重新量一遍,直接读上次的缓存。缓存键是 audit:<locale>:<key>,值存宽度和级别。缓存有效期定在 6 小时------文案是会改的,改完不重新审计,缓存里的"安全"结论就过期了,等于骗自己。

ts 复制代码
async function cacheResults(results: AuditResult[]): Promise<void> {
  const store = await preferences.getPreferences(getContext(), 'locale_audit')
  const now = Date.now()
  for (const r of results) {
    await store.put(`audit:${r.locale}:${r.key}`, JSON.stringify({
      ...r,
      ts: now
    }))
  }
  await store.flush()
}

function isStale(ts: number): boolean {
  const SIX_HOURS = 6 * 60 * 60 * 1000
  return Date.now() - ts > SIX_HOURS
}

面板右侧那个"问题清单"里,settings.sync 德语缺失是高优先级,checkout.hint 德语超长是中优先级。超长这条的处置建议我写死在问题条目上,排了三个档:翻译同学换短词排第一,缩字号排第二,maxLines(1) + ellipsis 兜底排最后------截断永远是最不体面的方案,用户看不到完整动作,转化率和可理解性一起掉。

界面中间那张设备预览图,就是这套审计最直接的产物。切到 Deutsch,能一眼看到哪个按钮要炸,哪个标签已经红了。

边界和安全

这套审计只做"量宽度",不做"改文案",它输出的是风险清单,不是自动修复。宽度测量依赖字体,如果目标设备上没有装 HarmonyOS Sans 而是回退到系统默认字体,测出来的宽度会有偏差,所以结果只能当参考,真正发布前还是要在真机或 Previewer 上过一遍四语言截图。另外审计脚本会读取并解析本地资源文件,里面如果有明文占位符(%s、{name})要原样保留、不能当成普通文本去量------占位符在运行时会被真实数据替换,替换后的宽度可能更大,这部分预算要在 maxWidthVp 里额外留出 20% 的余量,别卡得太死。

相关推荐
lucas_AI1 小时前
为什么大模型总敢一本正经地胡说?研究者关掉了它脑内的 8 个「自信开关」
人工智能·llm
2501_944521591 小时前
开源鸿蒙平台 CMP 三方库「scroll-effects」适配全流程
华为·开源·harmonyos
kiros_wang1 小时前
折叠屏状态监听:横竖屏、折叠态差异化布局开发
华为·harmonyos
科起点AI1 小时前
初稿写作指南:用科起点AI从文献阅读到初稿生成
人工智能·aigc·ai写作
打工仔折腾 AI1 小时前
后端部署避坑:吃透IPv4地址、网络接口与CIDR前缀的实际应用
arm开发·人工智能·后端·python·性能优化
非凸科技1 小时前
非凸科技助阵第51届ICPC亚洲区域赛
人工智能·科技
CappuccinoRose1 小时前
拖拽交互体系
前端·javascript·css·html·交互·1024程序员节
a puppy slide 葱1 小时前
ICML | 2026 | MFH‑NAS:面向多模态融合目标检测的混合神经架构搜索框架
人工智能·yolo·目标检测·计算机视觉·架构·nas·神经网络架构搜索