Android App内动态替换语言

背景:

剪映出海,产品需要在不同语言环境下验收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)
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342