发布校对工具从单模块扩展到入口HAP和语言HSP后,notice在两个模块中都出现了。如果把所有资源先塞进一张字典,后读入的值会覆盖前一个,缺键和参数检查也随之失去归属。另一种粗暴做法是把所有同名都判为错误,却又会阻止本来合法的独立资源。本篇为LocaleReview增加模块作用域,让同名观察、语言校对和引用绑定各自表达不同问题。

按模块区分资源身份

先区分独立资源与合并资源

本篇选择入口entry与共享包lexicon作为两个独立检查作用域。每个作用域拥有自己的默认资源与语言资源,同名notice可以表达不同内容。字符串身份由模块名、资源类型和键名共同确定,不能只凭notice进行全工程比较。语言校对仍在同一作用域内完成,前篇的参数签名规则保持不变。

HAR参与打包时有资源合并和覆盖关系,因此不能把每个HAR目录都机械当成独立命名空间。AppScope和HAP自身资源也存在合入关系。工具需要先定义当前扫描针对哪一个产物,再收集其资源。本文的两作用域配置服务于HAP与HSP,后续若检查HAR,应先按目标构建关系归并,再执行相同的语言检查。

依赖清单来自工程当前关系,必须与实际配置核对。脚本读取的是显式检查合同,不自行解析所有构建变体,也不从目录名称猜依赖。这样可以在工具起步阶段把输入保持确定,并为后续从构建信息导出合同保留接口。

用检查合同声明归属与引用

工程根目录增加下面的 resource_contract.json。resources指向各模块资源根目录;dependencies记录允许引用的目标模块;references记录需要核对的资源绑定。示例中的entry依赖lexicon,引用它的notice,和入口自身notice没有混为一项。

{
  "modules": [
    {
      "name": "entry",
      "resources": "entry/src/main/resources",
      "dependencies": ["lexicon"]
    },
    {
      "name": "lexicon",
      "resources": "lexicon/src/main/resources",
      "dependencies": []
    }
  ],
  "references": [
    { "from": "entry", "owner": "lexicon", "key": "notice" }
  ]
}

entry保留前篇四个键及其完整翻译。lexicon的base、zh_CN均增加notice,值为“%1$s 技术资料”,en_US对应值为“Resources for %1$s”。三个文件都采用标准string数组,每项包含name和value。这个样例特意让入口notice保留两个参数,共享包notice只有一个参数,以检验工具是否错误地跨模块比较签名。

将合同读成有类型的作用域

新建 resource_scope.py。路径按工程根目录解析,模块名不可重复,依赖必须指向已声明作用域。读取阶段检查这些结构问题,比在后续资源扫描中零散处理更容易定位。Path的边界判断也防止配置误指向工程外的同名目录。

from pathlib import Path
from dataclasses import dataclass
import json


@dataclass(frozen=True)
class Scope:
    name: str
    resources: Path
    dependencies: tuple[str, ...]


def load_scopes(root: Path, contract: Path) -> tuple[list[Scope], list[dict[str, str]]]:
    root = root.resolve()
    data = json.loads(contract.read_text(encoding="utf-8"))
    if not isinstance(data, dict) or not isinstance(data.get("modules"), list):
        raise ValueError("合同需要modules数组")
    scopes: list[Scope] = []
    names: set[str] = set()
    for item in data["modules"]:
        if not isinstance(item, dict):
            raise ValueError("模块项应为对象")
        name, relative = item.get("name"), item.get("resources")
        deps = item.get("dependencies", [])
        if not isinstance(name, str) or not name or name in names:
            raise ValueError("模块名称为空或重复")
        if not isinstance(relative, str) or not relative:
            raise ValueError("资源路径为空")
        if not isinstance(deps, list) or not all(isinstance(x, str) for x in deps):
            raise ValueError("依赖应为字符串数组")
        path = (root / relative).resolve()
        if not path.is_relative_to(root):
            raise ValueError("资源路径超出工程范围")
        scopes.append(Scope(name, path, tuple(deps)))
        names.add(name)
    if not scopes:
        raise ValueError("模块集合为空")
    for scope in scopes:
        if any(dep not in names for dep in scope.dependencies):
            raise ValueError(f"依赖模块未声明: {scope.name}")
    refs = data.get("references", [])
    if not isinstance(refs, list):
        raise ValueError("references应为数组")
    for ref in refs:
        if not isinstance(ref, dict) or not all(
            isinstance(ref.get(k), str) and ref[k] for k in ("from", "owner", "key")
        ):
            raise ValueError("资源引用字段错误")
    return scopes, refs

合同中的路径必须使用当前工程的实际位置。若资源根目录不存在,沿用的load_strings会报告输入问题,退出码为二。配置与资源内容都能失败,但原因需要分别显示:前者修合同,后者修资源文件,不能把二者都写成目标语言缺失。

在模块内校对,在模块间绑定

新建 workspace_audit.py,与前篇完整的locale_audit.py、placeholder_signature.py以及上面的作用域文件放在一起。audit继续执行每个模块的缺键和参数检查;同名信息放在sameNames中;引用问题放在referenceIssues中。下列文件给出完整入口,不改变原有退出码含义。

from pathlib import Path
import argparse
import json
import sys
from locale_audit import audit, load_strings
from resource_scope import Scope, load_scopes


def audit_workspace(scopes: list[Scope], refs: list[dict[str, str]]) -> dict[str, object]:
    by_name = {scope.name: scope for scope in scopes}
    values = {scope.name: load_strings(scope.resources / "base" / "element")
              for scope in scopes}
    modules = [{"module": scope.name,
                "audit": audit(scope.resources, ["en_US", "zh_CN"])}
               for scope in scopes]
    owners: dict[str, list[str]] = {}
    for module, strings in values.items():
        for key in strings:
            owners.setdefault(key, []).append(module)
    same_names = [{"key": key, "owners": sorted(names)}
                  for key, names in sorted(owners.items()) if len(names) > 1]
    issues: list[dict[str, str]] = []
    for ref in refs:
        origin, owner, key = ref["from"], ref["owner"], ref["key"]
        reason = ""
        if origin not in by_name or owner not in by_name:
            reason = "引用模块不存在"
        elif origin != owner and owner not in by_name[origin].dependencies:
            reason = "缺少已声明依赖关系"
        elif key not in values[owner]:
            reason = "目标模块默认资源缺键"
        if reason:
            issues.append({"from": origin, "owner": owner, "key": key,
                           "reason": reason})
    return {"modules": modules, "sameNames": same_names, "referenceIssues": issues}


def has_issues(result: dict[str, object]) -> bool:
    if result["referenceIssues"]:
        return True
    return any(row["missing"] or row["extra"] or row["directoryMissing"]
               or row["parameterIssues"]
               for module in result["modules"] for row in module["audit"]["locales"])


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)
        result = audit_workspace(scopes, refs)
    except (OSError, ValueError) as error:
        print(str(error), file=sys.stderr)
        return 2
    print(json.dumps(result, ensure_ascii=False, indent=2))
    return 1 if has_issues(result) else 0


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

按上述资源清单运行 python3 workspace_audit.py 工程根目录 resource_contract.json,两个notice应进入同名观察信息,而不是参数错误。把引用的key改成notices,才会出现目标模块缺键;删除entry的lexicon依赖,则出现引用关系问题。此时同名信息仍保留,便于维护者看到资源分布。

不要在sameNames非空时直接退出一。它只是提示同名存在,是否影响产物需要结合当前作用域模型判断。真正的阻断条件是模块内语言问题或明确引用问题。将观察信息和阻断信息分开,能够避免流水线为了通过而被迫给每个合法资源改名。

模块合同与资源归属开发视图

校对页面保留完整资源身份

下面替换 pages/Index.ets,展示固定样例的两种签名。页面使用模块与键组成的身份,不按键名合并卡片;查看按钮展开或收起引用绑定。这里是校对结果的呈现层,实际资源扫描仍在构建工具链执行。

interface ResourceRow {
  owner: string;
  key: string;
  signature: string;
}

@Entry
@Component
struct Index {
  private rows: ResourceRow[] = [
    { owner: 'entry', key: 'notice', signature: '1:s 2:d' },
    { owner: 'lexicon', key: 'notice', signature: '1:s' }
  ];
  @State showBinding: boolean = true;

  build() {
    Scroll() {
      Column({ space: 16 }) {
        Text('多语发布校对').fontSize(26).fontWeight(FontWeight.Bold)
        Text('资源归属与同名键').fontSize(18)
        ForEach(this.rows, (row: ResourceRow) => {
          Column({ space: 10 }) {
            Text(`${row.owner}.${row.key}`).fontSize(22).fontWeight(FontWeight.Bold)
            Text('参数签名').fontSize(16)
            Text(row.signature).fontSize(20)
          }.width('100%').padding(20).backgroundColor('#EDF3FF')
          .borderRadius(16).alignItems(HorizontalAlign.Start)
        }, (row: ResourceRow) => `${row.owner}:${row.key}`)
        Text('同名按模块区分').fontSize(18).fontColor('#245AC7')
        if (this.showBinding) {
          Text('引用绑定').fontSize(20)
          Text('entry → lexicon.notice').fontSize(18)
        }
        Button('查看引用绑定').width('100%').backgroundColor('#245AC7')
          .onClick(() => { this.showBinding = !this.showBinding; })
      }.width('100%').padding(20)
    }.width('100%').height('100%').backgroundColor('#FFFFFF')
  }
}

保留模块归属的资源校对页

从错误定位回到实际产物

资源调用出现找不到名称或ID时,应先确认当前上下文属于哪个模块,再检查目标资源是否进入对应产物。源码目录中有一个同名文件,并不足以说明当前包能够访问它。对于共享资源,还要核对依赖、引用方式和打包后的归属,避免在调用处反复改名称掩盖工程配置问题。

这次扩展让语言校对保留了明确的比较范围。模块内比较翻译和参数,模块间检查引用,同名信息只负责展示分布。等到下一阶段加入限定词组合时,仍可以在这个模块边界内继续细化资源匹配,而不必重新拆解一张已经混淆归属的全局字典。

同名信息应该怎样进入修复流程

发现同名键后,先询问它们是否属于同一个最终资源作用域。入口包与共享包拥有独立资源时,可以继续使用完整身份访问;若多个源码目录会合入同一产物,则需要在合并阶段确定优先级和所有者。两个问题发生在不同层次,统一要求给键加前缀只会把工程结构问题转移到命名规范中。

同名的内容完全一致,也不能直接合并所有者。后续某个模块单独修改文案或参数时,原本的一致关系会消失。如果产品决定抽取共享资源,应调整实际依赖和引用,再更新检查合同。工具可以帮助列出候选项,但不应仅凭字符串值相同自动决定资源归属。

当前引用检查针对合同中的直接依赖,不推导传递可达性。入口通过某个共享包间接接触另一个资源提供者,并不意味着应该绕过它们的模块接口直接引用资源。若工程允许特定传递访问,需要先明确这一规则,再扩展依赖图求解与诊断内容,而不是把所有可达模块默认视为合法目标。

合同中的references还不是源码引用的自动发现结果,因此维护时应把它与实际引用同步更新。可以在后续版本增加源码解析或产物信息导出,但新增入口不应改变已有字段的含义。from仍表示调用侧,owner仍表示资源归属,key仍表示目标名称,避免工具升级后已有结果失去可比较性。

分清输入错误、语言问题与绑定错误

重复模块名称会使字典覆盖旧作用域,应在读取合同阶段停止;目标语言缺键则应留在对应模块的语言报告中;引用目标不存在时,问题归入绑定列表。三者的修复人可能不同,清楚的分类能够让发布流程将问题送到正确位置,而不是把全部资源问题都交给翻译维护者。

两个模块的默认notice使用不同参数,是一个有价值的隔离样本。若工具错误地跨作用域比较,入口的两个参数会与共享包的一个参数发生冲突。保持这个样本可以防止重构时重新引入全局合并。再将共享包英文的一个参数改成另一类型,才应该在共享包自己的语言报告中出现签名问题。

对实际产物的核对也应保留模块维度。排查时记录当前包、资源名称、资源类型以及调用上下文,必要时查看打包后的资源信息;不要缓存某次构建产生的资源ID当作永久身份。源码检查负责尽早发现合同问题,产物核对则帮助定位打包关系与运行时查找范围。

清理废弃资源时,先查明调用归属,再删除资源与合同项。仅从一个模块移除同名键,不会自动把引用转向另一个模块;依赖也不会因为存在同名资源而自动建立。删除操作应该形成资源、引用和依赖的同步变更,避免后续构建才暴露找不到资源的问题。

相关资料:资源分类与访问、资源管理错误码。

Logo

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

更多推荐