美文网首页
Android App内动态替换语言

Android App内动态替换语言

作者: 0xCAFEBABE51 | 来源:发表于2020-05-08 19:58 被阅读0次

    背景:

    剪映出海,产品需要在不同语言环境下验收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()去拿的资源。而ResourcesImplmAssets字段又是在实例化时赋值。

    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)ContextFragment用的也是ActivityContext。当然因为程序内部某些地方会用到​applicationContext.getResources().getString()​,因此applicationContextConfigurationLocale配置我们也是需要修改的。

    PS:一个应用里面有多少个Context?答案是:Num Of Activity + Num Of Service + 1(Application),

    四大组件中ContentProvider&BroadcastReceiver并不继承于Context,他们只是使用到了Context来使用上下文环境。

    Context相关类

    那么我们需要在什么时机去替换Context的内部资源配置?

    我们需要Application&Activity在​attachBaseContext​,还有Fragment​attachActivity​时也需要修改ActivityConfiguration

    在程序内部的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<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));
        }
    }
    
    

    另外一种系统支持的替换语言方法?

    我们知道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<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],返回本机语言,如果本机语言不是语言集合中的一种[.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)
        }
    }
    

    相关文章

      网友评论

          本文标题:Android App内动态替换语言

          本文链接:https://www.haomeiwen.com/subject/xysqnhtx.html