Android开发&国际化多语言处理

写在前面

Android中的资源文件,在使用时都是根据系统语言来处理的,如果当前环境为英文,则在需要使用字符串等资源时,会自动从values-en类目录中提取,这也是应用国际化的基础

一般的软件中,不会在应用内进行语言环境的切换,默认在系统整体语言发生改变时,界面会进行重启,当然,也可以人为进行拦截操作。

不过由于api一直在变更,针对本地语言的变更处理方式也有了些不同,同样,如果想要在应用内自定义一套语言切换功能,也变得比较的繁琐。

最后的话,也会就ActivityThread类,来简单解读一下,在系统语言环境发生变化时,代码的执行逻辑

一、设定语言切换界面

在app内单独定义语言切换的话,至少得先有切换的功能,支持的语言应当至少有三种:

  1. 中文环境
  2. 英文环境
  3. 跟随系统

在选定了中文环境或者英文环境时,系统语言的切换对该应用本身不起作用,在选定了跟随系统时,则会根据当前系统语言,来动态的调整显示情况。

界面类似这样:

在这里插入图片描述

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中进行更替

ApplicationonCreate方法中,调用以下代码

//设置默认环境
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);
    }
}

``方法很简单,只是获取当前应用的,或者传入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_CHANGEDAPPLICATION_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);
            }
        }
    }
}

从判断语句中可以看到,当 callbackActivity 时,会执行 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()");
        }
    }

    //...
}

可以看到,最后是执行了 activityonConfigurationChanged 方法

-------------- 结语

随着安卓api的提升,已有的功能都可能会有大的更改,类似的问题可能会越来越多,即便是之前看到的源码,虽然大体逻辑不便,但还是可能有细微处的差别