Android7.0以上多系统语言的国际化适配

前言

近期偶然发现一个问题,我们的应用在7.0以上的个别机型上,会遇到国际化不对的问题,现象是:手机明明设置了中文,应用却可能显示成英文。

问题分析

问题机型:三星s8 plus
系统版本:Android7.0
系统语言设置:首选 - 中文简体、次选 - 日语、第三顺位 - 英语
应用支持的资源配置:values - 中文资源、values-en - 英文资源

我们国际化策略很明确,除非用户系统语言设置成英文,否则应用都显示为中文,那么在上述系统语言设置条件下,我们希望应用显示的是中文(values),可实际在上述机型上显示为英文。在7.0以下的系统上都正常。

那调查方向很明显了,7.0以上系统增加多语言支持导致的!也就是说,7.0之前的Android只支持设置单一系统语言,但是7.0开始,支持设置多个系统语言,按优先级排列。但这个问题并不是所有机型都能复现,比如我手上这台小米MIX2,虽然是8.0系统,但是MIUI限制只能设置单一的系统语言,就不会有问题。

原因

7.0开始,API获取到的系统语言不再是单一语言,而是一个列表,并且默认情况下列表的顺序是经API调整过的。调整策略大概是根据用户实际设置的系统语言优先级结合应用本身提供的资源种类做调整,而加载页面时会根据Context中保存的地域语言信息(Context → Resource → Configuration → Locale)选择资源文件,正是获取Locale的API发生了变化导致这个现象。

测试

接下来通过以下代码来测试一下:

/** 
 * 获取系统首选语言 
 * 
 * @return 
 */  
public Locale getSysPreferredLocale() {  
    Locale locale;  
    //7.0以下直接获取系统默认语言  
    if (Build.VERSION.SDK_INT < 24) {  
        // 等同于context.getResources().getConfiguration().locale;  
        locale = Locale.getDefault();  
    // 7.0以上获取系统首选语言  
    } else {  
        LocaleList defaultLocaleList = LocaleList.getDefault();  
        StringBuffer sb0 = new StringBuffer();  
        for (int i = 0; i < defaultLocaleList.size(); i++) {  
            sb0.append(defaultLocaleList.get(i));  
            sb0.append(",");  
        }  
        Log.d("jackie", "LocaleList.getDefault()        : " + sb0.toString());  
  
        LocaleList list = context.getResources().getConfiguration().getLocales();  
        StringBuffer sb1 = new StringBuffer();  
        for (int i = 0; i < list.size(); i++) {  
            sb1.append(list.get(i));  
            sb1.append(",");  
        }  
        Log.d("jackie", "Configuration.getLocales()     : " + sb1.toString());  
  
        LocaleList adjustedDefaultLocaleList = LocaleList.getAdjustedDefault();  
        StringBuffer sb2 = new StringBuffer();  
        for (int i = 0; i < adjustedDefaultLocaleList.size(); i++) {  
            sb2.append(adjustedDefaultLocaleList.get(i));  
            sb2.append(",");  
        }  
        Log.d("jackie", "LocaleList.getAdjustedDefault(): " + sb2.toString());  
  
        locale = defaultLocaleList.get(0);  
    }  
    return locale;  
}

case1:

手机设置:日语 → 英语 → 中文简体

输出:
04-16 19:41:21.382 13510-13510/? D/jackie: LocaleList.getDefault()        : ja_JP,en_US,zh_CN_#Hans,  
04-16 19:41:21.382 13510-13510/? D/jackie: Configuration.getLocales()     : en_US,ja_JP,zh_CN_#Hans,  
04-16 19:41:21.382 13510-13510/? D/jackie: LocaleList.getAdjustedDefault(): en_US,ja_JP,zh_CN_#Hans,  

case2:

手机设置:英语 → 日语 → 中文简体

输出:
04-16 19:50:02.543 24004-24004/? D/jackie: LocaleList.getDefault()        : en_US,ja_JP,zh_CN_#Hans,  
04-16 19:50:02.543 24004-24004/? D/jackie: Configuration.getLocales()     : en_US,ja_JP,zh_CN_#Hans,  
04-16 19:50:02.543 24004-24004/? D/jackie: LocaleList.getAdjustedDefault(): en_US,ja_JP,zh_CN_#Hans,  

case3:

手机设置:中文简体 → 日语 → 英语

输出:
04-16 19:53:30.618 28803-28803/? D/jackie: LocaleList.getDefault()        : zh_CN_#Hans,ja_JP,en_US,  
04-16 19:53:30.618 28803-28803/? D/jackie: Configuration.getLocales()     : en_US,zh_CN_#Hans,ja_JP,  
04-16 19:53:30.618 28803-28803/? D/jackie: LocaleList.getAdjustedDefault(): en_US,zh_CN_#Hans,ja_JP,  

结论

7.0以下获取系统语言的方式:

  1. context.getResources().getConfiguration().locale
  2. Locale.getDefault()

这两种效果等价。

7.0以上获取系统语言有3种方式:

  1. context.getResources().getConfiguration().getLocales().get(0)
  2. LocaleList.getAdjustedDefault().get(0)
  3. LocaleList.getDefault().get(0)

其中,(1)(2)等价,都是获取经API调整过的系统语言列表,并且应用启动时Android系统默认使用该方式,而(3)是获取系统实际的语言列表,如果单是获取首选语言的话,使用(3)的效果与7.0以前的API结果无差别。

关于(2)请看官方注释:

    /**
     * The result is guaranteed to include the default Locale returned by Locale.getDefault(), but
     * not necessarily at the top of the list. The default locale not being at the top of the list
     * is an indication that the system has set the default locale to one of the user's other
     * preferred locales, having concluded that the primary preference is not supported but a
     * secondary preference is.
     *
     * 

Note that the default LocaleList would change if Locale.setDefault() is called. This * method takes that into account by always checking the output of Locale.getDefault() and * recalculating the default LocaleList if needed.

*/

解决方案

1.让用户修改系统语言设置:

仍然以我们的应用为例,只要用户删除“英语”的系统语言 ,就一定会显示为中文;而只要系统语言中有“英语”(无所谓第几顺位),应用就一定会显示为英文。但这个方案明显是不可取的。

2.增加一套中文资源(values-zh):

在values、values-en的基础上再增加values-zh,那么显示效果就有以下情况:

  1. 系统首选语言为英文:应用显示为英文(values-en)
  2. 系统首选语言为中文:应用显示为中文(values-zh)
  3. 系统首选语言为其他:这时候还要根据非首选语言来分情况。非首选中含有英文和中文,谁在前就显示为谁;非首选中不和英文和中文,那么应用显示为中文(values)。

该方案比较简单,不用做其他处理,但是需要增加一套资源。由于我们是SDK类产品,所以最终选择了该方案,原因后面会提到。

3.代码上做7.0的国际化适配:

思路是在页面加载前通过程序动态改变Context中的Locale设置。推荐应用开发使用该方式,但SDK类产品会遇到一些问题,后面会说明。接下来给出该方案的实现。
4.这个方案有点出乎意料,我在文章的最后再给出来,请先看如何从代码上适配。

7.0以上系统国际化适配

首先需要一个管理系统语言的工具类:

/**
 * Created by weishj on 2018/4/13.
 */
public class MultiLanguageUtil {
    private static final String TAG = "MultiLanguageUtil";
    private static volatile MultiLanguageUtil instance;

    private MultiLanguageUtil() {}

    public static MultiLanguageUtil getInstance() {
        if (instance == null) {
            synchronized (MultiLanguageUtil.class) {
                if (instance == null) {
                    instance = new MultiLanguageUtil();
                }
            }
        }
        return instance;
    }

    /**
     * 设置语言信息
     *
     * 说明:
     * 该方法建议在attachBaseContext和onConfigurationChange中调用,attachBaseContext可以保证页面加载时修改语言信息,
     * 而onConfigurationChange则是为了对应横竖屏切换时系统更新Resource的问题
     *
     * @param context application context
     */
    public void setConfiguration(Context context) {
        if (context == null) {
            Log.e(TAG, "No context, MultiLanguageUtil will not work!");
            return;
        }
        /*
         * 为防止传入非ApplicationContext,这里做一次强制转化,目的是避免onConfigurationChange可能导致的问题,
         * 因为onConfigurationChange被触发时系统会更新ApplicationContext中的Resource,如果页面包含Runtime资源
         * (运行时动态加载的资源)时,有可能语言显示不一致。
         */
        Context appContext = context.getApplicationContext();
        Locale preferredLocale = getSysPreferredLocale();
        Log.d(TAG, "Set to preferred locale: " + preferredLocale);
        Configuration configuration = appContext.getResources().getConfiguration();
        if (Build.VERSION.SDK_INT >= 17) {
            configuration.setLocale(preferredLocale);
        } else {
            configuration.locale = preferredLocale;
        }
        // 更新context中的语言设置
        Resources resources = appContext.getResources();
        DisplayMetrics dm = resources.getDisplayMetrics();
        resources.updateConfiguration(configuration, dm);
    }

    /**
     * 获取系统首选语言
     *
     * 注意:该方法获取的是用户实际设置的不经API调整的系统首选语言
     *
     * @return
     */
    private Locale getSysPreferredLocale() {
        Locale locale;
        //7.0以下直接获取系统默认语言
        if (Build.VERSION.SDK_INT < 24) {
            // 等同于context.getResources().getConfiguration().locale;
            locale = Locale.getDefault();
        // 7.0以上获取系统首选语言
        } else {
            /*
             * 以下两种方法等价,都是获取经API调整过的系统语言列表(可能与用户实际设置的不同)
             * 1.context.getResources().getConfiguration().getLocales()
             * 2.LocaleList.getAdjustedDefault()
             */
            // 获取用户实际设置的语言列表
            locale = LocaleList.getDefault().get(0);
        }
        return locale;
    }
}

在应用启动或者页面加载前通过 setConfiguration() 修改Context中的Locale信息即可,具体在哪里调用,又有不同了:

1.Application中调用:
public class MyApplication extends Application {  
    @Override  
    protected void attachBaseContext(Context base) {  
        super.attachBaseContext(base);  
        MultiLanguageUtil.getInstance().setConfiguration(getApplicationContext());  
    }  
  
    @Override  
    public void onConfigurationChanged(Configuration newConfig) {  
        super.onConfigurationChanged(newConfig);  
        MultiLanguageUtil.getInstance().setConfiguration(getApplicationContext());  
    }  
}

推荐使用该方式。如果你是开发自己的应用,那么恭喜你,直接在Application中重写以上两个方法即可, attachBaseContext 用于应用启动时修改context中的locale信息, onConfigurationChanged 用于适配横竖屏切换。因为横竖屏切换属于系统配置信息的更新,此时Android会更新ApplicationContext中的Resource对象(ApplicationContext对象并未新建,只是更新了其中的Resource对象),关于这点,可以参考官方文档的说明:

Handling the Configuration Change Yourself
Now, when one of these configurations change, MyActivity does not restart. Instead, the MyActivity receives a call to onConfigurationChanged(). This method is passed a Configuration object that specifies the new device configuration. By reading fields in the Configuration, you can determine the new configuration and make appropriate changes by updating the resources used in your interface. At the time this method is called, your activity's Resources object is updated to return resources based on the new configuration, so you can easily reset elements of your UI without the system restarting your activity.

2.BaseActivity中调用:

如果你不在Application中调用,而是在BaseActivity(假设这是所有Activity的父类)中调用,也可以,同样是重写这两个方法。

public class BaseActivity extends Activity {  
    protected void attachBaseContext(Context newBase) {  
        super.attachBaseContext(newBase);  
        MultiLanguageUtil.getInstance().setConfiguration(newBase);  
    }  
      
    protected void onConfigurationChanged(Configuration newConfig) {  
        super.onConfigurationChanged(newConfig);  
        MultiLanguageUtil.getInstance().setConfiguration(getApplicationContext());  
    }  
}

但这个方法需要所有Activity都继承BaseActivity,如果有哪个忘记了继承或者根据业务设计,不能继承BaseActivity,那么就有可能出现一些问题,比如SDK类的产品,一般都会以Library的形式发布,开发者至少会有一个自己的MainActivity,在其中调用你的接口跳转你的页面。假设开发者的应用和你的Library同样都只提供两套资源:中文(values)、英文(values-en)。如果开发者的MainActivity没有做上述国际化适配处理,那么当系统语言设置为上述case3的“中文简体 → 日语 → 英语”时,MainActivity会显示为英文,当跳转到你的页面时会显示为中文,当然这也不是大问题,你无法控制开发者提供哪些资源,他的页面按照他的资源来显示,你的页面则按照你提供的资源来显示也很正常。有一个更严重的问题是,这里你修改的是ApplicationContext中的Locale,一旦开发者的应用跳转了你的页面后,ApplicationContext就被你修改了,再回到开发者页面时就有可能对开发者的页面造成影响,一般是对一些Runtime Resource(比如onActivityResult中动态显示的内容)有影响。

什么是Runtime Resource?就是运行时动态显示的资源,一般的页面资源都是加载时绘制一次,之后除非重建页面,就不会重新绘制了,那即便你的页面改变了ApplicationContext的Locale,再回到开发者的页面,他的页面仍然是显示为英文,但如果开发者需要在onActivityResult中接收你回传的某些值,然后根据情况动态显示一些内容,这时候显示出来的内容就会是中文,因为这些内容在显示时需要根据当前ApplicationContext中的Locale来动态判断,另外虽然我没有验证,但可能toast也是一样的。

说明

1.应用的Label名无法控制:

因为上述代码是在应用启动时动态改变ApplicationContext中的Locale,所以只能影响应用启动后的页面,而手机系统中显示的应用名称(icon label)还是会根据系统内默认的首选值显示。

2.AppCompat-v7包会影响上述3种系统API的返回值:

同样以case3的“中文简体 → 日语 → 英语”来测试。

(1)当项目只使用原生Android sdk时,一切都按上面测试结果来的。即getSysPreferredLocale()的输出为

D/jackie: LocaleList.getDefault()        : zh_CN_#Hans,ja_JP,en_US,
D/jackie: Configuration.getLocales()     : en_US,zh_CN_#Hans,ja_JP,
D/jackie: LocaleList.getAdjustedDefault(): en_US,zh_CN_#Hans,ja_JP,

(2)当项目引用了AppCompat-v7包后(即便你的所有Activity继承的仍然只是原生的Activity,而非AppCompatActivity),

compile 'com.android.support:appcompat-v7:25.3.1'

结果就变了,3种系统语言获取方式返回的都是系统实际的语言列表。此时getSysPreferredLocale()的输出为

D/jackie: LocaleList.getDefault()        : zh_CN_#Hans,ja_JP,en_US,
D/jackie: Configuration.getLocales()     : zh_CN_#Hans,ja_JP,en_US,
D/jackie: LocaleList.getAdjustedDefault(): zh_CN_#Hans,ja_JP,en_US,

这也就是前面留笔的方案4:引用AppCompat-v7包。大概google在这个包里做了处理,屏蔽了系统根据应用提供的资源调整语言列表的功能,相当于让一切回到7.0以前的版本。但如果你的项目明确规定不能使用该包的话,那就只能从方案2、3中选了。

3.认真考虑你究竟要不要屏蔽新系统对应用的国际化所做的该项优化处理:

既然7.0以上系统对API进行了优化,会根据应用支持的语言动态调整获取到的首选语言,就一定有这样设计的道理。用户已经明确告知你,他的首选语言习惯、以及次选语言习惯,那么当你的应用找不到首选语言资源却正好支持用户的次选语言时,改用次选语言(而不是直接使用默认的资源)来加载应用,对用户也是一种更加友好的体验,这么看来,还是方案2更加可取,就连系统的语言设置页面也做了很明确的说明,至于方案3,就是提供给其他开发者更多的选择。

你可能感兴趣的:(Android7.0以上多系统语言的国际化适配)