HarmonyOS 跨模块资源归属与同名键(连载03)【共创稿事节】
发布校对工具从单模块扩展到入口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当作永久身份。源码检查负责尽早发现合同问题,产物核对则帮助定位打包关系与运行时查找范围。
清理废弃资源时,先查明调用归属,再删除资源与合同项。仅从一个模块移除同名键,不会自动把引用转向另一个模块;依赖也不会因为存在同名资源而自动建立。删除操作应该形成资源、引用和依赖的同步变更,避免后续构建才暴露找不到资源的问题。
更多推荐

所有评论(0)