前阵子维护一个老项目被一个问题折腾了两个晚上Android 5.0 应用内多语言切换失效。表现很典型设置页里切换语言后当前页面偶尔会变跳转下一个页面又变回默认语言assets 目录下的本地化提示文件死活不刷新走移动应用分发渠道打出来的 bundle 包在部分机型上干脆完全不切换。这个坑说大不大但用户一旦选了自己习惯的语言再看到满满一屏旧文字第一反应就是应用坏了信任感直接崩掉。这篇内容是我从底层原理到修复方案完整梳理后的记录。我会把界面文字不刷新、asset 资源失效、bundle 包语言失效这三类现象分开讲再给出可以直接抄作业的代码和配置。适合正在做应用内语言切换、或者已经被同类问题折磨过的同学直接参考。1. 先把现象拆清楚三类“失效”其实不是一回事1.1 界面文字“半切半不切”的隐藏规律我在项目里看到的第一个现象是切换语言后界面状态非常混乱。设置页自己的标题、按钮文字立刻变过来了但跳转到下一个页面之后一部分文字是新的一部分还是旧的甚至同一个 Activity 里有的 TextView 显示中文有的 TextView 显示英文。这种“半切半不切”的情况绝大多数不是资源文件写错了而是代码里的 Context 引用没有跟着语言一起切换。应用内多语言切换和系统设置里切语言不太一样。系统切语言时系统会把当前 Activity 杀掉重建所有 Context 都会重新走一遍初始化流程而应用内切语言本质上是只改了一个全局状态比如 SharedPreferences 里存了一个语言代码然后让应用自己刷新界面。问题就出在“让应用自己刷新”这件事上。很多老代码喜欢在 Application 启动时取一次appContext然后在各个工具类里缓存appContext.getResources()。切换语言后Activity 重建会拿到新资源但那几个被缓存的 Context 和 Resources 还是旧的。于是就会出现“当前页面好了工具类里的文案还是旧语言”的诡异现象。你只要记住一条经验在应用内多语言方案里任何被长期持有、不是从最新 Activity 重新获取的 Context都是潜在的失效源。1.2 assets 语言文件失效资源匹配机制和常规 string 不一样第二个现象是 assets 里的本地化文件读取不到新语言。项目里为了减少包体把很多运营提示、帮助文档做成了 JSON 文件放在assets/langs/zh.json、assets/langs/en.json这种结构下。切换语言后代码里读文件时依然用的旧路径或者用的还是 Application 缓存过的 AssetManager结果读出来的永远是老文件。这里有个很容易被忽略的点assets目录下的文件并不会像res/values-zh/strings.xml那样根据当前 Locale 自动切换。系统资源框架有完善的限定符匹配机制切到中文 Localevalues-zh里的字符串会优先命中但 assets 里的文件路径没有任何限定符概念系统不会为你把assets/langs/zh.json自动映射成assets/langs/en.json。所以资产文件的多语言只能靠开发者自己约定目录结构再在代码里主动拼接新语言路径。如果加载资产文件的代码拿到的是一个没有更新过的 Context或者代码里拼路径提醒用的是切换前的语言标记那 asset 资源切换失效就不可避免。更隐蔽的是有人会在第一次加载时把AssetManager对象存进静态变量之后每次都用它去open文件这就相当于永久绑定了旧 Context后续切多少次语言都没用。1.3 bundle 包多语言失效语言资源被拆包导致的新问题第三个现象是打包成 bundle 分发后应用内切语言几乎不起作用。它的根因和前面两个不一样不是 Context 引用问题而是资源本身被拆到了不同的包里。现代移动应用市场普遍支持把安装包按语言拆分成多个资源包安装时只默认安装一种语言资源其他语言作为独立资源包在需要时才下发。这套机制本身能节省下载流量但它和你应用内“动态切换语言”的逻辑会发生冲突。你切换语言到系统里没安装的那个语言资源包时系统资源框架根本找不到对应语言的文件只能回退到默认语言表现出来就是 bundle 包多语言失效。如果你的分发发布流程没有走语言拆分那这个问题一般不会出现。但很多团队发布到市场时默认开启了按语言拆分开发环境里一切正常一到线上就翻车排查起来非常痛苦。2. 为什么 Android 5.0 容易出问题资源系统变化2.1 Android 5.0 之后 Resources 和 AssetManager 的初始化方式我最初以为是自己代码写得不够严谨后来才意识到Android 5.0 之后系统资源框架的变化让很多老方案失效得更彻底。在比较早的版本里开发者可以拿到当前全局的Resources手动创建一套新的Configuration调resources.updateConfiguration(config, metrics)再配合 Activity 重建就能把应用内多语言切换做起来。但到了 Android 5.0也就是 API 21系统把资源加载逻辑抽到了ResourcesImplAssetManager的初始化和管理方式也变了。直接改 Configuration 去影响已经创建好的Resources实例在很多场景下不再有效。比较稳妥的替代方案是context.createConfigurationContext(configuration)。它会在不污染全局的情况下创建一个绑定指定 Configuration 的新 Context新 Context 的内部 Resources 和 AssetManager 都是按照新语言重新创建的。这个 API 从 API 17 就有了所以对于最低版本 5.0 的项目来说完全够用。换句话说不要再去想怎么改旧 Resources而是要把整个 Context 包装成新的语言版本。所有界面的入口都从新 Context 拿资源才能保证统一。2.2 Locale 与 LocaleList系统多语言策略的变化Android 7.0也就是 API 24 以后系统支持了多语言列表 LocaleList。用户可能把应用语言设置成“中文、英文、日文”这种优先级列表而不只是单一语言。于是Configuration.setLocale(locale)在官方文档里被标记为废弃系统更希望你使用config.setLocales(LocaleList)。这个变化会造成一个很有意思的坑你在代码里切语言时如果只设置了locale高版本系统在某些系统组件内部还会参考完整的 LocaleList。比如你切的第一个语言不存在对应资源系统会按第二个语言去匹配如果代码里没有正确构造 LocaleList可能你以为切的是英文实际某些资源却走了系统默认的繁体中文看起来就是“切换不彻底”。在做兼容方案时我选择用官方兼容库的LocaleListCompat来构造语言列表在 API 24 以下转成普通 Locale在 API 24 以上转成LocaleList。这样一套代码能同时覆盖 Android 5.0 到最新版本不会因为高版本行为不一致而踩坑。2.3 应用内切换语言到底在切换什么把现象和系统机制理清后我对“应用内切换语言”做了一个本质定义要让所有从 Context 获取 Resources、AssetManager 的路径都切换到一个新语言的 Context 上去。这里有几个关键对象Application 的 BaseContext、Activity 的 BaseContext、Fragment 通过 Activity 拿到的 Context、Dialog 和 Toast 内部用的 Context以及那些静态工具类里缓存的 Context。任何一个对象没有跟着切就一定会出现某一类资源不刷新的问题。所以修复工作的核心清单很明确我需要一套能生成新语言 Context 的包装方法然后让所有 Context 在创建时被包装一次再处理那些已经存在的静态缓存最后处理 assets 文件路径和 bundle 拆包问题。3. 修复方案设计与落地3.1 统一语言入口一个 LocaleManager 管住所有上下文我设计的方案是一个全局LocaleManager类它负责两件事第一读取和保存当前语言第二根据当前语言生成一个绑定了新 Locale 的 Context。所有 Activity、Application、Dialog、工具类都通过它来获取语言上下文而不是自己随意new Resources或者缓存getResources()。先说明一下我选择不用反射、不去改系统字段主要原因是反射方案在 Android 5.0 之后极不稳定有的系统版本会直接崩而且后续系统更新随时可能让反射代码失效。用createConfigurationContext是官方推荐做法兼容性和稳定性都够好。在这个工具类里语言状态我存在SharedPreferences中因为它是应用内私有的不依赖任何资源文件。读取的时候要注意不要在 Application 还没初始化完成前用getResources()去拿默认语言直接base.getSharedPreferences就行。3.2 Application 与 Activity 的 attachBaseContext 重写要让应用全局生效第一步是重写Application.attachBaseContext。Application 本身也是一个 Context它创建的很多全局对象都会引用它内部的 Resources。如果不重写Application 持有的就是旧语言资源后面通过getApplicationContext()去读取文字、打开 assets 文件统统会回到旧语言。Activity 这边更关键。因为 Activity 是界面的载体界面的字符串资源全部由它的 BaseContext 决定。我抽了一个BaseActivity所有业务 Activity 都继承它。在attachBaseContext里把新语言 Context 传给系统后续setContentView、getString、getAssets都会使用新语言。这里要注意attachBaseContext在 Activity 生命周期里非常早一定不要在这里做耗时操作。读一下语言代码就够了不要解析 JSON、不要访问网络否则启动速度会明显变慢。3.3 assets 语言文件读取不要在旧 Context 上开流针对 asset 资源失效我改掉了之前缓存AssetManager的做法。读取资产文件时永远从当前语言的 Context 现场获取一次 AssetManager或者直接传入已经包装好的 Context。同时文件路径里必须带上语言代码。如果你的资产文件目录是assets/langs/zh.json这种结构那么读取时就拼成langs/{当前语言}/{文件名}。我建议不要为了省事把语言文件放在 assets 根目录那样一旦需要支持多语言文件名就得动态切换维护成本更高。目录结构按语言隔离是最直观、最不容易出错的。另外如果是启动时就要加载的资产文件我建议在读取完后放入内存缓存切换语言时统一清空缓存。这样可以避免每次打开页面都去读一遍大文件也能保证切换语言后不会拿到旧缓存。3.4 针对 bundle/资源拆分的配置调整对于 bundle 包多语言失效我给了自己两个选择。第一个选择是如果产品对安装包体积没有那么敏感直接在打包配置里关闭按语言拆分的选项。关闭后所有语言资源都会打进主安装包应用内切换到任何一个语言都不会因为缺资源包而失败。代价是包体变大但对大多数工具类应用来说完全可以接受。第二个选择是如果产品必须保留按语言拆分那就必须在切换语言时调系统提供的能力去安装或延迟下载目标语言资源包。只有资源包到位后切换语言才有意义。这个方案实现成本高还要处理下载失败、网络异常、下载进度提示等一堆问题。我建议非必要不采用。另外如果你的项目使用了新版支持库并且配置了系统级语言配置还要注意系统自动保存的语言和应用内手动保存的语言可能互相覆盖。我遇到过切到中文后一退到后台再回来又变成系统默认语言的情况最后就是在配置里关掉了系统自动保存语言的行为。3.5 刷新路径recreate 以外必须处理的 Fragment 和弹窗语言切换后最彻底的刷新方式是让当前 Activityrecreate()。它会重新走一遍创建流程整个视图全部重建。但 recreate 只能解决属于这个 Activity 的界面无法覆盖所有场景。Fragment 如果已经被添加到 Activity 中切换语言后需要重新创建或者至少手动刷新 Fragment 内的所有文案。最简单省事的方案是切换语言后用getSupportFragmentManager()重新替换一遍主 Fragment但要注意避免自己掉进状态恢复和回退栈的坑。Dialog、Toast、PopupWindow 这三类组件很容易被忽略。它们内部可能使用系统默认 Context 或 Application Context如果你的代码创建弹窗时传入了缓存的 Application Context那么弹窗上的文字一定是旧语言。记住这些组件的 Context 必须传最新包装过的 Context或者至少传所在 Activity 的上下文。4. 关键代码和配置实录4.1 LocaleManager 核心实现下面是我在项目里实际用到的核心代码去掉了业务封装只保留语言切换相关逻辑。语言代码用SharedPreferences保存默认语言按项目实际需求设定。public class LocaleManager { private static final String PREF_NAME app_locale; private static final String KEY_LANG key_lang; private static final String DEFAULT_LANG zh; public static String getLanguage(Context context) { SharedPreferences sp context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); return sp.getString(KEY_LANG, DEFAULT_LANG); } public static void setLanguage(Context context, String language) { SharedPreferences sp context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); sp.edit().putString(KEY_LANG, language).apply(); } public static Context wrap(Context context) { String language getLanguage(context); Locale locale new Locale(language); Configuration config new Configuration(context.getResources().getConfiguration()); if (android.os.Build.VERSION.SDK_INT 24) { config.setLocales(LocaleListCompat.forLanguageTags(language).toLocaleList()); } else { config.setLocale(locale); } return context.createConfigurationContext(config); } }代码里有一点要特别注意context.getResources().getConfiguration()拿到的可能已经是旧语言的配置所以我在上面重新拿了语言代码再设置setLocales或setLocale确保生成的 Configuration 一定基于目标语言而不是继承旧配置。4.2 attachBaseContext 模板Application 和 Activity 的包装方法基本一致。我把它们抽到公共基类里避免每个 Activity 都写一遍。public class App extends Application { Override protected void attachBaseContext(Context base) { super.attachBaseContext(LocaleManager.wrap(base)); } }public abstract class BaseActivity extends AppCompatActivity { Override protected void attachBaseContext(Context newBase) { super.attachBaseContext(LocaleManager.wrap(newBase)); } }这段代码的作用是在 Activity 和 Application 创建系统资源之前先把语言省进去。就这样一个看似不起眼的重写解决了大部分“跳转后回到旧语言”的问题。我当时做的时候还额外考虑了一个点很多老项目里还有自定义的 BaseFragmentActivity、BaseListActivity它们同样要重写attachBaseContext否则拦不住。所以改代码时要把所有继承链上的基类都梳理一遍不能只改一个。4.3 assets 切换加载示例读取 assets 里的语言文件我写了一个简单的工具方法。核心是路径必须基于当前语言动态拼接并且要用新 Context 打开流。public static String loadAssetString(Context context, String fileName) { String language LocaleManager.getLanguage(context); String path langs/ language / fileName; try (InputStream is context.createConfigurationContext( new Configuration(context.getResources().getConfiguration()) ).getAssets().open(path)) { BufferedReader reader new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(); String line; while ((line reader.readLine()) ! null) { sb.append(line); } return sb.toString(); } catch (IOException e) { return null; } }不过更推荐的做法是让调用方直接传入经过LocaleManager.wrap(context)的 Context然后在方法内部用context.getAssets()。这样不用每次手动创建 Configuration逻辑更清晰。要注意流编码。assets 里的 JSON 文件统一用 UTF-8 保存读取时也显式指定 UTF-8否则中文内容在部分机型上会变成乱码。这个问题和语言切换本身无关但排查时容易混在一起。4.4 打包配置调整如果要把语言资源拆分关掉在打包配置里加一段闭包配置就行。这个配置只影响打包阶段不影响运行时代码。android { bundle { language { enableSplit false } } }如果你不想全局关闭也可以按资源类型控制。比如只把语言拆分关掉其他资源拆分保留。具体字段因打包插件版本不同略有差异更新插件后需要重新确认。如果是保留语言拆分就要使用移动应用市场提供的拆分安装能力。具体接口各家渠道有一定差异但整体思路一样在切换语言之前触发目标语言包的安装监听安装完成回调然后再执行语言切换和界面重建。需要注意这个流程是异步的不能假设一调用就立刻有资源。4.5 测试用例我建议写一版覆盖这些场景的测试用例不要只在开发环境手动点两下。测试项包括首次进入应用默认语言是否正常。切换语言后当前页面是否完全刷新。切换语言后跳转新页面是否保持新语言。杀掉进程重新打开语言状态是否持久化。在低版本 5.0、6.0 和高版本 13、14 上分别验证。使用系统多语言列表确认 LocaleList 兼容。使用 assets 语言文件确认路径和内容都正确。使用分发包安装后确认目标语言资源包是否已存在。这些用例不需要自动化但至少要作为一个测试清单放在发布流程里。多语言问题往往不是必现而是和用户操作顺序、系统版本强相关回归成本很高。5. 踩坑实录与排查清单5.1 我踩过的三个典型坑第一个坑是只重写了 Activity 的attachBaseContext但没重写 Application。当时 Activity 界面看起来正常可所有用Application.getResources()读取的文字全是旧语言。由于项目里不少字符串是放在全局常量类里在启动时加载的导致切换语言后要重启进程才生效。加上 Application 的重写后问题才消失。第二个坑是 Fragment 里缓存的 Activity 引用。一个长列表页面切换语言后会recreate但是 Fragment 内部有个mContext是在onAttach时保存的旧 Activity。由于 FragmentManager 在重建时没有重新调用onAttach缓存里还是旧 Context列表里的空态文案就没刷新。最后我删掉了所有 Fragment 中的 Context 缓存改成必要时从requireContext()现取。第三个坑就是 assets 文件的编码问题。早期用FileReader读取 JSON在模拟器上正常真机上中文全是乱码。后来统一改成 UTF-8 读取并且把文件名路径全部小写避免在大小写敏感的系统上出现找不到文件的问题。5.2 多语言失效排查速查表我把常见现象和排查方向整理成了一张表遇到问题可以先按表定位。当前页面切换后部分文案未跟随变化检查该文案是否由静态缓存 Context 或 Application Context 读取。验证方式是打印该字符串来源的 Locale。跳转下一页面回到默认语言检查目标 Activity 是否继承未重写 attachBaseContext 的基类或是否手动 new 了 Context。assets 文件读取始终是旧语言检查文件路径是否拼接了旧语言变量或 AssetManager 是否来自 Application 缓存。发布包切语言完全无效检查打包配置是否启用了语言拆分并确认安装包内是否包含目标语言资源。切换后重启又变系统语言检查系统语言自动保存配置与应用内 SharedPreferences 是否冲突。Dialog/Toast 文字不改变检查创建弹窗时传入的 Context 类型。5.3 上线前的一页 checklist上线前我会把下面这几项全部过一遍避免漏改是否已经搜索到所有继承自 Activity、AppCompatActivity 的类并统一重写attachBaseContext。Application 是否重写attachBaseContext。所有静态工具类里的 Context、Resources、AssetManager 是否已经被清除或改为方法传参。所有 assets 文件名路径是否按语言目录组织。是否有地方在语言切换后仍然读取旧缓存。打包配置是否需要关闭语言拆分。是否有第三方库内部持有固定 Context例如 WebView、图片加载框架、数据库加密库。如有要确认是否需要重建。测试人员是否已在低版本 5.0、6.0 和高版本 13、14 上各验证一轮。5.4 一个我一直在用的排查习惯最后分享一个我自己的排查习惯。遇到多语言失效不要急着翻业务代码先在出问题的页面里把当前生效的 Locale 和 Context 类型打印出来。我经常在attachBaseContext和onCreate里各打一条日志对比同一个 Activity 两次拿到的 Locale 是否一致。如果onCreate里拿到的是新语言说明包装没问题如果还是旧语言多半是某个缓存 Context 绕过了attachBaseContext。这个习惯成功率很高。因为多语言切换错误本质上就是“某些地方拿到了旧 Context”日志能让我们快速定位到底是从 Activity 拿的、Application 拿的还是第三方库内部缓存拿的。把源头找出来修复方案基本就出来了。别迷信“全页面重建就能解决”重建只能解决 View解决不了 Context 引用错位。