本文转自 掘进

关于App国际化,之前有讲到国际化资源、字符换、布局相关,想要了解的猛戳用力抱一下APP国际化。借着本次重构多语言想跟大家聊一下多语言切换,多语言切换对于一款国际化App来讲是重中之重,并非难事,但是若要做好也是一件不容易的事情。

问题

  1. Android N版本适配问题
  2. AndroidX不同版本兼容问题
  3. 一些界面局部适配突然失效
  4. 切换系统导航,更改深色模式导致多语言无法适配
  5. 系统授权弹窗导致ApplicationContext中的Local被还原
  6. 切换语言,系统通知栏显示未翻译,重启后正常
  7. Service服务中Toast不适配
  8. 系统Local.getDefault()之伤,如何正确获取系统当前语言
  9. WebView第一次加载多语言不适配
  10. 系统广播中的获取context中的Local信息显示异常

上面我随手列出了项目中常见遇到的问题,有一些是随着Android版本升级而未做出相应兼容性调整造成的,有一些则是局部失效寻找原因所得。我们先了解下应用中一般多语言切换适配的方案,从中会提到这些问题相应的解决方案。

Android 13 语言偏好设置

最近Android 13发布了,讲多语言切换之前,我们先了解一下这个新平台多语言新特性。Android 13 在手机设置页面中新增了一个集中设置应用语言的选项,用于设置各个应用语言首选语言,如果你的应用存在多语言,谷歌强烈建议在设置中进行多语言切换,这样就无须在应用中去做多语言选择切换的功能,页面由设置中的应用语言界面统一管理,具体使用如下:

1. 如何让应用app显示在设置中的应用语言中
  • 创建一个名为 res/xml/locales_config.xml 的文件,并指定您的应用的语言,如下所示:
1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
   <locale android:name="ja"/>
   <locale android:name="fr"/>
   <locale android:name="en"/>
</locale-config>
  • 在清单中,添加一行指向这个新文件的代码:
1
2
3
4
5
6
7
<manifest
    ...
    <application
        ...
        android:localeConfig="@xml/locales_config">
    </application>
</manifest>
2. 如何处理设置中的语言偏好

对于具有或想要使用应用内语言选择器的应用,请使用这些新 API(而非自定义应用逻辑)来处理相关设置和获取用户对应用的首选语言设置。

1
2
3
4
// 如需设置用户的首选语言,您需要让用户在语言选择器中选择语言区域,然后在系统中设置该值
LocaleListCompat appLocale = LocaleListCompat.forLanguageTags("xx-YY");
// Call this on the main thread as it may require Activity.restart()
AppCompatDelegate.setApplicationLocales(appLocale);
  • 使用 Android 框架 API 来实现
1
2
3
4
5
// 1. Inside an activity, in-app language picker gets an input locale "xx-YY"
// 2. App calls the API to set its locale
mContext.getSystemService(LocaleManager.class).setApplicationLocales(newLocaleList(Locale.forLanguageTag("xx-YY")));
// 3. The system updates the locale and restarts the app, including any configuration updates
// 4. The app is now displayed in "xx-YY" language
  • 获取用户当前的首选语言
1
2
3
4
// 1. App calls the API to get the preferred locale
LocaleList currentAppLocales =
    mContext.getSystemService(LocaleManager.class).getApplicationLocales();
// 2. App uses the returned LocaleList to display languages to the user

举个栗子,手机系统如果是中文,设置界面中的应用语言如果首选英文,app如果已启动,那么需要自己监听 onConfigurationChanged来切换应用内部语言,如果未启动,第一次启动时候需要先去读取设置中的语言然后设置给当前应用。亦或是如果你的应用保留了内部切换语言的方案,那么语言切换是也应该调用以上API把当前语言刷到系统设置应用语言中,以保持同步。

讲完Android 13 多语言新特性,想要了解更多猛戳,我们继续回到本文的重点,应用内多语言切换如何去做适配。

多语言适配整体部分

1. application适配

我们为什么要适配Application,原因很简单,对于多语言来讲,我们其实最关心的是切换语言后,界面或者Toast等等显示是否已经翻译成所选择的语言,但是一般我们项目中都会直接或者间接用到ApplicationContext,比如Application中一些三方控件的初始化,还有一些项目中封装的工具类,为了方便全局一次行初始化,有可能甚至用到单例模式,当我们用到ApplicationContxt去getString(@StringRes int id),在切换语言后,如果不重启整个应用或者刷新ApplicaitonContext的local,那么肯定是无效的。
我们在启动APP时候,应该对Application中的context进行当前应用语言Local适配。

1
2
3
4
@Override
protected void attachBaseContext(Context newBase) {
super.attachBaseContext(LanguageUtil.attachBaseContext(newBase));
}

当我们做了系统的配置更改,比如说切换了系统导航或者说更改了深色模式,那么我们一般的处理是也是要对Application作出处理。

1
2
3
4
5
6
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
// 系统资源配置发生更改,例如主题模式,需要重新刷新多语言
LanguageUtil.attachBaseContext(this);
}

如果项目中有用到ApplicationContext去getString(@StringRes int id)实现加载的提示语,那么如果只是单纯的重启界面则无法让当前的提示语跟随当前切换的语言,所以我们要么重启整个应用,要么对ApplicationContext中的Local也作出相应的更新方可,这里有一点问题,虽然Android N之后updateConfiguration是过时方法,官方给出使用createConfigurationContext代替,但是更新ApplicationContext的Local发现无效使用老版本updateConfiguration正常。

1
2
3
4
5
6
7
8
9
10
11
Resources resources = context.getResources();
Configuration configuration = resources.getConfiguration();
Locale locale = getLanguageLocale(newLanguage);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// apply locale
configuration.setLocales(new LocaleList(locale));
} else {
configuration.setLocale(locale);
}
DisplayMetrics dm = resources.getDisplayMetrics();
resources.updateConfiguration(configuration, dm);

如果你发现你的应用广播通知栏适配无效,那就是context中的Local在切换语言是并未及时更新Local,这里调试一下便知,如果是Applicaiton注册的广播,那么多半情况下是没有更新ApplicationContext的Local所导致的。

2. Service适配

如果你的Service有用到Toast提示或者UI相关的东西,你必须要对Service也进行适配,这时候Service中也需要重写attachBaseContext进行语言适配,否则语言适配也是无效的。

1
2
3
4
@Override
protected void attachBaseContext(Context newBase) {
super.attachBaseContext(LanguageUtil.getNewLocalContext(newBase));
}
3. Activity适配

Activity是我们最主要的适配的界面,正常的情况下我们直接在基类BaseActivity中去处理即可,但是值得注意的一点是如果我们使用的是Androidx而非support库,那么不同的版本适配有点区别,这也是官方组件的问题.记得一些第三方界面如果不是继承我们的BaseActivity需要单独处理即可。

1
2
3
4
5
6
7
8
9
@Override
protected void attachBaseContext(Context newBase) {
if(isSupportMultiLanguage()){
// 多语言适配
super.attachBaseContext(LanguageUtil.getNewLocalContext(newBase));
}else {
super.attachBaseContext(newBase);
}
}

多语言适配基本步骤大概就是如此了,下面看一下适配的细节问题。

适配部分细节

1. Android N 适配

Android N开始,由于系统的API变更,updateConfiguration已经被沦为过时的方法。但是有一点需要大家注意,网上几乎全部的判断都是有问题的,API已经明确说明是在API25过时的,不等价于Build.VERSION_CODES.N,所以你的项目用对了嘛,详情可参考下图。

还有一点Android N之后,手机系统的语言配置选项已经不是单选了,改为一个列表了,具体可以参考手机设置中的语言和输入法,所以setLocal(@Nullable Locale loc)方法建议不要再使用了,我相信很多人还在用,正确的用法应该是setLocals(@Nullable LocaleList locales),需要传递一个集合。

1
2
3
4
5
6
7
8
public static Context attachBaseContext(Context context) {
String language = LanguageSp.getLanguage(context);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
return createConfigurationContext(context, language);
} else {
return updateConfiguration(context, language);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
// 注意此处不是Build.VERSION_CODES.N
@RequiresApi(api = Build.VERSION_CODES.N_MR1)
private static Context createConfigurationContext(Context context, String language) {
Resources resources = context.getResources();
Configuration configuration = resources.getConfiguration();
Locale locale = getLanguageLocale(language);
Log.d(TAG, "current Language locale = " + locale);
LocaleList localeList = new LocaleList(locale);
// 注意此处setLocales
configuration.setLocales(localeList);
return context.createConfigurationContext(configuration);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static Context updateConfiguration(Context context, String language) {
Resources resources = context.getResources();
Configuration configuration = resources.getConfiguration();
Locale locale = getLanguageLocale(language);
Log.e(TAG, "updateLocalApiLow==== " + locale.getLanguage());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// apply locale 注意此处是setLocales
configuration.setLocales(new LocaleList(locale));
} else {
// updateConfiguration
configuration.locale = locale;
DisplayMetrics dm = resources.getDisplayMetrics();
resources.updateConfiguration(configuration, dm);
}
return context;
}
2. 关于AndroidX版本兼容问题

当你的应用使用的是androidx.appcompat:appcompat:1.1.0时,BaseActivity中需要实现下面方法

1
2
3
4
5
6
7
8
9
10
@Override
public void applyOverrideConfiguration(Configuration overrideConfiguration) {
// 兼容androidX在部分手机切换语言失败问题
if (overrideConfiguration != null) {
int uiMode = overrideConfiguration.uiMode;
overrideConfiguration.setTo(getBaseContext().getResources().getConfiguration());
overrideConfiguration.uiMode = uiMode;
}
super.applyOverrideConfiguration(overrideConfiguration);
}

当你的应用使用的是androidx.appcompat:appcompat:1.2.0及以上时,BaseActivity中需要实现下面方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
protected void attachBaseContext(Context newBase) {
if (isSupportMultiLanguage()) {
String language = LanguageSp.getLanguage(newBase);
Context context = LanguageUtil.attachBaseContext(newBase, language);
final Configuration configuration = context.getResources().getConfiguration();
final ContextThemeWrapper wrappedContext = new ContextThemeWrapper(context,
R.style.Theme_AppCompat_Empty) {
@Override
public void applyOverrideConfiguration(Configuration overrideConfiguration) {
if (overrideConfiguration != null) {
overrideConfiguration.setTo(configuration);
}
super.applyOverrideConfiguration(overrideConfiguration);
}
};
super.attachBaseContext(wrappedContext);
} else {
super.attachBaseContext(newBase);
}
}
3. 系统授权弹框导致Local失效

我们惊奇的发现,当我们首次进入APP选择语言后,当首页检查系统权限弹框的时候,Local被莫名其妙的重置了,我在想,可能因为google授权弹框他有自己的多语言翻译,所以不会采取我们的,所以把ApplicationContext中的Local给重置了,所以当我们点击允许或者仅在使用此应用时允许后需要再次把Application中的Local修改掉。

1
2
3
4
5
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
// 更新Application中的local
LanguageUtil.updateApplicationLocale(AppApplication.getAppContext(),LanguageSp.getLanguage(mContext))
}
4. 如何真正的获取系统语言

我们有可能会存在这个场景,当我们的APP不跟随系统语言的时候,使用的APP内部语言,我们去检测系统语言的时候如何去判断,是不是很多人在此跌倒了,无论是Local.getDefault()还是LocalList.get(0)始终获取的语言是错误的,应该通过以下渠道获取当前的系统语言。

1
2
3
4
5
6
7
8
9
// 第一种方式
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return Resources.getSystem().getConfiguration().getLocales().get(0).getLanguage();//解决了获取系统默认错误的问题
} else {
return Locale.getDefault().getLanguage();
}
// 第二种方式(推荐)
return ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()).get(0).getLanguage();

5. 关于WebView适配

在原来的低版本切换语言中,我们会发现WebView第一次加载时,适配是无效的,再次加载则正常适配,所以网上也有了一道方案如下:

1
2
3
4
5
@Override public void onCreate(Bundle savedInstanceState) { 
// TODO 解决含有webView控件导致切换语言失效
~~new WebView(this).destroy(); ~~
super.onCreate(savedInstanceState);
}

这套方案目前不在推荐,直接去替换attatchBaseContext()中的context则可,经过测试是完全正常的。

工具类

以下则是多语言操作的工具类,现在提供出来,需要的朋友可以自行进行改造。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
/**
* @author : le.hu
* e-mail : 暂无
* time : 2021/11/26/16:08
* desc : 多语言适配方案,适配各种版本,核心未替换上下文Context中的Local
*/
public class LanguageUtil {

private static final String TAG = "LanguageUtil";

/**
* 默认支持的语言,英语、法语、阿拉伯语
*/
private static HashMap<String, Locale> supportLanguage = new HashMap<String, Locale>(4) {{
put(Language.ENGLISH, Locale.ENGLISH);
put(Language.FRANCE, Locale.FRANCE);
put(Language.ARABIC, new Locale("ar", "", ""));
}};

/**
* 应用多语言切换,重写BaseActivity中的attachBaseContext即可
* 采用本地SP存储的语言
*
* @param context 上下文
* @return context
*/
public static Context attachBaseContext(Context context) {
String language = LanguageSp.getLanguage(context);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
return createConfigurationContext(context, language);
} else {
return updateConfiguration(context, language);
}
}

/**
* 应用多语言切换,重写BaseActivity中的attachBaseContext即可
*
* @param context 上下文
* @param language 语言
* @return context
*/
public static Context attachBaseContext(Context context, String language) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
return createConfigurationContext(context, language);
} else {
return updateConfiguration(context, language);
}
}

/**
* 获取Local,根据language
*
* @param language 语言
* @return Locale
*/
private static Locale getLanguageLocale(String language) {
if (supportLanguage.containsKey(language)) {
return supportLanguage.get(language);
} else {
Locale systemLocal = getSystemLocal();
for (String languageKey : supportLanguage.keySet()) {
if (TextUtils.equals(supportLanguage.get(languageKey).getLanguage(), systemLocal.getLanguage())) {
return systemLocal;
}
}
}
return Locale.ENGLISH;
}

/**
* 获取当前的Local,默认英语
*
* @param context context
* @return Locale
*/
public static Locale getCurrentLocale(Context context) {
String language = LanguageSp.getLanguage(context);
if (supportLanguage.containsKey(language)) {
return supportLanguage.get(language);
} else {
Locale systemLocal = getSystemLocal();
for (String languageKey : supportLanguage.keySet()) {
if (TextUtils.equals(supportLanguage.get(languageKey).getLanguage(), systemLocal.getLanguage())) {
return systemLocal;
}
}
}
return Locale.ENGLISH;
}

/**
* 获取系统的Local
*
* @return Locale
*/
private static Locale getSystemLocal() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return Resources.getSystem().getConfiguration().getLocales().get(0);
} else {
return Locale.getDefault();
}
}

/**
* Android 7.1 以下通过 updateConfiguration
*
* @param context context
* @param language 语言
* @return Context
*/
private static Context updateConfiguration(Context context, String language) {
Resources resources = context.getResources();
Configuration configuration = resources.getConfiguration();
Locale locale = getLanguageLocale(language);
Log.e(TAG, "updateLocalApiLow==== " + locale.getLanguage());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// apply locale
configuration.setLocales(new LocaleList(locale));
} else {
// updateConfiguration
configuration.locale = locale;
DisplayMetrics dm = resources.getDisplayMetrics();
resources.updateConfiguration(configuration, dm);
}
return context;
}

/**
* Android 7.1以上通过createConfigurationContext
* N增加了通过config.setLocales去修改多语言
*
* @param context 上下文
* @param language 语言
* @return context
*/
@RequiresApi(api = Build.VERSION_CODES.N_MR1)
private static Context createConfigurationContext(Context context, String language) {
Resources resources = context.getResources();
Configuration configuration = resources.getConfiguration();
Locale locale = getLanguageLocale(language);
Log.d(TAG, "current Language locale = " + locale);
LocaleList localeList = new LocaleList(locale);
configuration.setLocales(localeList);
return context.createConfigurationContext(configuration);
}

/**
* 切换语言
*
* @param language 语言
* @param activity 当前界面
* @param cls 跳转的界面
*/
public static void switchLanguage(String language, Activity activity, Class<?> cls) {
LanguageSp.setLanguage(activity, language);
Intent intent = new Intent(activity, cls);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
activity.startActivity(intent);
activity.finish();
}

/**
* 切换语言,携带传递数据
*
* @param language 语言
* @param activity 当前界面
* @param cls 跳转的界面
*/
public static void switchLanguage(String language, Activity activity, Class<?> cls, Bundle bundle) {
LanguageSp.setLanguage(activity, language);
Intent intent = new Intent(activity, cls);
if (bundle != null) {
intent.putExtras(bundle);
}
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
activity.startActivity(intent);
activity.finish();
}

/**
* 获取新语言的 Context,修复了androidx.appCompact 1.2.0的问题
*
* @param newBase newBase
* @return Context
*/
public static Context getNewLocalContext(Context newBase) {
try {
// 多语言适配
Context context = LanguageUtil.attachBaseContext(newBase);
// 兼容appcompat 1.2.0后切换语言失效问题
final Configuration configuration = context.getResources().getConfiguration();
return new ContextThemeWrapper(context, R.style.Theme_AppCompat_Empty) {
@Override
public void applyOverrideConfiguration(Configuration overrideConfiguration) {
if (overrideConfiguration != null) {
overrideConfiguration.setTo(configuration);
}
super.applyOverrideConfiguration(overrideConfiguration);
}
};
} catch (Exception e) {
e.printStackTrace();
}
return newBase;
}

/**
* 更新Application的Resource local,应用不重启的情况才调用,因为部分会用到application中的context
* 切记不能走新api createConfigurationContext,亲测
* @param context context
* @param newLanguage newLanguage
*/
public static void updateApplicationLocale(Context context, String newLanguage) {
Resources resources = context.getResources();
Configuration configuration = resources.getConfiguration();
Locale locale = getLanguageLocale(newLanguage);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// apply locale
configuration.setLocales(new LocaleList(locale));
} else {
configuration.setLocale(locale);
}
DisplayMetrics dm = resources.getDisplayMetrics();
resources.updateConfiguration(configuration, dm);
}
}