寫在前面
Android中的資源文件,在使用時都是根據系統語言來處理的,如果當前環境爲英文,則在需要使用字符串等資源時,會自動從values-en
類目錄中提取,這也是應用國際化
的基礎
一般的軟件中,不會在應用內進行語言環境的切換,默認在系統整體語言發生改變時,界面會進行重啓
,當然,也可以人爲進行攔截操作。
不過由於api一直在變更,針對本地語言的變更處理方式也有了些不同,同樣,如果想要在應用內自定義一套語言切換功能,也變得比較的繁瑣。
最後的話,也會就ActivityThread
類,來簡單解讀一下,在系統語言環境發生變化時,代碼的執行邏輯
一、設定語言切換界面
在app內單獨定義語言切換的話,至少得先有切換的功能,支持的語言應當至少有三種:
- 中文環境
- 英文環境
- 跟隨系統
- …
在選定了中文環境
或者英文環境
時,系統語言的切換對該應用本身不起作用,在選定了跟隨系統
時,則會根據當前系統語言,來動態的調整顯示情況。
界面類似這樣:
該 activity
源碼如下:
@Route(path = ARouterConst.Activity_SwitchLocaleActivity)
@InjectActivityTitle(titleRes = R.string.label_switch_locale)
@DisableAPTProcess(disables = [APTPlugins.BUTTERKNIF, APTPlugins.AROUTER, APTPlugins.DAGGER])
class SwitchLocaleActivity : BaseActivity<BasePresenter<SoftSettingActivity>>(), LineMenuListener {
/**
* 佈局文件控件
*
* 默認
* 中文
* 臺灣
* 香港
* 英文
*/
private var lmvs = arrayOfNulls<LineMenuView>(5)
override fun getContentOrViewId(): Int {
verticalLayout {
//toolbar
include<AppBarLayout>(R.layout.layout_top_bar)
//內容區域
scrollView {
overScrollMode = View.OVER_SCROLL_ALWAYS
isVerticalScrollBarEnabled = false
verticalLayout {
//系統存儲的值
val locale = DefaultPreferenceUtil.getInstance().localeLanguageSwitch
val menus = getStringArray(R.array.array_locale_language)
//初始化界面
for (i in lmvs.indices) {
//lmv
lmvs[i] = lmv_select(menuText = menus[i]) {
rightSelect = i == locale
}.lparams(width = matchParent) {
if (i == 0) {
topMargin = dimen(R.dimen.view_padding_margin_10dp)
}
}
//分隔符divider
if (i < lmvs.size - 1) {
dv_line().lparams(width = matchParent)
}
}
}.lparams(width = matchParent).applyRecursively {
if (it is LineMenuView || it is DividerView) {
it.horizontalPadding = dimen(R.dimen.view_padding_margin_16dp)
it.backgroundColorResource = R.color.main_color_white
}
}
}.lparams(matchParent, matchParent)
}
return 0
}
/**
* @param v 被點擊到的v;此時應該是該view自身:LineMenuView
*/
override fun performSelf(v: LineMenuView) {
if (!v.rightSelect) {
(v.getTag(LMVConfigs.TAG_POSITION) as Int).let { position ->
ProgressDialog.getInstance(this@SwitchLocaleActivity).show()
DefaultPreferenceUtil.getInstance().localeLanguageSwitch = position
LOCALE_LANGUAGE_TYPES[position]?.also {
BaseApplication.app.setLocale(it)
} ?: BaseApplication.app.setLocale(BaseApplication.app.systemLocale)
ProgressDialog.getInstance(this@SwitchLocaleActivity).dismiss()
//恢復上個狀態
for (i in lmvs.indices) {
lmvs[i]?.rightSelect = i == position
}
//刷新界面佈局
onConfigurationChanged(resources.configuration)
}
}
}
/**
* 刷新當前界面:主要是標題和默認
*/
override fun onConfigurationChanged(newConfig: Configuration?) {
super.onConfigurationChanged(newConfig)
lmvs[0]?.menuText = getStringArray(R.array.array_locale_language)[0]
title = Unit.getString(R.string.label_switch_locale)
setResult(Activity.RESULT_OK)
}
}
這裏使用的是kotlin語言
類上的註冊主要是說明activity標題
等信息;
getContentOrViewId
方法是以anko
的形式注入了界面佈局
performSelf
方法用來控件LineMenuView
的點擊事件
onConfigurationChanged
方法處理當系統的語言改變時,activity
自定義的處理方法
這裏使用的控件LineMenuView
是一種敏捷開發的菜單庫,在實現一些行式
佈局時比較方便,github地址爲:LineMenuView
重要的是,要在Manifest
文件中,聲明該活動需要自定義語言環境改變的處理方式(這裏是連同橫豎屏切換一同進行了攔截):
<activity
android:name=".SwitchLocaleActivity"
android:configChanges="keyboard|screenSize|orientation|locale|layoutDirection"/>
當然,還有一個使用到的佈局文件沒有貼上源碼,不過查看效果圖,就可以明白未說明部分的含義了
二、選擇全局保存當前語言的方式
可以通過多種方式保存選擇的語言環境,這裏使用 preferences
處理:
public class DefaultPreferenceUtil {
//...
/**
* 切換語言環境
* <p>
* 0:未設置 或 跟隨系統變化
* 1:簡體中文-中國大陸
* 2:繁體中文-中國臺灣
* 3:繁體中文-中國香港
* 4:英語-全體-English
*/
@NotNull
public int getLocaleLanguageSwitch() {
return preferences.getInt(LOCALE_LANGUAGE_SWITCH, 0);
}
public boolean setLocaleLanguageSwitch(@NotNull @IntRange(from = 0, to = 4) int locale) {
return edit.putInt(LOCALE_LANGUAGE_SWITCH, locale).commit();
}
//...
}
用 int
來保存語言設置,默認爲 0
,表示跟隨系統。
就目前規定來說,不管在什麼情況下,getLocaleLanguageSwitch
方法獲取的值都只能處於 0 - 4
之間,同時還也對應着上面效果圖中的五個LineMenuView
控件
三、在Application中進行攔截處理
需要注意的是,如果不進行任何處理的話,應用在啓動時,讀取到的語言環境將是系統設置的那個,因此我們需要在Application
啓動時就做出處理,根據我們之前設定的環境進行更改
這個過程需要分兩步進行:
1、獲取配置對應的Locale對象
根據 preferences
中存儲的值,我們可以獲取對應的 Locale
對象
val LOCALE_LANGUAGE_TYPES = arrayOf(
null,
Locale.SIMPLIFIED_CHINESE,
Locale.TRADITIONAL_CHINESE,
Locale("zh", "HK"),
Locale.ENGLISH
)
如果應用之前沒有設置過語言環境,或者說設置的語言環境爲跟隨系統,則此處返回 null
,否則,返回語言地區對應的Locale
值
2、在Application中進行更替
在 Application的onCreate方法中,調用以下代碼
//設置默認環境
Locale target = Const.INSTANCE.getLOCALE_LANGUAGE_TYPES()[DefaultPreferenceUtil.getInstance().getLocaleLanguageSwitch()];
if (target != null) {
setLocale(target);
}
這裏獲取的 target
就是第一步中的Locale
對象,如果爲null的話,就不修改原有邏輯,否則,更替目前應用使用的 Locale
值
setLocale
方法比較複雜,因此將其單獨提出來:
/**
* 設置語言對象
*/
@SuppressLint("ObsoleteSdkInt")
public void setLocale(@NotNull Locale target) {
// 獲得res資源對象
Resources resources = getResources();
// 獲得設置對象
Configuration config = resources.getConfiguration();
// 獲得屏幕參數:主要是分辨率,像素等。
DisplayMetrics dm = resources.getDisplayMetrics();
// 語言
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
config.setLocale(target);
} else {
config.locale = target;
}
resources.updateConfiguration(config, dm);
}
這樣,就完成了基本的語言切換功能;
不過僅僅如此的話,如果應用正在運行過程中,系統語言環境發生了改變,那麼應用將不能夠正確的進行處理;
比如,如果應用已經鎖定了環境爲英文,在系統語言環境切換爲中文時,回到之前的界面,界面將自動重啓,然後以中文的樣式進行顯示。
事實上,在系統環境發生變化時,我們需要根據應用中保存的值來判斷,如果是跟隨系統,那麼將不進行任何處理,如果是其他情況,則會判斷切換的語言是否與當前設置的語言相同,不相同則進行一次修改,相同則不進行任何處理。
具體邏輯如下:
/**
* application監聽到環境發生變化時,需要 根據情況來判斷是否切換語言環境
* <p>
* 1.如果當前應用設置了語言環境(非跟隨系統變化) ,則不會通知應用切換語言(使用自身默認的語言)
* 2.如果當前應用設置跟隨系統變化,或者未設置默認語言環境(兩者可做統一處理),則判斷當前與系統語言是否相同,不同則進行切換(默認不操作)
*/
@Override
public synchronized void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
//系統切換的Locale值
systemLocale = PlusFunsPluginsKt.getApplicationLocale(null, newConfig);
Logger.v(getClass().getSimpleName() + "監聽到:環境發生變化:%s", newConfig.toString());
int current = DefaultPreferenceUtil.getInstance().getLocaleLanguageSwitch();
switch (current) {
case 0:
case 1:
case 2:
case 3:
case 4:
Locale target = Const.INSTANCE.getLOCALE_LANGUAGE_TYPES()[current];
if (target != null && !target.equals(systemLocale)) {
setLocale(target);
}
break;
default:
showToast("error language!!!");
System.exit(0);
}
}
getApplicationLocale
方法很簡單,只是獲取當前應用的,或者傳入config
對應的Locale
值:
/**
* get Application Locale
*
* 如果傳入config則獲取當前config的locale值,如果未傳入,則默認返回當前應用的locale(非系統)
*/
inline fun <T> T.getApplicationLocale(config: Configuration? = null): Locale {
return (config ?: BaseApplication.app.resources.configuration).run {
if (Build.VERSION.SDK_INT < 24) locale else locales.get(0)
}
}
如此一來,哪怕系統環境發生了改變,app也能對應的做出處理:是保持原有,或者跟隨系統變化
四、 處理Activity環境,兼容8.0系統
經過以上三個步驟,Application對應的Context對象在獲取資源時,沒有任何問題,但Activity中的Context對象,其實語言環境並沒有切換過來
我們通過BaseApplication.app.getResources().getString()
方法獲取到 的 值 和通過 activity.getResources().getString()
方法獲取到的值可能會不同;因爲他們分別對應着不同的Context
上下文對象。
因此只是在環境切換後重啓Activity是不起作用的(安卓碎片化比較嚴重,api各個版本都有差別),還需要在BaseActivity
(活動的基類)中重載Context的綁定方式:
@Override
protected void attachBaseContext(Context newBase) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // 8.0需要使用createConfigurationContext處理
newBase = updateResources(newBase);
}
super.attachBaseContext(newBase);
}
@TargetApi(Build.VERSION_CODES.N)
public Context updateResources(Context context) {
Locale locale = PlusFunsPluginsKt.getApplicationLocale(null, null);
Configuration configuration = context.getResources().getConfiguration();
configuration.setLocale(locale);
configuration.setLocales(new LocaleList(locale));
return context.createConfigurationContext(configuration);
}
這樣,在activity重啓之後,界面語言環境才能顯示正常;
然後我們回頭再來看一下第一部分列出的Activity的部分源碼:
/**
* 刷新當前界面:主要是標題和默認
*/
override fun onConfigurationChanged(newConfig: Configuration?) {
super.onConfigurationChanged(newConfig)
lmvs[0]?.menuText = getStringArray(R.array.array_locale_language)[0]
title = Unit.getString(R.string.label_switch_locale)
setResult(Activity.RESULT_OK)
}
這裏在回調中,在獲取字符串值時,並沒有直接使用getString(R.string.label_switch_locale)
,因爲這樣的話,將使用Activity的Context對象
來獲取資源,此時,Activity的Context對象
對應的語言環境根本沒有任何變化,因此界面會出現錯誤;
注:這裏Unit.getString(R.string.label_switch_locale)
是利用kotlin動態添加的方法,實際上是利用BaseApplication的Context對象進行取值:
/**
* 獲取string
*/
@Suppress("NOTHING_TO_INLINE")
inline fun <T> T.getString(@StringRes res: Int, vararg formatArgs: Any?): String {
return BaseApplication.app.getString(res, *formatArgs)
}
五、攔截/不攔截 系統語言改變事件
如果需要自定義系統語言事件的處理方法,則需要向前面說明的那樣,首先在manifest
文件中進行configChanges
聲明,然後在Activity中重載onConfigurationChanged
方法進行邏輯處理。
如果是在應用內切換了語言環境,那麼一般來說,需要手動的進行重啓,像這樣:
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
//重啓整個應用
if (requestCode == Const.REQUEST_CODE_ONE && resultCode == Activity.RESULT_OK) {
recreate()
}
}
這裏假定的情況是:該代碼所在的Activity啓動了一個可以切換應用語言環境的 Activity,如果語言環境被切換,那麼在界面返回時,代碼所在界面要進行刷新處理,否則界面內容不會發生改變。
這裏還有一點需要注意,有些包含Fragment的Activity,是不能直接調用recreate
方法的,否則會導致應用崩潰,因此需要採用其他方法來進行處理(可以重新啓動一個新的activity,然後結束老的活動,或者其他方法)
六、最後
在最後部分,簡單來看一下語言環境改變時,系統方法的調用順序;
首先,需要找到ActivityThread
類,該類是一切的起點,然後找到這個:
private class ApplicationThread extends IApplicationThread.Stub{
//...
public void scheduleApplicationInfoChanged(ApplicationInfo ai) {
sendMessage(H.APPLICATION_INFO_CHANGED, ai);
}
@Override
public void scheduleActivityConfigurationChanged(
IBinder token, Configuration overrideConfig) {
sendMessage(H.ACTIVITY_CONFIGURATION_CHANGED,
new ActivityConfigChangeData(token, overrideConfig));
}
//...
}
其實看到*Stub
的樣式,就應該明白,則是個AIDL
調用,具體誰調用的,我們不去深究,以上兩個方法已經指明:當Config有變化時,將發送Message消息 : ACTIVITY_CONFIGURATION_CHANGED
、 APPLICATION_INFO_CHANGED
APPLICATION_INFO_CHANGED
單從名字上就可以看出,是讓Application執行相應邏輯
查看這段代碼:
private class H extends Handler {
public void handleMessage(Message msg) {
if (DEBUG_MESSAGES) Slog.v(TAG, ">>> handling: " + codeToString(msg.what));
switch (msg.what) {
//...
case APPLICATION_INFO_CHANGED:
mUpdatingSystemConfig = true;
try {
handleApplicationInfoChanged((ApplicationInfo) msg.obj);
} finally {
mUpdatingSystemConfig = false;
}
break;
//...
}
}
}
查看 handleApplicationInfoChanged
方法邏輯
void handleApplicationInfoChanged(@NonNull final ApplicationInfo ai) {
// ...
handleConfigurationChanged(newConfig, null);
// ...
}
接着看 handleConfigurationChanged
方法
final void handleConfigurationChanged(Configuration config, CompatibilityInfo compat) {
//...
ArrayList<ComponentCallbacks2> callbacks = collectComponentCallbacks(false, config);
freeTextLayoutCachesIfNeeded(configDiff);
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);
} else if (!equivalent) {
performConfigurationChanged(cb, config);
}
}
}
}
從判斷語句中可以看到,當 callback
爲 Activity
時,會執行 performConfigurationChangedForActivity
方法,這個邏輯在分割線後就可以看到;
那麼 collectComponentCallbacks(false, config);
代碼的含義大概就是搜尋所有需要處理 config - change 的對象。
--------------------- 分割線
然後再查看 ACTIVITY_CONFIGURATION_CHANGED
這個消息:
找出這段代碼
private class H extends Handler {
public void handleMessage(Message msg) {
if (DEBUG_MESSAGES) Slog.v(TAG, ">>> handling: " + codeToString(msg.what));
switch (msg.what) {
//...
case ACTIVITY_CONFIGURATION_CHANGED:
Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityConfigChanged");
handleActivityConfigurationChanged((ActivityConfigChangeData) msg.obj,
INVALID_DISPLAY);
Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
break;
//...
}
}
}
進入handleActivityConfigurationChanged
方法進行查看:
void handleActivityConfigurationChanged(ActivityConfigChangeData data, int displayId) {
ActivityClientRecord r = mActivities.get(data.activityToken);
// Check input params.
if (r == null || r.activity == null) {
if (DEBUG_CONFIGURATION) Slog.w(TAG, "Not found target activity to report to: " + r);
return;
}
final boolean movedToDifferentDisplay = displayId != INVALID_DISPLAY
&& displayId != r.activity.getDisplay().getDisplayId();
// Perform updates.
r.overrideConfig = data.overrideConfig;
final ViewRootImpl viewRoot = r.activity.mDecor != null
? r.activity.mDecor.getViewRootImpl() : null;
if (movedToDifferentDisplay) {
if (DEBUG_CONFIGURATION) Slog.v(TAG, "Handle activity moved to display, activity:"
+ r.activityInfo.name + ", displayId=" + displayId
+ ", config=" + data.overrideConfig);
final Configuration reportedConfig = performConfigurationChangedForActivity(r,
mCompatConfiguration, displayId, true /* movedToDifferentDisplay */);
if (viewRoot != null) {
viewRoot.onMovedToDisplay(displayId, reportedConfig);
}
} else {
if (DEBUG_CONFIGURATION) Slog.v(TAG, "Handle activity config changed: "
+ r.activityInfo.name + ", config=" + data.overrideConfig);
performConfigurationChangedForActivity(r, mCompatConfiguration);
}
// Notify the ViewRootImpl instance about configuration changes. It may have initiated this
// update to make sure that resources are updated before updating itself.
if (viewRoot != null) {
viewRoot.updateConfiguration(displayId);
}
mSomeActivitiesChanged = true;
}
然後追蹤進入 performConfigurationChangedForActivity
方法
private Configuration performConfigurationChangedForActivity(ActivityClientRecord r,
Configuration newBaseConfig, int displayId, boolean movedToDifferentDisplay) {
r.tmpConfig.setTo(newBaseConfig);
if (r.overrideConfig != null) {
r.tmpConfig.updateFrom(r.overrideConfig);
}
final Configuration reportedConfig = performActivityConfigurationChanged(r.activity,
r.tmpConfig, r.overrideConfig, displayId, movedToDifferentDisplay);
freeTextLayoutCachesIfNeeded(r.activity.mCurrentConfig.diff(r.tmpConfig));
return reportedConfig;
}
然後追蹤進入 performActivityConfigurationChanged
方法
private Configuration performActivityConfigurationChanged(Activity activity,
Configuration newConfig, Configuration amOverrideConfig, int displayId,
boolean movedToDifferentDisplay) {
// ...
if (shouldChangeConfig) {
activity.mCalled = false;
activity.onConfigurationChanged(configToReport);
if (!activity.mCalled) {
throw new SuperNotCalledException("Activity " + activity.getLocalClassName() +
" did not call through to super.onConfigurationChanged()");
}
}
//...
}
可以看到,最後是執行了 activity
的 onConfigurationChanged
方法
-------------- 結語
隨着安卓api的提升,已有的功能都可能會有大的更改,類似的問題可能會越來越多,即便是之前看到的源碼,雖然大體邏輯不便,但還是可能有細微處的差別