导读
APP包优化主要手段
内置图网络化技术分析
经过调研相关实现内置图网络化所涉及的Android源码,本着对现有工程代码无侵入或小量侵入(私有Api访问、反射)、后期维护方便、使用简单几个目标。实现App内置图网络化总体思路为拦截Drawable加载过程,将Drawable替换为自己实现的Drawable,同步返回默认图异步返回网络图,缓存所有需要网络化的图片名称,最后在构建过程根据名称将对应内置图删除。整个方案落地过程可以概括分为以下四点,本文也主要是针对这四点展开。
拦截图片加载过程
图片显示渲染
图片下载和缓存
内置图片删除
内置图网络化需要对Android系统加载Drawable时机进行Hook,Drawable加载主要是通过Resources类,那么需要将与每个Activity映射的Resources对象替换。替换手段有两种,第一种通过字节码修改Activity#getResources(),第二种反射Resources替换。我这里前期调研为了实现简单一些采用了反射,其实利用第一种更好因为对工程代码没有侵入方便后期维护。
代码动态设置Background、Drawable使用Resources#getDrawable
Xml布局文件设置Background、Drawable,View初始化时Resources#loadDrawable(为了方便描述以下用静态设置称呼)
Android系统View显示图片最终都是通过Resources类获得图片的Drawable对象显示。获得Drawable对象有两个接口getDrawable、loadDrawable。getDrawable是一个公共接口,可以重载这个方法达到拦截,一般setBackground或者setImageDrawable会调用,loadDrawable方法系统View初始化获取Drawable调用。
//Resource#getDrawable,
public Drawable getDrawable( int id, Theme theme)
throws NotFoundException {
if(图片是否需要网络化){
return 网络加载
} else {
//返回正常流程
return baseResources.getDrawable(id, theme);
}
}
getDrawable比较好处理,但是loadDrawable方法是一个受保护方法,无法拦截。查看源码loadDrawable之后流程也没有找到可以Hook的机会。一度以为拦截Drawable很容易就可以实现,最后没想到在这个问题上花费很多时间。查资料、看源码最终想到一种方法!
因为loadDrawable这个方法只有Xml配置的系统基础View (如"ImageView"、“TextView”、各种布局管理器等")的Src和Background属性,在初始化View过程获得Drawable对象才会用到。所以影响的只是Xml布局文件配置的View。那么通过实现LayoutInflater$Factory2,拦截Xml View创建过程将Xml的View替换为我们自定义基础View。在自定义View内通过遍历当前Attr属性判断使用Src或者Background,然后调用相应的setImageDrawable或者setBackground达到触发Resournces#getDrawable接口完成Hook。通过这种Hook的方式可以达到我们对Xml布局View设置Drawable的拦截目的。
这种实现可能会对View初始化性能有些影响,但没有耗时操作影响很小。
class SkinTextView extend TextView {
public SkinTextView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
setSkin(this,attrs);
}
private static final int[] ATTR_ARRAY = {
// 这个属性是系统类View的属性,对于APP领域是不可见的。
// 但是这个值是固定的,所以可以这样写,这里参考了RecycleView#NESTED_SCROLLING_ATTRS实现
16842964/* android.R.attr.background */,
android.R.attr.src
};
public static void setSkin(View view, AttributeSet attrs, DraweeHolderSupplier supplier){
Context context = view.getContext();
Resources resources = context.getResources();
TypedArray ta = context.obtainStyledAttributes(attrs, ATTR_ARRAY);
Drawable background ;
int drawableId ;
for (int i = 0; i < ATTR_ARRAY.length; i++) {
int attr = ATTR_ARRAY[i];
drawableId = ta.getResourceId(i,0);
if (drawableId == 0){
continue;
}
background = resources.getDrawable(drawableId,context.getTheme());
switch (attr) {
case 16842964:
view.setBackground(background);
break;
case android.R.attr.src:
if (view instanceof ImageView) {
((ImageView)view).setImageDrawable(background);
}
break;
}
}
ta.recycle();
}
}
但是以上方案只能解决Xml中系统基础的View,如果Xml中使用开发自定义View则不管用。为了解决自定义View的问题我想到了两种解决方案。
通过字节码修改方式将所有自定义View继承的系统基础View改为继承我们自定义的基础View
我通过Asm字节码修改将APP内所有自定义View继承的系统基础View改为自定义基础View,这个方案可行,但是缺点比较多需要全局修改所有库的字节码包括Androidx库AppCompatView,修改范围太大,框架稳定性不太容易保证,由于自定义基础View有一些拦截代码所以对View初始化性能也有一定影响,且Asm代码编写出现Bug不易排查。如果只修改我们业务线的字节码,可以正常运行。但修改第三方AAR字节码后,遇到一个坑,应用一直ANR期间没找到具体原因。
Hook LayoutInflater解析Xml自定义View过程
// LayoutInflater#createViewFromTag
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
try {
View view;
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}
// 以下onCreateView方法可以重载,拿到view对象强制触发getDrawable即可,中间需要一些过滤。讲一下大致思路,细节就不加了。
if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
if (-1 == name.indexOf('.')) {
view = onCreateView(parent, name, attrs);
} else {
view = createView(name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
}
}
这个方案可以将自定义View拦截,缺点就是依赖Android系统版本,如果Android系统这块逻辑发生变化那么需要适配。不过对于后续需要使用Fresco框架加载图片以及内存管理,这个方案无法做到融合Fresco,所以该方案最终也没有利用起来。
放弃处理自定义View和Xml属性
最终决定放弃对自定义View和属性这种情况处理。通过遍历Xml将自定义Attr和自定义View过滤。
字节码修改和自定义属性、View过滤方案可以参考下图。
/**
* 自定义属性、View过滤
* Hook aapt打包过程,得到所有模块Res资源路径,遍历所有res/layout下的Xml
*/
Pattern pattern = Pattern.compile("(?<=(android:(background|src)=\"@drawable/))([a-z_0-9]*)")
void eachLayoutXml(File[] resDirs){
resDirs.each {
if (it.isDirectory() && it.name == "res") {
eachLayoutXml(it.listFiles())
} else if (it.isDirectory() && it.name == "layout") {
it.listFiles().each { xml ->
// 获得xml内容,通过正则表达式匹配字符串
}
}
}
}
下载图片的方案和图片显示渲染
当时考虑过两种下载图片方案
图片插件Apk,将所有需要的图片打包到Apk,然后只下载一次插件,无需考虑图片内存问题
网络直接下载图片,通过Fresco管理内存问题
对比这两种方案我选择了实现比较容易的第二种。
这个问题比较好解决,View、Drawable之间是通过Drawable$Callback进行传递,所以下载图片得到Drawable对象后通过Drawable$Callback#invalidateDrawable即可。当然这里返回的Drawable可以选用一个LayerDrawable,因为Drawable$Callback执行更新的Drawable必须是同一个Drawable对象,同时方便同步状态下返回默认图,异步网络图返回后重新渲染。
需要注意一点,这里不能直接使用Fresco RootDrawable对象返回,因为Fresco不支持View wrap_content属性
因为需要用到Fresco,简单介绍下。Fresco结构分层可以分为三层,分别是图层、控制器、图片获取,每一层结构、功能如图。
RootDrawable是最终返回的图片Drawable对象
DataSources 返回图片信息的订阅源
Controler 图片获取和图层显示中间桥梁
第三层是图片三级缓存,获取图片可以从缓存和网络获取
大致了解Fresco,下面描述内置图网络化框架融合Fresco,基于Fresco进行图片下载、缓存、内存释放。
这个实现可以类比Fresco的DraweeView的实现,利用View的attach、detach、visible几个生命周期函数通过DraweeHolder触发Drawble的加载和销毁,做到对Fresco的图片缓存和内存释放。具体过程不介绍了,可以阅读Fresco源码类比SimpleDraweeView类。
实现流程如下图。
经过实际调研,不能直接删除内置图,否则在打包过程进行图片链接的时候会抛出找不到资源错误,所以主要思路通过1像素图片替换要删除的图片。
删除内置图有以下几种方案
我选择方案四,具体有以下优点
方便根据图片大小选择批量删除
可以直接计算得到优化的包大小
可以将falt文件图片数据转成对应图片,之后用于服务器上传
可以直接融合到APP编译过程,编译一步到位
Hook AAPT资源打包过程MergeResources结束的时候
遍历所有生成的图片Flat二进制文件,根据之前缓存的网络化图片名称筛选到需要网络化的Flat文件,将Flat文件里Png|Webp|Jpg二进制数据替换为一像素的默认图
这个方案实现比较麻烦的是对Flat文件二进制流的读取过程
Flat文件容器格式传送门:https://my.oschina.net/dtqq/blog/4815032
总结
有奖互动
Perseverance Prevails
欢迎在评论区为作者大佬打call,将抽取点赞前五名送出58技术周边奖品一份。
开奖时间:6月28日 18:00