第一章:360GO3语言切换的核心机制与设计哲学
360GO3作为面向多语言终端用户的嵌入式交互系统,其语言切换并非简单的资源包替换,而是一套融合运行时上下文感知、UI组件动态重绘与国际化策略分级的协同机制。系统在启动阶段即通过/etc/locale.conf读取默认区域设置,并结合用户会话级LANG环境变量构建双层语言优先级链:系统级(只读)→ 用户级(可持久化)→ 会话级(临时覆盖)。
运行时语言热切换流程
当用户触发语言变更时,360GO3执行以下原子操作:
- 调用
locale-gen --lang <target>预编译目标语言的.mo二进制消息目录; - 向所有已注册UI进程发送
SIGUSR2信号,触发gettext()上下文重绑定; - 强制刷新
QApplication::instance()->setLayoutDirection()以适配RTL语言(如阿拉伯语); - 保存新配置至
~/.config/360go3/lang.pref,确保下次启动继承该设置。
国际化资源组织规范
系统采用三级资源定位策略,确保高内聚低耦合:
| 目录层级 | 示例路径 | 用途说明 |
|---|---|---|
| 系统级 | /usr/share/360go3/locale/zh_CN/LC_MESSAGES/app.mo |
只读基础翻译,由固件镜像固化 |
| 用户级 | ~/.local/share/360go3/locale/ja_JP/LC_MESSAGES/app.mo |
用户自定义覆盖,支持.po源码热编译 |
| 运行时级 | /run/360go3/tmp/lang_cache/en_US.bin |
内存映射缓存,避免重复解析大文件 |
关键代码片段:安全的语言重载逻辑
# 在应用主循环中调用此函数实现无闪退切换
reload_i18n() {
local target_lang="$1"
# 验证目标语言包完整性
[[ -f "/usr/share/360go3/locale/${target_lang}/LC_MESSAGES/app.mo" ]] || return 1
# 原子性更新环境变量并通知Qt框架
export LANG="${target_lang}"
export TEXTDOMAINDIR="/usr/share/360go3/locale"
export TEXTDOMAIN="app"
# 触发Qt事件循环中的翻译重载钩子
qdbus org.360go3.UI /ui reloadTranslations "${target_lang}"
}
该机制严格遵循POSIX locale标准,同时通过DBus总线实现跨进程翻译状态同步,避免传统setlocale()调用引发的线程安全风险。
第二章:系统级语言配置的五重验证路径
2.1 分析固件版本与语言资源包兼容性(理论+adb shell dumpsys package com.qihoo.appstore 实战校验)
固件版本号(ro.build.version.release)与语言资源包(.apk 中 res/values-xx-rYY/ 目录结构)存在语义约束:Android 8.0+ 强制要求 android:localeConfig 声明支持的区域,否则 Resources.getIdentifier() 可能返回 0。
实战校验命令
adb shell dumpsys package com.qihoo.appstore | grep -E "versionName|versionCode|resource|locales"
输出解析:
versionName=5.7.5.1表明当前安装包为 v5.7.5 系列;locales: [zh-CN, en-US, ja-JP]显示运行时加载的语言集,需与固件config_locale_list属性匹配,否则触发 fallback 逻辑。
兼容性判定依据
- ✅ 固件
ro.product.locale=zh-CN→ 匹配资源包中values-zh-rCN - ❌ 固件
ro.build.version.release=13+ 资源包未声明android:localeConfig→ 触发MissingLocaleConfigException
| 固件 API Level | 资源包要求 | fallback 行为 |
|---|---|---|
| ≤25 (7.1) | 无显式 localeConfig | 自动匹配最接近区域 |
| ≥26 (8.0) | 必须在 AndroidManifest.xml 声明 |
否则仅加载 values/ |
2.2 解析/system/etc/region_config.xml区域策略对UI语言链的约束逻辑(理论+sed -n ‘/lang/p’ /system/etc/region_config.xml 实战提取)
Android 系统通过 /system/etc/region_config.xml 实现区域化语言策略的硬编码约束,其 <lang> 节点定义了该地区允许的 UI 语言优先级链,直接覆盖 Settings 中用户选择。
实战提取语言策略
sed -n '/<lang>/p' /system/etc/region_config.xml
# 输出示例:<lang value="zh-CN" order="0"/><lang value="en-US" order="1"/>
sed -n '/<lang>/p' 精准匹配含 <lang> 标签的行;order 属性决定 fallback 顺序,值越小优先级越高。
约束生效机制
- 系统启动时由
RegionManagerService解析该文件 - 与
persist.sys.locale、ro.product.locale协同构建最终Configuration.locales - 非白名单语言(如
ja-JP在中国区配置中缺失)将被静默降级
| 属性 | 含义 | 示例 |
|---|---|---|
value |
ISO 639-1 + 639-2 地域码 | zh-CN |
order |
fallback 权重(升序) | , 1 |
2.3 定位/system/framework/framework-res.apk中resources.arsc语言索引表(理论+aapt dump resources framework-res.apk | grep ‘locale’ 实战逆向)
resources.arsc 是 Android 资源编译后的二进制索引表,其中 locale 条目决定系统级多语言资源的加载路径与优先级。
理论:语言配置在 resources.arsc 中的存储结构
- 每个
Package下的TypeSpec→Type→Config层级中,Config的locale字段以 ISO 639-1(如zh、en)+ 可选 ISO 3166-1(如_CN)编码; framework-res.apk作为系统资源包,其 locale 配置直接影响Toast、Dialog等原生 UI 的显示语言。
实战逆向:提取 locale 配置项
aapt dump resources framework-res.apk | grep -E 'locale|config'
输出示例节选:
config zh_CN: ...
config en_US: ...
config [DEFAULT]: ...
该命令解析resources.arsc的字符串池与配置块,grep 'locale'实际匹配的是 aapt 内部对config行的 locale 标识渲染逻辑(非原始二进制字段名),本质是反查ResTable_config::imsi和locale字段的 ASCII 表示。
关键 locale 配置类型对照表
| Config 标识 | 语言区域 | 用途说明 |
|---|---|---|
zh_CN |
中文(简体,中国) | 默认系统中文界面 |
en_US |
英语(美国) | 国际化基准 fallback |
[DEFAULT] |
无 locale 限定 | 通用资源兜底项 |
graph TD
A[framework-res.apk] --> B[resources.arsc]
B --> C{Config Entry}
C --> D[locale=zh_CN]
C --> E[locale=en_US]
C --> F[locale=[DEFAULT]]
2.4 验证SettingsProvider数据库中system表language字段的写入时序(理论+sqlite3 /data/data/com.android.providers.settings/databases/settings.db “SELECT * FROM system WHERE name=’language’;” 实战比对)
数据同步机制
language 字段由 Settings.Global.putString() → ContentProvider.insert() → SQLiteStatement.execute() 逐层写入,关键路径受 SettingsService 的 writeDelayed() 批量提交策略影响。
实战验证命令
# 需 root 权限;注意路径权限隔离
adb shell "su -c 'sqlite3 /data/data/com.android.providers.settings/databases/settings.db \"SELECT _id, name, value, package_name FROM system WHERE name=\"language\";\"'"
逻辑分析:
package_name为空表示全局设置;_id唯一性确保单例写入;value格式为zh-CN或en-US,非空即生效。该查询绕过 ContentResolver 缓存,直读磁盘状态,反映真实写入时序。
写入时序关键点
- 首次写入触发
onCreate()初始化system表 - 后续更新走
UPDATE system SET value=? WHERE name=? android_id关联的last_update_time隐式记录时间戳(需 JOINsecure表获取)
| 阶段 | 触发条件 | 是否阻塞UI |
|---|---|---|
| 内存缓存写入 | putString() 调用 |
否 |
| 磁盘落库 | Handler.postDelayed() |
否 |
| 广播通知 | ContentObserver 回调 |
是(主线程) |
2.5 检查Zygote进程启动参数中persist.sys.language的动态继承关系(理论+cat /proc/1/cmdline | tr ‘\0’ ‘\n’ | grep persist.sys.language 实战溯源)
Zygote作为Android系统所有应用进程的父进程,其启动参数直接决定子进程的语言环境继承链。persist.sys.language虽为系统属性,但仅当显式写入Zygote cmdline时才被静态继承;否则由SystemProperties在运行时动态读取。
实战命令解析
cat /proc/1/cmdline | tr '\0' '\n' | grep persist.sys.language
/proc/1/cmdline:Zygote(PID 1在部分AOSP版本中为Zygote或init,实际应查ps -A | grep zygote确认PID)的原始空字符分隔参数;tr '\0' '\n':将\0替换为换行符,使参数可读;grep过滤语言相关键——若无输出,说明该参数未静态注入,依赖后续PropertyService动态同步。
动态继承关键路径
- init → Zygote:通过
ro.前缀属性预加载; - Zygote → App:
AppRuntime::start()中调用AndroidRuntime::startVm(),触发property_init()和property_get(); - 属性值最终来自
/data/property/persist.sys.language文件或init.rc中的setprop指令。
| 阶段 | 是否继承persist.sys.language | 依据 |
|---|---|---|
| Zygote启动 | 仅当cmdline含该参数 | cat /proc/<zygote-pid>/cmdline |
| SystemServer | 是(通过SystemProperties) | SystemProperties.get("persist.sys.language") |
| 普通App进程 | 是(fork+exec后继承Zygote VM) | RuntimeInit.commonInit()中初始化 |
graph TD
A[init.rc setprop persist.sys.language en] --> B[init进程写入property_service]
B --> C[Zygote fork前读取ro.*属性]
C --> D{cmdline含persist.sys.language?}
D -->|是| E[静态继承至所有子进程]
D -->|否| F[运行时通过SystemProperties动态获取]
F --> G[App进程首次调用时触发property_get]
第三章:嵌入式环境下的安全语言切换三原则
3.1 基于TrustZone的OTA语言包签名验证流程(理论+fastboot flash tz tz.mbn + logcat -b kernel | grep “tz: verify_lang” 实战日志捕获)
TrustZone安全世界在OTA语言包更新中承担关键签名验签职责:语言资源(如lang_en-US.dat)的数字签名由OEM私钥生成,经TZ固件在Secure World中调用硬件加速引擎(e.g., QSEE Crypto)完成RSA-2048/PSS验证。
验证触发机制
当系统发起语言包安装时,Android Framework通过SVC调用进入TZ,触发tz_app_lang_verify()入口函数,加载预置公钥证书链并校验签名完整性。
实战日志捕获
# 刷入最新TZ镜像以启用增强日志
fastboot flash tz tz.mbn
# 实时过滤语言包验证内核日志
logcat -b kernel | grep "tz: verify_lang"
该命令依赖TZ固件已编译
CONFIG_TZ_LANG_VERIFY_LOG=y。tz.mbn需包含lang_verify.c中的pr_info("tz: verify_lang: %s, result=%d\n", ...)语句,输出形如tz: verify_lang: en-US, result=0(0表示成功)。
验证流程概览
graph TD
A[Android Recovery加载lang.dat] --> B[SVC进入Secure World]
B --> C[TZ读取Embedded Certificate]
C --> D[Hardware-accelerated RSA-PSS Verify]
D --> E{Signature OK?}
E -->|Yes| F[Allow lang mount in /system/etc/lang/]
E -->|No| G[Abort OTA & log error]
| 阶段 | 关键组件 | 安全保障 |
|---|---|---|
| 签名生成 | OEM Build Server | 私钥离线存储,防泄露 |
| 公钥固化 | TZ ROM Image | 不可篡改,BootROM校验 |
| 验证执行 | QSEE Crypto Engine | 硬件隔离,抗侧信道攻击 |
3.2 低内存设备的语言资源动态卸载策略(理论+dumpsys meminfo com.qihoo360.mobilesafe | grep -A5 “AssetManager” 实战内存映射分析)
Android 应用多语言支持常通过 assets/ 或 res/values-xx/ 加载资源,但 AssetManager 的 native asset map 会常驻内存,难以被 GC 回收。
AssetManager 内存映射特征
执行以下命令可定位其内存占用:
dumpsys meminfo com.qihoo360.mobilesafe | grep -A5 "AssetManager"
输出示例:
AssetManager:
Native heap: 4.2 MB (mMappedFileCount=17, mAssetMapSize=3.8 MB)
Asset map entries: 214 (avg 17.8 KB/entry)
mMappedFileCount=17:表示 17 个已 mmap 的资源文件(如strings.xml、assets/lang_zh.bin)mAssetMapSize=3.8 MB:所有 asset 映射页表 + 元数据总开销
动态卸载关键路径
- ✅ 持有
AssetManager引用的Resources实例需主动调用updateConfiguration(null, null)触发重置 - ✅ 调用
AssetManager.close()(API 28+)或反射调用mObject释放 native handle - ❌
AssetManager不受 GC 管理,仅close()可解绑 mmap 区域
| 卸载时机 | 是否释放 mmap | 风险点 |
|---|---|---|
| Activity.onDestroy | 否 | 资源仍被 Resources 持有 |
| Resources.updateConfiguration | 是(部分) | 需配合 AssetManager.close |
| AppLifecycle.onTrimMemory(TRIM_MEMORY_UI_HIDDEN) | 是(推荐) | 最佳实践窗口期 |
graph TD
A[检测低内存 TRIM_MEMORY_MODERATE] --> B{是否启用多语言热切换?}
B -->|是| C[保留默认语言 AssetManager]
B -->|否| D[close() 非当前 locale 的 AssetManager]
C --> E[unmap 对应 mmap 区域]
D --> E
3.3 多Bootloader分区下recovery模式语言同步一致性保障(理论+dd if=/dev/block/by-name/recovery of=/tmp/recovery.img && strings /tmp/recovery.img | grep -i “locale” 实战镜像扫描)
数据同步机制
在多Bootloader(如A/B槽+独立recovery分区)架构中,recovery镜像需与主系统locale配置严格对齐,否则触发OTA后可能出现界面语言错乱。关键在于:recovery自身是否内嵌locale资源,且其默认语言标识(如ro.product.locale=zh-CN)是否与当前system分区一致。
镜像级验证命令解析
dd if=/dev/block/by-name/recovery of=/tmp/recovery.img bs=4096 && \
strings /tmp/recovery.img | grep -i "locale"
dd从设备节点读取原始recovery分区(规避挂载差异),bs=4096提升I/O效率;strings提取可打印ASCII序列,grep -i "locale"捕获大小写不敏感的locale键值(如ro.boot.locale、persist.sys.language等);- 输出示例:
ro.product.locale=zh-CN→ 表明该recovery镜像硬编码中文,需与当前active slot匹配。
关键检查项
- ✅ recovery镜像中
ro.product.locale值是否与/system/build.prop一致 - ✅ A/B槽切换时,recovery分区是否随slot自动更新(依赖
fastboot set_active后update_engine_client --update触发)
| 检查维度 | 合规表现 | 风险表现 |
|---|---|---|
| locale键存在性 | ro.product.locale=xx-XX |
无任何locale相关字符串 |
| 多分区一致性 | A/B槽各自recovery均含相同locale | A槽为en-US,B槽为ja-JP |
第四章:面向产线部署的自动化语言适配四步法
4.1 使用360GO3专用烧录工具注入多语言固件补丁(理论+go3flash –lang=zh_CN –partition=system –patch=lang_zh.zip 实战命令执行)
go3flash 是360GO3平台官方支持的轻量级固件注入工具,专为安全、原子化地更新分区镜像设计。其多语言补丁机制基于分区级差分覆盖,不重刷整包,仅替换 /system 中的 resources.apk、i18n/ 目录及 build.prop 本地化字段。
补丁注入原理
- 固件补丁 ZIP 必须包含
META-INF/com/360go3/patch.json描述文件 --lang=zh_CN触发语言环境校验与ro.product.locale自动写入--partition=system锁定目标挂载点,避免误写boot或recovery
实战命令执行
go3flash --lang=zh_CN --partition=system --patch=lang_zh.zip
✅ 参数说明:
--lang指定BCL语言码并激活UI本地化开关;--partition映射到/dev/block/by-name/system;--patch解压后按路径白名单校验并原子写入。失败时自动回滚至前一快照。
| 关键行为 | 安全保障机制 |
|---|---|
| 分区校验 | SHA256比对 system.img 签名 |
| 补丁路径白名单 | 仅允许 res/values-zh-rCN/ 等预注册路径 |
| 写入原子性 | 基于 overlayfs 临时挂载 + sync 刷盘 |
graph TD
A[执行 go3flash] --> B{校验 lang_zh.zip 签名}
B -->|通过| C[挂载 system 为 overlay 可写]
B -->|失败| D[中止并输出 error code 0x17]
C --> E[解压补丁至临时层]
E --> F[触发 init.rc 语言重载服务]
4.2 通过串口AT指令集触发底层语言重初始化(理论+echo -e “AT+LANG=\”en_US\”\r\n” > /dev/ttyS2 实战响应解析)
AT指令集是嵌入式通信模块与主机交互的标准协议,AT+LANG为厂商扩展指令,用于动态切换固件内建的本地化资源上下文。
指令语义与执行约束
- 必须在模块已注册网络且处于
READY状态时下发 - 语言代码需严格符合ISO 639-1 + ISO 3166-1 alpha-2格式(如
zh_CN、en_GB) - 部分模组要求重启AT任务线程后生效,非立即全局生效
实战响应解析
# 向串口ttyS2发送语言切换指令(注意\r\n换行符)
echo -e "AT+LANG=\"en_US\"\r\n" > /dev/ttyS2
该命令将原始AT帧写入串口设备文件。关键点:
-e启用\r\n转义解析;- 双引号被透传至模块,固件按字符串匹配语言ID;
- 若模块返回
OK,表示语言资源句柄已重建,后续AT+CPIN?等响应文本将使用英文。
| 响应码 | 含义 | 处理建议 |
|---|---|---|
OK |
语言上下文切换成功 | 可继续其他AT操作 |
ERROR |
语言ID不支持或忙 | 检查模块文档并重试 |
graph TD
A[主机执行echo] --> B[内核TTY层添加\r\n]
B --> C[UART驱动发送字节流]
C --> D[Modem固件解析AT+LANG]
D --> E{语言包加载成功?}
E -->|是| F[更新全局locale句柄]
E -->|否| G[返回ERROR]
4.3 利用JNI桥接层调用SystemProperties.set()强制刷新语言上下文(理论+ndk-build && adb push libgo3lang.so /system/lib/ && adb shell “insmod /system/lib/libgo3lang.so” 实战注入)
JNI桥接层需绕过Android SDK对SystemProperties.set()的隐藏限制,通过dlsym()动态解析libcutils.so中的符号实现调用。
核心JNI逻辑(C++)
#include <cutils/properties.h>
JNIEXPORT void JNICALL Java_com_go3lang_LangBridge_forceUpdateLocale(JNIEnv*, jclass, jstring lang) {
const char* langStr = env->GetStringUTFChars(lang, nullptr);
property_set("persist.sys.language", langStr); // 持久化语言键
property_set("persist.sys.country", "CN"); // 同步区域
property_set("ro.product.locale", langStr); // 触发Zygote重载
env->ReleaseStringUTFChars(lang, langStr);
}
property_set()直接写入底层共享内存页,比Java层SystemProperties.set()更底层;参数为const char* key与const char* value,值长度上限92字节。
注入流程关键步骤
- 编译:
ndk-build APP_ABI=armeabi-v7a生成libgo3lang.so - 推送:
adb push libs/armeabi-v7a/libgo3lang.so /system/lib/(需adb root) - 加载:
adb shell "chmod 644 /system/lib/libgo3lang.so && insmod /system/lib/libgo3lang.so"
| 阶段 | 权限要求 | 失败常见原因 |
|---|---|---|
adb push |
root shell | /system只读挂载 |
insmod |
kernel module | SELinux策略拒绝加载 |
graph TD
A[Java层调用LangBridge.forceUpdateLocale] --> B[JNI加载libcutils.so]
B --> C[调用property_set更新3个关键prop]
C --> D[init进程监听变更→触发zygote重启]
D --> E[所有新进程继承更新后的locale上下文]
4.4 基于Yocto Project构建语言可配置的BitBake镜像(理论+bitbake-layers add-layer meta-360lang && echo ‘LOCALE_PACKAGES += “glibc-locale-base-en-us”‘ >> conf/local.conf 实战编译集成)
Yocto Project 通过 LOCALE_PACKAGES 变量控制目标系统中预安装的区域设置包,实现镜像级语言定制。
添加语言支持层
bitbake-layers add-layer meta-360lang
该命令将自定义层 meta-360lang 注册到当前构建环境,使其中定义的 locale 配方(如 glibc-locale-base-en-us)可供 BitBake 解析。层必须包含 conf/layer.conf 且满足 BBPATH 路径优先级规则。
注入本地化配置
echo 'LOCALE_PACKAGES += "glibc-locale-base-en-us"' >> conf/local.conf
向构建配置注入 locale 包依赖。LOCALE_PACKAGES 是 BitBake 内置变量,被 packagegroup-base 等核心配方引用,最终触发对应 locale 数据的编译与打包。
支持的语言包对照表
| 包名 | 语言/地区 | 安装路径 |
|---|---|---|
glibc-locale-base-en-us |
英语(美国) | /usr/lib/locale/en_US |
glibc-locale-base-zh-cn |
中文(简体) | /usr/lib/locale/zh_CN |
graph TD
A[local.conf] --> B[LOCALE_PACKAGES]
B --> C[packagegroup-base]
C --> D[glibc-locale-base-en-us]
D --> E[/usr/lib/locale/en_US/]
第五章:语言切换失效的终极归因与反模式警示
常见失效场景还原:React i18n 与路由状态的隐式耦合
某电商中台项目在升级 React Router v6 后,用户点击语言切换按钮(如从 zh-CN 切至 en-US)时界面文本未更新,但 URL 中 ?lang=en-US 已正确变更。排查发现:useTranslation() Hook 在组件挂载后未响应 i18n.language 变更,根本原因是 I18nextProvider 被错误地包裹在 <Suspense> 外层,导致 i18n 实例在 Suspense fallback 触发时被重复初始化,新语言资源加载完成前旧实例已缓存了 zh-CN 的 namespace 数据。
状态同步断裂:Vue I18n 的 $t 函数与 Composition API 的生命周期错位
// ❌ 反模式:在 setup() 中提前解构 $t,失去响应性
export default {
setup() {
const { t } = useI18n(); // 此处 t 是静态函数引用
const label = t('button.submit'); // 非响应式,语言切换后不更新
return { label };
}
}
// ✅ 正确:在模板中直接调用或使用 computed
const label = computed(() => t('button.submit'));
服务端渲染中的双重陷阱:Next.js App Router 的 locale 检测失效链
| 环节 | 问题表现 | 根本原因 |
|---|---|---|
generateStaticParams |
仅生成 en 和 zh 路由,缺失 ja |
locales 数组硬编码未与 i18n 配置同步 |
getServerSideProps |
locale 始终为默认值 en |
accept-language header 解析逻辑未覆盖 zh-Hans 等变体 |
| 客户端 hydration | 页面首次渲染后立即闪回默认语言 | NEXT_LOCALE cookie 未在 SSR 阶段读取,useRouter().locale 返回空字符串 |
构建时静态化导致的动态语言失效
某企业官网采用 Gatsby + gatsby-plugin-react-i18next,构建产物中所有语言版本均指向同一份 en.json 编译结果。根源在于 gatsby-node.js 中未正确配置 createPages 的 context.locale,且 gatsby-config.js 的 pluginOptions 将 defaultLanguage 错设为 'en',而实际多语言 JSON 文件存于 src/locales/{lang}/translation.json,构建脚本却只扫描 src/locales/en/ 目录。
Mermaid 流程图:语言切换请求的完整执行路径与断点识别
flowchart TD
A[用户点击语言切换按钮] --> B{前端触发 i18n.changeLanguage(lang)}
B --> C[检查资源是否已加载]
C -- 已加载 --> D[更新 i18n.language & 触发事件]
C -- 未加载 --> E[发起 HTTP GET /locales/{lang}/common.json]
E --> F{HTTP 响应 200?}
F -- 是 --> G[解析 JSON 并注入 store]
F -- 否 --> H[回退至 fallbackLng 或显示空文本]
G --> I[触发 react-i18next 的 useTranslation 重新渲染]
D --> I
I --> J[DOM 文本批量更新]
H --> J
Webpack 构建插件的隐式覆盖行为
i18next-webpack-plugin 默认启用 removeUnusedKeys: true,当团队在开发环境使用 t('auth.login.title'),但测试环境未运行完整用例集,该 key 因未被执行而被插件静默剔除。上线后用户访问登录页时,t('auth.login.title') 返回空字符串——日志中无报错,控制台亦无 warning,仅表现为 UI 文本消失。
localStorage 与 Cookie 的竞态写入冲突
在 Next.js 中同时使用 next-i18next 的 appWithTranslation 和自定义 setLanguage 工具函数,前者将语言写入 NEXT_LOCALE cookie,后者将语言写入 localStorage.i18nextLng。当用户在 Safari 无痕模式下操作时,cookie 写入失败但 localStorage 成功,后续页面重载时 getServerSideProps 读取不到 cookie,降级为默认语言,而客户端 useTranslation 仍读取 localStorage,造成服务端与客户端语言不一致,触发 React Hydration Error。
