背景:
剪映出海,产品需要在不同语言环境下验收UI,手机切换语言效率较低,因此需要在App内支持动态替换语言提高产品/设计同学验收效率,这套方案亦可作为App内设置语言方案。
替换语言意味着什么?
我们知道Context
里是能够通过getResources
函数获取当前上下文对应的资源,然后就可以通过getString
获得对应的文案。
而getString
会返回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
字段又是在实例化时赋值。
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
去获取对应语系文案的配置应该就是在mConfiguration.getLocales()
里配置的了,所以我们如果能修改掉Configuration.mLocaleList
字段那应该就可以实现替换语言的功能了。
所以动态替换语言也就意味着动态替换掉context.resources.configuration.mLocaleList
的值。
替换语言只需要对与界面相关的Context
相关,也就是Activity(ContextThemeWapper)
的Context
,Fragment
用的也是Activity
的Context
。当然因为程序内部某些地方会用到applicationContext.getResources().getString()
,因此applicationContext
的Configuration
的Locale
配置我们也是需要修改的。
PS:一个应用里面有多少个Context?答案是:Num Of Activity + Num Of Service + 1(Application),
四大组件中ContentProvider
&BroadcastReceiver
并不继承于Context
,他们只是使用到了Context
来使用上下文环境。
那么我们需要在什么时机去替换Context的内部资源配置?
我们需要Application
&Activity
在attachBaseContext
,还有Fragment
在attachActivity
时也需要修改Activity
的Configuration
。
在程序内部的Application
/BaseActivity
/BaseFragment
的attachBaseContext
/onAttach
执行了以下方法,在运行时语言就会全局替换了。
//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))
}
//其实重点是这个方法,一般都需要走到这里
//因为fragment的getContext会拿对应activity做context
override fun onAttach(activity: Activity) {
AppLanguageUtils.onFragmentAttach(activity)
super.onAttach(activity)
}
为什么Fragment里的UI没有替换语言?
Fragment需要在onAttach(activity: Activity)
时修改一下Activity的配置的原因是因为我们的getResource
方法内部调用了getResourceInternal
方法,这个并不一定会在fragment实例化UI之前调用,在一开始的时候就因为这部分踩了坑,如果在Activity里面没有使用到getResource
方法的话,而UI都在Fragment实现,就会导致嵌套Fragment的Activity部分UI是替换了语言的,而Fragment对应的UI语言没替换,所以我们需要在onAttacth
的时候去修改一下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;
}
我们为什么是在attachBaseContext时替换Context?
看ContextWrapper的源码,我们可以看到是mBase
是在attachBaseContext
里赋值的,这就是为什么我们需要在子类的attachBaseContext
方法里调用super.attachBaseContext
替换掉父类方法参数的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怎么拷贝个新的出来,可以使用:
android.content.ContextWrapper#createConfigurationContext
我们目前使用的替换方案
目前我们使用的替换方法,只有在Android N以上才执行了更新语言的操作,主要有用的方法就是onFragmentAttach
& updateResources
,其实做的事情就是把context.resources.configuration
获取出来,修改一下Locale,调用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
的时候作为参数传入,才实现了从不同的Locale获取不同的语言文案。至于最终的getString
会走到AssetManager
的native源码中获取,这里就不细入研究了,我们只需要做到能替换context.resources.configuration.mLocaleList
的值就可以了。
这里mLocaleList
是Android N以上新加入的配置,在Android N以上语言可以配置一个列表,类似于巴西地区可以用葡萄牙语作为第一语言,英语作为第二语言,假设APP没有适配葡萄牙语言但适配了英语,这时候系统就会fallback到mLocalList[1]
也就是英语配置,如果还没有就会继续往下fallback,最后都没有就显示app默认资源语言了。
package android.content.res;
public final class Configuration implements Parcelable, Comparable {
@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));
}
}
另外一种系统支持的替换语言方法?
我们知道Activity都继承于ContextThemeWapper
,可以看到ContextThemeWapper
内部有个mResources
字段,还有个mOverrideConfiguration
成员变量,可以看到当mOverrideConfiguration
不为null
时,getResourcesInternal
实际上会从这个mOverrideConfiguration
复写配置上去取资源,所以原则上我们也是可以通过在activity获取资源之前调用public方法applyOverrideConfiguration
去配置一个新语言的复写配置,让获取语言时通过这个新语言配置来获取,理论上也一样可以达到效果。
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的代码,拷贝一下这个类,然后在Application
/BaseActivity
/BaseFragment
的attachBaseContext
/onAttach
执行了一下对应方法,在运行时语言就会全局替换了,具体可以参考第二节。
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 =
object : HashMap(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],返回本机语言,如果本机语言不是语言集合中的一种[.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)
}
}