挺早之前项目中的一个多语言相关的需求,觉得还蛮有意思的,现在想起来决定写一篇文章记录一下。第一部分主要是Android字符串资源加载的源码分析,第二部分是需求的解决方案,只对解决方案感兴趣的直接看第二部分即可。
项目背景
需求描述
由于我们的App用户包括普通公司与各大高校,在文案显示上,同样位置的文案,可能显示的中文/英文都是不同的。例如有个文案,”我的公司”,对于公司来说是没问题的,但是对于学校来说,就会很奇怪。因此同一个文案,需要在学校那边显示”我的学校”,在公司这边显示”我的公司”。
问题分析
Android自带的多语言国际化功能是不能满足我们的需求的,因此需要我们自己去拦截App的字符串资源加载过程,以加载我们自己的字符串资源。
具体的实现细节可以直接看第三节的内容。
Android字符资源加载源码分析
关键类
先看一下关键类的关系图:

再介绍一下几个关键类的作用:
- 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,加入这两个类后我们的结构图大致如下

具体的细节可以直接看下面的代码。
CustomContextWrapper.java
1 | // CustomContextWrapper.java |
CustomResourcesWrapper.java
1 | // CustomResourcesWrapper.java |
这两个核心类完成后,还需要将其挂载到Activity与Application上:
1 | // BaseActivity.java |
1 | // MyApplication.java |
最后再简单讲一下前面都没怎么提到的CustomStringManager,从结构上看,其实也不难猜出,这个类的定位就相当于我们自定义的一个AssetManager,它的作用就是帮我们读取与管理我们自定义的字符串资源。
1 | public class CustomStringManager { |
问题与优化
仍然存在的问题
这个人解决方案在线上稳定运行了挺久了,目前发现的有以下已知问题
- 不支持在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
27public 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;
}
}
public Resources getResources() {
return mustUseSystemRes ? super.getResources() : mResourcesWrapper;
}
}
其他
我这里的实现省略了多语言相关的代码,具体实现的时候,CustomStringManager中的缓存需要添加所需所有语言资源的缓存,而不是像上文一样只实现了一种。
其次,还需要做一些配置,以满足动态切换语言,参考文章 Android 国际化(多语言)兼容8.0