引言
事情是这样的,我们接到一个需求,是要为我们的应用做多语言版本并且提供多语言切换。事后证明,这个事情还真的是很蛋疼的一件事。
在android系统中,应用的语言环境会跟随系统环境。如果在resource文件夹中,如果设置了对应语言环境的资源文件夹,那么在使用资源的时候会由AssetManager
到对应的资源文件夹中取出展示。
如果想让应用不跟随系统环境,而是能使用自己的语言配置呢?这也不难,只要将context.getResoure().getConfiguration().locale
改成设置的语言环境即可。
如何设置应用的app locale
-
添加多语言文本文件
在resource
文件下增加不同语言的value文件夹,例如英文的添加value-en
文件夹,繁体中文添加value-zh-rTW
文件夹
更新
configuration
的locale
属性
android中,configuration
包含了activity
所有的配置信息,包括屏幕密度,屏幕宽度,语言设置等等。修改应用的configuration
使应用根据configuration
中配置的语言环境来展示资源。
public class LanguageUtil {
/**
* 设置应用语言类型
*/
@SuppressWarnings("deprecation")
public static void setAppLocale(Locale locale) {
if (locale!=null) {
Configuration configuration = context.getResources().getConfiguration();
configuration.locale = locale;
}
}
}
- 重新启动
activity
已经启动了的activity
当然不会自己把页面全部换一遍,最简单粗暴的方法当然是重新启动他们。把栈里的activity
统统干掉,重新启动第一个activity
如何能够保持所有的activity不需要重新启动?这是另一个问题了,这里不作讨论
public static void startMainNewTask(Context context) {
Intent intent = new Intent(context, MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
}
- 持久化存储应用
locale
配置
使用sharePreference
或者什么的存一下应用的语言设置,在下次启动应用的时候重新恢复一下即可。这里就不赘述了。
这样就OK了吗?
并没有。上述方法看起来很美,网上很多能搜到的资料也基本都止步于此。但是我们改的configuration
,真的不会再变了吗?
事实上,configuration
在很多的情况下会被系统所修改,比如在切换系统语言的时候、在横竖屏切换的时候等等。当configuration
发生改变的时候,ActivityThread
会拷贝一份系统的configuration
,覆盖到appContext
里。如果在configuration change发生之后,页面更新数据并且通过resource去获取文字的话,会以系统的locale为准去获取文字资源。
于是我们开始研究怎么改这个bug。
通过调试,发现每一次横竖屏切换过后,Application$onConfigurationChanged(Configuration newConfig)
方法都会被调用一次。于是很自然地,我们想到,如果在这里我们把newConfig
在调用super方法之前改掉,是不是就能够解决这个问题了?
很不幸,不是的。在Application$onConfigurationChanged(Configuration newConfig)
被调用的时候,Resource#mResourceImpl#mConfiguration
已经被修改了。从下面这段代码可以看出来:
public final class ActivityThread {
......
final void handleConfigurationChanged(Configuration config, CompatibilityInfo compat) {
......
synchronized (mResourcesManager) {
......
// 这个方法最终会调用Resource$updateConfiguration方法,导致locale被覆盖
mResourcesManager.applyConfigurationToResourcesLocked(config, compat);
......
}
......
if (callbacks != null) {
final int N = callbacks.size();
for (int i=0; i<N; i++) {
ComponentCallbacks2 cb = callbacks.get(i);
if (cb instanceof Activity) {
// If callback is an Activity - call corresponding method to consider override
// config and avoid onConfigurationChanged if it hasn't changed.
Activity a = (Activity) cb;
performConfigurationChangedForActivity(mActivities.get(a.getActivityToken()),
config, REPORT_TO_ACTIVITY);
} else {
// 这个方法会调用Application$onConfigurationChanged
performConfigurationChanged(cb, null, config, null, REPORT_TO_ACTIVITY);
}
}
}
}
}
同时,在Application$onConfigurationChanged
方法里修改Configuration#locale
会引起另外一个bug:Activity
会不断地重启。表现在视觉上就是这个Activity
启动之后一直在闪烁。这个是什么原因?
原因在于当Orientation
发生改变的时候,ActivityManagerService
会去检查新启动的Activity
的Configuration
是否是一致的,否则会重新启动Activity
,关键的代码是:
final class ActivityStack {
......
/**
* Make sure the given activity matches the current configuration. Returns false if the activity
* had to be destroyed. Returns true if the configuration is the same, or the activity will
* remain running as-is for whatever reason. Ensures the HistoryRecord is updated with the
* correct configuration and all other bookkeeping is handled.
*/
boolean ensureActivityConfigurationLocked(
ActivityRecord r, int globalChanges, boolean preserveWindow) {
......
// Figure out how to handle the changes between the configurations.
if (DEBUG_SWITCH || DEBUG_CONFIGURATION) Slog.v(TAG_CONFIGURATION,
"Checking to restart " + r.info.name + ": changed=0x"
+ Integer.toHexString(changes) + ", handles=0x"
+ Integer.toHexString(r.info.getRealConfigChanged()) + ", newConfig=" + newConfig
+ ", taskConfig=" + taskConfig);
if ((changes&(~r.info.getRealConfigChanged())) != 0 || r.forceNewConfig) {
// Aha, the activity isn't handling the change, so DIE DIE DIE.
r.configChangeFlags |= changes;
r.startFreezingScreenLocked(r.app, globalChanges);
r.forceNewConfig = false;
preserveWindow &= isResizeOnlyChange(changes);
if (r.app == null || r.app.thread == null) {
if (DEBUG_SWITCH || DEBUG_CONFIGURATION) Slog.v(TAG_CONFIGURATION,
"Config is destroying non-running " + r);
destroyActivityLocked(r, true, "config");
} else if (r.state == ActivityState.PAUSING) {
// A little annoying: we are waiting for this activity to finish pausing. Let's not
// do anything now, but just flag that it needs to be restarted when done pausing.
if (DEBUG_SWITCH || DEBUG_CONFIGURATION) Slog.v(TAG_CONFIGURATION,
"Config is skipping already pausing " + r);
r.deferRelaunchUntilPaused = true;
r.preserveWindowOnDeferredRelaunch = preserveWindow;
return true;
} else if (r.state == ActivityState.RESUMED) {
// Try to optimize this case: the configuration is changing and we need to restart
// the top, resumed activity. Instead of doing the normal handshaking, just say
// "restart!".
if (DEBUG_SWITCH || DEBUG_CONFIGURATION) Slog.v(TAG_CONFIGURATION,
"Config is relaunching resumed " + r);
if (DEBUG_STATES && !r.visible) {
Slog.v(TAG_STATES, "Config is relaunching resumed invisible activity " + r
+ " called by " + Debug.getCallers(4));
}
relaunchActivityLocked(r, r.configChangeFlags, true, preserveWindow);
} else {
if (DEBUG_SWITCH || DEBUG_CONFIGURATION) Slog.v(TAG_CONFIGURATION,
"Config is relaunching non-resumed " + r);
relaunchActivityLocked(r, r.configChangeFlags, false, preserveWindow);
}
// All done... tell the caller we weren't able to keep this activity around.
return false;
}
// Default case: the activity can handle this new configuration, so hand it over.
// NOTE: We only forward the task override configuration as the system level configuration
// changes is always sent to all processes when they happen so it can just use whatever
// system level configuration it last got.
r.scheduleConfigurationChanged(taskConfig, true);
r.stopFreezingScreenLocked(false);
return true;
}
}
既然在Application$onConfigurationChanged
方法里无法修改locale
。那么我们考虑在Activity$onResume
方法里再更新一次locale
行不行呢?在所有页面的基类BaseActivity
执行如下测试代码:
public class BaseActivity extend AppCompatActivity {
@Override
protected void onResume() {
super.onResume();
// 语言状态检测
recoverLanguage();
}
private void recoverLanguage() {
// 系统语言是中文环境,将configuration#locale强制改成英文环境进行测试
getResource().getConfiguration().locale = Locale.ENGLISH;
}
}
在Activity$onResume
里执行这样的代码,已经规避了Activity
启动流程中对于Configuration
的校验了,因为在Activity$onResume
被执行的时候,校验已经结束了。
我们可以看到,Activity
已经不再重复循环地去relaunch了。那么Configuration#locale
修改成功了吗?修改成功了,但未起作用。通过调试,我们发现:
从断点数据中我们可以看到,
mResource.mResourceImpl.mConfiguration.locale
已经是Locale.ENGLISH
了。但是通过执行
Context$getString
方法我们却发现,取出来的文字是中文。这就耐人寻味了,为何原本修改Resource
中的locale
可以修改语言环境,而现在修改又不行了呢?
还是通过源码来探究一下。
从这三段源码可以看到,Context$getString
方法实际上,是通过AssetManager
来获取StringRes的,那是不是说,AssetManager
里面也有一个locale
呢?
是的!通过查看源码,我们发现AssetManager里有一个native方法:
原因就很明确了,虽然我们修改了Resource
的locale
,却没有修改这里的,所以修改不生效。至此,解决办法就剩下:
- 通过反射,拿到'AssetManager'的这个方法,将locale设置进去。
- 通过寻找调用了这个方法的别的API,然后通过调用此API,更新进去。
反射的方法我并不喜欢,原因是这一个方法的参数列表太长了,反射的话写起来会很痛苦(微笑)
所以最终的解决办法是:
我们在Resourse
里发现了一个方法:
public void updateConfiguration(Configuration config, DisplayMetrics metrics,
CompatibilityInfo compat) {
Trace.traceBegin(Trace.TRACE_TAG_RESOURCES, "ResourcesImpl#updateConfiguration");
try {
synchronized (mAccessLock) {
......
mAssets.setConfiguration(mConfiguration.mcc, mConfiguration.mnc,
adjustLanguageTag(mConfiguration.getLocales().get(0).toLanguageTag()),
mConfiguration.orientation,
mConfiguration.touchscreen,
mConfiguration.densityDpi, mConfiguration.keyboard,
keyboardHidden, mConfiguration.navigation, width, height,
mConfiguration.smallestScreenWidthDp,
mConfiguration.screenWidthDp, mConfiguration.screenHeightDp,
mConfiguration.screenLayout, mConfiguration.uiMode,
Build.VERSION.RESOURCES_SDK_INT);
......
}
synchronized (sSync) {
if (mPluralRule != null) {
mPluralRule = PluralRules.forLocale(mConfiguration.getLocales().get(0));
}
}
} finally {
Trace.traceEnd(Trace.TRACE_TAG_RESOURCES);
}
}
那么只需要在Activity$onResume
里添加如下代码:
public class BaseActivity extend AppCompatActivity {
@Override
protected void onResume() {
super.onResume();
// 语言状态检测
recoverLanguage();
}
/**
* 通过updateConfiguration方法修改Resource的Locale,连带修改Resource内Asset的Local.
*/
private void recoverLanguage() {
Resources resources = getContext().getResources();
Configuration configuration = resources.getConfiguration();
DisplayMetrics metrics = resources.getDisplayMetrics();
// 从Preference中取出语言设置
configuration.locale = PreferenceUtil.getCustomLanguageSetting();
resources.updateConfiguration(configuration, metrics);
}
}