本文首发于 vivo互联网技术 微信公众号
链接: https://mp.weixin.qq.com/s/jG8rAjQ8QAOmViiQ33SuEg
作者:陈龙
最近做的项目需要支持几十种语言,很多小语种在不认识的人看来跟乱码一样,,翻译一般是由翻译gongsi翻译的,翻译完成后再导入到项目里面,这就容易存在一些问题。
一、问题一:翻译容易出错
翻译的流程是客户端开发编写中文文案---翻译成英文----外包翻译根据英文字符串翻译小语种,在这个流程中,有些多义词和一些涉及语境的词就很容易翻译错误。
二、问题二:错误无法及时发现
前面说了,翻译gongsi提供回来的字符串我们都看不懂,错了也不知道,几乎都是上线之后,用户反馈过来,我们才知道。
因此小语种的翻译bug一直是项目里面比较多的一类bug,于是就需要探索一种可以用于动态更新翻译字符串的方案。
三、设计思路
在Android中,多语言字符串都是以各种不同文件夹下的xml保存的,每种文件夹中的限定符表示一种语言,这个一般Android的开发人员都是了解的。
如下图所示
String文件作为Resource的一种,在使用时不管是layout中使用还是在java代码中使用其实都是调用Resource的各种方法。
那么其实翻译语言的动态更新实际上是Resource资源的替换更新。
在早些年的开发经验中,我们都知道有一种Android换主题的方案来给应用进行资源替换,简单来讲方案流程如下:
-
使用addAssertPath方法加载sd卡中的apk包,构建AsserManager实例。
-
AsserManager构建PlugResource实例。
-
使用装饰者模式编写ProxyResource,在各个获取资源的方法中优先获取PlugResource,获取不到再从备份的AppResource中获取。
-
替换Application和Activity中的Resource对象为ProxyResource。
-
继承LayoutInflater.Factory,拦截layout生成过程,并将资源获取指向ProxyResource,完成layout初始化。
既然有可参考的方案,那就可以直接开工了。
事实上在后续的开发过程中遇到很多细节问题,但万事开头难,我们可以先从第一步开始做起。
四、开发
流程一:从独立的plugapk包中取出PlugResources资源
流程二:构建自己的TextResResources 实现getText等方法 将getText方法代理到PlugResources的getText
流程三:Application启动的时候将Application的mResources对象Hook掉并设置TextResResources对象
流程四:Activity启动的时候将Activity的mResources对象Hook掉并设置TextResResources对象
流程五:注册ActivtyLifecycleCallbacks 在onActivityCreated中对activity的LayoutInfater实现自己的Factory,在Factory中对text的Attribute的属性进行拦截并重新setText
但是真的就就这么简单吗?
上述几段代码就已经构成了资源替换的雏形,基本上完成了一个基础的资源替换流程。
再后续的调试点检过程种,我发现这才刚刚开始入坑。
五、探索
探索一:api 限制调用
demo一跑起来就发现log中打印诸多告警信息。
因为是使用反射的方法将Resource替换,因此也触发了Google的Api限制调用机制,于是研究了一下Api的限制调用。
结论:
系统签名应用暂时没有限制,因为demo使用的是调试签名,换用系统签名之后,告警消失。
探索二:性能测试
使用sd卡中的plugapk包生成PlugResources,主要是在生成assetManager过程,该过程耗时10-15ms,对于页面启动来说,这个时间还是太长了,于是尝试将AssetManager缓存起来,缩短了时间。
在反射替换resource完成后,调用PlugResources的getText方法,要先从本地Resources中根据Id获取原资源的name和type,然后在使用name和type调用getIndentifier获取PlugResources中的resId,这个过程耗时较长,虽然也是纳秒级别的,但其耗时比不hook场景下高一个数据级。
然而幸运的是,在页面流畅性性能测试中,并没有发现流畅性有所下降,页面启动速度也没有明显的下降。
探索三:系统版本兼容
真正的大坑来了。
解决完之前的问题之后,开始进入monkey测试,在测试中发现7.0以上的机器,只要在webView界面长按内容弹出复制粘贴对话框,就会崩溃从日志里面可以看出来是找不到webView的资源导致的,如果我try住这个崩溃,原资源位置显示的字符串就会变成类似@1232432这种id标签。
google搜索了半天,发现相关资料甚少,看来是需要从源码层面了解webView资源加载的相关逻辑才行。
看源码,总是需要带着问题去看,目标才够清晰。
问题:为什么6.0的系统可以使用这套方案而且不会有webView的问题而7.0以上的系统却会崩溃,6.0和7.0以上的资源管理有什么具体的区别。
想要得到答案 ,就得阅读6.0和7.0以上的Resource源码,先从6.0的源码看起。
1、6.0资源管理源码解析
Context初始化
在Context创建之初,Resource就已经创建完成。
这里有两个地方涉及到了Resource创建
-
resources =packageInfo.getResources(mainThread);
-
resources =mResourcesManager.getTopLevelResources(packageInfo.getResDir(),
先从packageInfo.getResources(mainThread); 说起packageInfo 其实就是LoadedApk
packageInfo 的 getResources 方法
再看ActivityThread
ActivityThread 的 getTopLevelResources 方法
其实调用的都是mResourcesManager.getTopLevelResources
Android M 的ResourcesManager写的比较简单
其内部有一个Resource缓存
getTopLevelResource 方法会使用传入的参数 组装一个key
ResourcesKey key = new ResourcesKey(resDir, displayId, overrideConfigCopy, scale);
使用这个key去缓存里面找,找到了就拿出来用。
WeakReference<Resources> wr = mActiveResources.get(key);
找不到就新创建一个assets 来生成一个Resource实例
缓存的另一个作用就是configuration变化的时候 可以从缓存里面找到所有当前正在激活状态的Resource。
并且调用这些Resource的public void updateConfiguration(Configuration config,DisplayMetrics metrics, CompatibilityInfo compat) {方法,最终生效的是对Resource中的mAssets的configuration
再来看一下Resource.java
其核心包含两个部分
1:封装Assets,讲所有资源调用最终都是调用到mAssets的方法
2:提供缓存
看完6.0的源码我们再找一份9.0的代码来看下,9.0的资源管理基本上与7.0一脉相承,因此我们直接使用了9.0的源码来进行分析。
相比于Android6.0 ,9.0源码中Resources中不在维护AssertManager 而是将AssertManager与其他的一些缓存 封装成了一个ResourcesImpl。
ResourcesImpl 承担着老版本里面Resources的职责, 包装AssertManager 和 维护数据缓存。
而Resources的代码也变的更加简单,其方法调用最终都是交给了ResourcesImpl来实现。
不变的是Resources的管理还是要交给ResourcesManager来管理的,跟Android6.0一样ResourcesManager是一个单例模式。
那么9.0的ResourcesManager与6.0的ResourcesManager有和不同?
还是从应用启动开始看起,还是熟悉的ContextImpl。
2、9.0资源管理源码解析
无论是生成Application的Resource还是生成Activity的Resource最终调用的是ResourceManager中的方法区别。在于一个调用的是
ResourcesManager.getInstance().getResources ,另一个调用的是resourcesManager.createBaseActivityResources。
OK 我们看一下ResourcesManager的源码。
先看下它提供的各种属性,我们挑重要的放上来。
了解了这些重要的属性之后,我们再来看一下ResourceManager提供的诸多方法。
ResourceManager提供了如下以写public方法供调用。
先看getResources和createBaseActivityResources 最终都是使用一个ResourcesKey去调用getOrCreateResources。
getOrCreateResources 我在各行代码处都写了注释,大家注意看代码中的注释,部分注释是对代码中引文注释的翻译。
画个流程图看下
看完这个图基本上大体的逻辑就通我们使用如下的代码 hook 系统ResourcesManger的几个缓存 看一下当一个App启动并且打开一个Activity时,这些缓存里面都包含了哪些对象。
打印出来的结果如下图:
分析完两个不同api level的资源管理源码,我们再来分析一下两个不同apiLevel在加载完成一个webView组件之后Resource的区别。
先说以下6.0的 。
根据6.0 ResourceManager的代码 我们先做一个测试:
编写如下代码 我们将mActiveResources中保存的内容打印出来。
3、6.0 web资源注入分析
打印输出
可以看到当前包的Resources已经被加入到mActiveResources中了。
再修改代码:
在打印之前添加webView初始化 WebView webView = new WebView(context);
打印输出:
可以看到添加了webView初始化代码之后 mActiveResources中增加了一个Resources实例,该实例指向webView组件安装路径。
WebView就是从这个Resources取到了自己所需要的资源。这也是7.0以下版本中替换Activity和Application的Resources不会出现Web组件崩溃的原因,因为在这个level的系统中,web组件资源与主apk资源是分离的。
OK 分析完6.0的再看9.0的。
9.0的ResourceManager相对复杂,我们也是使用反射的方法将两种情况下的ResourceManager数据打印出来。
编写打印代码。
4、9.0 web资源注入分析
打印输出在这份打印代码中 我们输出了mResourceImpls和mActivityResourceReferences中的数据 不理解这两个缓存作用的可以去看之前的文章。
根据 mActivityResourceReferences中AcitvityResource 我们找到对应的ResourcesImpl并且根据ResourceKey得知了ResourcesImpl中的内容。
打印输出下面我们在打印代码之前添加初始化webView的源码 WebView webView = new WebView(context);
同样 根据 mActivityResourceReferences中AcitvityResource 我们找到对应的ResourcesImpl并且根据ResourceKey得知了ResourcesImpl中的内容。
对比没有添加webview 实例化之前的代码 我们发现mLibDirs中新增了/data/app/com.android.chrome-dO2jAeCdfgkLjVHzK2yx0Q==/base.apk
结论:9.0源码中 android将Web组件资源作为libDir添加至Assert中,用于资源查找,没有使用Resource分离的方式。
了解了这个原因之后 我们进一步寻找libDir添加web组件资源的地方。
webView在初始化阶段 会调用WebViewDelegate的addWebViewAssetPath方法。
最终调用的方法是 ResourcesManager.getInstance().appendLibAssetForMainAssetPath(appInfo.getBaseResourcePath(), newAssetPath);
传入两个参数 第一个是当前应用的respath 第二个是webView的resPath 具体看如下源码注释。
当appendLibAssetForMainAssetPath方法被调用时,逻辑顺序如下好吧,不喜欢看源码,还是来个画个流程图吧。
WebView就是通过这种方式,在Activity的Resource中加入了WebView的资源。
最终解决方案
这样其实我们就已经分析出在7.0以上的机器中长按WebView 因为资源缺失导致崩溃的原因了。
我们在资源替换方案中将Context的Resource替换成了我们的ProxyResources,而ProxyResources其实并没有被ResourcesManager管理,也就是说webView资源注入的时候 我们的ProxyResources并没有被更新。
了解了全部原理之后 解决方法一目了然。
见如下代码:
至此,webView崩溃问题解决。
六、问题回顾
问题一:
为什么要在attachBaseContext中进行反射替换Resource?
回答:
不管替换的是Application还是Activity的mResources 一定是在attachBaseContext里面对baseContext进行Hook,直接将Activity或者Application本身进行hook是不成功的 因为Activity或者Application本身并不是Context,他只是一个ContextWapper。而ContextWapper中真正的Context其实就是在attachBaseContext时赋值的。
问题二:
既然已经替换了Activity和Application的Resource,为什么还要使用factory处理layout初始化,难道layout初始化不是使用Activity中的Resource吗?
回答:
我们对Activity或者Application的mResources进行了替换,但是如果不实现流程5中的ActivtyLifecycleCallbacks,那么XML中编写的text无法实现替换,原因在于View使用TypedArray在进行赋值的时候,并不是直接使用mResources,而是直接使用mResourcesImpl,所以直接hooke了mResources还是没用,其实mResources的getText方法也是调用mResources中的mResourcesImpl的方法。
问题三:
对于已经使用了换肤模式的app(比如说浏览器)如何做String在线更新?
回答:
只需要修改原有换肤模式使用的SkinProxyResource,并getText,getString等方法代理到在线更新的TextProxyResources上即可。
更多内容敬请关注 vivo 互联网技术 微信公众号
注:转载文章请先与微信号:Labs2020 联系。