做Android开发这些年,隔三差五就会遇到一个需求:把App翻译成其他语言。老板说“做个海外版”,产品说“要支持多语言”,客户说“最好能自动翻译”。但等你真正坐下来动手,会发现这个需求至少有两层含义:一是把App界面本身做成多语言版本,用户切换系统语言时界面跟着变;二是让App具备翻译能力,比如聊天工具里的消息翻译、新闻阅读器里的内容翻译。很多人一开始会把两者混在一起,导致方案越做越复杂。
这篇内容不聊框架,不画大饼,就把我实际做过的“Android App自动翻译成其他语言”这条路完整拆开:从最基础的strings.xml多语言方案讲起,到运行时动态切换语言,再到给App接入实时翻译能力,最后再分享一套能把几十种语言批量翻译落地的流水线脚本,以及我在真实项目里踩过的一堆坑。不管你是刚入门的小白,还是已经在做国际化项目的开发,这篇内容应该都能帮上忙。
1. 动手之前,先想清楚你要“翻界面”还是“翻内容”
很多项目一听到“自动翻译”,第一反应就是找翻译SDK接入,结果接完了发现界面上的按钮还是中文,用户骂骂咧咧。原因很简单,翻译能力解决的是“输入一段文本,输出另一段文本”,而界面国际化解决的是“整个App的UI文本要随着语言环境变化”。这是两条完全不同的技术路线。
1.1 两个容易混淆的需求
先说“翻界面”。它的本质是把代码里写死的界面文字全部抽离出来,按语言分别存储。Android系统有一套成熟的本地化机制:res/values/strings.xml 放默认语言,res/values-en/ 放英文,res/values-zh-rTW/ 放繁体中文,依此类推。系统会根据用户的语言环境自动加载对应资源。这套方案是Android自带的,稳定、轻量、离线可用,也是我做国际化项目时的首选方案。
再说“翻内容”。它面向的是运行期动态产生的文本,比如用户在聊天框里输入的外语消息、新闻App抓取到的海外文章、旅行App里的景点介绍。这些内容不可能提前翻译好,必须在用户看到之前实时翻译。这种场景才需要接翻译能力,通常是调用云端翻译API,或者用设备端的离线翻译模型。
当一个需求说“自动翻译成其他语言”,你得先当面问清楚对方到底要哪一种。我遇到过一个项目,产品经理说要“多语言”,结果开发到一半发现他想要的是“用户把App里的所有文章一键翻译成英文阅读”。方案完全不一样,提前问清楚能省后面数周返工。
1.2 翻译是个流程问题,不只是字符串问题
不管选哪条路线,我都要强调一个观点:多语言不是简单地把翻译结果填进项目,而是一个从代码规范到资源管理、再到翻译发布的完整流程。
- 写代码时要养成习惯,所有用户可见的文本一律走资源引用,严禁硬编码。
- 字符串资源要有清晰的命名规范,比如
settings_title、login_error_network,不能随便写string1、string2。 - 翻译要跟着版本走,每加一个新功能,都要考虑旧语言的翻译何时补齐。
- 语言切换的入口要做成用户可感知的,最好在设置页提供语言选项,而不要只依赖系统语言。
这一点很像装修房子:你首先得把水电管线布置好,后面刷墙贴砖才顺利。代码规范没做好,翻译做得再好也白搭。
2. 界面多语言化:从字符串抽取到运行时切换
如果你的目标真的就是“让App界面变成多语言”,那先别急着接SDK,把官方那套资源方案吃透就够用了。这一节我把整个流程压缩成一套能直接照做的步骤,项目里已经有多语言基础的可以跳过前半段,重点看运行时切换那部分。
2.1 用Android Studio一键抽取硬编码文本
老项目里最容易出现的情况是:界面文字直接写在布局文件里,比如<TextView android:text="确定"/>,代码里也到处都是setText("确定")。Android Studio提供了一个好用的功能叫“Extract string resource”,选中有问题的文本,按Alt+Enter(Windows)或Option+Enter(Mac),选择“Extract string resource”,IDE会自动把这段文本提取到strings.xml里,并生成一个resource引用替换原来的位置。
但IDE不是万能的。它只能帮你抽取当前文件里能被静态识别的字符串,对于动态拼接文本就无能为力了。比如代码里写"共" + count + "条记录",这种硬编码IDE能提取变量拼好的部分,但很多团队往往是手动处理漏掉了,结果中文文案就悄悄留在代码里。更麻烦的是,第三方依赖库内部的字符串你管不到,比如一个图片选择器库可能是英文的,也可能自带中文资源,最终显示什么基本靠运气。
所以我的习惯是:首先明确“硬编码字符串是bug”的观念,code review时就拦截;其次,遇到第三方库的文案问题,能通过库自身的语言设置解决就用,不能就考虑自己包一层界面,把文案替换成App自己的多语言资源。
2.2 创建多语言目录:不是建几个文件夹那么简单
抽取完成后,下一步是创建语言目录。在Android Studio里,右键res目录,选择New > Android Resource Directory,资源类型选values,然后在Locale列表里添加需要的语言地区。IDE会自动生成类似values-en、values-zh-rTW的目录名,并创建对应的strings.xml。
这里有几个细节容易被忽略:
- 默认的
values/strings.xml不要留空。系统在找不到对应语言时会回退到这里,如果这里只有中文,而用户是法语,界面会显示中文;如果连默认的都没有,可能直接崩溃或者空白。所以默认语言建议用覆盖面最广的英文,或者至少是你App用户量最大的语言。 - 不是每种语言都翻译得完美。法语、德语、阿拉伯语这些语言文本通常比英文长很多,硬塞到固定高度的按钮里会显示不全。我一般会在翻译完成后做一轮“文本溢出检查”,给TextView适当留足padding,关键按钮用
android:minWidth兜底。 - 右到左语言(阿拉伯语、希伯来语)不只是翻译,布局还要镜面化。Android有
supportsRtl="true"的属性,开启后布局会自动翻转,但如果代码里用了很多绝对坐标或者左对齐的习惯性写法,RTL下体验会非常怪。这块经常被团队遗漏,等上线后中东用户反馈才想起来。
2.3 运行时切换语言:从传统locale到Android 13新特性
很多产品不想跟着系统语言走,而是希望用户在App内单独设置语言。这意味着“切换语言”是运行时操作,开发要把新的语言设置应用到当前Activity,甚至整个App。
Android 13之前,开发者普遍的做法是通过LocaleManager.setLocale()或者反射改Configuration。我比较推荐兼容性好的方案:保存用户选择到SharedPreferences,然后在Application.onCreate()里读出来,调用Locale.setDefault(newLocale),再通过Configuration.setLocale()更新每个Activity的configuration,最后recreate()刷新界面。
fun applyLocale(context: Context, languageCode: String) { val locale = Locale(languageCode) Locale.setDefault(locale) val configuration = context.resources.configuration configuration.setLocale(locale) context.resources.updateConfiguration(configuration, context.resources.displayMetrics) }但这个方法有个经典问题:在Android 7.0以上,系统已经支持多语言环境,LocaleManager的优先级反而比updateConfiguration更高,导致你辛苦改完又被系统覆盖。网上流传的很多“运行时切换语言”代码都有这个隐患,我在旧项目里也踩过。
Android 13(API 33)推出了官方支持的Per-app language preferences,也就是“应用内语言设置”。直接在应用的AndroidManifest.xml里给activity加上android:localeConfig="@xml/locales_config",然后系统设置里就会出现该App的语言选项,App内部再用同样的API读取用户选中的语言。它解决了长期以来的兼容性问题。
<application android:localeConfig="@xml/locales_config"> </application><?xml version="1.0" encoding="utf-8"?> <locale-config xmlns:android="http://schemas.android.com/apk/res/android"> <locale android:name="zh-rCN" /> <locale android:name="en" /> <locale android:name="ja" /> </locale-config>运行时读取用户语言切换,调用LocaleManager.getApplicationLocales();设置语言后调用LocaleManager.setApplicationLocales(...),系统会替你处理Activity重建。这个方案想法很好,但有个现实问题:市面上还有大量Android 12以下的设备,你要么用兼容方案,要么接受旧的updateConfiguration方式。我的项目是同时维护两套逻辑:低版本走旧方案,高版本走官方新API,最后在onCreate里统一应用。
3. 应用内自动翻译:给App装上“读得懂外语”的能力
接下来是另一种场景:App本身已经有内容了,用户需要“自动翻译”内容。这个需求常见于聊天类App、社区类App、阅读类App。你不可能提前准备所有译文,必须在用户点击“翻译”按钮后实时返回结果。
3.1 ML Kit Translation:Google的端侧离线翻译方案
ML Kit是Google提供的移动端机器学习SDK,其中的翻译模块支持50多种语言,最大的优势是翻译过程可以在设备端离线完成。你把模型下载到本地后,翻译时不消耗网络流量,也不会有延迟,适合对隐私敏感的内容。
接入方式非常简单。先引入依赖:
implementation "com.google.mlkit:translate:17.0.2"然后创建翻译器并下载模型:
val translator = Translation.getClient( TranslatorOptions.Builder() .setSourceLanguage(TranslateLanguage.ENGLISH) .setTargetLanguage(TranslateLanguage.CHINESE) .build() ) translator.downloadModelIfNeeded() .addOnSuccessListener { translator.translate("Hello, world") .addOnSuccessListener { translatedText -> textView.text = translatedText } }注意几点:
- 模型是按语言对拆分的,比如英文到中文是一个模型,英文到日文是另一个模型。每个模型几十MB到一百多MB不等,首次下载前要跟用户确认,最好在Wi-Fi环境下下载。
- 如果目标语言不固定(用户可能随便选来源和目标),模型数量会爆炸,建议只支持固定几种常用语言组合。
- 翻译质量对短句还行,遇到长段文本、俚语、专业术语,效果比云端API差一截。所以我的定位是“应急翻译”,不是“高质量翻译”。
我在一个旅行App里试过这套方案:用户看到景点介绍是日语,一键翻译成中文,尽管模型翻译有些生硬,但核心意思能看懂,满意度还不错。关键是它离线能用,流量和隐私问题都解决了。
3.2 云端翻译API:质量更好,但要处理好网络与流量
如果对翻译质量要求更高,云端API是更稳妥的选择。国内用得比较多的有百度翻译开放平台、有道智云、腾讯云机器翻译,每家都有免费额度,个人项目试用期够用,商业项目按量付费。选型时我建议从三个维度比较:
- 免费额度:百度和有道有一定免费调用量,适合早期验证。
- 语言覆盖:看App目标市场,如果要做小语种出海,语言覆盖率比价格更重要。
- 签名复杂度:有些平台签名简单,有些要算HMAC,接入工作量不一样。
云端的通用调用模式基本一致:App拿到待翻译文本,请求后端接口,后端再去调用翻译服务商API,然后把结果返回给App。有人问为什么不直接在App里调翻译API,原因很简单:密钥不能放在客户端,否则别人反编译一下你的APK就能把你的翻译额度偷光。所以正确做法是App请求自己的服务器,服务器统一管理密钥和限流。
3.3 一个可落地的翻译引擎封装思路
为了避免每个页面都写一遍网络请求和回调,我一般会先定义一个接口,屏蔽不同翻译服务商的差异:
interface TranslateEngine { fun translate( text: String, targetLanguage: String, sourceLanguage: String, callback: (String?, String?) -> Unit ) }需要说明的是,这里的 callback 只是示意,生产环境建议用协程挂起或者Kotlin Flow,避免回调地狱。然后分别实现百度引擎、腾讯引擎等,再写一个 TranslatorManager 根据配置选择当前引擎。这样将来要换翻译服务商,或者同时接入多家做兜底,都只改一个类。
具体的翻译API调用,以百度翻译为例(国内常用的开放平台之一),签名规则是sign = md5(appId + query + salt + secretKey),请求参数通过GET或POST提交。这是一个极简的调用示例:
val salt = System.currentTimeMillis().toString() val sign = MD5(appId + text + salt + secretKey) val url = "https://fanyi-api.baidu.com/api/trans/vip/translate" + "?q=${URLEncoder.encode(text, "UTF-8")}" + "&from=auto&to=$targetLanguage" + "&appid=$appId" + "&salt=$salt" + "&sign=$sign"返回的JSON里有trans_result数组,取dst字段就是译文。实际项目里我更推荐用Retrofit或OkHttp封装,统一处理超时、重试、错误码。翻译API不是100%可用,网络波动、参数错误、语言方向不支持都会返回错误码,要做好用户提示和降级处理。如果企业级项目,建议在后端加缓存,一样的文本只翻译一次,既能省费用又能提高响应速度。
4. 批量翻译流水线:用脚本让多语言落地不再重复劳动
真正让人头疼的不是翻译一两个字符串,而是几十种语言的资源文件同步维护。上网搜教程,很多人教你手动创建每个values-xx/strings.xml,然后一个个粘贴翻译。听起来还行,但项目有几百条字符串,要维护中英日韩法德西俄八种语言,手工人肉翻译一次就想辞职。所以我后来的做法是:把“导出未翻译字符串 → 调用翻译API → 写入目标语言资源文件”这条流水线固化成脚本。
4.1 整体思路:用脚本自动生成多语言资源
流水线的核心分三步:
- 读取
res/values/strings.xml,拿到所有<string>条目的name和text。 - 对每条字符串,调用翻译API,把默认语言翻译成目标语言。
- 把翻译结果写入
res/values-xx/strings.xml,注意保持XML格式合法,特殊字符要转义。
第一步和第三步本质上是XML解析与生成,Python的xml.etree.ElementTree足够用。第二步完全复用上一节提到的翻译API,把请求地址和签名规则封装成函数即可。翻译时有一个性能优化点:有些API支持单次传入多个文本批量翻译,务必用批处理,一次传几十条,速度比一条条调用快得多,也能降低触发限流的概率。
下面是一个示例脚本核心片段,演示读取源语言文件并按条目翻译写入英文资源目录:
import hashlib import random import json import time import requests import xml.etree.ElementTree as ET APP_ID = "你的百度翻译APP_ID" SECRET_KEY = "你的百度翻译SECRET_KEY" def translate_batch(texts, to_lang): query = "\n".join(texts) salt = str(random.randint(32768, 65536)) sign = hashlib.md5((APP_ID + query + salt + SECRET_KEY).encode()).hexdigest() resp = requests.get("https://fanyi-api.baidu.com/api/trans/vip/translate", params={ "q": query, "from": "zh", "to": to_lang, "appid": APP_ID, "salt": salt, "sign": sign, }) result = resp.json() return [item["dst"] for item in result["trans_result"]] def generate_en_resources(): tree = ET.parse("res/values/strings.xml") root = tree.getroot() texts = [item.text for item in root.findall("string")] translated = translate_batch(texts, "en") new_root = ET.Element("resources") for i, item in enumerate(root.findall("string")): new_item = ET.SubElement(new_root, "string", name=item.get("name")) new_item.text = translated[i] ET.ElementTree(new_root).write("res/values-en/strings.xml", encoding="utf-8", xml_declaration=True) if __name__ == "__main__": generate_en_resources()这段脚本在单语言简单场景下能跑通,但生产环境还有很多细节要处理:带占位符的字符串(如%1$s)会被翻译API当作普通文本处理,格式符号可能被翻没;带格式化标签的字符串(如<b>、<a href>)需要做特殊保护;复数资源plurals的翻译策略跟普通字符串不同。所以脚本只是起步,最后一定要人工走查一版,不能完全信任机器翻译。
4.2 辅助工具:资源翻译检查和lint自动化
即便有了脚本,项目依然可能因为某条字符串漏翻而出现界面一半中文一半英文的尴尬。Android本身提供了lint规则来检查多语言缺失,在Android Studio里执行:
./gradlew lintlint报告里会列出MissingTranslation和ExtraTranslation问题。前者告诉你某条默认字符串在某个语言目录中没有对应翻译,后者告诉你某个语言目录里有些字符串在默认语言里已经不存在了(大多是删接口时忘了清理翻译)。这两个规则看着简单,实际帮助非常大。我一般在提交MR前跑一遍,凡是新增的英文文案,都要保证所有目标语言同步更新。
4.3 翻译内容质量:机器翻译完一定要有确认环节
机器翻译最大的问题不是“能不能翻”,而是“翻得对不对”。产品里用户能看到的所有文案都影响品牌调性,出现低级翻译错误非常败好感。我踩过一个例子:品牌Slogan被直译成一句完全不通的外文,团队没人懂那个小语种,直到海外用户发截图吐槽才发现。
所以我的个人实践是:机器翻译的结果仅作为初稿,主流语言(英、日、韩)交给真人翻译或至少让懂语言的同学过一遍;小语种用机器翻译后要让人工质检抽检,重点检查术语一致性。如果项目有预算,优先找专业翻译供应商,机器翻译负责高效生成初稿,人工负责精修定稿,这是目前行业里性价比最高的方式。
5. 翻译项目里那些必踩的坑,逐个记下来
做过一轮完整的多语言/翻译落地,下面这些问题的出现率几乎是100%。我把它们整理成“问题-原因-解法”的形式,方便你排查时快速对照。
| 问题现象 | 常见原因 | 我的处理办法 |
|---|---|---|
| 切换语言后部分界面仍显示旧语言 | 硬编码字符串未走资源引用 | 项目里用lint HardcodedText规则,强制代码评审时拦截 |
| 动态拼句翻译后句子乱序 | 原文包含%s等占位符 | 翻译时保护占位符,译文里的%s数量必须一致 |
| 阿拉伯语布局反了但有些控件位置不对 | 未开启RTL适配 | 布局上用start/end替代left/right,检查ViewPager2循环方向 |
| 日期和时间显示不对 | 代码里直接用硬编码格式 | 用DateFormat和locale一起格式化,不要写死MM/dd/yyyy |
| 某些语言文本在按钮里显示不全 | 默认英文短,翻译后变长(德语尤其明显) | 设计稿预留20%-30%空间,给TextView设置maxLines和ellipsize |
| 首次翻译慢,用户等太久 | 大段文本串行调用API | 批量翻译加缓存,服务端把相同文本缓存住,前端加loading状态 |
| 用户切换语言后App崩溃 | 资源目录缺失或locale变更后Activity未重建 | 加try-catch兜底,res下所有语言目录保持一致 |
5.1 硬编码字符串:多语言最大的隐形杀手
这个问题写在表格里,但值得单独拎出来多说几句。硬编码字符串意味着翻译工作做得再多,总有几个角落里漏掉。实际排查时我不能只看源码,还得检查第三方SDK是否自带中文资源、BaseApplication里有没有对系统默认语言做特殊处理、WebView里的网页文案是不是由后端直接下发的。
我给团队的代码规范有三条:
- 布局和代码里禁止出现中文或英文原文,只能引用
@string/。 - 新建字符串必须同时提交默认语言翻译,哪怕是先用占位符,也要保证占位一致。
- CI(持续集成)脚本里跑一次lint,HardcodedText和MissingTranslation视为错误级别,直接让流水线失败。
规矩定在前面,比上线后补窟窿省事一百倍。
5.2 格式化字符串和复数:机器翻译最容易翻车的区域
字符串资源里会有%1$s、%2$d这类占位符,比如:
<string name="order_count">共 %1$d 条订单</string>翻译成英文时,由于语序变化,原文的位置指示符可能完全失效,机器翻译容易把占位符弄丢或者重复。翻译前我先给翻译人员一份术语表,并说明占位符不能动;如果走API翻译,则用一种变通方法:把占位符替换成特殊标记(比如__PH1__),翻译完成后再替换回来。同样,plurals(复数)是Android提供的多数量语言处理机制,英语有1条和一个多于一条的区别,中文则没有复数形态。翻译时不能简单复制原文的plurals结构,得让翻译者判断目标语言的数量词规则,并正确填写所有quantity字段。
5.3 文件编码和特殊字符:XML资源文件里的暗坑
生成的strings.xml如果编码不对,轻则乱码,重则编译不过。很多翻译API返回的是UTF-8文本,但有些平台默认输出GBK,或者Windows机器上Python脚本写文件没有指定编码,就会把资源文件写成GBK导致中文乱码。写文件时一定要显式encoding="utf-8"。此外XML里有保留字符,文本内容里出现&时必须写成&,出现<或>时要写成</>。脚本生成时只想着拼接字符串,很容易漏掉这步,到编译时报错才想起来。
5.4 切换语言后的Activity重建与缓存清理
运行时切换语言后,所有已经存在的Activity都要响应语言变化,最直接的做法是重建。但重建后会丢失页面状态,用户正在填写的表单内容也会丢失,体验很糟糕。我的建议是:把用户选中的语言持久化,在Activity.onCreate()里应用;切换语言时,对需要保留状态的页面,用onSaveInstanceState()保存数据,再调用recreate()。同时还要注意图片、字体、SharedPreferences缓存里如果存了根据语言生成的内容,也要清理或按语言区分存储,否则切完语言后旧缓存图片还会在页面上出现。
5.5 动态语言列表和维护成本:别一开始做全语言
很多团队一上来就想支持30种语言,结果后续每次加文案都痛苦得想放弃。我的经验是先小后大:首发只支持默认语言、英文、一个目标市场语言,上线验证后再逐渐扩展。多语言不只是翻译成本,每次版本更新都要同步更新所有语言文件,这是持续的维护负担。真上线后,优先看各语言活跃度和用户反馈再决定扩展方向,不需要为了“齐全”而堆语言数量。
最后再分享两句体会
这两年做了不少国际化项目,最大的感受是:多语言/翻译这个需求,技术本身并不难,难的是把流程理顺、把边界想清楚。你在哪个环节先定义清楚“翻界面还是翻内容”,后面就不会走弯路;你在代码规范上多花一周功夫,后面每个版本都能省一周。别迷信“接个SDK自动翻译就完事了”——合规、质量、维护这三座大山,靠的是持续投入。如果非让我给一句建议:第一步永远是重新审视项目里所有用户可见的文本,把硬编码清理干净。这个基础不打牢,其他一切优化都是空中楼阁。