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 语言混用"这类问题,才会真正收敛。

相关推荐
释然小师弟2 小时前
Android开发十年:反思与回顾
android·后端·嵌入式
黄林晴4 小时前
用了这么久 Koin Scope,原来一直都用错了?
android·kotlin
爱勇宝17 小时前
我做了一个只用来搜歌词的小 App
android·前端·后端
众少成多积小致巨20 小时前
JNI (Java Native Interface) 技术手册中文参考指南
android·java·c++
唐青枫1 天前
Kotlin Context Parameters 详解:别再把 Logger、事务和配置层层往下传
kotlin
Coffeeee1 天前
如何使用Glide和Coil加载WebP动图
android·kotlin·glide
Kapaseker1 天前
5 分钟搞懂 Kotlin DSL
android·kotlin
恋猫de小郭1 天前
AI Agent 开发究竟是啥?如何用 AI 开发 Agent ?深入浅出给你一套概念
android·前端·ai编程
黄林晴1 天前
Android 17 正式发布!target 37 一大批旧代码直接不能用了
android