SmartI18n:Android 多语言资源自动化同步插件实践指南

Android多语言SmartI18n插件strings.xml
于 2026-10-01 07:47:14 修改
·本内容遵循CC 4.0 BY-SA版权协议

1. 从一段发版前夜的加班谈起:为什么我需要一个自动国际化插件

先交代一下背景。我维护的一个工具类App,用户量不算大,但分布在南美、东南亚和欧洲几个区域,所以一直维持着英文、西班牙语、葡萄牙语、印尼语、简体中文、繁体中文六套语言。每两周发一个版本,每次迭代新增文案大概在二十到六十条之间。听起来不多,对吧?但就是这几十条文案,几乎每一次都能让我在发版前夜加班到凌晨。

原因很简单:六套 strings.xml 要同步改。手动改的流程是这样的——先在 values/strings.xml 里加上新 key,然后逐个打开 values-es、values-pt、values-in、values-zh-rTW 去补翻译。英文和中文我自己能搞定,西班牙语和葡萄牙语勉强能写个大概,印尼语基本靠翻译软件。翻译软件一次只能翻译一条,遇到带占位符的句子还得小心翼翼,翻译完还得回头检查 %1$s 有没有被翻坏。漏掉一个文件,编译是不报错的,只有跑到那个语言环境才会看到英文硬编码或者干脆显示 key 名。

后来我实在受不了了,就开始琢磨:能不能做一个插件,帮我自动扫描硬编码字符串,自动在六个文件里创建 key,自动调翻译接口把其他语言填充好?这就是 SmartI18n 的起点。

这篇文章不是广告帖,我是想把我做这个插件的思路、踩过的坑、以及最后沉淀下来的工作流完整写出来。如果你也是被多语言维护折磨的 Android 开发者,或者你想自己动手写一个 Studio 插件来解决类似问题,这篇应该能帮到你。

2. 六种语言的文件同步噩梦:SmartI18n 到底帮我省了哪些事

先说结论:SmartI18n 不是一个“一键全自动翻译”的神器,它解决的核心问题是多语言资源文件的同步维护成本。翻译质量这种事,机器永远只能做到“可用”,人工校对那一环省不掉。但它把最浪费时间、最容易出错的过程——跨文件同步、重复劳动、格式检查——全部自动化了。

2.1 硬编码扫描:把代码里的漏网之鱼揪出来

Android 开发规范里一直强调不要硬编码文案,要用 @string/ 资源引用。但实际项目里,硬编码几乎不可能完全杜绝。紧急改需求的时候,先写个 "加载中..." 顶上去,后来说要补,一忙就忘了。等要做国际化的时候,全项目搜一遍,少说能搜出几十处。

SmartI18n 的第一个能力就是扫描硬编码。它会在你打开文件时、或者手动触发扫描时,把代码里出现的:

  • 字符串字面量(比如 "加载失败")
  • 已经被 R.string.xxx 引用但多语言文件里缺失的 key
  • 布局文件 XML 里直接写死的中文/英文文本

全部列出来,形成一个候选列表。然后你可以逐个决定:提取成 key、忽略、还是合并到已有 key。这个功能听起来简单,但实际做的时候有很多细节,后面第三章我会专门讲实现原理。

2.2 一键提取:从候选列表到多语言文件

选中一条硬编码文案以后,插件会做三件事:

  1. 在 values/strings.xml 的合适位置插入一个新 key,value 就是原始文案。
  2. 把代码里那个硬编码字符串替换成 @string/xxx(或者 getString(R.string.xxx))。
  3. 同步在其他语言的 strings.xml 里创建同样的 key,value 先留空,或者用机器翻译填充。

其中第 3 步是省时间的大头。六套文件同时创建 key,排序规则还完全一致,以后不管谁来维护,打开文件不会因为 key 顺序不一致而 diff 得满屏飘红。

2.3 翻译回填:机器翻译与人工校对的衔接

创建好 key 之后,下一步是翻译。SmartI18n 支持接入翻译服务,把 values/strings.xml 里的原文作为源语言,翻译成目标语言后回填到对应文件。

这里有个原则:翻译结果只作为初稿,不代表终稿。印尼语和葡萄牙语翻译错的概率不低,尤其是复合句。但作为初稿,它的价值在于把“完全空白”变成“基本可用”,人工校对只需要看有疑问的地方,而不是从头翻。实测下来,一条常规文案从“完全没有”到“可以发布”,平均只要花原来十分之一的时间。

2.4 占位符保护与格式校验:不让 %1$s “被翻译”

这是整个插件里我认为最值得写的一个特性。

Android 的字符串资源经常带占位符,比如 "已选择 %1$d 个文件"、"正在下载 %1$s(%2$d%%)"。这类句子扔给机器翻译,经常出现两个结果:一是占位符被拆散,比如 %1$d 被翻成 1% d,导致运行时崩溃;二是翻译软件把 % 当成百分号处理,直接改写。

SmartI18n 的处理方式是在翻译前做令牌保护:先把文案里的占位符统一替换成 [[PH_0]]、[[PH_1]] 这样的占位令牌,拿令牌文本去翻译,翻译完成后再把令牌还原回原始占位符。这样不管翻译引擎怎么折腾,占位符都能原样保住。

另外,插件还会在写入之前做一次格式校验:检查翻译结果里的占位符数量和顺序跟原文是否一致。不一致就标记成“待人工校对”,不会直接写入文件。这个校验规则对 plurals(复数)资源同样有效。

3. 从插件开发者的视角看实现逻辑:PSI 扫描、翻译回填与文件写入

这一章写给那些不只是想用插件、还想搞清楚它背后原理的人。当然,如果你单纯想用工具,也可以直接跳到第四章,不影响使用。但了解原理至少有两个好处:出了问题你自己能定位,遇到工具不支持的场景你也知道是为什么。

3.1 为什么选择 IntelliJ Platform SDK 来做这件事

Android Studio 本身就是基于 IntelliJ IDEA 的,所以做 Studio 插件最正统的方式就是基于 IntelliJ Platform SDK 开发。它提供了一整套 API 来操作项目模型、编辑器、代码分析、文件系统事件等,远比写一个独立 GUI 程序去改文件要可靠。

具体来说,用 SDK 的插件能做到:

  • 监听编辑器事件,在用户写代码时实时扫描硬编码;
  • 通过 UI 框架让操作面板直接嵌入 Studio 的工具窗口;
  • 利用 PSI 解析代码结构,而不是用正则硬匹配;
  • 调用 WriteCommandAction 安全地修改文件,保证 Studio 的撤销栈正常生效。

开发语言是 Kotlin,工程结构就是一个标准的 Gradle 插件项目,用 intellij 插件配置依赖的 IDE 版本。编译产物是一个 zip 包,里面包含插件描述文件、代码和资源。

3.2 PSI 扫描与文本识别:怎么找到字符串字面量

扫描硬编码,最粗暴的做法是正则匹配 "xxx",但这样会有大量误报:日志字符串、注释、json 里的字段名、甚至包名都是带引号的字符串。

正确的做法是用 PSI(Program Structure Interface,程序结构接口)。PSI 是 IntelliJ 平台对代码文件的解析树,每种语言都有自己的 PSI 节点类型。比如 Java 里,PsiLiteralExpression 代表字符串/数字字面量,PsiJavaCodeReferenceElement 代表引用某个符号的表达式。Kotlin 也有对应的 KtStringTemplateExpression。

扫描逻辑大概是这样的:

  • 遍历当前文件(或者当前选中的目录)下的所有 PSI 元素;
  • 命中字符串字面量时,判断它的父节点位置:如果是在 Log.d(...) 调用里,或者是注解参数、常量定义、测试代码里,就跳过;
  • 如果是在 setText()、Toast.makeText()、setHint() 这类 UI 方法里,就判定为“疑似需要国际化的硬编码”;
  • 同时检查这个字符串里是否包含中文、日文、韩文、阿拉伯文等非英文语种的字符,作为候选优先级参考。

布局 XML 里的扫描类似:遍历 XML 文件的属性值,如果某个 android:text="直接写死的文案" 出现在布局里,就提取出来。XML 的解析相对简单,但需要额外处理 tools:ignore 等注解标记。

3.3 翻译服务对接与限流重试

翻译引擎这块,SmartI18n 的设计是可插拔的。我实现过两套:一套是 Google 翻译的网页版接口(非官方,仅用于个人小规模场景),另一套是百度翻译开放平台的官方 API。实际用下来,官方 API 稳定性好得多,而且有免费额度,几百条文案完全够用。

对接翻译 API 的核心模块不复杂:构造 HTTP 请求 -> 携带密钥和待翻译文本 -> 解析响应 -> 拿回翻译结果。但这里有一个非常容易翻车的点:批量翻译时的限流(rate limit)。

Google 的非官方接口如果不加节制地并发请求,很快会被限制 IP;百度官方 API 每秒请求数有配额,超过了就返回错误码。我的处理方式是:

  • 默认串行请求,每条请求之间间隔 200~500ms;
  • 遇到限流错误时,指数退避重试,最多重试 3 次;
  • 已经翻译成功的 key 做本地缓存,下次不用重复请求。

3.4 写回 strings.xml 的可靠方式

提取到新 key、拿到翻译结果之后,就要写文件了。直接拿字符串替换文本文件?不行。因为 XML 可能有转义字符、CDATA、注释,直接替换很容易破坏文件。

正确的做法还是走 PSI:先解析 XML 文件拿到 PsiFile,然后通过 XmlFile 的 API 找到 <resources> 根节点,用 XmlTag 创建 <string name="xxx"> 子节点,再通过 WriteCommandAction 提交修改。这样 Studio 里执行撤销操作也能正常工作。

排序问题也很重要。默认情况下,新 key 会插入到文件末尾,这会导致 diff 不友好。SmartI18n 提供了两种策略:一是按 key 名 ASCII 排序插入;二是按原文注释分组,同组的 key 放在一起。我推荐第一种,团队协作时冲突最少。

4. 三分钟跑通:安装配置到一键提取翻译的完整实操

讲完原理,来点实际的。假设你现在就想把这个流程用到项目里。

4.1 安装:Marketplace 与本地安装两种方式

SmartI18n 是发布在 JetBrains Marketplace 上的,所以正常安装很简单:

打开 Android Studio 的 File -> Settings -> Plugins -> Marketplace,搜索 “SmartI18n”,点 Install,重启 IDE 即可。

如果你用的是内网环境,或者插件还没发布到 Marketplace,也可以走本地安装:到发布页面下载 zip 包,然后在 Settings -> Plugins -> 右上角齿轮 -> Install Plugin from Disk... 选中 zip,重启。

提示:本地安装时注意 zip 包里的结构,必须是包含 plugin.xml 和 lib 目录的标准结构,不能直接选解压后的文件夹,否则会提示无效插件描述文件。

4.2 配置:翻译源、目标语言、key 前缀

安装完成后,先打开 Settings -> SmartI18n 配置面板,需要设置这么几项:

  • 源语言:项目里 values/ 目录的语言是什么。中文项目就是 zh,英文项目是 en。
  • 目标语言列表:勾选要同步的语言,比如 es、pt、in、zh-rTW,插件会为每个语言维护一份 strings.xml。
  • 翻译服务:选择“不使用翻译/百度翻译/Google 翻译”,填上 API Key 和密钥。不填的话也能工作,只是其他语言文件里的 value 会留空。
  • key 命名前缀:建议设一个业务前缀,比如 common_、order_、profile_,方便分类管理。
  • 硬编码扫描粒度:可以选择只扫描当前文件、当前目录、整个模块。

配置好之后,大多数操作入口在 Android Studio 的右侧工具窗口栏能找到“SmartI18n”面板。

4.3 实操:一个示例项目全流程跑通

我用一个假想场景带你把流程走一遍。假设项目里有一段代码:

KOTLIN
button.text = "下载完成"

这是典型的需要国际化的硬编码。现在操作:

  1. 打开 SmartI18n 工具窗口,点击“扫描当前文件”,面板里会出现 "下载完成" 这一条,标黄色,提示是中文硬编码。
  2. 选中这条,点击“提取并翻译”。插件会弹出一个小窗让你确认 key 名,默认按原文拼音转英文生成,比如 download_finished,你也可以自己改成 download_complete。
  3. 确认后,插件自动执行三步:在 values/strings.xml 创建 <string name="download_finished">下载完成</string>,把 Kotlin 代码替换成 button.text = getString(R.string.download_finished),同时在其他五个语言的 strings.xml 里创建同名 key 并填入机器翻译的结果。
  4. 右侧面板会显示执行结果:五个 key 翻译成功、一个 key 需要人工校对(因为原句比较短,机器翻译的印尼语版本不太自然)。

整个过程不到十秒。如果完全手动做,光拷贝 key 名和开五个文件补翻译就够折腾好几分钟。

4.4 生成的资源目录长什么样

插件不会重建你的 values 目录结构,而是在已存在的目录上操作。一个规范的多语言项目目录大致是这样:

TEXT
app/src/main/res/
├── values/
│ └── strings.xml
├── values-es/
│ └── strings.xml
├── values-pt/
│ └── strings.xml
├── values-in/
│ └── strings.xml
└── values-zh-rTW/
└── strings.xml

如果某个语言目录不存在,插件会用系统默认模板帮你创建目录和空白的 strings.xml。这里有个小细节:插件创建的文件头会带一段注释,标明是由 SmartI18n 自动生成、建议人工校对,方便日后追根溯源。

4.5 把提取的文案交给专业翻译:导出/导入机制

机器翻译初稿不是终点。我的习惯是:发版前两天,把新增的 key 和机器翻译结果导出一份表格,发给懂印尼语或者葡萄牙语的兼职翻译看一遍,回来再整体导入回插件。SmartI18n 支持导出 CSV、导入 CSV,格式是 key, 源语言, 目标语言, 当前翻译, 建议翻译。翻译只用改“建议翻译”那一列,导入后插件会覆盖对应文件里的值。

这个功能一开始我觉得多余,后来发现是团队协作里的救命稻草。不是每个团队都有专职翻译,让外包看 CSV 比让他们改代码舒服多了,也不容易改坏文件结构。

5. 真实项目里的七个坑:SmartI18n 使用与开发的排查链路

工具做得再顺手,也会遇到意外。这一节我按“现象 -> 排查 -> 解决”的结构,把我和其他使用者反馈过的问题整理出来,后面再做同类插件的人可以直接绕开。

5.1 坑一:扫描结果出现大量非文案字符串

现象:扫描整个模块,结果有几百条,大半是 "https://"、"UTF-8"、"json"、"user_id" 这类代码里要用到的非展示字符串。

排查链路:我最初就是无差别收集所有 PsiLiteralExpression,结果灾难。后来加了一个过滤规则:字符串变量的上游使用者必须是 View 相关的 setter 方法,或者是 Toast、Dialog、Notification 这类 UI 组件调用,才判为文案候选。String.format 里的格式串、网络请求的 URL、JSON 字段名,一律直接跳过。

解决:过滤规则按“调用链上下文”判断,不是单纯看字符串内容。比如 "https://" 即便全英文,也不会被当成文案;而 "下载中..." 即使不带中文,但只要传给 setText(),照样标记为文案。这个逻辑写起来不算难,但效果立竿见影,扫描结果直接干净了一个量级。

5.2 坑二:翻译结果里的占位符被改写

现象:源字符串是 "共 %1$d 个项目,已完成 %2$d%%",翻译成西班牙语后变成了 "Un total de %1$d proyectos, %2$d%% completados" 看着没问题,但有一次机器翻译返回的是 "%1$d proyectos en total, completado %2$d por ciento"——%% 被改写了,或者占位符顺序被打乱。

排查链路:最初是直接把原文丢给翻译 API,拿回结果直接写。某次发版后用户反馈西班牙语下崩溃,报错 java.util.IllegalFormatConversionException。查到最后,就是格式化字符串被翻译引擎改了顺序和格式符。

解决:在翻译前先正则提取所有占位符(%\d+\$[dsf]、%%、%s、%d),替换成 [[P0]]、[[P1]] 这类标记,翻译完成后再用正则替换回去。还原以后再做二次校验:翻译结果的占位符集合必须和原文的占位符集合完全一致,不一致就标黄提示,绝不直接入库。这个机制比“翻译后人工检查”可靠一万倍,因为人工检查六种语言的占位符,十次有九次会漏。

5.3 坑三:批量翻译请求被限流

现象:一次提取了 80 条新文案,插件逐个调用翻译 API,翻到第 43 条时开始报错,后面全部失败。

排查链路:百度翻译 API 免费版有 QPS 限制(每秒请求上限),串行请求本来已经比较保守,但 80 条一次性连续请求,仍然会触发一分钟内的总量限制。Google 非官方接口更敏感,连续请求 30 次以上就可能被掐断。

解决:三层措施——第一层是全局缓存,同一个源文案不管在哪次扫描中出现,都直接读缓存;第二层是增加自适应限速,检测到返回错误码时把间隔时间动态拉长;第三层是把“大任务拆成小批次”,比如每次最多提交 20 条,批次之间暂停 3 秒,这样基本不会触发限流。如果某个 API Key 被临时禁用,插件会保留已提交的任务状态,用户换一把 Key 之后可以“继续未完成任务”,不用从头开始。

5.4 坑四:多模块项目的扫描范围缺失

现象:项目是组件化结构,app 模块之外还有 module_common、module_order、module_user,各自都有独立的 res/values/strings.xml。插件默认只扫描当前选中目录,用户以为整个项目都被扫了,结果只处理了 app 模块,其他模块漏了一大半。

排查链路:起初我在代码里用的是 FileEditorManager 拿当前打开文件的虚拟文件,再往上找模块边界。这样只能覆盖“当前打开的文件”,范围太窄。后来改成遍历整个 Project 的模块列表,对每个模块的 src/main/res 目录做递归扫描。

解决:在扫描面板里增加“作用域”选项:当前文件 / 当前目录 / 当前模块 / 全项目。默认选“当前目录”,但面板顶部会用文字明确提示“当前目录只包含 xxx,共 y 个资源文件”,让用户直观感知扫描范围。跨模块提取时,key 会带上模块名前缀,避免不同模块里出现同名 key 相互覆盖。

5.5 坑五:string-array 和 plurals 资源被漏掉

现象:某些文案不在 <string> 节点里,而是放在 <string-array> 或者 <plurals> 里。插件最初只处理 <string> 节点,导致一部分文案没有被同步到其他语言。

排查链路:Android 资源不止一种类型。列表类文案(比如“周一、周二、周三”)会用 <string-array>,有一条源字符串就会对应数组里的多个 item;数量相关文案(比如“1 条消息 / n 条消息”)会用 <plurals>。如果插件不处理这两种结构,等于漏了另一半常见场景。

解决:处理 XmlTag 时,除了 <string>,同时判断 <string-array> 的 <item> 子节点和 <plurals> 的 <item quantity="one"> 子节点。这部分的翻译逻辑和 <string> 类似,但校验逻辑更复杂:<plurals> 里每个 quantity 子节点都必须存在,不能只翻译 one 而漏掉 other。

5.6 坑六:key 名冲突合并出错

现象:两个英文句子翻译成中文是同一个词,提取时如果不小心,会生成两个不同 key,但 value 一样。后来想合并 key,手动删一个改引用,容易漏改,导致编译后取到空字符串。

排查链路:这是“自动化 vs 约定”的经典冲突。自动生成 key 时,基于原文内容生成的可读 key 很容易撞车。比如 "删除" 和 "删除该项目" 在英文里可能都是 Delete,但语义范围不同,强行合并会出错。

解决:插件在生成 key 时,如果重名,会自动追加短哈希后缀(比如 delete_8f3a),保证 key 唯一。同时提供一个“重复文案检测”功能,把同语言中 value 完全一致的 key 列出来,由开发者人工决定合并。自动去重危险,人工去重才稳妥。

5.7 坑七:Studio 版本升级后插件失效

现象:Android Studio 从某个版本升级后,插件按钮灰了,工具窗口打不开,日志里报 ClassNotFoundException 或者 NoClassDefFoundError。

排查链路:IntelliJ Platform SDK 的 API 有大批量变更节点。之前的插件基于旧版本 API 编译,新版本 IDE 里某些类被移除或挪了包名,加载时就崩了。

解决:插件发布时声明 since-build 兼容范围,并针对当前主流的 Studio 版本分别编译。Studio 升级前建议先看一下插件兼容声明。如果升级后插件坏了,最简单的方案是去 Marketplace 找新版;如果新版没跟上,可以临时用旧版 Studio 顶着,等插件适配再升。

6. 让多语言成为工程习惯:CI 校验、命名规范与团队协作思路

工具解决了“动作”层面的效率,但真正让一个项目长期保持健康国际化状态,还得靠制度和规范。这一章聊聊工具之外的工程化思考。

6.1 团队里的 key 命名规范

提取文案只是一瞬间的事,但 key 的命名会影响后续所有维护成本。

我的建议是三层式命名:模块_界面_语义。比如 order_detail_download_finished、profile_avatar_uploading。好处有三点:排序时同类 key 聚在一起;出问题时能快速定位模块;避免不同模块里语义相同的文案生成重复 key,合并时反而麻烦。

插件可以帮你生成默认名,但我会在操作时手动把 key 改成符合团队约定的形式。别嫌这一步麻烦,一次命名到位,比以后全局搜索替换省心得多。

6.2 接进 CI:构建时自动检查翻译缺失

人总有疏忽的时候,新文案没补全照样能编译通过。所以我在 CI 流程里加了一个 Lint 检查:扫描所有 values-* 目录,凡是 values/strings.xml 里存在的 key,其他语言文件里必须存在;如果 key 存在但 value 与源语言完全一样,而且源语言不是英文,就标记为“疑似未翻译”。这条规则写成 Gradle 任务,挂在 preBuild 阶段,一旦发现缺失直接让构建失败。

这个检查可能比插件本身更值钱。因为它把“国际化遗漏”从“运行时才暴露”提前到了“构建时就报警”。开发环境里每个人都会跑构建,等于强制每个改动都过一遍多语言完整性检查。

6.3 翻译管理平台协同与人工校对流

机器翻译初稿的价值前面说过了,但最终发布之前,我建议至少有一轮人工校对。中小团队不一定养得起专职翻译,我的做法是:

  1. 插件导出 CSV,附上源文案出现的页面截图,交给外包翻译或海外用户群里的热心用户。
  2. 校对结果导回插件。
  3. 插件比对“建议翻译”和“当前翻译”,有出入的地方高亮出来,人工确认后写入资源文件。

这个流程不需要额外的翻译管理平台,一套 CSV 就能跑通。如果团队规模大了,可以考虑接 Lokalise、Crowdin 这类平台,SmartI18n 的导出格式基本能兼容。

6.4 工具本身的边界:机器翻译永远不能替代母语者

最后说说我做了这个插件以后最深的体会。

自动化的价值集中在确定性劳动上——创建 key、同步文件、检查占位符、补缺失项,这些都是“规则明确、重复度高”的工作,机器做得又快又准。但翻译质量这件事,涉及语气、文化、长句结构,机器翻译近几年进步很大,但仍然会在俚语、双关、特定文化语境上翻车。

所以我在插件里特意设计了一句提醒:翻译结果写入时,如果置信度低于阈值,会在 key 旁边打一个特殊标记,表示“机器翻译初稿,未经人工确认”。这个标记的存在不是为了偷懒,而是明确告诉使用的人:这里需要看一眼。

多语言的本质不是把字面意思翻过去,而是让不同语言环境下的用户用起来都感到自然。工具能把门槛降低、把效率提上来,但最终那条“人工确认”的线,我还是建议留着。