背景:
剪映出海,產(chǎn)品需要在不同語言環(huán)境下驗(yàn)收UI,手機(jī)切換語言效率較低,因此需要在App內(nèi)支持動(dòng)態(tài)替換語言提高產(chǎn)品/設(shè)計(jì)同學(xué)驗(yàn)收效率,這套方案亦可作為App內(nèi)設(shè)置語言方案。
替換語言意味著什么?
我們知道Context里是能夠通過?getResources?函數(shù)獲取當(dāng)前上下文對(duì)應(yīng)的資源,然后就可以通過getString獲得對(duì)應(yīng)的文案。
而getString會(huì)返回?getText(id).toString();?
//android.content.res.Resources#getText(int)
@NonNull public CharSequence getText(@StringRes int id) throws NotFoundException {
CharSequence res = mResourcesImpl.getAssets().getResourceText(id);
if (res != null) {
return res;
}
throw new NotFoundException("String resource ID #0x"
+ Integer.toHexString(id));
}
可以看到getText又是通過getAssets()去拿的資源。而ResourcesImpl的mAssets字段又是在實(shí)例化時(shí)賦值。
public ResourcesImpl(@NonNull AssetManager assets, @Nullable DisplayMetrics metrics, @Nullable Configuration config, @NonNull DisplayAdjustments displayAdjustments) {
mAssets = assets;
mMetrics.setToDefaults();
mDisplayAdjustments = displayAdjustments;
mConfiguration.setToDefaults();
updateConfiguration(config, metrics, displayAdjustments.getCompatibilityInfo());
}
public void updateConfiguration(Configuration config, DisplayMetrics metrics,
CompatibilityInfo compat) {
//...
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,
mConfiguration.colorMode, Build.VERSION.RESOURCES_SDK_INT);
//...
}
從上面可以看到,通過Resources去獲取對(duì)應(yīng)語系文案的配置應(yīng)該就是在mConfiguration.getLocales()里配置的了,所以我們?nèi)绻苄薷牡?code>Configuration.mLocaleList字段那應(yīng)該就可以實(shí)現(xiàn)替換語言的功能了。
所以動(dòng)態(tài)替換語言也就意味著動(dòng)態(tài)替換掉context.resources.configuration.mLocaleList的值。
替換語言只需要對(duì)與界面相關(guān)的Context相關(guān),也就是Activity(ContextThemeWapper)的Context,Fragment用的也是Activity的Context。當(dāng)然因?yàn)槌绦騼?nèi)部某些地方會(huì)用到?applicationContext.getResources().getString()?,因此applicationContext的Configuration的Locale配置我們也是需要修改的。
PS:一個(gè)應(yīng)用里面有多少個(gè)Context?答案是:Num Of Activity + Num Of Service + 1(Application),
四大組件中ContentProvider&BroadcastReceiver并不繼承于Context,他們只是使用到了Context來使用上下文環(huán)境。

那么我們需要在什么時(shí)機(jī)去替換Context的內(nèi)部資源配置?
我們需要Application&Activity在?attachBaseContext?,還有Fragment在?attachActivity?時(shí)也需要修改Activity的Configuration。
在程序內(nèi)部的Application/BaseActivity/BaseFragment的?attachBaseContext?/?onAttach?執(zhí)行了以下方法,在運(yùn)行時(shí)語言就會(huì)全局替換了。
//com.vega.launcher.ScaffoldApplication
override fun attachBaseContext(base: Context) {
super.attachBaseContext(AppLanguageUtils.attachBaseContext(base))
}
override fun onCreate() {
AppLanguageUtils.changeAppLanguage(this, AppLanguageUtils.getAppLanguage(this))
}
//com.vega.infrastructure.base.BaseActivity
override fun attachBaseContext(newBase: Context?) {
if (newBase == null) {
super.attachBaseContext(newBase)
} else {
super.attachBaseContext(AppLanguageUtils.attachBaseContext(newBase))
}
}
//com.vega.ui.BaseFragment
override fun onAttach(context: Context) {
super.onAttach(AppLanguageUtils.attachBaseContext(context))
}
//其實(shí)重點(diǎn)是這個(gè)方法,一般都需要走到這里
//因?yàn)閒ragment的getContext會(huì)拿對(duì)應(yīng)activity做context
override fun onAttach(activity: Activity) {
AppLanguageUtils.onFragmentAttach(activity)
super.onAttach(activity)
}
為什么Fragment里的UI沒有替換語言?
Fragment需要在?onAttach(activity: Activity)?時(shí)修改一下Activity的配置的原因是因?yàn)槲覀兊?getResource?方法內(nèi)部調(diào)用了?getResourceInternal?方法,這個(gè)并不一定會(huì)在fragment實(shí)例化UI之前調(diào)用,在一開始的時(shí)候就因?yàn)檫@部分踩了坑,如果在Activity里面沒有使用到?getResource?方法的話,而UI都在Fragment實(shí)現(xiàn),就會(huì)導(dǎo)致嵌套Fragment的Activity部分UI是替換了語言的,而Fragment對(duì)應(yīng)的UI語言沒替換,所以我們需要在onAttacth的時(shí)候去修改一下Activity的語系配置。?getResourceInternal?方法如下所示:
//android.view.ContextThemeWrapper#getResourcesInternal
private Resources getResourcesInternal() {
if (mResources == null) {
if (mOverrideConfiguration == null) {
mResources = super.getResources();
} else {
final Context resContext = createConfigurationContext(mOverrideConfiguration);
mResources = resContext.getResources();
}
}
return mResources;
}
我們?yōu)槭裁词窃赼ttachBaseContext時(shí)替換Context?
看ContextWrapper的源碼,我們可以看到是mBase是在?attachBaseContext?里賦值的,這就是為什么我們需要在子類的?attachBaseContext?方法里調(diào)用?super.attachBaseContext?替換掉父類方法參數(shù)的base。
public class ContextWrapper extends Context {
Context mBase;
public ContextWrapper(Context base) {
mBase = base;
}
protected void attachBaseContext(Context base) {
if (mBase != null) {
throw new IllegalStateException("Base context already set");
}
mBase = base;
}
@Override
public Resources getResources() {
return mBase.getResources();
}
@Override
public Context createConfigurationContext(Configuration overrideConfiguration) {
return mBase.createConfigurationContext(overrideConfiguration);
}
}
至于Context怎么拷貝個(gè)新的出來,可以使用:
android.content.ContextWrapper#createConfigurationContext
我們目前使用的替換方案
目前我們使用的替換方法,只有在Android N以上才執(zhí)行了更新語言的操作,主要有用的方法就是?onFragmentAttach? & ?updateResources?,其實(shí)做的事情就是把context.resources.configuration獲取出來,修改一下Locale,調(diào)用configuration的setLocale&setLocales修改成自己需要的語系。
我們看看AppLanguageUtils.attachBaseContext(base)方法還有onFragmentAttach方法到底做了什么:
//com.vega.infrastructure.util.AppLanguageUtils
fun attachBaseContext(context: Context): Context {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val appLanguage = getAppLanguage(context)
if (TextUtils.isEmpty(appLanguage)) {
context
} else {
updateResources(context, appLanguage)
}
} else {
context
}
}
fun onFragmentAttach(activity: Activity) {
val config = activity.resources.configuration
val dm = activity.resources.displayMetrics
val locale = getLocaleByLanguage(getAppLanguage(activity))
config.setLocale(locale)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
config.setLocales(LocaleList(locale))
}
activity.resources.updateConfiguration(config, dm)
}
@TargetApi(Build.VERSION_CODES.N)
private fun updateResources(
context: Context,
language: String
): Context {
val resources = context.resources
val locale = getLocaleByLanguage(language)
val configuration = resources.configuration
configuration.setLocale(locale)
configuration.setLocales(LocaleList(locale))
return context.createConfigurationContext(configuration)
}
Configuration的源碼如下,?locale?&?mLocaleList?就是在?resource.getString?的時(shí)候作為參數(shù)傳入,才實(shí)現(xiàn)了從不同的Locale獲取不同的語言文案。至于最終的?getString?會(huì)走到?AssetManager?的native源碼中獲取,這里就不細(xì)入研究了,我們只需要做到能替換context.resources.configuration.mLocaleList的值就可以了。
這里mLocaleList是Android N以上新加入的配置,在Android N以上語言可以配置一個(gè)列表,類似于巴西地區(qū)可以用葡萄牙語作為第一語言,英語作為第二語言,假設(shè)APP沒有適配葡萄牙語言但適配了英語,這時(shí)候系統(tǒng)就會(huì)fallback到mLocalList[1]也就是英語配置,如果還沒有就會(huì)繼續(xù)往下fallback,最后都沒有就顯示app默認(rèn)資源語言了。
package android.content.res;
public final class Configuration implements Parcelable, Comparable<Configuration> {
@Deprecated public Locale locale;
private LocaleList mLocaleList;
public void setLocales(@Nullable LocaleList locales) {
mLocaleList = locales == null ? LocaleList.getEmptyLocaleList() : locales;
locale = mLocaleList.get(0);
setLayoutDirection(locale);
}
public void setLocale(@Nullable Locale loc) {
setLocales(loc == null ? LocaleList.getEmptyLocaleList() : new LocaleList(loc));
}
}
另外一種系統(tǒng)支持的替換語言方法?
我們知道Activity都繼承于ContextThemeWapper,可以看到ContextThemeWapper內(nèi)部有個(gè)mResources字段,還有個(gè)mOverrideConfiguration成員變量,可以看到當(dāng)mOverrideConfiguration不為null時(shí),getResourcesInternal實(shí)際上會(huì)從這個(gè)mOverrideConfiguration復(fù)寫配置上去取資源,所以原則上我們也是可以通過在activity獲取資源之前調(diào)用public方法applyOverrideConfiguration去配置一個(gè)新語言的復(fù)寫配置,讓獲取語言時(shí)通過這個(gè)新語言配置來獲取,理論上也一樣可以達(dá)到效果。
public class ContextThemeWrapper extends ContextWrapper {
private int mThemeResource;
private Resources.Theme mTheme;
private LayoutInflater mInflater;
private Configuration mOverrideConfiguration;
private Resources mResources;
@Override
public Resources getResources() {
return getResourcesInternal();
}
private Resources getResourcesInternal() {
if (mResources == null) {
if (mOverrideConfiguration == null) {
mResources = super.getResources();
} else {
final Context resContext = createConfigurationContext(mOverrideConfiguration);
mResources = resContext.getResources();
}
}
return mResources;
}
public void applyOverrideConfiguration(Configuration overrideConfiguration){
if (mResources != null) {
throw new IllegalStateException(
"getResources() or getAssets() has already been called");
}
if (mOverrideConfiguration != null) {
throw new IllegalStateException("Override configuration has already been set");
}
mOverrideConfiguration = new Configuration(overrideConfiguration);
}
附錄
貼一下我們用到的AppLanguageUtil的代碼,拷貝一下這個(gè)類,然后在Application/BaseActivity/BaseFragment的?attachBaseContext?/?onAttach?執(zhí)行了一下對(duì)應(yīng)方法,在運(yùn)行時(shí)語言就會(huì)全局替換了,具體可以參考第二節(jié)。
package com.vega.infrastructure.util
import android.annotation.TargetApi
import android.app.Activity
import android.content.Context
import android.os.Build
import android.os.LocaleList
import android.text.TextUtils
import android.util.Log
import java.util.HashMap
import java.util.Locale
/**
* @author xiedejun
*/
object AppLanguageUtils {
private const val TAG = "AppLanguageUtils"
private const val STORAGE_PREFERENCE_NAME = "language_pref_storage"
private const val PREF_KET_LANGUAGE = "key_language"
private val mAllLanguages: HashMap<String, Locale> =
object : HashMap<String, Locale>(7) {
init {
put("en", Locale.ENGLISH)
put("zh", Locale.SIMPLIFIED_CHINESE)
put("zh-TW", Locale.TRADITIONAL_CHINESE)
put("zh-Hant-TW", Locale.TRADITIONAL_CHINESE)
put("ko", Locale.KOREA)
put("ja", Locale.JAPAN)
// put("hi", Locale("hi", "IN"))
// put("in", Locale("in", "ID"))
// put("vi", Locale("vi", "VN"))
put("th", Locale("th", "TH"))
put("pt", Locale("pt", "BR"))
}
}
fun changeAppLanguage(
context: Context,
newLanguage: String
) {
val resources = context.resources
val configuration = resources.configuration
// app locale
val locale = getLocaleByLanguage(newLanguage)
configuration.setLocale(locale)
// updateConfiguration
val dm = resources.displayMetrics
resources.updateConfiguration(configuration, dm)
}
private fun isSupportLanguage(language: String): Boolean {
return mAllLanguages.containsKey(language)
}
fun setAppLanguage(context: Context, locale: Locale) {
val sharedPreferences =
context.getSharedPreferences(STORAGE_PREFERENCE_NAME, Context.MODE_PRIVATE)
sharedPreferences.edit().putString(PREF_KET_LANGUAGE, locale.toLanguageTag()).apply()
}
fun getAppLanguage(context: Context): String {
val sharedPreferences =
context.getSharedPreferences(STORAGE_PREFERENCE_NAME, Context.MODE_PRIVATE)
val language = sharedPreferences.getString(PREF_KET_LANGUAGE, "")
Log.i(TAG, "lzl app language=$language")
return if (isSupportLanguage(language ?: "")) {
language ?: ""
} else ""
}
/**
* 獲取指定語言的locale信息,如果指定語言不存在[.mAllLanguages],返回本機(jī)語言,如果本機(jī)語言不是語言集合中的一種[.mAllLanguages],返回英語
*
* @param language language
* @return
*/
fun getLocaleByLanguage(language: String): Locale {
return if (isSupportLanguage(language)) {
mAllLanguages[language] ?: Locale.getDefault()
} else {
val locale = Locale.getDefault()
if (TextUtils.isEmpty(language)) {
return locale
}
for (key in mAllLanguages.keys) {
if (TextUtils.equals(
mAllLanguages[key]!!.language, locale.toLanguageTag()
)) {
return locale
}
}
locale
}
}
fun attachBaseContext(context: Context): Context {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val appLanguage = getAppLanguage(context)
if (TextUtils.isEmpty(appLanguage)) {
context
} else {
updateResources(context, appLanguage)
}
} else {
context
}
}
fun onFragmentAttach(activity: Activity) {
val config = activity.resources.configuration
val dm = activity.resources.displayMetrics
val locale = getLocaleByLanguage(getAppLanguage(activity))
config.setLocale(locale)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
config.setLocales(LocaleList(locale))
}
activity.resources.updateConfiguration(config, dm)
}
@TargetApi(Build.VERSION_CODES.N)
private fun updateResources(
context: Context,
language: String
): Context {
val resources = context.resources
val locale = getLocaleByLanguage(language)
val configuration = resources.configuration
configuration.setLocale(locale)
configuration.setLocales(LocaleList(locale))
return context.createConfigurationContext(configuration)
}
}