Android 多语言切换实战:从 Context 到 Android 13 应用语言适配

最近在一个已经模块化的 Android 项目里处理多语言切换问题,现象挺典型:

  • App 已经切到英文,但部分页面、弹窗仍然显示中文;
  • 同一个弹窗里,有的文案是英文,有的文案又变回中文;
  • 切换页面之后再次打开弹窗,第二次更容易复现;
  • 部分接口字段偶尔返回中文,导致 UI 本地文案和服务端文案混在一起;
  • Android 13、Android 16 这类支持"应用语言"的系统上,表现和老系统还不完全一致。

这类问题看上去像"资源没翻译全",但真正排查下来,核心通常不是某一个 strings.xml 漏了,而是语言源不统一

一套完整的语言切换链路里,至少有这些入口会参与取文案:

  • Activity/Fragment 的 getString()
  • Adapter、Dialog、工具类里的 context.getString()
  • 全局工具方法,例如 getS()
  • Application 级 Resources
  • Android 13+ 的系统应用语言 LocaleManager.applicationLocales
  • 接口请求头里的 Accept-Language
  • 服务端返回的多语言字段

只要其中两处使用了不同语言源,就很容易出现"部分文案没切换"的问题。

下面结合一次实际修复,整理一套比较稳的 Android 多语言切换实现方案。

1. 问题根因:多套语言源互相打架

项目里原先大概有几类典型问题。

第一类是 createConfigurationContext() 的结果没有真正使用:

kotlin 复制代码
val config = context.resources.configuration
config.setLocale(locale)
context.createConfigurationContext(config)
return context

这段代码看起来设置了语言,但实际上 createConfigurationContext(config) 返回的是一个新的 Context。如果直接丢弃返回值,再把原来的 context 传给 super.attachBaseContext(),Activity 仍然可能继续使用旧资源。

第二类是使用了已不推荐的全局资源更新:

kotlin 复制代码
context.resources.updateConfiguration(config, context.resources.displayMetrics)

updateConfiguration() 会影响全局 Resources,在复杂项目里容易带来副作用。尤其是模块多、页面多、全局工具类多的时候,不同 Context 之间可能出现资源状态不一致。

第三类是 Android 13+ 的应用语言和 App 内保存的语言分裂。

Android 13 开始支持系统级"应用语言",也就是 LocaleManager.applicationLocales。如果 App 内只把语言保存到 SharedPreferences,但全局工具类又去读 LocaleManager.applicationLocales,就会出现:

  • Activity 使用 SP 里的语言;
  • 工具类使用系统应用语言;
  • 接口请求头又根据 Locale.getDefault() 生成;
  • 最终页面文案混用。

所以第一步不是"到处 setLocale",而是先决定:项目里到底谁是唯一语言源

2. 统一语言入口:LanguageManager

我们把语言相关能力统一收敛到 LanguageManager

  • 保存当前语言;
  • 规范化 language tag;
  • 返回当前 Locale
  • 判断是否中文;
  • 生成接口请求头需要的语言值;
  • 创建带目标语言的 Context;
  • Android 13+ 同步系统应用语言。

语言建议统一保存为 BCP-47 language tag,例如:

  • zh-CN
  • en
  • fr
  • ja
  • pt-PT
  • th-TH
  • id-ID

核心入口示例:

kotlin 复制代码
object LanguageManager {
    private const val PREFS_NAME = "language_prefs"
    private const val KEY_LANGUAGE = "selected_language"
    private const val DEFAULT_CHINA_LANGUAGE_TAG = "zh-CN"
    private const val DEFAULT_OVERSEAS_LANGUAGE_TAG = "en"

    fun setLanguage(context: Context, languageCode: String) {
        val languageTag = normalizeLanguageTag(languageCode)
        val prefs = context.applicationContext
            .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

        prefs.edit { putString(KEY_LANGUAGE, languageTag) }

        // 同步默认 Locale,保证重启前的工具类取值也一致。
        applyDefaultLocale(languageTag)

        // Android 13+ 同步系统应用语言,避免系统语言源和 SP 语言源分裂。
        syncSystemApplicationLocale(context.applicationContext, languageTag)
    }

    fun getLanguageTag(): String {
        val prefs = app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

        return prefs.getString(KEY_LANGUAGE, null)
            ?: getSystemApplicationLanguageTag(app)
            ?: normalizeLanguageTag(Locale.getDefault().toLanguageTag())
    }

    fun getCurrentLocale(): Locale {
        return Locale.forLanguageTag(getLanguageTag())
    }

    val isChineseLanguage: Boolean
        get() = getCurrentLocale().language.equals("zh", ignoreCase = true)
}

这里有一个关键点:不要让业务页面自己判断语言来源

业务层只应该问 LanguageManager

  • 当前语言是什么;
  • 当前是不是中文;
  • 接口应该带什么语言头。

至于 Android 版本差异、系统应用语言、默认 Locale、SP 读取,都放在 LanguageManager 内部消化。

3. Activity 语言适配:attachBaseContext 必须返回新 Context

Activity 的资源加载依赖 base context,所以多语言切换通常要从 attachBaseContext() 入口处理。

推荐在根 Activity 统一做,而不是每个业务 Activity 各写一份:

kotlin 复制代码
abstract class BaseRootActivity : AppCompatActivity() {

    override fun attachBaseContext(newBase: Context?) {
        // 所有 Activity 从根类统一套语言 Context,避免不同继承链使用不同语言源。
        val context = newBase?.let {
            LanguageManager.updateAppLanguage(it)
        } ?: newBase

        super.attachBaseContext(context)
    }
}

LanguageManager.updateAppLanguage() 的重点是:返回 createConfigurationContext() 生成的新 Context。

kotlin 复制代码
fun updateAppLanguage(context: Context): Context {
    val languageTag = getLanguageTag()
    val locale = applyDefaultLocale(languageTag)

    val config = Configuration(context.resources.configuration)
    config.setLocale(locale)

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        config.setLocales(LocaleList(locale))
    }

    // 必须返回这个新 Context,Activity 才会真正使用目标语言资源。
    return context.createConfigurationContext(config)
}

这里不要再调用:

kotlin 复制代码
resources.updateConfiguration(...)

它是旧方案,在复杂项目里容易造成全局资源污染。更稳的做法是:谁需要目标语言资源,谁拿目标语言 Context。

4. Android 7+:LocaleList 也要同步

Android 7.0 引入了 LocaleList。如果只调用 Locale.setDefault(locale),部分场景下仍然可能取到旧语言列表。

因此切换语言时要同步:

kotlin 复制代码
private fun applyDefaultLocale(languageTag: String): Locale {
    val locale = Locale.forLanguageTag(languageTag)

    Locale.setDefault(locale)

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        // Android 7+ 多语言列表也要同步,全局取资源会读取这个默认列表。
        LocaleList.setDefault(LocaleList(locale))
    }

    return locale
}

这样对于 Android 7-12,项目里的全局工具类可以基于 LocaleList.getDefault() 创建本地化 Context。

5. Android 13+:同步系统应用语言

Android 13 开始,系统设置里可以单独设置某个 App 的语言。

如果 App 自己也有语言选择页,一定要决定好策略:

  • 要么完全跟随系统应用语言;
  • 要么 App 内语言为准,同时同步到系统应用语言;
  • 不要一部分代码读 SP,一部分代码读 LocaleManager.applicationLocales

我们采用的是第二种:App 内选择语言后,同步 Android 13+ 系统应用语言。

kotlin 复制代码
private fun syncSystemApplicationLocale(context: Context, languageTag: String) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        val localeManager =
            context.getSystemService(Context.LOCALE_SERVICE) as? LocaleManager

        localeManager?.applicationLocales =
            LocaleList(Locale.forLanguageTag(languageTag))
    }
}

语言选择页调用时,需要传入 Context:

kotlin 复制代码
LanguageManager.setLanguage(this, selectedLocale)
restartApplication(this)

为什么要传 Context?因为 Android 13+ 同步系统应用语言需要通过 Context.LOCALE_SERVICE 拿到 LocaleManager

6. 全局 getS() 也要走本地化 Context

很多项目都会封装类似 getS() 的全局取字符串方法:

kotlin 复制代码
fun getS(@StringRes id: Int): String {
    return ViewUtils.getString(id)
}

这类工具方法最容易被忽略。因为它通常拿的是 Application:

kotlin 复制代码
application.getString(id)

如果 Application 的 Resources 没跟着 Activity Context 更新,就会出现:

  • Activity 标题是英文;
  • 弹窗默认提示是中文;
  • Adapter 里某个状态又变回中文。

所以全局取资源也要创建 localized context:

kotlin 复制代码
private fun getLocalizedContext(context: Context): Context {
    val config = Configuration(context.resources.configuration)

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        val localeManager =
            context.getSystemService(Context.LOCALE_SERVICE) as LocaleManager

        val appLocales = localeManager.applicationLocales
        if (!appLocales.isEmpty) {
            // Android 13+ 优先使用系统应用语言。
            config.setLocales(appLocales)
            return context.createConfigurationContext(config)
        }
    }

    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        // Android 7-12 使用 LanguageManager 同步过的默认 LocaleList。
        config.setLocales(LocaleList.getDefault())
        context.createConfigurationContext(config)
    } else {
        config.setLocale(Locale.getDefault())
        context.createConfigurationContext(config)
    }
}

格式化字符串也要走同一套逻辑:

kotlin 复制代码
fun getString(@StringRes resId: Int, vararg args: Any): String {
    val context = getLocalizedContext(application)
    return context.getString(resId, *args)
}

这个点非常关键。很多"只有带参数的文案没切换"的问题,就是因为无参 getString(id) 走了 localized context,而 getString(id, args) 又直接读了原始 Application Resources。

7. 接口语言也要统一:Accept-Language

多语言不只影响本地资源,也影响接口返回。

比如接口里返回:

  • 角色生成状态;
  • 错误提示;
  • 推荐文案;
  • 语言列表;
  • 商品描述。

如果请求头语言和本地 UI 语言不一致,就会出现本地按钮是英文、接口字段是中文的混合状态。

因此请求拦截器里应该统一从 LanguageManager 取:

kotlin 复制代码
builder.addHeader(
    "Accept-Language",
    LanguageManager.getCountryCodeByLanguage()
)

示例映射:

kotlin 复制代码
fun getCountryCodeByLanguage(): String {
    return when (getCurrentLocale().language) {
        "zh" -> "zh-CN"
        "en" -> "en-US"
        "id" -> "id-ID"
        "fr" -> "fr-FR"
        "ja" -> "ja-JP"
        "th" -> "th-TH"
        "pt" -> "pt-PT"
        else -> "en-US"
    }
}

注意印尼语在 Android/Java 历史上经常会遇到 inid 的兼容问题。项目里最好统一规范化成 id-ID,避免前端、系统、服务端各认一套。

8. 版本兼容整理

Android 版本 重点能力 处理方式
Android 6 及以下 LocaleList 使用 config.setLocale(locale) 创建新 Context
Android 7-12 支持 LocaleList 同步 LocaleList.setDefault(),Context 使用 config.setLocales()
Android 13+ 支持系统应用语言 App 内切换后同步 LocaleManager.applicationLocales
所有版本 Activity 资源 根 Activity 统一在 attachBaseContext() 包装
所有版本 全局工具类资源 不直接读原始 Application Resources,创建 localized context
所有版本 接口语言 Accept-Language 从统一语言源生成

9. 实战注意事项

1. 不要多处硬编码 Locale

比如某个基类里强制:

kotlin 复制代码
Locale.CHINA

另一个基类又根据用户选择设置语言,这就会导致不同继承链表现不一致。

正确做法是:所有 Activity 都从根类统一走 LanguageManager.updateAppLanguage()

2. 不要丢弃 createConfigurationContext 的返回值

错误写法:

kotlin 复制代码
context.createConfigurationContext(config)
return context

正确写法:

kotlin 复制代码
return context.createConfigurationContext(config)

这个问题非常隐蔽,因为代码看起来确实"调用了 setLocale",但实际上 Activity 没用到新 Context。

3. 不要只修 Activity,忘了全局 getS()

很多 Dialog、Adapter、ViewModel、工具类并不直接使用 Activity 的 getString()

如果全局 getS() 仍然读 Application 原始资源,就会出现局部文案没切换。

4. Android 13+ 不要忽略 LocaleManager

如果 App 目标用户覆盖 Android 13+,要明确系统应用语言和 App 内语言选择的关系。

我们这次采用的是:

App 内语言选择为入口,同时同步到系统应用语言。

这样系统和 App 内部都能看到同一份语言配置。

5. 切换语言后建议重启应用或重建任务栈

理论上也可以逐页 recreate,但复杂项目里:

  • 页面栈深;
  • Dialog/Adapter 缓存多;
  • 部分单例已经缓存字符串;
  • 接口请求也可能已经发出。

所以更稳的是切换语言后重启应用或重建任务栈。

kotlin 复制代码
val intent = packageManager.getLaunchIntentForPackage(packageName)
val componentName = intent!!.component
val mainIntent = Intent.makeRestartActivityTask(componentName)
startActivity(mainIntent)
Runtime.getRuntime().exit(0)

这不是唯一方案,但在业务复杂、模块多的项目里,稳定性通常更好。

10. 排查 checklist

如果你也遇到"切换语言后部分文案没变",可以按下面顺序查:

  • attachBaseContext() 是否返回了 createConfigurationContext() 的新 Context?
  • 是否还在调用 resources.updateConfiguration()
  • 是否有某个 BaseActivity 或业务 Activity 硬编码了 Locale.CHINALocale.ENGLISH
  • getString(id)getString(id, args) 是否走了同一套 localized context?
  • 全局 getS() 是否直接读了 Application 原始资源?
  • Adapter/Dialog 是否持有了切换前的旧 Context?
  • Android 13+ 是否同步了 LocaleManager.applicationLocales
  • Locale.setDefault() 和 Android 7+ LocaleList.setDefault() 是否同时同步?
  • 接口请求头 Accept-Language 是否来自同一个语言源?
  • 服务端返回字段是否有本地兜底策略?
  • 切换语言后是否重启应用或重建 Activity 栈?

总结

Android 多语言切换最怕的不是 API 难用,而是语言源太多:

  • Activity 一套;
  • Application 一套;
  • 全局工具类一套;
  • Android 13+ 系统一套;
  • 接口请求头又一套。

真正稳定的方案,是先把语言源收敛到一个 LanguageManager,再让所有资源读取、页面 Context、系统应用语言和接口请求头都围绕它展开。

一句话总结:

多语言切换不是简单 setLocale,而是要让所有取文案的入口都相信同一个 Locale。

做到这一点之后,类似"英文环境弹窗第二次显示中文""部分页面切换后仍是中文""服务端字段和本地 UI 语言混用"这类问题,才会真正收敛。

相关推荐
Coffeeee7 小时前
Android功耗优化,哪些代码会让你的机子发热又耗电
android·架构·kotlin
Android打工仔7 小时前
Kotlin 编译器是如何把 suspend 函数变成状态机的?
kotlin
Android打工仔7 小时前
一个 suspend 函数,为什么会变成一个普通函数?
kotlin
xianrenli388 小时前
Android14自动时区更新
android·学习·framework·aosp14·自动时区
心中有国也有家8 小时前
AtomGit Flutter 鸿蒙客户端:零外部资源的应用打包策略
android·javascript·flutter·华为·harmonyos
恋猫de小郭8 小时前
Fluter 共享内存多线程正在落地,IsolateGroupBound 来了
android·前端·flutter
三少爷的鞋9 小时前
Kotlin 协程的七重境界:从 launch(IO) 到执行模型设计
android
-SOLO-19 小时前
Android Event 日志完全指南
android
壮哥_icon20 小时前
【Android 系统开发】从掉帧卡顿到丝滑:高频硬件中断(IRQ)的 CPU 亲和性性能优化实战
android·性能优化
千里马学框架20 小时前
Android Framework 新手学习踩坑建议指南
android·智能手机·性能优化·framework·aaos·车载开发·系统工程师