把 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% 的余量,别卡得太死。