如果你在一个氛围场景团队待过,就会发现“语言切换”这个需求从一开始就不该叫“翻译一下就行”。
我这次做的模块叫 Atmosphere Space Series - Lang Switch。Atmosphere Space Series 是我们在做的整套氛围空间场景服务——预设灯光、白噪音、香薰设备联动、定时场景、语音播报都算在里面。Lang Switch 是它的语言切换模块。最初需求单上只写了一句话:“支持多语言,切换后所有界面和场景内容跟着变。”等真正拆解完,才发现这句话背后牵涉 UI 文案、场景元数据、语音播报、推送通知、云同步,甚至硬件显示面板的字符集。这篇文章会把这些拆开讲清楚,并给出我在项目里实际采用的方案和踩坑记录,给正在做类似功能的同学一个可以直接参考的版本。
1. 氛围场景与语言切换的边界:Lang Switch 到底管哪些内容
1.1 Atmosphere Space Series 不是“一套皮肤”,是整套场景服务
Atmosphere Space Series 这个名字很容易让人误以为它只是换壁纸、换灯光的皮肤包。实际上,一个完整的氛围场景至少包含四层:场景预设层、设备联动层、内容资源层和交互反馈层。场景预设层定义“日落烛光”“森林晨雾”这类场景的元数据;设备联动层负责把灯光色温、加湿器档位、香薰强度组合成可执行指令;内容资源层包含白噪音音频、动态光效参数;交互反馈层则覆盖 App 上的按钮文案、语音助手的应答话术、设备面板上的状态字。
Lang Switch 一开始只被当作“App 设置里的一个下拉框”,后来所有端都要求支持切换时才意识到,它是横跨这四层的公共能力。场景名称要切、设备报错信息要切、语音播报要切、云端推送的定时任务描述也要切。只改 App 的 navigation 标题,根本不算支持多语言。
1.2 语言切换的内容边界:从按钮文案到语音播报
我在项目里维护了一张“语言切换影响面”表,每次评审新功能都会对照这张表检查有没有遗漏。这里直接贴出来:
| 内容类型 | 是否必须切换 | 典型例子 |
|---|---|---|
| App 静态文案 | 必须 | 按钮、菜单、设置项、弹窗 |
| 场景元数据 | 必须 | 场景名称、描述、标签 |
| 语音播报内容 | 必须 | “日落烛光已开启”“还有十分钟结束” |
| 推送通知 | 必须 | “你的场景已执行完毕” |
| 设备错误码 | 必须 | “设备离线”“香薰液不足” |
| 用户生成内容 | 视情况 | 用户自建场景名,通常不翻译 |
| 日志/调试信息 | 建议保留原文 | 便于线上问题排查 |
最容易漏的就是设备错误码。氛围场景硬件的状态提示经常写死在固件里,App 拿到的只是DEVICE_LOW_CAPSULE这样的错误码。如果 App 端不做映射,用户就可能看到一串英文代号。我们曾经有一个版本把“香薰液不足”直接透传成LOW_CAPSULE_ERR,被用户当 bug 反馈上来,其实就是文案层少做了一层本地化映射。
1.3 谁在使用这个切换能力
Lang Switch 不是为“出国用户”准备的,真正的场景比想象中常见。家庭场景里,儿童房间的设备往往由家长用中文控制,但孩子自己看的场景卡片可能想切成英文学习;酒店式公寓里,客人可能是多语言用户;办公室或展厅部署 Atmosphere Space Series 后,访客设备也需要根据现场人群快速切换。
这就决定了 Lang Switch 不能只保存在本地设置里,而是要考虑账号级同步和访客态隔离。我在第三章会详细讲同步策略,这里先给结论:本地切换是底线,云端同步是体验加分项,两者必须同时存在。
2. 运行时切换的技术骨架:语言包组织、状态管理和调用链
2.1 语言包用 JSON 维护,但别把层级压得过深
Atmosphere Space Series 的语言包最开始按模块拆成scene.json、device.json、common.json,然后在代码里分别import。随着场景类型增多,这种“按模块拆文件”的方式开始出现麻烦:场景 A 和场景 B 都引用了scene.common下的 key,一旦要整体调整文案,必须同时改两个文件,还容易漏。
我更推荐按语言拆成单体 JSON,但内部用两层命名空间来控制 key 数量:
{ "_locale": "zh-CN", "_version": 3, "common": { "save": "保存", "cancel": "取消", "confirm": "确认" }, "scene": { "sunset.name": "日落烛光", "sunset.desc": "模拟日落时分的光影与轻微烛火闪烁", "forest.name": "森林晨雾" }, "device": { "offline": "设备离线", "low_capsule": "香薰液不足" } }key 的命名建议用“模块.对象.字段”的方式展平,不要写成嵌套对象。比如用scene.sunset.name,不要写成scene: { sunset: { name: ... } }。嵌套对象在加载时容易产生 undefined 异常,而且 key 对比脚本写起来也更麻烦。我们最后统一成扁平 key + 两层语义前缀,维护成本降了很多。
每个语言包文件里都留_locale和_version字段。版本号用来做缓存失效判断,避免 App 升级后还显示旧语言包。这个字段在联调时特别有用,两边对不上 key 时,先看版本号就知道是不是缓存问题。
2.2 切换入口:一个可订阅的 LangManager
运行时切换语言,本质上要做两件事:把全局当前语言换成新值,然后通知所有订阅方重新渲染。我实现了一个精简的LangManager,核心逻辑如下:
type Locale = "zh-CN" | "en-US" | "ja-JP" | "de-DE"; type TranslateParams = Record<string, string | number>; class LangManager { private static instance: LangManager; private currentLocale: Locale = "zh-CN"; private translations: Record<string, string> = {}; private listeners: Array<(locale: Locale) => void> = []; static getInstance() { if (!LangManager.instance) { LangManager.instance = new LangManager(); } return LangManager.instance; } setLocale(locale: Locale, translations: Record<string, string>) { this.currentLocale = locale; this.translations = translations; this.listeners.forEach((listener) => listener(locale)); } t(key: string, params?: TranslateParams): string { let template = this.translations[key] || key; if (params) { Object.entries(params).forEach(([name, value]) => { template = template.replace( new RegExp(`\\{${name}\\}`, "g"), String(value) ); }); } return template; } subscribe(listener: (locale: Locale) => void) { this.listeners.push(listener); return () => { const index = this.listeners.indexOf(listener); if (index >= 0) this.listeners.splice(index, 1); }; } } export const langManager = LangManager.getInstance();这里最简单的实现把翻译表直接存成Record<string, string>,方便定位问题。实际生产环境里,setLocale往往还会做异步加载:先显示 loading,从本地缓存或云端拉对应语言包,再一次性更新状态。这样能避免切换语言时界面出现“一半中文一半英文”的中间态。
2.3 为什么不用“刷新页面”或“重启 App”来生效
有些团队为了省事,切换语言后直接location.reload()或者提示用户重启 App。这在纯展示型网站里勉强能用,但在 Atmosphere Space Series 这种有设备联动的体系里有三个问题。
第一,语音播报服务可能正在运行,刷新页面会中断当前播报,用户会明显感觉“切换语言把场景打断了”。第二,灯光、香薰等设备的运行状态是实时保存在内存里的,刷新后需要重新从云端拉取,期间设备状态显示会出现闪烁。第三,某些入口是硬件面板发起的,重启 App 的路径在无头设备上根本不成立。所以 Lang Switch 必须支持运行时无感切换,这也是我坚持做订阅式状态管理的原因。
2.4 调用链上的每一层都要拿到当前语言
语言状态不能只在 UI 层传递。Atmosphere Space Series 的典型链路是:用户点击场景卡片,App 拼装设备指令,云端记录执行日志,硬件返回结果,App 弹出提示。如果只有 UI 层切换了语言,设备指令里的场景名称、云端日志里的描述、push 通知里的文案可能还是旧语言。
我的做法是:所有服务层方法都接收一个Locale参数,或者在内部调用langManager.getCurrentLocale()。尽量不要在深层的 service 里依赖某个全局变量以外的隐式上下文。显式传参虽然麻烦,但测试时很容易 mock,也避免了“这个函数在什么时候语言对、什么时候语言不对”的玄学问题。
2.5 说说t()函数的入参设计
我见过很多项目把t("当前剩余 {count} 分钟")和t("current_remaining_minutes")混着用。少量文案看不出问题,语言包一多,这种混乱会让翻译成本翻倍。我的建议是统一使用“语义化 key + 插值变量”,而不是直接拿中文做 key,也不是拿整句英文做 key。
t("scene.timer.remaining", { count: 15, unit: t("unit.minute") });原则上,凡是会变化的数字、单位、时间,都要通过参数传入。因为不同语言的语序不一样,“还有 15 分钟”在英文里是15 minutes remaining,在日语里又是另一种结构。直接把数字拼进字符串的做法,在 Lang Switch 上线后一定会被测试打回来。
3. 多端同步、持久化和默认语言策略
3.1 用户选择先落本地,再等云端同步
Lang Switch 的语言选择必须持久化,否则用户每次打开 App 都要重新选。我的实现是:切换时立刻写入本地存储,同时异步推送到云端用户配置。
本地存储的 key 用了带命名空间的值:
const LANG_SWITCH_KEY = "lang-switch:locale"; function saveLocaleToLocal(locale: Locale) { localStorage.setItem(LANG_SWITCH_KEY, locale); } function readLocaleFromLocal(): Locale | null { return localStorage.getItem(LANG_SWITCH_KEY) as Locale | null; }如果用的是 React Native,就换成AsyncStorage;如果要在设备面板上加,可以用固件支持的偏好存储区。关键点是“先写本地,再推云端”,因为本地写入是同步的,云端写入是异步的,用户感知到的切换速度更多取决于本地写入和界面刷新,而不是网络请求。
3.2 默认语言的探测顺序
很多新手做默认语言时只写一句return navigator.language,这会在两类用户身上翻车:一类是系统语言不在支持列表里的用户,另一类是之前手动切换过语言、但系统语言后来又变了的用户。
我最终采用的探测顺序是:
| 优先级 | 数据源 | 说明 |
|---|---|---|
| 1 | 本地保存的lang-switch:locale | 用户显式切换的记录 |
| 2 | 云端用户配置里的locale | 账号级偏好 |
| 3 | 系统语言 | navigator.language或设备系统设置 |
| 4 | 支持列表的第一个语言 | 通常是产品主市场语言 |
判断系统语言时,不能直接判断完整zh-CN或en-US,还要处理zh-TW、en-GB、pt-BR这类变体。我一般先做精确匹配,匹配不到再做语言代码前缀匹配,比如zh可以匹配zh-CN、zh-TW,但需要根据产品策略决定繁体中文是否单独支持。
对于 Atmosphere Space Series 当前版本,我建议把支持列表里第一个语言设为默认兜底语言。如果产品主市场是中文,就默认zh-CN;如果后续出海了,把列表第一位改成en-US即可,逻辑不用变。
3.3 多端同步时怎么避免“你改我也改”
Atmosphere Space Series 的 App 和硬件面板会同时使用同一个账号。如果家里有两个人,一个在手机上切成英文,另一个在面板上切成中文,云端就会发生冲突。
我们的做法是“最后写入者获胜”,但比较的不是“谁先提交”,而是每条配置的updatedAt时间戳。分布式系统里,多端同时写入一定会出现时间戳完全相同的情况,所以我还加了一个单调递增的version字段,避免因为毫秒精度不足导致旧配置覆盖新配置。
注意:这里说的是用户偏好同步,不是设备状态同步。设备状态同步不能简单用“最后写入者获胜”,因为不同设备可能控制不同区域。语言偏好是全局唯一的,所以这种策略够用。
3.4 特殊场景:硬件面板和语音助手的语言
硬件面板的屏幕很小,不可能像 App 一样完整展开语言列表。我的做法是把硬件端语言限制为 2 到 3 个常用选项,跟随账号同步,但允许现场管理员在面板上临时切换。
语音助手的语言和 UI 语言要分开处理。用户可能界面想看中文,但语音指令习惯说英文。Lang Switch 不能一刀切地把所有语音模型都切成界面语言。我在语音模块上额外保留了一个voiceLocale字段,没设置时默认跟随主体语言,但允许用户在语音设置页单独覆盖。
4. 最常翻车的三处实现细节:拼接、日期与字体回退
4.1 文案拼接翻车实录
我在 Atmosphere Space Series 里踩过最典型的坑是文案拼接。第一次做倒计时提醒时,代码写的是:
const message = "将在 " + minutes + " 分钟后关闭";这个写法在中文里没问题,但英文版直接变成Will be turned off in 15 分钟后,完全不可用。正确做法是使用语言包里的插值模板:
{ "scene.timer.off_in": "将在 {count} 分钟后关闭" }英文语言包里对应:
{ "scene.timer.off_in": "Will turn off in {count} minutes" }日语、德语、法语都有各自的语序。只靠“翻译单词”解决不了语序差异,必须把整句作为一个翻译单元。这个原则同样适用于“XX 已开启”“XX 设备离线”这类动态文案。
如果文案里还涉及复数,就不能只靠{count}插值。英文是1 item和2 items的区别,中文没有复数形态。我引入了Intl.PluralRules来处理复数选择,或者直接用支持复数规则的 ICU MessageFormat,避免写count > 1 ? "items" : "item"这种只能覆盖一种语言逻辑的判断。
4.2 日期、时间和相对时间不能只靠翻译
氛围场景经常出现在定时任务里,比如“每周五晚上七点开启”“还有 20 分钟结束”。这些内容不能把Friday翻译成星期五就完事,因为日期格式、周起始日、时区都可能不同。
我建议统一使用 JavaScript 内置的Intl系列 API:
const locale = langManager.getCurrentLocale(); const dateFormatter = new Intl.DateTimeFormat(locale, { year: "numeric", month: "long", day: "numeric", weekday: "long", hour: "2-digit", minute: "2-digit" }); const relativeFormatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); console.log(dateFormatter.format(new Date())); console.log(relativeFormatter.format(-20, "minute"));使用Intl的好处是它自动适配zh-CN、en-US、ja-JP的日期表达习惯,不需要自己维护一套“月份翻译表”和“星期翻译表”。另外要注意,所有传给格式化函数的日期都应使用带时区信息的绝对时间戳,不要用"2025-06-01 19:00:00"这种无时区字符串,否则云端的定时任务在不同地区会显示成不同时间。
4.3 字体回退与文字方向
语言切到非拉丁语系时,最先出问题的往往是字体。中文、日文、韩文都需要对应的 CJK 字体,泰文、阿拉伯文也需要单独的字形覆盖。如果 App 只加载了一套英文字体,切换语言后就会出现方框字。
我的做法是在全局样式里设置一串 font-family 回退链:
html { font-family: -apple-system, "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "Noto Sans Arabic", "Noto Sans Thai", sans-serif; }这只是兜底方案,更严谨的做法是@font-face按unicode-range加载子集字体。对于 Atmosphere Space Series 这种有大量场景文案的场景,我会把中文场景名常用的字提前打进子集,而不是整个字体文件全量加载。
阿拉伯文还要处理dir="rtl"布局方向。语言切换不只是改文案,布局方向也要跟着变。如果我检测到语言是ar,就要把document.documentElement.dir切换成rtl,并且让场景卡片、开关按钮做镜像布局。这个点最容易在测试时被忽略,因为多数开发者并不熟悉 RTL 阅读习惯。
4.4 设备端小字库:氛围场景硬件上容易忽略
Atmosphere Space Series 的一些硬件面板只有一块小屏幕,内存也小,不能直接复用 App 的字体方案。工程上常用的是“子集字体 + 预生成位图”的方式:把当前语言包中出现的字符预生成到字库里,或者把常用字符串做成位图索引。
这里有一个很实际的建议:硬件端不要试图加载全量中文字库,会占掉大量 Flash 空间。可以在编译时扫描语言包里的所有字符串,生成最小字库。这样切换语言后,硬件端只需要更新一组渲染资源,不需要频繁升级固件。我们早期在这上面吃过亏,硬件端放了全量字体,结果一次语言包升级导致 OTA 包体积暴涨,后面改成子集字体才解决。
5. 接入 Lang Switch 的落地步骤与验收清单
5.1 初始化模块的最小代码
把 Lang Switch 接进一个现有项目,不一定要重构所有页面。我的建议是先做一个最小闭环:启动时读取持久化语言,注册语言包,然后让设置页的切换按钮调用setLocale。
import { langManager } from "./LangManager"; import zhCN from "./locales/zh-CN.json"; import enUS from "./locales/en-US.json"; const translations = { "zh-CN": zhCN, "en-US": enUS }; function initLang() { const savedLocale = readLocaleFromLocal() || detectSystemLocale(); const locale = supportedLocale(savedLocale); langManager.setLocale(locale, translations[locale]); } function switchLang(locale: Locale) { saveLocaleToLocal(locale); langManager.setLocale(locale, translations[locale]); syncLocaleToCloud(locale); }这段代码里supportedLocale必须处理“系统语言不在支持列表里”的情况。我的实现是先精确匹配支持列表,再按语言前缀匹配,最后回退到支持列表的第一项。
5.2 接入点的代码规范:不要直接调字符串
团队最容易犯的错是,今天接了一个新页面,顺手就在按钮上写死保存两个字。一个两个看不出来,三个月后语言包越来越全,这种硬编码文案就变成了多语言盲区。
我在代码评审里有一条硬性约定:用户可见文本一律不允许中文字符串直接写在组件里。评审脚本可以扫描jsx文件里的中文字符,发现就提示改为t()。虽然偶尔会有误报,但收益远大于噪音。
对于场景元数据,也不是 UI 层调用t()就能解决的。场景名称是动态数据,不能预埋在语言包里,我会用“多语言字段映射”的结构存到云端:
{ "sceneId": "sunset-candle", "name": { "zh-CN": "日落烛光", "en-US": "Sunset Candle", "ja-JP": "夕暮れキャンドル" }, "description": { "zh-CN": "模拟日落时分的光影与轻微烛火闪烁", "en-US": "Simulates sunset light with subtle candle flicker", "ja-JP": "夕暮れの光とろうそくの揺らめきを再現" } }前端根据当前语言取对应字段,取不到时回退到默认语言,再回退到name字段的第一个非空值。这里要注意,不能把整个name对象当成字符串渲染,否则界面上会出现[object Object]。
5.3 语言包 key 的自动校验
语言包越多,越容易出现“中文多了一个 key,英文漏了”的问题。靠人工检查不现实,我在 CI 里加了一个脚本:对比所有语言包扁平化后的 key 集合。
function flattenKeys(obj: Record<string, unknown>, prefix = "") { return Object.entries(obj).flatMap(([key, value]) => { const fullKey = prefix ? `${prefix}.${key}` : key; return typeof value === "string" ? [fullKey] : flattenKeys(value as Record<string, unknown>, fullKey); }); }脚本会输出缺失 key 和多余 key 的完整清单。多余 key 也要处理,因为语言包里残留旧 key 会让维护者误以为这个文案还在使用。我们的策略是:新功能必须同步补齐所有支持语言的 key,否则 PR 不能合入;每周再跑一次全量检查,防止有人绕过 CI。
5.4 验收清单
我整理了一份 Lang Switch 的回归测试清单,每次发版前都会让测试同学照着跑一遍:
| 测试项 | 通过标准 |
|---|---|
| 静态文案切换 | App 所有按钮、菜单、弹窗在切换后无遗漏 |
| 场景元数据 | 场景名称、描述、标签全部切换为所选语言 |
| 语音播报 | 当前语音语言正确,且切换后不中断正在执行的场景 |
| 推送通知 | 新触发通知使用新语言,历史通知不受影响 |
| 日期时间 | 定时任务的日期、周起始日、相对时间正确 |
| 布局方向 | RTL 语言下页面正常镜像,无遮挡 |
| 本地持久化 | 杀掉 App 重新打开,语言仍为上次选择 |
| 云端同步 | 多端同时切换,最终保持一致 |
这张表最初只有前四项,后来补上了后四项,都是实际踩坑后加进去的。日期和布局方向尤其容易漏,建议测试同学在语言切换专项测试时,至少覆盖一个 CJK 语言、一个拉丁语言、一个 RTL 语言。
6. 我建议团队保留的几条工程习惯
6.1 每次发版前跑一遍语言包差异脚本
语言包差异脚本不是写一次就完了,要放进发布流程里成为强制环节。我们现在的做法是:打包脚本执行前自动比对所有语言包 key,有差异就中断打包。这样做看起来有点“狠”,但确实避免了好几次带病发布。语言包漏 key 的 bug 修复成本很低,但线上用户看到英文或中文混排的体验损失很高。
6.2 把“切换语言”当成核心流程做自动化测试
自动化测试最常覆盖的是“登录、创建场景、打开设备”等主流程,语言切换经常被当成辅助功能。我的建议是把它当成一等公民:至少写一条 e2e 用例,在真实环境里切到每种支持语言,断言关键页面包含该语言的特征文案。
这个测试的价值不只是防回归,还能暴露翻译质量问题,比如某个翻译因为过长导致按钮溢出,或者某个语言包引入了不支持的字符。
6.3 给每一条场景元数据留多语言扩展位
无论当前版本是否需要,所有写入云端的数据模型都建议预留多语言字段。后来加语言不用改表结构,只要在已有字段里补内容。我们最早做场景时只设计了name和desc,等要做 Lang Switch 时,后端接口、管理后台、缓存结构都要跟着改,非常被动。
现在的新数据结构里,只要是用户可见的文本,都优先设计成name: { zh-CN: "", en-US: "" }这种形式。这样前端渲染逻辑天然支持多语言,不需要为了某个语言单独写兼容分支。
6.4 一个小习惯:在截图时用英文/中文各跑一遍
我最后分享一个个人习惯。每次 UI 验收或发版前做视觉走查时,我会要求团队把主要页面分别在英文、中文、日文下截图对比。不需要打开所有设备,只看几张关键截图就能发现大量问题:按钮文案过长导致的换行、日期格式不对、字体在非拉丁语言下缺字形、RTL 布局错位。
这个习惯帮我省下很多线上客诉。语言切换这类需求,看起来简单,但它会同时触达 UI、业务逻辑、数据模型、硬件渲染、云端同步和测试策略。只要有一条链路没接上,用户感知到的就是“这个产品做得很糙”。所以我一直认为,Lang Switch 不是一个翻译功能,而是一套贯穿全产品的国际化基础设施,值得用对待核心模块的态度去设计和维护。