多语资源已经按模块归属校对,但只检查en_US和zh_CN仍会漏掉一种问题:横屏平板的深色界面需要更短的提示文案,指定组合目录却没有提供notice。普通英文目录中存在同名键,页面可以获得通用文本,发布前却仍需确认专用文案是否按计划交付。LocaleReview因此增加组合限定词覆盖合同,把语言齐全和场景直接覆盖分成两项要求。

语言资源与组合目录分别检查

组合目录先遵守命名顺序

本篇使用语言、方向、设备、颜色模式四个维度,组合为en_US-horizontal-tablet-dark。语言与地区内部使用下划线,维度之间使用中划线,排列顺序保持一致。工程还可能存在文字、网络和密度等限定词,新增维度时应按资源目录规范扩展合同,不能随意拼接一个看起来可读的文件夹名。

覆盖合同定义的是产品要求:指定目录直接提供某些资源。普通语言目录中出现同名键,仅作为定位信息,不计入这项直接覆盖条件。系统资源匹配服务于运行时取值,发布合同服务于交付完整性,二者承担不同职责;不能把目录覆盖检查写成一套未经定义的资源选择规则。

继续保留前篇entry与lexicon作用域、引用绑定以及模块内语言检查。新增profile限定在某个模块中,防止入口包的组合资源被拿去满足共享包要求。每项规则只列需要专用文案的键,不强迫组合目录复制全部默认资源。

把专用覆盖要求写进合同

在前篇resource_contract.json已有modules和references之外,增加下面的profiles字段。此处展示新增字段的完整对象,合入现有根对象即可。entry原有四个键及各语言翻译不变,lexicon继续保留自己的notice。

{
  "profiles": [
    {
      "id": "tablet-dark-notice",
      "module": "entry",
      "locale": "en_US",
      "orientation": "horizontal",
      "device": "tablet",
      "theme": "dark",
      "requiredKeys": ["notice"]
    }
  ]
}

entry的组合目录需要新建element/string.json,内容为一个string数组,其中notice的value是“%1$s: %2$d items”。它比通用英文文案更紧凑,参数位置和类型仍与默认资源一致。若目录暂缺,报告应同时列出缺少直接覆盖的notice,以及普通英文目录已有同名键,帮助维护者判断应该新增哪份文件。

组合目录中的完整资源内容如下,存放路径为 entry/src/main/resources/en_US-horizontal-tablet-dark/element/string.json。

{
  "string": [
    { "name": "notice", "value": "%1$s: %2$d items" }
  ]
}

从结构化维度生成目录名

新建 qualifier_contract.py。当前项目仅开放两个语言、两种方向、两类设备和两种颜色模式,空字符串表示该维度未指定。取值和所需键都在读取阶段校验,避免将拼错的维度传到文件系统后再表现为一串缺键。

from pathlib import Path
from dataclasses import dataclass
import json


@dataclass(frozen=True)
class Profile:
    id: str
    module: str
    locale: str
    orientation: str
    device: str
    theme: str
    required_keys: tuple[str, ...]

    def folder(self) -> str:
        return "-".join(value for value in (
            self.locale, self.orientation, self.device, self.theme) if value)


def load_profiles(contract: Path) -> list[Profile]:
    data = json.loads(contract.read_text(encoding="utf-8"))
    rows = data.get("profiles") if isinstance(data, dict) else None
    if not isinstance(rows, list) or not rows:
        raise ValueError("需要非空profiles数组")
    profiles: list[Profile] = []
    ids: set[str] = set()
    allowed = {
        "locale": {"en_US", "zh_CN"},
        "orientation": {"", "horizontal", "vertical"},
        "device": {"", "phone", "tablet"},
        "theme": {"", "dark", "light"}
    }
    for row in rows:
        if not isinstance(row, dict):
            raise ValueError("覆盖规则应为对象")
        name, module = row.get("id"), row.get("module")
        if not isinstance(name, str) or not name.strip() or name in ids:
            raise ValueError("覆盖规则身份为空或重复")
        if not isinstance(module, str) or not module.strip():
            raise ValueError("覆盖规则缺少模块")
        values: dict[str, str] = {}
        for field, choices in allowed.items():
            value = row.get(field, "")
            if not isinstance(value, str) or value not in choices:
                raise ValueError(f"限定词取值错误: {field}")
            values[field] = value
        keys = row.get("requiredKeys")
        if not isinstance(keys, list) or not keys or not all(
            isinstance(key, str) and key.strip() == key and key for key in keys
        ) or len(set(keys)) != len(keys):
            raise ValueError("requiredKeys为空、重复或字段错误")
        profiles.append(Profile(name, module, values["locale"],
            values["orientation"], values["device"], values["theme"], tuple(keys)))
        ids.add(name)
    return profiles

合同以维度字段保存规则,目录名由同一方法生成,能够减少不同脚本各自拼接带来的差异。需要检查完整目录名时,可以直接查看folder输出,再对照工程结构。不要在报告里为了排版把横线换成其他符号,路径表达应和文件系统保持一致。

将专用覆盖与参数检查接起来

新增 profile_audit.py。它继续调用前篇的load_scopes、audit_workspace和参数解析器,先完成既有模块校对,再检查指定组合目录。默认集合之外的键会被标出,组合文案的参数也需要保持契约,避免只补齐名称却改变调用类型。

from pathlib import Path
import argparse
import json
import sys
from resource_scope import Scope, load_scopes
from workspace_audit import audit_workspace, has_issues
from locale_audit import load_strings
from placeholder_signature import placeholder_signature
from qualifier_contract import Profile, load_profiles


def audit_profiles(scopes: list[Scope], profiles: list[Profile]) -> list[dict[str, object]]:
    modules = {scope.name: scope for scope in scopes}
    reports: list[dict[str, object]] = []
    for profile in profiles:
        if profile.module not in modules:
            raise ValueError(f"覆盖规则模块不存在: {profile.module}")
        resources = modules[profile.module].resources
        base = load_strings(resources / "base" / "element")
        if any(key not in base for key in profile.required_keys):
            raise ValueError(f"覆盖合同引用未知默认键: {profile.id}")
        signatures = {key: placeholder_signature(value) for key, value in base.items()}
        element = resources / profile.folder() / "element"
        direct = load_strings(element) if element.is_dir() else {}
        language_path = resources / profile.locale / "element"
        language = load_strings(language_path) if language_path.is_dir() else {}
        parameter_issues: list[dict[str, str]] = []
        for key in sorted(set(base) & set(direct)):
            try:
                if placeholder_signature(direct[key]) != signatures[key]:
                    parameter_issues.append({"key": key, "reason": "参数签名不同"})
            except ValueError as error:
                parameter_issues.append({"key": key, "reason": str(error)})
        reports.append({
            "profile": profile.id, "module": profile.module,
            "folder": profile.folder(), "directDirectoryMissing": not element.is_dir(),
            "missing": sorted(set(profile.required_keys) - set(direct)),
            "unknown": sorted(set(direct) - set(base)),
            "localeKeysPresent": sorted(set(profile.required_keys) & set(language)),
            "parameterIssues": parameter_issues
        })
    return reports


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("root", type=Path)
    parser.add_argument("contract", type=Path)
    args = parser.parse_args()
    try:
        scopes, refs = load_scopes(args.root, args.contract)
        workspace = audit_workspace(scopes, refs)
        profiles = audit_profiles(scopes, load_profiles(args.contract))
    except (OSError, ValueError) as error:
        print(str(error), file=sys.stderr)
        return 2
    print(json.dumps({"workspace": workspace, "profiles": profiles},
                     ensure_ascii=False, indent=2))
    failed = has_issues(workspace) or any(
        row["directDirectoryMissing"] or row["missing"] or row["unknown"]
        or row["parameterIssues"] for row in profiles)
    return 1 if failed else 0


if __name__ == "__main__":
    raise SystemExit(main())

所需文件均来自当前连载正文:locale_audit.py和placeholder_signature.py沿用第二篇,resource_scope.py和workspace_audit.py沿用第三篇,再加入本篇两个文件。运行 python3 profile_audit.py 工程根目录 resource_contract.json,退出码零表示合同检查通过,一表示资源问题,二表示输入或合同结构错误。

把组合目录补齐后,notice的缺失列表应清空;将其中的第二个参数改成字符串类型,应只在对应profile里出现参数问题。若把notice误写成notcie,报告会同时指出所需键缺失和额外未知键。两条信息指向同一次拼写问题,修复时应更正资源名,而不是新增一份重复文案。

组合覆盖合同与资源目录开发视图

页面明确说明是哪一种缺失

下面替换 pages/Index.ets,展示组合目录尚缺notice、语言目录已有同名键的固定校对样例。页面的规则按钮展开和收起合同要求,扫描仍由上述工具链负责。将两种存在性分别表达,可以避免维护者看到“英文已有文案”后误以为不需要处理。

@Entry
@Component
struct Index {
  @State showRule: boolean = true;

  build() {
    Scroll() {
      Column({ space: 16 }) {
        Text('多语发布校对').fontSize(26).fontWeight(FontWeight.Bold)
        Text('组合限定词覆盖').fontSize(18)
        Column({ space: 12 }) {
          Text('entry').fontSize(20)
          Text('en_US-horizontal-tablet-dark').fontSize(20).maxLines(3)
          Text('notice').fontSize(22).fontWeight(FontWeight.Bold)
          Text('缺少直接覆盖').fontSize(18).fontColor('#9A5B00')
        }.width('100%').padding(20).backgroundColor('#FFF3DD').borderRadius(16)
        Text('语言目录已有同名键').fontSize(18).fontColor('#245AC7')
        if (this.showRule) {
          Text('要求:组合目录直接提供notice').fontSize(18)
            .width('100%').padding(20).backgroundColor('#EDF3FF')
        }
        Button('查看覆盖规则').width('100%').backgroundColor('#245AC7')
          .onClick(() => { this.showRule = !this.showRule; })
      }.width('100%').padding(20)
    }.width('100%').height('100%').backgroundColor('#FFFFFF')
  }
}

按发布合同呈现组合覆盖问题

让产品要求与运行时匹配各有位置

资源目录可以只覆盖部分键,其他键继续沿用普通资源。发布合同也只应要求确有专用表达的内容,避免把每个语言、方向和颜色组合都变成一份完整拷贝。复制越多,后续参数变更越容易遗漏,维护成本也会随组合数量增加。

直接覆盖只针对明确交付的内容

并非每个限定词组合都需要一份完整字符串文件。本例要求横屏平板深色场景提供notice,是因为该场景采用更紧凑的提示表达;app_name、publish和cancel仍可继续使用通用语言资源。合同列出的键越明确,报告越容易解释,维护者也不需要通过复制全部默认资源来消除检查提示。

同一个组合目录可以承担多项产品要求,不同profile也可以针对不同键检查同一目录。规则身份用于区分这些交付要求,不能直接拿文件夹名作为唯一结果键。反过来,如果同一规则身份重复出现,读取阶段就应停止,防止报告覆盖前一项要求,让某些缺失内容从结果中消失。

普通语言目录中的同名键只说明通用资源存在。它有助于定位当前已有哪份文案,却没有表达专用文案是否被审阅和交付。把localeKeysPresent保留为独立字段,页面便能同时呈现两项事实,而不必用一个模糊的“资源存在”标记掩盖差异。

目录名称错误要回到维度配置

如果报告持续提示组合目录缺失,而工程中确实能看到一个相近文件夹,应逐项比对语言地区、方向、设备与颜色模式,再比对连接符和顺序。不要给检查器增加一个“忽略目录顺序”的开关。资源目录有自己的命名合同,容忍错误拼写会让源码检查和系统加载看到不同的路径。

当前代码从结构化字段生成路径,因此合同里填写错误的颜色模式或设备类型会立即失败。若工程需要加入其他设备或语言,应在允许集合和样本中一起增加,避免只放开输入却没有核对相应目录规范。规则扩展应保持可追溯的含义,而不是把任意字符串都视为有效限定词。

组合目录中的未知键也不能被静默忽略。它可能是资源名称拼写错误,也可能是默认集合还没有同步更新。先核对键的归属和引用,再决定改名、补默认资源或移除无用项。无论采取哪种修复,都应保持模块作用域,不能因为另一个模块存在同名键就自动放行。

用成对样本防止检查规则退化

校对时保留一对相反样本:普通英文目录已有notice而组合目录缺失,应仍报告直接覆盖不足;组合目录补齐notice后,再把第二个参数改错,应报告参数问题。这两步分别检验覆盖要求和字符串合同,能够防止后续重构只保留其中一项检查。

再加入一个无效profile模块,以及一个不在默认集合中的requiredKeys,二者都属于合同输入问题。它们与目标目录缺文件不同,修复位置也不同。把输入错误保留为退出码二,可以让流水线明确提示维护检查配置,而不是把问题错误地分配给翻译或设计人员。

如果产品取消某个专用场景,应同时移除对应profile并审视现有组合资源是否还被使用。删除合同只意味着不再要求专用交付,并不会自动清理资源文件;删除资源也不会自动取消交付要求。两类变更需要在同一发布修订中核对,避免留下长期无人维护的组合目录。

从模块内语言校对推进到组合覆盖后,工具链仍然保留原有边界:模块决定归属,语言检查保证通用翻译,profile检查保证指定交付。将这些要求结构化后,新增场景只需增加明确规则,再为新维度补齐校验与样例,而不必依靠人工逐个打开目录猜测资源是否齐全。

相关资料:华为资源分类与访问、OpenHarmony资源目录规则。

Logo

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

更多推荐