最近在一个已经模块化的 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-CNenfrjapt-PTth-THid-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 历史上经常会遇到 in 和 id 的兼容问题。项目里最好统一规范化成 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.CHINA、Locale.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 语言混用"这类问题,才会真正收敛。