02 - Android Locale 体系与两个落点
知识库第 2 篇。聚焦一个被反复踩中的坑:为什么
setprop persist.sys.locale改了、重启之后 Configuration 还是 zh_CN?答案一句话:Android 13 之后权威落点是
Settings.System.system_locales,persist.sys.locale只是 fallback。本文把这条结论拆透——三个存储点、三条 mermaid 图、一份实测复盘。
⚠️ 可验证性说明:本文 §1 表格的优先级、§2/§4/§5 的 framework 调用链(
com.android.internal.app.LocalePicker→AMS.updatePersistentConfiguration→SettingsProvider写system_locales)均基于 AOSP android-13.0.0_r53 公开实现描述,这些 framework 类不在本仓,无法从本仓源码直接验证;本仓可验证部分(项目内调用点、LocaleUtils、LocaleChangedReceiver)均已标文件:行号,下文标[framework]者皆非本仓代码。“system_locales 权威、prop 仅 fallback”这一核心结论由 §8 fermi 实测佐证。
1. 三个存储点(先记住这张脸)
Android 的「当前系统 locale」在系统里同时存在 3 个存储点,各自有不同的写入者、读取者和生命周期。理解它们的关系是后面所有内容的基础。
| 存储点 | 写入者 | 读取者 | 优先级 | 持久化 | 生效时机 |
|---|---|---|---|---|---|
Settings.System.system_locales | LocalePicker.updateLocale() 经由 AMS → SettingsProvider 写入;本项目所有调用点最终都走这条路径 | SystemServer 启动时;AMS.updatePersistentConfiguration() | 最高(非空时直接用) | 跨重启(保存在 /data/system/users/0/settings_system.xml) | 重启后由 SystemServer 应用到运行时 Configuration |
persist.sys.locale (System Property) | setprop;OTA;LocalePicker.updateLocale() 在 Android 13 之前的旧路径会同时写这里 | SystemServer 启动时(仅当 system_locales 为空才用作 fallback) | 次高(fallback) | 跨重启(prop 持久化) | 仅在 system_locales 为空时影响下次启动 |
运行时 Configuration.locales | SystemServer 启动时由前两者计算得出;AMS.updateConfiguration() 在切换时实时更新 | 所有 App(Resources.getConfiguration().locales[0]、ActivityManager.getService().configuration) | 派生值,不参与决策 | 不持久化(进程重启即丢) | 实时;广播 ACTION_LOCALE_CHANGED 后所有 App 重载资源 |
关键事实:项目内读取系统 locale 唯一入口是
base/settingsBaseLib/src/main/java/com/android/micar/settings/utils/LocaleUtils.kt:15ActivityManager.getService().configuration.locales[0]读的就是第三列「运行时 Configuration」。所以只改 prop 不重启 / 重启但 system_locales 锁死,LocaleUtils.getConfiguredLocale() 永远拿到旧值——这是实测里
[zh_CN]死活切不过去的根因。
2. LocalePicker.updateLocale 的 framework 链路(AOSP 公开实现)
项目内 4 个调用点(详见 §5)全部归一为 com.android.internal.app.LocalePicker.updateLocale(Locale)。com.android.internal.app.LocalePicker 是 framework 类,非本仓,下面按 AOSP (android-13.0.0_r53) 公开实现描述链路,标 [framework] 表示非本仓代码。
完整调用链:
[项目内] 调用方 (SystemLanguageDialogActivity 等)
│ LocalePicker.updateLocale(locale)
▼
[framework] com.android.internal.app.LocalePicker.updateLocale(Locale)
│ 内部调用 IActivityManager.updatePersistentConfiguration(config)
▼
[framework] ActivityManagerService.updatePersistentConfiguration()
│ 1) 更新 mCurrentConfig (运行时 Configuration,对应 §1 第三列)
│ 2) 通过 SettingsProvider.updateSystemSettingLocked()
│ 将 localeList 序列化为 "zh-Hans-CN" 这样的 BCP-47 tag 串
│ 写入 Settings.System.system_locales(对应 §1 第一列)
│ ※ Android 13 之后 system_locales 是权威落点
│ 3) 同步 persist.sys.locale 为该 locale 的语言 tag(仅作 fallback,权威性低于 system_locales)
▼
[framework] ActivityManagerService.broadcastConfiguration()
│ 发送 Intent.ACTION_LOCALE_CHANGED (ordered broadcast)
▼
[framework → 所有 App]
所有进程的 Application/Resources 收到 Configuration 更新
系统 PackageManagerService 重新解析 locale_label / 资源
▼
[项目内] LocaleChangedReceiver.onReceive()
│ 匹配 ACTION_LOCALE_CHANGED
│ 调用 VoiceSearchManager.onLocaleChanged()
核心结论:updateLocale 不是「改一个属性」,而是一次三处同步——运行时 Configuration 立刻换、system_locales 持久化、persist.sys.locale 跟着兜底。正因为 updateLocale 会同时写 system_locales,所以只有走 updateLocale 才能真正切;只 setprop 是绕开了权威落点。
3. 类关系图(项目内 vs framework)
classDiagram class LocalePicker { <<framework com.android.internal.app>> +updateLocale(Locale) static +updateLocales(LocaleList) static } class LocaleStore { <<framework com.android.internal.app>> +getLocaleInfo(Locale) LocaleInfo static } class LocaleHelper { <<framework com.android.internal.app>> +addLocale(LocaleList, Locale) LocaleList static } class LocaleInfo { <<framework com.android.internal.app.LocaleStore>> +Locale locale +String id +String fullNameNative +String fullNameInUiLanguage } class LocaleInfoWrapper { <<项目内 settingsSystem/universal>> +LocaleInfo mLocaleInfo +String id +String name +String summary +Boolean isSelected } class SystemLanguageConfig { <<项目内 settingsSystem/universal>> +getLanguageList() List~LocaleInfoWrapper~ +getLanguageListWithSelected() List~LocaleInfoWrapper~ } class LocaleUtils { <<项目内 base/settingsBaseLib/utils>> +getConfiguredLocale() Locale } class SystemLanguageDialogActivity { <<项目内 settingsSystem/universal>> -onPositiveButtonClick() } LocaleInfoWrapper o-- LocaleInfo : wraps SystemLanguageConfig ..> LocaleStore : getLocaleInfo() SystemLanguageConfig ..> LocaleInfoWrapper : creates SystemLanguageConfig ..> LocaleUtils : getConfiguredLocale() SystemLanguageDialogActivity ..> LocalePicker : updateLocale() SystemLanguageDialogActivity ..> SystemLanguageConfig : getLanguageListWithSelected() SystemLanguageDialogActivity ..> LocaleUtils : getConfiguredLocale() SystemLanguageDialogActivity ..> LocaleInfoWrapper : reads selected LocalePicker ..> LocaleHelper : uses LocaleStore ..> LocaleInfo : produces
读这张图的姿势:
- framework 三件套(LocalePicker / LocaleStore / LocaleHelper / LocaleInfo)= AOSP 类,不在本仓。
- 项目内三件套(LocaleInfoWrapper / SystemLanguageConfig / LocaleUtils)= 本仓包装层,位置都在文中标注的相对路径。
- 项目内不直接
new LocaleInfo,必须通过LocaleStore.getLocaleInfo(locale)拿(避免踩 framework 私有构造)——见SystemLanguageConfig.kt:24,28,32,49。
4. SystemServer 启动时 locale 决策树(解释”为什么改 prop 没用”)
flowchart TD A[SystemServer 启动] --> B{读取<br/>Settings.System.system_locales} B -->|非空| C[解析为 LocaleList<br/>赋给 mCurrentConfig] B -->|空| D{读取<br/>persist.sys.locale} D -->|非空| E[构造单元素 LocaleList<br/>赋给 mCurrentConfig] D -->|空| F[使用默认 Locale<br/>通常为 en-US 或编译时 RO 值] C --> G[广播 ACTION_LOCALE_CHANGED<br/>触发所有 App 重载资源] E --> G F --> G G --> H[LocaleUtils.getConfiguredLocale<br/>返回 mCurrentConfig.locales[0]] style C fill:#d4edda,stroke:#28a745 style B fill:#fff3cd,stroke:#ffc107 style D fill:#f8d7da,stroke:#dc3545 style E fill:#f8d7da,stroke:#dc3545
对应到本次 fermi/XCD 实测的现象:
system_locales出厂锁了zh-CN→ 走绿色路径 C → mCurrentConfig = [zh_CN]- 你
setprop persist.sys.locale en-US改的是 prop → 但 system_locales 非空,根本进不了红色路径 E - 重启后
dumpsys activity Configuration仍然是[zh_CN],因为权威源就没动过
正确解:直接改权威落点
adb shell settings put system system_locales en-US
adb reboot
重启后 SystemServer 读 system_locales=“en-US” → mCurrentConfig = [en_US] → LocaleUtils.getConfiguredLocale() 返回 en_US。
5. updateLocale 完整时序(含 App 侧响应)
sequenceDiagram autonumber participant User participant Act as SystemLanguageDialogActivity<br/>(项目内) participant LP as LocalePicker<br/>(framework) participant AMS as ActivityManagerService<br/>(framework) participant SP as SettingsProvider<br/>(framework) participant Apps as 所有 App / Resources participant LCR as LocaleChangedReceiver<br/>(项目内) participant VSM as VoiceSearchManager<br/>(项目内) User->>Act: 选择语言并确认 Act->>Act: LocaleUtils.getConfiguredLocale()<br/>比对是否变化 Note over Act: SystemLanguageDialogActivity.kt:49-52 Act->>LP: LocalePicker.updateLocale(locale) LP->>AMS: IActivityManager.updatePersistentConfiguration(config) AMS->>SP: 写 Settings.System.system_locales<br/>= "zh-Hans-CN" 等 BCP-47 tag AMS->>AMS: 同步 persist.sys.locale<br/>(fallback) AMS->>AMS: 更新运行时 mCurrentConfig AMS-->>Apps: broadcast ACTION_LOCALE_CHANGED Note over Apps: 所有 App 收到 Configuration 变更<br/>Resources 重载 strings_*.xml Apps->>LCR: onReceive(ACTION_LOCALE_CHANGED) Note over LCR: LocaleChangedReceiver.kt:22 LCR->>VSM: VoiceSearchManager.onLocaleChanged() Note over VSM: 重新解析语音控件<br/>通知汽车问答上传
Note: 步骤 6 的「同步 persist.sys.locale」在 Android 13 之后是单向副作用——updateLocale 会顺手把 prop 也设上,但反过来不成立(setprop 不会回写 system_locales)。这就是为什么”改 prop 看似啥都改了,重启还是 zh_CN”。
6. 项目内调用点全梳理(已核实)
| # | 文件:行号 | 触发场景 | 调用形式 |
|---|---|---|---|
| 1 | settingsPage/settingsSystem/src/main/java/com/android/car/settings/universal/SystemLanguageDialogActivity.kt:52 | 系统语言弹窗点击”切换确认”按钮 | LocalePicker.updateLocale(selectedLocale.mLocaleInfo.locale) |
| 2 | settingsPage/settingsSystem/src/main/java/com/android/car/settings/universal/ProvisionLanguageDialogActivity.kt:100 | 开机引导选择语言,点”前进”按钮 | LocalePicker.updateLocale(selectedLocale.mLocaleInfo.locale) |
| 3 | settingsPage/settingsSystem/src/main/java/com/android/car/settings/utils/LanguageUtils.java:26 | 工具类快速切换确认弹框(AlertDialog.MiCarBuilder,方法名虽叫 showTipToast 但实际弹 Dialog,切换成功后顺带 toast 反馈),二选一切换 zh/en | LocalePicker.updateLocale(newLocale) |
| 4 | settingsPage/micarDisplaySettings/src/main/java/com/android/car/settings/miauto/display/LanguagePickerPrefController.java:46 | 显示设置页 Tab 切换(zh/en) | LocalePicker.updateLocale(locale) |
入口归一性:除已废弃的 settingsPage/micarDisplaySettings/.../display/language/LocalePicker.java:186(am.updatePersistentConfiguration(config),见下方说明)外,活跃路径上没有任何一处直接调用 IActivityManager.updatePersistentConfiguration、Settings.System.putString 或 setprop 来改 locale。所有活跃切换最终都通过 LocalePicker.updateLocale 这一条 framework 入口——这也是为什么改 framework 行为(比如多用户隔离、备份还原)只需要关注一个 hook 点。
已核实(2026-07-19):全仓 grep
LocalePicker.updateLocale|updatePersistentConfiguration共 5 处 locale 写入点——4 处是LocalePicker.updateLocale(即表中 #1~#4),唯一 1 处am.updatePersistentConfiguration直调在display/language/LocalePicker.java:186,但该类被 XML 注释停用、无任何活跃调用方(见 04 篇 §4.2)。结论:global flavor 没有第二条活跃切换路径,无需运行时反射假设,原”待核实”项结案。
7. App 侧监听:LocaleChangedReceiver
app/src/main/AndroidManifest.xml:1583-1589 注册了 receiver:
<receiver android:name=".miauto.LocaleChangedReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.LOCALE_CHANGED" />
</intent-filter>
</receiver>app/src/main/java/com/android/car/settings/miauto/LocaleChangedReceiver.kt:14-26:
class LocaleChangedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (TextUtils.equals(Intent.ACTION_LOCALE_CHANGED, intent?.action)) {
VoiceSearchManager.getInstance().onLocaleChanged()
}
}
}作用:语言切换后,通知语音搜索模块重新解析控件、重新上传到汽车问答(座舱语音助手依赖 Locale 索引)。
注意:exported="true" 是因为 system server 发的广播需要跨进程——但缺少 android:permission 限制,理论上第三方 App 也能伪造这条广播(安全审计点,待核实是否需要加 android:permission="android.permission.BROADCAST" 限制)。
8. 实战踩坑复盘:fermi/XCD cn 实测案例
现象
设备:fermi / XCD / cn 用户版 / adbd 有 root 目标:把系统语言从 zh-CN 切到 en-US
错误做法(只改 prop):
adb shell setprop persist.sys.locale en-US # 或 su 后 setprop
adb shell getprop persist.sys.locale # 返回 en-US,看似生效
adb reboot
# 重启后
adb shell dumpsys activity | grep -A1 Configuration
# 仍然是 Configuration [... zh_CN ...]诊断:
adb shell settings get system system_locales # 返回 zh-CN(出厂锁)→ 命中 §4 决策树的绿色路径 C,system_locales 非空,prop 完全不参与决策。
正确做法(改权威落点):
adb shell settings put system system_locales en-US
adb reboot
# 重启后
adb shell dumpsys activity | grep -A2 Configuration
# Configuration [... en_US ...]
adb shell getprop persist.sys.locale # 仍可能是旧值
# 但 LocaleUtils.getConfiguredLocale() 拿到的是 en_US,因为读 Configuration 不读 prop复盘要点
- prop 是 fallback,不是权威。Android 13 之后权威落点是
Settings.System.system_locales。 - setprop 之所以”看着生效”:因为 updateLocale 内部会顺手 setprop,让人误以为 prop 是源头;其实它只是个镜像。
- 代码侧调用 updateLocale 永远不会踩这个坑:因为 updateLocale 自己会写 system_locales。踩坑的都是绕过 updateLocale 直接 setprop 的脚本/OTA。
- 本项目切换路径全部走 updateLocale(§6 四个调用点),所以业务代码无问题;问题只出在测试/调试场景下用 adb setprop 想快速切换。
常用验证命令
# 当前运行时 locale(最权威)
adb shell dumpsys activity | grep -A2 "Configuration"
# 权威持久化源
adb shell settings get system system_locales
# Fallback prop
adb shell getprop persist.sys.locale
# App 侧 LocaleUtils 等价读法(基于 Configuration)
# ActivityManager.getService().configuration.locales[0]9. 关键结论(一句话版)
「权威落点是
Settings.System.system_locales,persist.sys.locale只是 fallback;切换走LocalePicker.updateLocale,它会同时同步运行时 Configuration + system_locales + prop 三处;只改 prop 重启必被覆盖回 system_locales 的值。」
配套事实:
- 项目内 4 个切换调用点全部走
LocalePicker.updateLocale。 - 项目内读 locale 唯一入口是
LocaleUtils.getConfiguredLocale()读ActivityManager.getService().configuration.locales[0]。 - 项目内监听变更唯一入口是
LocaleChangedReceiver(manifest 已注册 ACTION_LOCALE_CHANGED),转发给VoiceSearchManager.onLocaleChanged()。
10. 待核实 / 后续动作
- global flavor 是否有第二条切换路径(运行时反射 / IActivityManager 直调)
-
LocaleChangedReceiver是否应当加android:permission限制(exported=true 但无 permission,伪造广播风险) - 多用户场景下 system_locales 是否按用户存储(AOSP 13 后
Settings.System表是否走 per-user,影响车主/副驾语言隔离设计) - OTA 升级时若 system_locales 被强制回写为 zh-CN(出厂值),切换路径是否需要主动 check & fixup