02 - Android Locale 体系与两个落点

知识库第 2 篇。聚焦一个被反复踩中的坑:为什么 setprop persist.sys.locale 改了、重启之后 Configuration 还是 zh_CN?

答案一句话:Android 13 之后权威落点是 Settings.System.system_localespersist.sys.locale 只是 fallback。本文把这条结论拆透——三个存储点、三条 mermaid 图、一份实测复盘。

⚠️ 可验证性说明:本文 §1 表格的优先级、§2/§4/§5 的 framework 调用链(com.android.internal.app.LocalePickerAMS.updatePersistentConfigurationSettingsProvidersystem_locales)均基于 AOSP android-13.0.0_r53 公开实现描述,这些 framework 类不在本仓,无法从本仓源码直接验证;本仓可验证部分(项目内调用点、LocaleUtilsLocaleChangedReceiver)均已标 文件:行号,下文标 [framework] 者皆非本仓代码。“system_locales 权威、prop 仅 fallback”这一核心结论由 §8 fermi 实测佐证。


1. 三个存储点(先记住这张脸)

Android 的「当前系统 locale」在系统里同时存在 3 个存储点,各自有不同的写入者、读取者和生命周期。理解它们的关系是后面所有内容的基础。

存储点写入者读取者优先级持久化生效时机
Settings.System.system_localesLocalePicker.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.localesSystemServer 启动时由前两者计算得出;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:15

ActivityManager.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&#91;0&#93;]

    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. 项目内调用点全梳理(已核实)

#文件:行号触发场景调用形式
1settingsPage/settingsSystem/src/main/java/com/android/car/settings/universal/SystemLanguageDialogActivity.kt:52系统语言弹窗点击”切换确认”按钮LocalePicker.updateLocale(selectedLocale.mLocaleInfo.locale)
2settingsPage/settingsSystem/src/main/java/com/android/car/settings/universal/ProvisionLanguageDialogActivity.kt:100开机引导选择语言,点”前进”按钮LocalePicker.updateLocale(selectedLocale.mLocaleInfo.locale)
3settingsPage/settingsSystem/src/main/java/com/android/car/settings/utils/LanguageUtils.java:26工具类快速切换确认弹框(AlertDialog.MiCarBuilder,方法名虽叫 showTipToast 但实际弹 Dialog,切换成功后顺带 toast 反馈),二选一切换 zh/enLocalePicker.updateLocale(newLocale)
4settingsPage/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:186am.updatePersistentConfiguration(config),见下方说明)外,活跃路径上没有任何一处直接调用 IActivityManager.updatePersistentConfigurationSettings.System.putStringsetprop 来改 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

复盘要点

  1. prop 是 fallback,不是权威。Android 13 之后权威落点是 Settings.System.system_locales
  2. setprop 之所以”看着生效”:因为 updateLocale 内部会顺手 setprop,让人误以为 prop 是源头;其实它只是个镜像。
  3. 代码侧调用 updateLocale 永远不会踩这个坑:因为 updateLocale 自己会写 system_locales。踩坑的都是绕过 updateLocale 直接 setprop 的脚本/OTA。
  4. 本项目切换路径全部走 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_localespersist.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