01 - 系统语言切换全链路时序

知识库第 1 篇。本篇只回答一个问题:用户从「系统 → 通用 → 系统语言」点一下,到底发生了什么,到 locale 真正落盘为止。

一句话结论

入口 Controller 只是「拉起弹窗 + 显示当前语言」,真正的语言列表、选中态、落盘全部集中在 SystemLanguageDialogActivity:列表写死 10 条,单选互斥,点确定时通过 framework 的 LocalePicker.updateLocale() 把所选 locale 写回 ActivityManagerService 的系统配置,UI 的「刷新」完全依赖 onResume() 重新拉一次列表。


涉及角色

角色文件职责
SystemLanguagePreferenceControllersettingsPage/settingsSystem/src/main/java/com/android/car/settings/universal/SystemLanguagePreferenceController.kt入口条目,显示当前语言、点击拉起弹窗
SystemLanguageDialogActivitysettingsPage/settingsSystem/src/main/java/com/android/car/settings/universal/SystemLanguageDialogActivity.kt语言弹窗 Activity,承载列表 + 确定按钮
BaseConflictDialogActivitybase/settingsBaseUi/src/main/java/com/android/car/settings/common/BaseConflictDialogActivity.kt弹窗基类:冲替管理、Toolbar、确定/取消按钮容器
SystemLanguageAdaptersettingsPage/settingsSystem/src/main/java/com/android/car/settings/universal/SystemLanguageAdapter.kt列表 Adapter,单选互斥
SystemLanguageConfigsettingsPage/settingsSystem/src/main/java/com/android/car/settings/universal/SystemLanguageConfig.kt写死 10 种语言,标记选中
LocaleInfoWrappersettingsPage/settingsSystem/src/main/java/com/android/car/settings/universal/LocaleInfoWrapper.ktLocaleStore.LocaleInfo 的薄包装,暴露 id/name/summary/isSelected
LocaleUtilsbase/settingsBaseLib/src/main/java/com/android/micar/settings/utils/LocaleUtils.kt读当前系统 locale
LocalePickercom.android.internal.app.LocalePicker(framework)调用 updateLocale() 把 locale 写入系统配置
ActivityManagerService / system_localesframeworklocale 真正落盘的地方

全链路 Flowchart

flowchart TD
    A[用户进入 系统→通用] --> B[SystemLanguagePreferenceController.onCreateInternal]
    B --> C[updateRightText\n用 LocaleUtils.getConfiguredLocale +\nLocaleStore.getLocaleInfo.fullNameNative\n显示当前语言]
    C --> D[用户点击条目\nhandlePreferenceClicked]
    D --> E[startActivity SystemLanguageDialogActivity]

    E --> F[DialogActivityConflictManager.closeOtherDialog\n冲替: 关掉同类弹窗]
    F --> G[BaseConflictDialogActivity.onCreate\n初始化 Toolbar 与按钮容器]

    G --> H[SystemLanguageDialogActivity.onCreate\nsetContentView + initToolbar + initView]
    H --> I[initToolbar:\ntitle = system_universal_language_title\nnegativeButton.visibility = GONE\npositiveButton.text = system_universal_language_switch_confirm\npositiveButton.onClick -> 防双击 + 落盘]

    I --> J[onResume -> initData]
    J --> K[SystemLanguageConfig.getLanguageListWithSelected\n10 条语言 + 标记当前选中]
    K --> L[mAdapter.setData -> notifyDataSetChanged]

    L --> M[用户点击列表项]
    M --> M1{item.isSelected?}
    M1 -- 是 --> M[点击被吞 不变更]
    M1 -- 否 --> N[Adapter.selectItem:\nmCurrentSelected.isSelected = false\n本项.isSelected = true\nmCurrentSelected = 本项\nnotifyDataSetChanged]

    N --> O[用户点击 positiveButton 'OK']
    O --> P{isDoubleClick\n300ms 内重复?}
    P -- 是 --> O[直接 return\n防连击]
    P -- 否 --> Q[mAdapter.getCurrentSelected]
    Q --> R{selectedLocale != null?}
    R -- 否 --> S[MLog.w 'selected locale is null'\nfinish]
    R -- 是 --> T[LocaleUtils.getConfiguredLocale\n读当前 system_locales 0]
    T --> U{selected == current?}
    U -- 是 --> S[短路 不调 updateLocale\n直接 finish]
    U -- 否 --> V[LocalePicker.updateLocale selectedLocale]
    V --> W[AMS 更新 system_locales\n应用层 Configuration 变更]
    W --> X[finish\n弹窗关闭]

    X --> Y[外层 Controller 再次 onResume]
    Y --> Z[updateRightText 刷新右侧文案\n显示新 locale.fullNameNative]

关键点:

  • onResume 刷新:列表数据不缓存,每次 onResume() 都重新 getLanguageListWithSelected() + setData()。因此用户从外层回来时,当前语言标签会随系统 locale 同步。
  • 防双击 isDoubleClick:确定按钮用全局函数 isDoubleClick()DOUBLE_CLICK_INTERVAL = 300L 毫秒,时钟为 SystemClock.elapsedRealtime()(开机时间,免受系统时间回调影响)。
  • 当前语言比较短路:点确定时如果 selected == current不会调用 updateLocale,直接 finish(),避免无效 IPC。

全链路 Sequence Diagram

sequenceDiagram
    actor U as User
    participant C as SystemLanguagePreferenceController
    participant D as SystemLanguageDialogActivity
    participant B as BaseConflictDialogActivity
    participant A as SystemLanguageAdapter
    participant CF as SystemLanguageConfig
    participant LU as LocaleUtils
    participant LP as LocalePicker (framework)
    participant AMS as ActivityManagerService

    U->>C: 点击「系统语言」条目
    Note over C: handlePreferenceClicked
    C->>C: getCurrentLanguage()<br/>LocaleUtils.getConfiguredLocale()<br/>LocaleStore.getLocaleInfo(...).fullNameNative
    C->>C: 条目右侧文案 = 当前语言

    C->>D: startActivity(SystemLanguageDialogActivity)
    D->>B: super.onCreate (BaseConflictDialogActivity)
    B->>B: DialogActivityConflictManager<br/>.closeOtherDialog() 关掉同类冲替弹窗
    B->>B: initCustomTitleView()<br/>显示 Toolbar

    D->>D: setContentView(micar_system_language_dialog)
    D->>D: initToolbar()<br/>title = "System language"<br/>negativeButton.visibility = GONE<br/>positiveButton.text = "OK"
    D->>D: initView()<br/>new SystemLanguageAdapter

    Note over D: onResume
    D->>CF: getLanguageListWithSelected()
    CF->>LU: getConfiguredLocale()
    LU->>AMS: ActivityManager.getService()<br/>.configuration.locales[0]
    AMS-->>LU: currentLocale
    LU-->>CF: currentLocale
    CF->>CF: 按固定顺序生成 10 条 LocaleInfoWrapper<br/>zh-Hans-CN / en-US / en-GB / 其他 7 条 A-Z<br/>命中 currentLocale 的那条 isSelected=true
    CF-->>D: List<LocaleInfoWrapper>
    D->>A: setData(list)
    A->>A: 找到 isSelected=true 的项<br/>记为 mCurrentSelected<br/>notifyDataSetChanged
    A-->>U: 渲染列表(10 行)

    U->>A: 点击某个未选中项
    A->>A: setSingleClick -> isDoubleClick 守门<br/>item.isSelected==false 才进入
    A->>A: selectItem(pos)<br/>mCurrentSelected.isSelected=false<br/>mList[pos].isSelected=true<br/>mCurrentSelected=mList[pos]<br/>notifyDataSetChanged
    A-->>U: 选中标记(对勾)移到新行

    U->>D: 点击 positiveButton "OK"
    D->>D: isDoubleClick() 守门 (300ms)
    alt selectedLocale == null
        D->>D: MLog.w "selected locale is null"
    else selectedLocale != null
        D->>LU: getConfiguredLocale()
        LU->>AMS: 读 system_locales[0]
        AMS-->>LU: currentLocale
        alt selected == current
            Note over D: 短路, 不调 updateLocale
        else selected != current
            D->>LP: LocalePicker.updateLocale(selectedLocale)
            LP->>AMS: 更新 system_locales<br/>广播 ACTION_LOCALE_CHANGED
            Note over AMS: locale 真正落盘
        end
    end
    D->>D: finish()

    Note over C: 外层 onResume
    C->>LU: getConfiguredLocale()
    LU-->>C: 新 currentLocale
    C->>C: updateRightText 刷新为<br/>新语言 fullNameNative

列表项选中态 State Diagram

stateDiagram-v2
    [*] --> Unselected: 默认创建
    Unselected --> Selected: 用户点击n(selectItem pos)nmCurrentSelected.isSelected=falsen本项.isSelected=truenmCurrentSelected=本项
    Selected --> Unselected: 另一项被点击n(mCurrentSelected 被改成新项)
    Selected --> Selected: 用户再次点击本项nAdapter 守门: item.isSelected==truen点击被吞, 无变化
    Selected --> Selected: onResume 重新 setDatan若仍是 currentLocalen保持选中
    Unselected --> Selected: onResume 重新 setDatan若变成 currentLocalen被标记 isSelected=true

注意:Adapter 的点击守门(SystemLanguageAdapter.kt:59)只对「未选中项」放行,点击已选中项不会触发 selectItem,避免无效的 notifyDataSetChanged


关键代码切片

1. 入口点击:拉起弹窗

settingsPage/settingsSystem/src/main/java/com/android/car/settings/universal/SystemLanguagePreferenceController.kt:46

override fun handlePreferenceClicked(preference: BasicPreference): Boolean {
    val intent = Intent(context, SystemLanguageDialogActivity::class.java)
    context.startActivity(intent)
    return true
}

2. 入口显示当前语言

SystemLanguagePreferenceController.kt:52

private fun getCurrentLanguage(): String? {
    val locale = LocaleUtils.getConfiguredLocale()
    return LocaleStore.getLocaleInfo(locale)?.fullNameNative
}

fullNameNative(语言在自身体系下的写法,例如中文环境下仍显示 “English (United States)”),不随 UI 语言变。

3. 读系统 locale

base/settingsBaseLib/src/main/java/com/android/micar/settings/utils/LocaleUtils.kt:15

fun getConfiguredLocale(): Locale {
    return try {
        val configLocale: Locale =
            ActivityManager.getService().configuration.locales[0]
        configLocale ?: Locale.getDefault()
    } catch (e: Exception) {
        MLog.tag(TAG).e("...")
        Locale.getDefault()
    }
}

直接走 ActivityManager.getService() IPC 拿系统层 Configuration,不是本进程 Resources.configuration,保证读到的是刚落盘的全局 locale。

4. 列表生成(写死 10 条,顺序固定)

settingsPage/settingsSystem/src/main/java/com/android/car/settings/universal/SystemLanguageConfig.kt:18:62

fun getLanguageList(): List<LocaleInfoWrapper> {
    val localeList = mutableListOf<LocaleInfoWrapper>()
    // 1. 简体中文(中国)—— 用 "zh-Hans-CN" tag 显式构造
    LocaleStore.getLocaleInfo(Locale.forLanguageTag("zh-Hans-CN"))?.let { localeList.add(LocaleInfoWrapper(it)) }
    // 2. 美式英语
    LocaleStore.getLocaleInfo(Locale.US)?.let { localeList.add(LocaleInfoWrapper(it)) }
    // 3. 英式英语
    LocaleStore.getLocaleInfo(Locale.UK)?.let { localeList.add(LocaleInfoWrapper(it)) }
    // 4~10. 其他 7 种按原生名 A-Z 排序:de/es/fr/it/nb/nl/sv
    val otherLangs = listOf("de", "es", "fr", "it", "nb", "nl", "sv")
    // ... Collator.getInstance() 排序后追加
    return localeList
}
 
fun getLanguageListWithSelected(): List<LocaleInfoWrapper> {
    val localeList = getLanguageList()
    val currentLocale = LocaleUtils.getConfiguredLocale()
    for (localeWrapper in localeList) {
        if (Objects.equals(localeWrapper.mLocaleInfo.locale, currentLocale)) {
            localeWrapper.isSelected = true
            return localeList
        }
    }
    // 兜底:currentLocale 是 zh-CN 但未精确命中时,选 zh 那条
    if (currentLocale.language == "zh" && currentLocale.country == "CN") {
        localeList.firstOrNull { it.mLocaleInfo.locale.language == "zh" }?.isSelected = true
    }
    return localeList
}

5. 单选互斥

settingsPage/settingsSystem/src/main/java/com/android/car/settings/universal/SystemLanguageAdapter.kt:96

private fun selectItem(position: Int) {
    if (position !in mList.indices) {
        return
    }
    mCurrentSelected?.isSelected = false
    mList[position].isSelected = true
    mCurrentSelected = mList[position]
    notifyDataSetChanged()
}

并在 onBindViewHolder 里做了「点击已选中项无效」的守门:

SystemLanguageAdapter.kt:56

holder.item.apply {
    isSelected = item.isSelected
    setSingleClick {
        val pos = holder.adapterPosition
        if (!item.isSelected && pos in mList.indices) {
            selectItem(pos)
        }
    }
}

6. 确定按钮:防双击 + 短路 + 落盘

settingsPage/settingsSystem/src/main/java/com/android/car/settings/universal/SystemLanguageDialogActivity.kt:42

positiveButton.setText(R.string.system_universal_language_switch_confirm)
positiveButton.setOnClickListener(View.OnClickListener {
    if (isDoubleClick()) {
        return@OnClickListener
    }
    val selectedLocale = mAdapter.getCurrentSelected()
    if (selectedLocale != null) {
        val currentLocale = LocaleUtils.getConfiguredLocale()
        if (!Objects.equals(selectedLocale.mLocaleInfo.locale, currentLocale)) {
            MLog.tag(TAG).i("updateLocale locale to ${selectedLocale.mLocaleInfo.locale}")
            LocalePicker.updateLocale(selectedLocale.mLocaleInfo.locale)
        }
    } else {
        MLog.tag(TAG).w("selected locale is null")
    }
    finish()
})

7. onResume 重新拉列表

SystemLanguageDialogActivity.kt:34

override fun onResume() {
    super.onResume()
    initData()
}
 
private fun initData() {
    mFunctionData = SystemLanguageConfig.getLanguageListWithSelected()
    mAdapter.setData(mFunctionData)
}

8. 防双击实现

base/settingsBaseLib/src/main/java/com/android/car/settings/common/SettingsExt.kt:137

const val DOUBLE_CLICK_INTERVAL = 300L
fun isDoubleClick(): Boolean {
    val currentTime = SystemClock.elapsedRealtime()
    clickedTime = if (clickedTime == -1L) {
        currentTime
    } else if ((currentTime - clickedTime) < DOUBLE_CLICK_INTERVAL) {
        return true
    } else {
        currentTime
    }
    return false
}

注意 clickedTime 是模块级 varSettingsExt.kt:121),全局共享:弹窗的「确定」按钮和外层条目的 setSingleClick 用的是同一个时间戳,因此连续操作(点列表 → 立刻点确定)也可能命中 300ms 闸门。待核实:是否在实测中出现误吞。

9. 冲替基类:关掉同类弹窗

base/settingsBaseUi/src/main/java/com/android/car/settings/common/BaseConflictDialogActivity.kt:27

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    if (isConflictDialog()) {
        DialogActivityConflictManager.addCallback(this)
        DialogActivityConflictManager.closeOtherDialog(this::class.java.simpleName)
    }
    // ...
}

SystemLanguageDialogActivity.kt:74 重写 isConflictDialog() = true,因此只要本弹窗起来,其它已存在的 SystemLanguageDialogActivity 实例会被 close。这是单例式的「冲替」语义。

10. positiveButton / negativeButton 的来源

SystemLanguageDialogActivity 里直接写 positiveButton / negativeButton / title,这些不是 BaseConflictDialogActivity 自己的字段,而是其父类 micarx.appcompat.app.BaseDialogActivity(外部库 MiCarSupport/appcompat)通过 getter 暴露的合成属性:

/home/zbc/car/MiCarSupport/appcompat/src/main/java/micarx/appcompat/app/BaseDialogActivity.java:392

@Override
public Button getPositiveButton() {
    if (mDialogFrame != null) {
        return mDialogFrame.getPositiveButton();
    }
    return null;
}

Kotlin 端用 positiveButton.setText(...)negativeButton.visibility = View.GONE 直接访问。


用户可见文案(直抄 strings.xml)

用途string name英文(values/strings.xml)中文(values-zh-rCN/strings.xml)
弹窗页面标题(Toolbar title)system_universal_language_titleSystem language系统语言
确定按钮文字(positiveButton)system_universal_language_switch_confirmOK确定

来源:

  • settingsPage/settingsSystem/src/main/res/values/strings.xml:325 <string name="system_universal_language_title">System language</string>
  • settingsPage/settingsSystem/src/main/res/values/strings.xml:326 <string name="system_universal_language_switch_confirm">OK</string>
  • settingsPage/settingsSystem/src/main/res/values-zh-rCN/strings.xml:295 <string name="system_universal_language_title">系统语言</string>
  • settingsPage/settingsSystem/src/main/res/values-zh-rCN/strings.xml:296 <string name="system_universal_language_switch_confirm">确定</string>

取消按钮 negativeButtoninitToolbar() 里被 View.GONESystemLanguageDialogActivity.kt:41),用户看不到。


设计要点总结

  1. 职责单一:入口 Controller 不持有列表逻辑,列表/选中/落盘全部在 SystemLanguageDialogActivity 内部完成,方便后续多 Flavor 替换弹窗而不动外层。
  2. 写死列表:10 种语言不依赖系统 LocaleConfig,避免海外不同 ROM build 出现列表漂移;顺序也写死,前后端测试有稳定预期。
  3. 状态无持久化:选中态只在内存 (mCurrentSelected),不做缓存。每次 onResume() 重新计算,规避「系统 locale 在弹窗外面被改过」导致 UI 残影。
  4. 三层守门:列表项点击有 isDoubleClick + isSelected 双闸;确定按钮有 isDoubleClick + selected == current 短路;落盘前再 getConfiguredLocale() 读一次系统当前值,最大限度减少无谓的 IPC 和广播。
  5. 落盘靠 framework:本应用不直接写 Settings.SystemAssetManager,而是 LocalePicker.updateLocale(),由 framework 统一广播 ACTION_LOCALE_CHANGED,全机一致性由 framework 保证。