引言
事情是這樣的,我們接到一個需求,是要為我們的應用做多語言版本并且提供多語言切換。事后證明,這個事情還真的是很蛋疼的一件事。
在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);
}
}