一个多语言需求引发的思考

挺早之前项目中的一个多语言相关的需求,觉得还蛮有意思的,现在想起来决定写一篇文章记录一下。第一部分主要是Android字符串资源加载的源码分析,第二部分是需求的解决方案,只对解决方案感兴趣的直接看第二部分即可。

项目背景

需求描述

由于我们的App用户包括普通公司与各大高校,在文案显示上,同样位置的文案,可能显示的中文/英文都是不同的。例如有个文案,”我的公司”,对于公司来说是没问题的,但是对于学校来说,就会很奇怪。因此同一个文案,需要在学校那边显示”我的学校”,在公司这边显示”我的公司”。

问题分析

Android自带的多语言国际化功能是不能满足我们的需求的,因此需要我们自己去拦截App的字符串资源加载过程,以加载我们自己的字符串资源。

具体的实现细节可以直接看第三节的内容。

Android字符资源加载源码分析

关键类

先看一下关键类的关系图:

image-20190523222408816

再介绍一下几个关键类的作用:

  • ContextWrapper:实现了Context接口,但其中所有方法的实现都最终代理给了内部对象 Context mBase
  • ContextImpl:真正实现了Context接口的类,即ContextWrapper中的mBase。
  • Resources:提供了资源相关的对外接口,但具体的实现依赖于内部对象 ResourcesImpl mResourcesImpl
  • ResourcesImpl:Resources接口的具体实现,实现资源的读取(依赖于AssetManager)与管理。
  • AssetManager:各类资源获取的最终实现类,核心代码为native实现。
类初始化、关键类之间的关系建立

以下的过程都是以Activity的启动过程主线的,Application以及其他组件的初始化也会有类似过程,就不赘述了。

初始化:

  • ContextImpl的初始化:入口在performLaunchActivity()中,最终会调用到ContextImpl.createActivityContext()
  • ResourcesImpl的初始化:随着ContextImpl的初始化,会调用到ResourcesManager的createBaseActivityResources(),最终会调用到ResourcesManager的createResourcesImpl(),完成ResourcesImpl的实例化。
  • AssetManager:在ResourcesImpl实例化之前,会先创建AssetManager,然后作为创建ResourcesImpl的参数

类关系:

从创建过程中,也可以看出来这几个关键类的依赖关系。ContextImpl依赖ResourcesImpl提供资源相关功能,ResourcesImpl依赖AssetManager提供资源获取的具体实现。

而当上述三个关键类都创建完成后,会走到Activity的attach(),最终调用一个关键方法attachBaseContext()将我们创建的ContextImpl赋值给Activity,也即ContextWrapper的mBase。

接着所有与资源相关的调用都会顺着这条依赖链调用到AssetManager中的方法。

加载一个字符串资源

下面以Activity.getString()方法为入口看下加载一个字符串资源的具体过程。

调用链比较清晰,Activity.getString()->Resources.getString()->AssetManager.getResourceText()

解决问题

核心思路

通过上面的代码分析可以发现获取资源的过程在上层是依赖于mBase指向的ContexImpl,并且mBase的赋值方法非常容易hook,因此,我们只要将mBase指向我们自己的ContexImpl,就可以顺势替换掉其中所有的资源获取相关的实现。

当然我们不是完全重写一个ContexImpl,而是实现一个我们自己的ContexWrapper,代理ContexImpl的getResources()方法,返回我们自定义的Resources,剩余的不需要hook的方法依然让它走默认的实现即可。

而在我们自定义的Resources中,我们可以根据业务逻辑,加载我们放在assets目录下的高校版的字符串。

关键类与方法

关键的类主要是CustomContextWrapper与CustomResourcesWrapper,加入这两个类后我们的结构图大致如下

image-20190527212455231

具体的细节可以直接看下面的代码。

CustomContextWrapper.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// CustomContextWrapper.java
public class CustomContextWrapper extends ContextWrapper {
private final CustomResourcesWrapper mResourcesWrapper;

public CustomContextWrapper(Context base) {
// 这个base即为系统生成的ContextImpl
super(base);
Resources resources = super.getResources();
mResourcesWrapper = new CustomResourcesWrapper(this, resources);
}

public static ContextWrapper wrap(Context context) {
return new CustomContextWrapper(context);
}

// 只代理这一个方法,其他的方法依然走默认的实现
@Override
public Resources getResources() {
return mResourcesWrapper;
}
}

CustomResourcesWrapper.java

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
// CustomResourcesWrapper.java
public class CustomResourcesWrapper extends Resources {
@NonNull
private final Resources mOriginRes;
private Context context;

public CustomResourcesWrapper(Context context, @NonNull Resources originRes) {
super(originRes.getAssets(), originRes.getDisplayMetrics(), originRes.getConfiguration());
this.context = context;
this.mOriginRes = originRes;
}

@NonNull
@Override
public String getString(@StringRes int id) throws NotFoundException {
// 是否需要加载自定义资源
if (condition) {
return CustomStringManager.get(mOriginRes, id);
}
return mOriginRes.getString(id);
}

// 除了要处理getString,还要处理getText(),代码这里就不重复写了

// 这些无需被当前类代理的方法需要转发给原来的Resources处理
@Override
public Drawable getDrawable(@DrawableRes int id) throws NotFoundException {
return mOriginRes.getDrawable(id);
}

// 省略很多方法的实现...
}

这两个核心类完成后,还需要将其挂载到Activity与Application上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// BaseActivity.java
public class BaseActivity extends AppCompatActivity {
private ContextWrapper mContextWrapper;
private Context baseContext;

@Override
public Context getBaseContext() {
if (mContextWrapper != null) {
return mContextWrapper.getBaseContext();
}
return super.getBaseContext();
}

public Context getOriginContext() {
return baseContext;
}

@Override
protected void attachBaseContext(Context newBase) {
baseContext = newBase;
mContextWrapper = CustomContextWrapper.wrap(newBase);
super.attachBaseContext(mContextWrapper);
}
}
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
// MyApplication.java
public class MyApplication extends Application {
private ContextWrapper mContextWrapper;

@Override
public Context getBaseContext() {
if (mContextWrapper != null) {
return mContextWrapper.getBaseContext();
}
return super.getBaseContext();
}

@Override
protected void attachBaseContext(Context base) {
mContextWrapper = CustomContextWrapper.wrap(base);
super.attachBaseContext(mContextWrapper);
}

@Override
public void onCreate() {
super.onCreate();
try {
CustomStringManager.init(this, "custom");
} catch (IOException e) {
e.printStackTrace();
}
}
}

最后再简单讲一下前面都没怎么提到的CustomStringManager,从结构上看,其实也不难猜出,这个类的定位就相当于我们自定义的一个AssetManager,它的作用就是帮我们读取与管理我们自定义的字符串资源。

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
public class CustomStringManager {
public static String get(Resources resources, int id) {
return stringSparseArray.get(id);
}

public static void init(Context context, String assetsName) throws IOException {
// 将Xml文件解析进入stringInfo
String[] list = context.getAssets().list(assetsName);
for (String name : list) {
initStrings(context, assetsName, name);
}
// 将xml解析出来的StringInfo与drawableId绑定在一起
for (String key : strings.keySet()) {
int identifier = context.getResources().getIdentifier(key, "string", context.getPackageName());
if (identifier > 0) {
stringSparseArray.put(identifier, strings.get(key));
}
}
}

private static void initStrings(Context context, String assetsName, String localName) {
try {
String[] strings = context.getAssets().list(assetsName + File.separator + localName);
for (String name : strings) {
Document document = DocumentBuilderFactory
.newInstance()
.newDocumentBuilder()
.parse(context.getAssets().open(assetsName + File.separator + localName + File.separator + name));
NodeList nodeList = document.getElementsByTagName("string");
if (nodeList != null) {
for (int i = 0; i < nodeList.getLength(); i++) {
Element item = (Element) nodeList.item(i);
String key = item.getAttribute("name");
String value = item.getFirstChild().getNodeValue();
CustomStringManager.strings.put(key, value);
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}

问题与优化

仍然存在的问题

这个人解决方案在线上稳定运行了挺久了,目前发现的有以下已知问题

  • 不支持在xml中直接设置资源:不支持xml中的字符资源引用是因为xml中的字符资源引用是走的View构造函数中的方法调用。以TextView为例,其text的值是在构造函数中通过AssetManager直接获取的,不走Context.getResources()这一链路的逻辑,因此绕过了我们的hook。我们的方案想要生效只有在代码中主动调用getString()。
  • 在红米2A|红米3|MI NOTE PRO|小米4C这4部手机出现问题,版本在5.0.2或者5.1.1,使用该方法获取自定义资源会抛错。
进一步优化
  • 如果想要在xml中配置也生效,可能需要考虑换一个方案。仔细想一下,我们的需求其实很类似动态替换资源,换言之,即热更新方案中的资源热更新。因此热更新中的解决方案都可以迁移过来,解决我们这一需求。但是考虑到这一些系列的方法维护成本略高,最终我们没有采用这样的实现。如果想要往这个方向实现的话,可以参考一下 零私有api调用,实现Android热修复

  • 针对小米相关手机上的问题,我们做了一定适配,目前是直接放弃这一特性:

    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
    public class CustomContextWrapper extends ContextWrapper {
    public CustomContextWrapper(Context base) {
    super(base);
    // ...

    // 下面代码是为了确定资源包装类是否在此机器上适用
    // 目前发现在红米2A|红米3|MI NOTE PRO|小米4C这4部手机出现问题,版本在5.0.2或者5.1.1
    // 在这些手机上舍弃了高校版的显示
    if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
    Drawable drawable = null;
    try {
    // 尝试使用我们的包装类读取Drawable资源,如果能成功失败,说明存在适配问题
    drawable = mResourcesWrapper.getDrawable(R.drawable.vector_action_back, mResourcesWrapper.newTheme());
    } catch (Exception e) {
    e.printStackTrace();
    }
    mustUseSystemRes = drawable == null || !(drawable instanceof VectorDrawable);
    } else {
    mustUseSystemRes = false;
    }
    }

    @Override
    public Resources getResources() {
    return mustUseSystemRes ? super.getResources() : mResourcesWrapper;
    }
    }
其他

我这里的实现省略了多语言相关的代码,具体实现的时候,CustomStringManager中的缓存需要添加所需所有语言资源的缓存,而不是像上文一样只实现了一种。

其次,还需要做一些配置,以满足动态切换语言,参考文章 Android 国际化(多语言)兼容8.0

参考资料

零私有api调用,实现Android热修复

Android 国际化(多语言)兼容8.0

更深层次的理解Context