把公共设置面板从 entry 搬进 HSP 后,中文环境一切正常;切到英文,标题退回中文,图标还变成宿主模块里的同名文件。组件代码没有报错,问题却已经写在资源边界里。

这次整理的是一个叫 LocaleScope Lab 的小工程。它有一个 uikit HSP,向 entry 和 feature 两个 HAP 提供网络重试面板。迁移前组件在宿主里用相对路径读图,字符串则由宿主传入;迁移后为了省参数,我把字符串和图片一起放进 HSP,却保留了旧的相对路径写法。

结果很有迷惑性。entry 中正好也有 retry.png,所以图片“能显示”,只是显示错了。zh_CN 资源齐全,中文测试也通过;en_US 少了 dialog_retry,运行时回落到 base 的中文文案。只有切到英文并从 feature 页面打开面板,两个问题才同时出现。

本轮诊断编号为 loc_20261001_05。资源审计最初统计 base 42 个字符串、en_US 41 个,缺失键为 dialog_retry,另外发现 2 处相对路径。修复后状态为 PASS,15:24 在 en-US 环境运行,标题显示 Network retry,HSP 图标来源为 uikit,语言回退数量为 0。

一、HSP 里的资源属于组件,不属于调用方

HSP 用于运行时复用代码和资源,与宿主应用一起发布。组件既然放进 HSP,它自己的图片、字符串和配置也应该在 HSP 内部保持高内聚。调用方不应知道组件内部资源名,更不应该靠目录层级去猜图片位置。

旧代码看起来只是普通路径:Image('../../resources/base/media/retry.png')。

问题在于,相对路径最终可能按调用模块解释。组件从 entry 移到 HSP 后,目录结构已经不是原来的所有权边界;宿主恰好存在同名文件时,错误会被“成功加载”掩盖。官方 HSP 指南也明确建议在组件内部使用 $r 或 $rawfile 访问当前模块资源,避免相对路径引用错误。

另一个常见做法是让宿主直接写 $r('app.string.uikit_dialog_retry')。它能工作,却把 HSP 内部命名暴露给每个调用方。资源改名以后,所有宿主都要跟着改,HSP 的封装就只剩一层目录。

当前方案由 HSP 导出资源门面。下面这段代码解决的是“调用方知道内部资源名”和“相对路径落到宿主模块”的问题。

// uikit/src/main/ets/resources/SharedRes.ets
export class SharedRes {
  static retryIcon(): Resource {
    return $r('app.media.retry_panel_icon')
  }

  static retryTitle(): Resource {
    return $r('app.string.dialog_retry')
  }

  static retryAction(): Resource {
    return $r('app.string.action_retry')
  }
}

// uikit/index.ets
export { SharedRes } from './src/main/ets/resources/SharedRes'

页面组件同样位于 HSP 内部,直接使用 SharedRes.retryIcon() 和 SharedRes.retryTitle()。entry 若确实需要跨包访问某个资源,也只依赖门面方法,不依赖 dialog_retry 这个内部名字。

返回 Resource 而不是已经解析好的字符串,还有一个好处:资源选择可以跟随当前配置。若把中文字符串在模块初始化时解析成普通 string 并缓存,语言切换后组件仍可能拿到旧值。

二、语言回退能保证有值,却不能保证值是对的

HarmonyOS 资源匹配会按照偏好语言和限定词寻找最合适的目录;找不到匹配资源时,会回到默认 base 资源。因此 en_US 少一个键不一定崩溃,它可能安静地显示 base 中的中文。

这正是 LocaleScope Lab 难查的地方。开发机默认中文,base 也是中文,测试一直通过。切到英文以后,按钮“Retry”正常,标题却还是“网络重试”。从 API 角度看,资源解析成功;从产品角度看,这已经是本地化缺陷。

我的处理原则是:base 必须完整,目标语言目录也要通过覆盖率检查。允许回退的键必须进入白名单,例如品牌名或法规要求保留的专有名词;其他缺失一律在构建前报错。

下面的脚本解决的是“运行时回退掩盖缺失翻译”的问题。它比较 base 与 en_US 的字符串键,并同时扫描 ArkTS 中遗留的 ../resources 相对路径。

// tools/audit-hsp-resources.ts
const base = readStringKeys('uikit/src/main/resources/base/element/string.json')
const enUS = readStringKeys('uikit/src/main/resources/en_US/element/string.json')
const fallbackAllowList = new Set(['product_name'])

const missing = [...base].filter(key =>
  !enUS.has(key) && !fallbackAllowList.has(key)
)
const relativeRefs = scanArkTS('uikit/src/main/ets', source =>
  source.includes('../resources/') || source.includes('..\\resources\\')
)

const report = {
  auditId: 'loc_20261001_05',
  baseKeys: base.size,
  enUSKeys: enUS.size,
  missing,
  relativePathCount: relativeRefs.length,
  result: missing.length === 0 && relativeRefs.length === 0 ? 'PASS' : 'BLOCKED'
}

console.log(JSON.stringify(report, null, 2))
if (report.result !== 'PASS') process.exit(2)

第一次执行输出 baseKeys=42、enUSKeys=41、missing=[dialog_retry]、relativePathCount=2,构建状态为 BLOCKED。补齐英文资源并改用资源门面后,base 与 en_US 都是 42 个键,相对路径为 0。

这个脚本不需要解析整个编译产物,重点是把最容易静默失败的规则提前。正式工程还可以继续检查占位符数量、复数格式、图片密度限定词和深色模式资源,但不能把所有语言都机械要求 100% 相同;是否允许回退要结合产品支持范围定义。

三、运行时诊断要保留模块来源

资源门面修好后,我又加了一层运行时诊断。原因很简单:静态脚本只能看到源码,无法证明实际运行时拿到的是哪个模块的资源。如果宿主与 HSP 存在同名资源,仅看界面截图很难分辨来源。

LocaleScope Lab 的 ResourceProbe 记录资源的 bundleName、moduleName 和 id,并通过当前上下文的 ResourceManager 解析字符串。解析失败时保留错误码,例如找不到匹配资源时关注 9001004,而不是统一显示“资源异常”。

这段代码解决的是“页面看见错误文案,却不知道来自哪个模块”的问题。

import { BusinessError } from '@kit.BasicServicesKit'

export interface ResourceProbeResult {
  name: string
  moduleName: string
  value?: string
  errorCode?: number
}

export async function probeString(
  context: Context,
  name: string,
  resource: Resource
): Promise<ResourceProbeResult> {
  try {
    const value = await context.resourceManager.getStringValue(resource)
    return {
      name,
      moduleName: resource.moduleName ?? 'unknown',
      value
    }
  } catch (error) {
    const err = error as BusinessError
    return {
      name,
      moduleName: resource.moduleName ?? 'unknown',
      errorCode: err.code
    }
  }
}

如果直接使用资源名动态查询,也可以调用 getStringByName()。不过 HSP 对外仍应优先导出受控的 Resource,避免宿主拼写内部名字。动态名称查询更适合诊断页和资源审计工具,不适合让业务页面到处散落字符串常量。

ResourceProbe 只在调试构建显示详细模块信息,正式版本不把内部模块结构暴露到用户界面。日志中也不记录用户输入,只记录资源名、模块和解析结果。

DevEco Studio 图中,左侧可以看到 uikit HSP 的 SharedRes.ets、base 与 en_US 资源目录以及审计脚本;中间代码使用 $r('app.string.dialog_retry') 导出资源;右侧模拟器显示 en-US / Network retry;底部 HiLog 则记录 module=uikit、fallback=0 和 audit=PASS。

四、语言切换后不要继续用旧字符串缓存

另一个真实问题发生在系统语言切换以后。应用收到配置更新,页面重新构建了,HSP 里的单例却仍缓存着之前解析的普通字符串。结果新页面一半是英文,一半还是中文。

我把缓存分成两类:Resource 描述可以复用,解析后的本地化字符串不跨配置缓存。确实需要缓存时,必须把语言、色彩模式等配置摘要放进 key,并在配置更新时递增 localeEpoch。

下面的实现解决的是“HSP 单例跨语言继续返回旧值”的问题。

export class LocalizedTextCache {
  private epoch: number = 0
  private values: Map<string, { epoch: number, value: string }> = new Map()

  invalidate(): void {
    ++this.epoch
    this.values.clear()
  }

  async resolve(context: Context, key: string, res: Resource): Promise<string> {
    const cached = this.values.get(key)
    if (cached?.epoch === this.epoch) return cached.value
    const value = await context.resourceManager.getStringValue(res)
    this.values.set(key, { epoch: this.epoch, value })
    return value
  }
}

// EntryAbility.onConfigurationUpdate 中通知 uikit:
// localizedTextCache.invalidate()

当前 Demo 切换语言后不自动保留旧面板实例,而是关闭弹层、清空解析值,再按新配置重新打开。实际产品若必须无感切换,需要让组件状态与本地化文本分离,不能因为清缓存把用户填写的表单也一起清掉。

五、最终验收不是“英文能显示”

修复后的运行页把三个证据放在一起:当前区域为 en-US,标题解析结果为 Network retry,资源来源模块为 uikit。审计卡片显示 base 42、en_US 42、缺失键 0、相对路径 0,诊断编号仍是 loc_20261001_05。

15:24 的日志固定为:

[LocaleScope] audit=loc_20261001_05 base=42 en_US=42
[LocaleScope] missing=0 relativePaths=0 result=PASS
[LocaleScope] locale=en-US module=uikit key=dialog_retry
[LocaleScope] value="Network retry" fallback=0

我还补了两个反向测试。第一,把 dialog_retry 从 en_US 删除,脚本必须在构建阶段返回 BLOCKED;第二,把图片改回相对路径,扫描结果必须变成 relativePaths=1。只有“错误确实能被门禁拦住”,这套检查才不是一张永远绿色的装饰报表。

六、HAR 转 HSP 时容易漏掉的边界

第一,HAR 是编译态复用,代码和资源会跟随使用方编译;HSP 是运行时复用,资源所有权和跨模块访问更值得明确。迁移不能只改模块类型和依赖声明。

第二,组件内部使用 $r,对外需要的资源通过门面导出。不要把内部资源名字写进每个宿主,也不要依靠宿主提供同名资源“补齐”组件。

第三,base 目录必须有默认资源。语言目录缺失时系统回退可以避免空值,却不能替代翻译覆盖率检查。

第四,图片、rawfile 与字符串的策略不同。大文件是否适合放入 HSP、是否会被多个功能同时使用,需要结合包体和运行时访问决定,不能一概迁移。

第五,资源错误码要分类。9001003 是资源名无效,9001004 是没有匹配资源,9001006 涉及循环引用。把它们全捕获成“加载失败”,会让真正的修复方向消失。

第六,配置更新不仅是语言。色彩模式、屏幕密度与设备形态都可能改变资源匹配;缓存 key 不能只写 zh 或 en 就认为覆盖全部情况。

七、共享模块最怕“碰巧能用”

这个问题最值得复盘的地方,是所有错误最初都能显示出一个结果:相对路径碰巧命中宿主同名图片,缺失英文碰巧回退到中文,旧缓存碰巧在默认语言下没有变化。没有崩溃,反而更容易带到提测阶段。

改完以后,HSP 只对外暴露资源门面,构建脚本检查语言键和相对路径,运行时探针确认模块来源,配置更新负责失效缓存。loc_20261001_05 最终的 PASS 不只是“页面看起来对”,而是能说明资源从哪里来、为什么选中这份语言、错误发生时会在哪一层被拦住。

共享组件真正的稳定性,不是宿主帮它把缺口补上,而是换一个宿主、换一种语言、换一次配置以后,它仍然清楚地拥有自己的资源。

参考资料:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐