最近看滴滴开源的Dokit框架中有一个大图监控的功能,能够对图片的文件大小和所占用的内存大小设置一个阈值,当图片超过该值的时候进行提示。这个功能对于咱们在作APK体积压缩,内存管理的时候仍是颇有用的,好比当咱们要从后台返回的链接中加载一张图片,这张图片的大小咱们是不知道的,虽然如今你们都使用Glide等三方 图片加载框架,框架会自动对图片进行压缩,可是依然会出现压缩后所占内存超过预期的状况。这时候咱们能够在开发、测试和预生产阶段使用大图监控来识别出那些超标的图片。java
在讨论如何作以前,咱们必须明确咱们要作什么。该大图监控框架我以为应该实现如下功能:android
要实现对图片文件大小和所占内存的监控,那么咱们就得先知道图片的文件大小和加载该图片所耗费的内存。目前加载图片通常都使用第三方框架,因此能够对经常使用的图片加载框架进行Hook,这里主要对主流的四种图片加载框架进行Hook操做。git
Glidegithub
Picasso算法
Fresco数据库
Image Loaderapi
以从网络加载一张图片举例,当使用图片框架加载一张网络图片时,会使用OkHttp或者是HttpUrlconnection去下载该图片,这时候咱们就能获得图片文件的大小。当图片框架将图片文件构形成Bitmap对象之后,咱们又能获得其所占用的内存,这样咱们就同时的获得了图片的文件大小和所占用的内存。那么这里咱们也必须对OkHttp和HttpUrlconnection进行Hook。缓存
既然要对三方框架进行Hook操做,那么咱们如何进行Hook呢?在选择Hook的实现方案时,我对如下几种方案进行了调研。服务器
反射+动态代理微信
ASM
AspectJ
ByteBuddy
首先反射+动态代理 只能在程序运行时进行,这样会影响效率,因此暂不考虑。其余三种方案都可以在编译期进行字节码插桩,ASM直接操纵字节码,阅读起来不那么友好。AspectJ之前用过,常常出一些莫名其妙的问题,体验不是很好。ByteBuddy 封装了ASM,听说效率很高,并且使用JAVA编写,代码可读性好,只是网上的资料太少了,大部分都是那么几篇文章再转发。因此这里最后选择了ASM实现。
有了ASM进行字节码插入,那何时将咱们编写好的字节码插入到第三方框架中呢?
网页上讲从Android Gralde插件1.5.0版本开始,添加了Transform API,来容许第三方插件在通过编译的class文件转换为dex文件以前对其进行操做。Gradle会按照如下顺序执行转换:JaCoCo->第三方插件->ProGuard。其中第三方插件的执行顺序与第三方插件添加顺序一致,而且第三方插件没法经过Api控制转换的执行顺序。
有了Transform API +ASM咱们就可以将咱们本身编写的字节码插入到第三方框架的class文件中,从而在编译器完成插桩。
如今咱们已经决定了用ASM在编译期经过Transform API进行插桩。那么具体该怎么实现呢?咱们回想一下咱们须要实现的功能,咱们要对图片进行监控,为了监控咱们要获取图片的数据,获得数据后发现超标图片咱们要给与提示。这意味着有两部分功能,一部分负责经过插桩获取数据,另一部分负责显示超标数据。因而整个大图监控项目咱们采用Gradle自定义插件+Android Library的形式。
如何建立Gralde插件项目在这里就很少说了,网上有不少教程。网上的大多数教程会告诉你把插件项目名称改成buildSrc,这样作有不少好处,尤为是在代码编写阶段,能够采用如下这种形式进行测试
apply plugin:org.zzy.largeimage.LargeImageMonitorPlugin 复制代码
不须要每次编写完成之后发布到maven仓库,插件项目修改之后,会直接在使用模块体现出来。
在这里笔者自建了本地maven库,而且为了名称上的统一,并无将插件项目的名称改成buildSrc,这两种形式均可以,你们能够根据自身的状况来使用。
若是在编译期存在不少Transform那么确定会对编译速度有必定的影响,那么有没有什么方式能够减小这种影响?有!并发+增量编译。
在这里推荐一个开源库Hunter,它可以帮助你快速的开发插件,而且支持并发+增量编译,笔者在这里就使用了该开源库。
使用该开源库很简单,只须要在插件项目的build.gradle中引入依赖就行。
接下来为了建立咱们的Transform而且将其注册到整个Transform队列中,咱们须要建立一个类实现Plugin接口。
public class LargeImageMonitorPlugin implements Plugin<Project> { @Override public void apply(Project project) { List<String> taskNames = project.getGradle().getStartParameter().getTaskNames(); //若是是Release版本,则不进行字节码替换 for(String taskName : taskNames){ if(taskName.contains("Release")){ return; } } AppExtension appExtension = (AppExtension)project.getProperties().get("android"); //建立自定义扩展 project.getExtensions().create("largeImageMonitor",LargeImageExtension.class); project.afterEvaluate(new Action<Project>() { @Override public void execute(Project project) { LargeImageExtension extension = project.getExtensions().getByType(LargeImageExtension.class); Config.getInstance().init(extension); } }); //将自定义Transform添加到编译流程中 appExtension.registerTransform(new LargeImageTransform(project), Collections.EMPTY_LIST); //添加OkHttp appExtension.registerTransform(new OkHttpTransform(project),Collections.EMPTY_LIST); //添加UrlConnection appExtension.registerTransform(new UrlConnectionTransform(project),Collections.EMPTY_LIST); } } 复制代码
该类主要作了三件事:
在代码中能够看见,咱们注册了三个自定义Transform,由于咱们同时要对图片加载框架和网络请求库进行插桩。
因为使用了Hunter框架,使得咱们编写Transform变得更加简单,不须要使用传统的方式编写Transform,咱们主要来看关键代码。
public class LargeImageClassAdapter extends ClassVisitor { private static final String IMAGELOADER_METHOD_NAME_DESC = "(Ljava/lang/String;Lcom/nostra13/universalimageloader/core/imageaware/ImageAware;Lcom/nostra13/universalimageloader/core/DisplayImageOptions;Lcom/nostra13/universalimageloader/core/assist/ImageSize;Lcom/nostra13/universalimageloader/core/listener/ImageLoadingListener;Lcom/nostra13/universalimageloader/core/listener/ImageLoadingProgressListener;)V"; /** * 当前类名 */ private String className; public LargeImageClassAdapter(ClassVisitor classWriter) { super(Opcodes.ASM5, classWriter); } @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { super.visit(version, access, name, signature, superName, interfaces); this.className = name; } @Override public MethodVisitor visitMethod(int access, String methodName, String desc, String signature, String[] exceptions) { MethodVisitor mv = cv.visitMethod(access, methodName, desc, signature, exceptions); //若是插件开关关闭,则不插入字节码 if(!Config.getInstance().largeImagePluginSwitch()) { return mv; } // TODO: 2020/4/2 这里考虑作版本兼容 //对Glide4.11版本的SingleRequest类的构造方法进行字节码修改 if(className.equals("com/bumptech/glide/request/SingleRequest") && methodName.equals("<init>") && desc!=null){ return mv == null ? null : new GlideMethodAdapter(mv,access,methodName,desc); } //对picasso的Request类的构造方法进行字节码修改 if(className.equals("com/squareup/picasso/Request") && methodName.equals("<init>") && desc!=null){ return mv == null ? null : new PicassoMethodAdapter(mv,access,methodName,desc); } //对Fresco的ImageRequest类的构造方法进行字节码修改 if(className.equals("com/facebook/imagepipeline/request/ImageRequest") && methodName.equals("<init>") && desc!=null){ return mv == null ? null : new FrescoMethodAdapter(mv,access,methodName,desc); } //对ImageLoader的ImageLoader类的displayImage方法进行字节码修改 if(className.equals("com/nostra13/universalimageloader/core/ImageLoader") && methodName.equals("displayImage") && desc.equals(IMAGELOADER_METHOD_NAME_DESC)){ return mv == null ? null : new ImageLoaderMethodAdapter(mv,access,methodName,desc); } return mv; } } 复制代码
从继承类的名字来看,这是一个类的访问者,咱们项目和第三方库中的类都会通过这。
咱们在visit方法中记录下当前通过的类的名字。而且在visitMethod方法中判断当前访问的是不是某个类的某个方法,若是当前访问的方法是咱们须要hook的方法,那么咱们就执行咱们的字节码插桩操做。
那么问题来了,咱们如何知道咱们要hook哪一个类的哪一个方法呢?这就须要咱们去阅读须要hook框架的源码了。在visitMethod方法中咱们打算对Glide,Picasso,Fresco,ImageLoader四大图片加载框架进行hook。那么咱们就先须要知道这四大框架的Hook点在哪。那么如何寻找Hook点呢?虽然滴滴的Dokit项目中已经给出了Hook点,可是抱着学习的态度,咱们能够试图的分析一下,如何去寻找Hook点?
咱们对图片加载框架进行Hook,必需要知足如下几点:
1.该Hook点是流程执行的必经之路。
2.在进行Hook之后,咱们能获取到咱们想要的数据。
3.进行Hook之后,不能影响正常的使用。
在通过对四大图片加载框架源码的大体分析之后,我发现大部分框架都在成功加载图片后会对接口进行回调,用来通知上层,图片加载成功。那么咱们是否有可能把图片加载成功后回调的接口替换成咱们的?或者增长一个咱们自定义的接口进去,让图片加载成功之后也回调咱们的接口,这样咱们就能获取到图片的数据。
以Glide框架举例,Glide在成功加载完图片之后会在SingleRequest类的onResourceReady方法中对RequestListener接口进行遍历回调。
private void onResourceReady(Resource<R> resource, R result, DataSource dataSource) { ... try { boolean anyListenerHandledUpdatingTarget = false; if (requestListeners != null) { for (RequestListener<R> listener : requestListeners) { anyListenerHandledUpdatingTarget |= listener.onResourceReady(result, model, target, dataSource, isFirstResource); } } anyListenerHandledUpdatingTarget |= targetListener != null && targetListener.onResourceReady(result, model, target, dataSource, isFirstResource); if (!anyListenerHandledUpdatingTarget) { Transition<? super R> animation = animationFactory.build(dataSource, isFirstResource); target.onResourceReady(result, animation); } } finally { isCallingCallbacks = false; } notifyLoadSuccess(); } 复制代码
从这段代码中咱们能够知道几点:
这样一来咱们只须要在requestListeners中添加一个咱们自定义的RequestListener。这样在接口回调时,咱们也能获取到图片数据。那么在什么地方插入咱们自定义的RequestListener呢?咱们先来看requestListeners在SingleRequest中的定义。
@Nullable private final List<RequestListener<R>> requestListeners; 复制代码
requestListeners被声明成了final类型,那么在编写代码的时候就只可以赋值一次,若是是成员变量的话,则必须在构造方法中进行初始化。
private SingleRequest( Context context, GlideContext glideContext, @NonNull Object requestLock, @Nullable Object model, Class<R> transcodeClass, BaseRequestOptions<?> requestOptions, int overrideWidth, int overrideHeight, Priority priority, Target<R> target, @Nullable RequestListener<R> targetListener, @Nullable List<RequestListener<R>> requestListeners, RequestCoordinator requestCoordinator, Engine engine, TransitionFactory<? super R> animationFactory, Executor callbackExecutor) { this.requestLock = requestLock; this.context = context; this.glideContext = glideContext; this.model = model; this.transcodeClass = transcodeClass; this.requestOptions = requestOptions; this.overrideWidth = overrideWidth; this.overrideHeight = overrideHeight; this.priority = priority; this.target = target; this.targetListener = targetListener; this.requestListeners = requestListeners; this.requestCoordinator = requestCoordinator; this.engine = engine; this.animationFactory = animationFactory; this.callbackExecutor = callbackExecutor; status = Status.PENDING; if (requestOrigin == null && glideContext.isLoggingRequestOriginsEnabled()) { requestOrigin = new RuntimeException("Glide request origin trace"); } } 复制代码
若是咱们在SingleRequest的构造方法中进行Hook,把咱们自定义的RequestListener添加进requestListeners中,那么在图片成功加载时,就会回调咱们的方法,从而获取到图片数据。这样咱们就找到了对Glide框架的Hook点,也就有了visitMethod方法中下面这段代码:
//对Glide4.11版本的SingleRequest类的构造方法进行字节码修改 if(className.equals("com/bumptech/glide/request/SingleRequest") && methodName.equals("<init>") && desc!=null){ return mv == null ? null : new GlideMethodAdapter(mv,access,methodName,desc); } 复制代码
这段代码就是用于判断当前访问的是不是Glide框架中的SingleRequest类的构造方法?若是是的话就进行字节码插入。
如今咱们已经有了Hook点,咱们要把自定义的RequestListener添加到requestListeners中。那么如今有两种选择。
第一种,在SingleRequest类构造方法进入时,获得传入的参数requestListeners,将自定义RequestListener加入其中,接着再把参数requestListeners赋值给成员变量this.requestListeners。
第二种,让参数requestListeners先赋值给成员变量this.requestListeners,在方法退出以前拿到this.requestListeners,将咱们自定义的RequestListener加入其中。
两种方法看似实现了相同的功能,可是字节码却不同。
第一种方法的语句与字节码以下:
//语句 GlideHook.process(requestListeners); //字节码 mv.visitVarInsn(ALOAD, 12); mv.visitMethodInsn(INVOKESTATIC, "org/zzy/lib/largeimage/aop/glide/GlideHook", "process", "(Ljava/util/List;)Ljava/util/List;", false); 复制代码
第二种方法的语句与字节码以下:
//语句 GlideHook.process(this.requestListeners); //字节码 mv.visitVarInsn(ALOAD, 0); mv.visitFieldInsn(GETFIELD, "com/bumptech/glide/request/SingleRequest", "requestListeners", "Ljava/util/List;"); mv.visitMethodInsn(INVOKESTATIC, "org/zzy/lib/largeimage/aop/glide/GlideHook", "process", "(Ljava/util/List;)Ljava/util/List;", false); 复制代码
咱们知道java在执行一个方法的同时会建立一个栈帧,栈帧中包括局部变量表,操做数栈,动态连接,方法出口等。其中局部变量表是在编译期就已经肯定了,其索引是从0开始,表示该对象的实例引用,你能够大致认为就是this。
在第一种方法中,咱们先是经过ALOAD指令将局部变量表中索引为12的引用型变量入栈(requestListeners),而后调用GlideHook的静态方法process,将其传入。
在第二种方法中,咱们经过ALOAD指令将this入栈,而后访问this对象的requestListeners字段,将其传入GlideHook的静态方法process中。
从指令上来看,第一种方式的指令更少。可是咱们考虑一个问题,第一种方式咱们手动的获取了该方法局部变量表第12个索引的值。万一哪一天Glide想在该构造方法中增长或者删除一个参数,那咱们的代码就不兼容了。因此为了代码的兼容性考虑,咱们采用第二种方法,起码直接删除一个成员变量的几率要小于对构造方法入参的修改。
在这里你们能够思考一下,是否能直接在构造方法中add咱们的自定义RequestListener?能够是能够,可是若是下次要再增长一个自定义RequestListener,咱们又得在插件端修改字节码指令,太过于麻烦,咱们不如直接获得List,而后在GlideHook的process方法中add。
咱们来看看具体的实现代码:
public class GlideMethodAdapter extends AdviceAdapter { /** * 方法退出时 * 1.先拿到requestListeners * 2.而后对其进行修改 * GlideHook.process(requestListeners); * 做者: ZhouZhengyi * 建立时间: 2020/4/1 15:51 */ @Override protected void onMethodExit(int opcode) { super.onMethodExit(opcode); mv.visitVarInsn(ALOAD, 0); mv.visitFieldInsn(GETFIELD, "com/bumptech/glide/request/SingleRequest", "requestListeners", "Ljava/util/List;"); mv.visitMethodInsn(INVOKESTATIC, "org/zzy/lib/largeimage/aop/glide/GlideHook", "process", "(Ljava/util/List;)Ljava/util/List;", false); } } 复制代码
onMethodExit表示在SingleRequest构造方法退出前加入如下指令。这时候确定有人会问了,字节码指令这么麻烦我写错了咋办?在这里推荐一款android studio插件ASM Bytecode Outline。安装成功之后,用Java将代码编写完成,而后右键生成字节码便可。例如咱们能够建立一个测试类:
public class Test { private List<RequestListener> requestListeners; //模拟glide private void init(){ GlideHook.process(requestListeners); } } 复制代码
这样咱们就能获得咱们想要的字节码指令了,别忘了修改一下类的全限定名。该插件的详细操做网上有不少教程,这里就很少说了。
到此为止咱们就成功将编写好的字节码插入到了Glide框架中。对其余三种图片加载框架的Hook点寻找也是相似的思路,并且大部分也都是在某个类的构造方法中进行Hook。这里提一下寻找Fresco Hook点的过程,原本按照以上寻找Hook点的思路,在Fresco中找到了一个接口,图片成功加载后也会回调该接口,可是郁闷的是回调该接口时,咱们拿不到图片数据。最后是经过Hook Postprocessor拿到的Bitmap。具体的你们能够结合我github上的源码来分析。
总结一下:
1.寻找到的Hook点可能不止一个,你们根据自身状况进行采用。
2.拿到Hook对象之后,要看看是否能获得咱们想要的数据,若是得不到须要从新寻找。
3.构造方法是一个好的Hook点,由于在这里通常都进行初始化操做。
4.在选择Hook方式的时候必定要考虑到代码兼容性问题。
在插入完字节码之后,当Glide执行到SingleRequest的构造方法时就会执行咱们插入的字节码指令了。在图片成功加载后就会回调咱们的自定义RequestListener,接着该怎么作,咱们后面再说,这部分的逻辑咱们将它放到了largeimage 这个Library中。
咱们前面说到,当咱们使用图片框架加载一张网络图片时,图片框架会先从网络将图片下载,而后再加载。以Glide为例,Glide会将图片下载存到本地,而后再把本地图片读入内存构建一个Resource,当图片加载成功的时候,就会回调咱们自定义的监听器,可是这个时候咱们只能获取到图片加载到内存后的数据,也就是说咱们获取不到图片的文件大小。因此就考虑是否能再图片下载成功后拿到图片的文件大小呢?这就须要咱们对网络下载框架进行Hook,每次获得Response时判断Content-Type是不是image开头,若是是的话咱们就认为本次请求的是图片。
有了思路之后,咱们就开始着手对OkHttp进行Hook,OkHttp的Hook点很容易寻找,一方面在于你们对OkHttp的源码都比较熟悉,另一方面在于OkHttp的优秀架构。咱们都知道OkHttp采用拦截链的方式来处理数据,而且做者预留了两处能够添加拦截器的地方,一处是应用拦截器,一处是网络拦截器。只要咱们在这两处添加咱们本身的拦截器,那么请求和响应数据都会通过咱们的拦截器。因此OkHttp的Hook点咱们就放在OkHttpClient$Builder类的构造方法中。
public class OkHttpClassAdapter extends ClassVisitor { private String className; public OkHttpClassAdapter(ClassVisitor cv) { super(Opcodes.ASM5, cv); } @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { super.visit(version, access, name, signature, superName, interfaces); this.className = name; } @Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor methodVisitor = cv.visitMethod(access, name, desc, signature, exceptions); //若是插件开关关闭,则不插入字节码 if(!Config.getInstance().largeImagePluginSwitch()) { return methodVisitor; } if(className.equals("okhttp3/OkHttpClient$Builder") && name.equals("<init>") && desc.equals("()V")){ return methodVisitor == null ? null : new OkHttpMethodAdapter(methodVisitor,access,name,desc); } return methodVisitor; } } 复制代码
并且这种拦截器的添加是全局性的,之前你在项目中添加OkHttp的拦截器,只是你本项目的网络请求会回调。可是经过这种方法添加的拦截器,本项目中和第三方库中,只要使用了OkHttp框架都会添加相同的拦截器。说到这是否是想到了HttpDns?之前咱们为了防止DNS劫持加快DNS解析速度,在OkHttp中经过自定义DNS的方式来实现HttpDns访问,可是若是使用第三方图片框架加载服务器上的图片,仍是走的53端口的UDP形式。那么咱们能不能顺便把OkHttp中的Dns也Hook了?这样就能全局添加咱们自定义的Dns,实现整个项目都使用HttpDns来解析域名。
public class OkHttpMethodAdapter extends AdviceAdapter { /** * 方法退出时插入 * interceptors.addAll(LargeImage.getInstance().getOkHttpInterceptors()); * networkInterceptors. * addAll(LargeImage.getInstance().getOkHttpNetworkInterceptors()); * dns = LargeImage.getInstance().getDns(); * 做者: ZhouZhengyi * 建立时间: 2020/4/5 9:39 */ @Override protected void onMethodExit(int opcode) { super.onMethodExit(opcode); //添加应用拦截器 mv.visitVarInsn(ALOAD, 0); mv.visitFieldInsn(GETFIELD, "okhttp3/OkHttpClient$Builder", "interceptors", "Ljava/util/List;"); mv.visitMethodInsn(INVOKESTATIC, "org/zzy/lib/largeimage/LargeImage", "getInstance", "()Lorg/zzy/lib/largeimage/LargeImage;", false); mv.visitMethodInsn(INVOKEVIRTUAL, "org/zzy/lib/largeimage/LargeImage", "getOkHttpInterceptors", "()Ljava/util/List;", false); mv.visitMethodInsn(INVOKEINTERFACE, "java/util/List", "addAll", "(Ljava/util/Collection;)Z", true); mv.visitInsn(POP); //添加网络拦截器 mv.visitVarInsn(ALOAD, 0); mv.visitFieldInsn(GETFIELD, "okhttp3/OkHttpClient$Builder", "networkInterceptors", "Ljava/util/List;"); mv.visitMethodInsn(INVOKESTATIC, "org/zzy/lib/largeimage/LargeImage", "getInstance", "()Lorg/zzy/lib/largeimage/LargeImage;", false); mv.visitMethodInsn(INVOKEVIRTUAL, "org/zzy/lib/largeimage/LargeImage", "getOkHttpNetworkInterceptors", "()Ljava/util/List;", false); mv.visitMethodInsn(INVOKEINTERFACE, "java/util/List", "addAll", "(Ljava/util/Collection;)Z", true); mv.visitInsn(POP); //添加DNS mv.visitVarInsn(ALOAD, 0); mv.visitMethodInsn(INVOKESTATIC, "org/zzy/lib/largeimage/LargeImage", "getInstance", "()Lorg/zzy/lib/largeimage/LargeImage;", false); mv.visitMethodInsn(INVOKEVIRTUAL, "org/zzy/lib/largeimage/LargeImage", "getDns", "()Lokhttp3/Dns;", false); mv.visitFieldInsn(PUTFIELD, "okhttp3/OkHttpClient$Builder", "dns", "Lokhttp3/Dns;"); } } 复制代码
咱们在OkHttpClient$Builder构造方法退出以前,将咱们的拦截器和自定义dns插入。
一样的,插件端只负责插入字节码,后续全部的逻辑都放在了Library中。
可能不少人会以为,如今还有人用HttpUrlConnection吗?还有必要对它进行处理吗?虽然如今广泛使用OkHttp框架,但使用HttpUrlConnection的还不少,并且还得考虑兼容性不是吗?像Glide框架使用的就是HttpUrlConnection请求网络,虽然Glide框架能够采用自定义ModelLoader的方式实现OkHttp请求网络。可是为了保险起见,咱们统一进行处理。那这里要怎么对HttpUrlConnection进行Hook呢?HttpUrlConnection的源码也没看过呀?那咱们能不能换一种思路,既然在前面咱们已经对OkHttp进行了Hook,那么咱们能不能将全部的HttpUrlConnection请求换成OkHttp来实现?也就是将HttpUrlConnection请求导向OkHttp,这样就能够在统一在OkHttp中对数据进行处理。
那怎么才能将HttpUrlConnection换成OkHttp呢?咱们之前在作Hook的时候,一般的思路是,若是Hook的对象是接口,那么咱们就使用动态代理,若是是类,那么咱们就继承它而且重写其方法。在这里咱们也能够自定义一个类继承HttpUrlConnection而后重写它的方法,方法里所有改用OkHttp来实现。那接下来的问题就是在什么地方将系统的HttpUrlConnection换成咱们自定义的HttpUrlConnection。HttpUrlConnection是一个抽象类,不能直接用new来建立,要获得HttpUrlConnection对象,须要使用URL类的openConnection方法获得一个HttpURLConnection对象,那么咱们就能够在全部调用openConnection方法的地方进行Hook,将系统返回的HttpURLConnection对象替换成咱们自定义的HttpURLConnection对象。
既然全部调用到openConnection方法的地方都要Hook,那么就没用特定的类,因此此次咱们不针对特定类。
public class UrlConnectionClassAdapter extends ClassVisitor { /** * 这个方法跟其余几个methodAdapter不同 * 其余的methodAdapter是根据类名和方法名来进行hook * 也就是说当访问到某个类的某个方法时进行 * 而这个方法是,全部的类和方法都有可能存在hook, * 因此这里不作类和方法的判断 * 做者: ZhouZhengyi * 建立时间: 2020/4/5 17:25 */ @Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor methodVisitor = cv.visitMethod(access, name, desc, signature, exceptions); //若是插件开关关闭,则不插入字节码 if (!Config.getInstance().largeImagePluginSwitch()) { return methodVisitor; } return methodVisitor == null ? null : new UrlConnectionMethodAdapter(className, methodVisitor, access, name, desc); } } 复制代码
URL类有两个openConnection方法,都要进行Hook。
public class UrlConnectionMethodAdapter extends AdviceAdapter { /** * 这里复写的方法与其余的methodAdapter也不一样 * 其余的methodAdapter是在方法进入或者退出时操做 * 而这个methodAdapter是根据指令比较的 * 这个方法的意思是当方法被访问时调用 * @param opcode 指令 * @param owner 操做的类 * @param name 方法名称 * @param desc 方法描述 (参数)返回值类型 * 做者: ZhouZhengyi * 建立时间: 2020/4/5 17:29 */ @Override public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) { //全部的类和方法,只要存在调用openConnection方法的指令,就进行hook if(opcode == Opcodes.INVOKEVIRTUAL && owner.equals("java/net/URL") && name.equals("openConnection")&& desc.equals("()Ljava/net/URLConnection;")){ mv.visitMethodInsn(INVOKEVIRTUAL,"java/net/URL", "openConnection", "()Ljava/net/URLConnection;", false); super.visitMethodInsn(INVOKESTATIC,"org/zzy/lib/largeimage/aop/urlconnection/UrlConnectionHook","process","(Ljava/net/URLConnection;)Ljava/net/URLConnection;",false); }else if(opcode == Opcodes.INVOKEVIRTUAL && owner.equals("java/net/URL") && name.equals("openConnection")&& desc.equals("(Ljava/net/Proxy;)Ljava/net/URLConnection;")){ //public URLConnection openConnection(Proxy proxy) mv.visitMethodInsn(INVOKEVIRTUAL,"java/net/URL", "openConnection", "(Ljava/net/Proxy;)Ljava/net/URLConnection;", false); super.visitMethodInsn(INVOKESTATIC,"org/zzy/lib/largeimage/aop/urlconnection/UrlConnectionHook","process","(Ljava/net/URLConnection;)Ljava/net/URLConnection;",false); }else{ super.visitMethodInsn(opcode, owner, name, desc, itf); } } } 复制代码
这样咱们就成功把由OkHttp实现的HttpURLConnection返回给使用者。
HttpUrlConnection字节码插桩部分到这里就结束了,剩下的逻辑也都在Library中。
Library端主要完成这么几件事:
1.负责初始化并接收用户的配置。
2.从框架的回调中获得所需的数据。
3.对超标的图片数据进行保存。
4.对超标的图片进行展现。
LargeImage类负责初始化和接收用户的配置,是用户直接操做的类,该类被设置成了单例,而且采用链式调用的方式接收用户的配置。经过该类能够设置图片的文件大小阈值,图片所占内存大小的阈值,OkHttp应用拦截器的添加,OkHttp网络拦截器的添加等配置。
LargeImage.getInstance() .install(this)//必定要调用该方法进行初始化 .setFileSizeThreshold(400.0)//设置文件大小阈值单位为KB (可选) .setMemorySizeThreshold(100)//设置内存占用大小阈值单位为KB (可选) .setLargeImageOpen(true)//是否开启大图监控,默认为开启,若是false,则不会在大图列表和弹窗显示超标图片 (可选) .addOkHttpInterceptor(new CustomGlobalInterceptor())//添加OKhttp自定义全局应用监听器 (可选) .addOkHttpNetworkInterceptor(new CustomGlobalNetworkInterceptor())//添加Okhttp值得你故意全局网络监听器 (可选) .setDns(new CustomHttpDns);//设置自定义的全局DNS,能够本身实现HttpDns (可选) 复制代码
当咱们在插件端将字节码插入到框架之后,框架会自动回调咱们自定义的方法,在这些方法中就能够获取到图片的数据,因此关于这一块没什么好说的,都比较简单,无非就是获取到数据之后调用相关类的方法保存数据,并不作过多的业务处理。这里值得一说的是,在HttpUrlConnection进行Hook时,咱们提到要自定义HttpUrlConnection而且使用OkHttp来实现,这部分的实现不用咱们本身来完成,在OkHttp3.14版本以前有提供一个叫ObsoleteUrlFactory的类,已经帮咱们实现好了,只是从3.14版本之后该类被去掉了,咱们只须要把这个类拷贝过来直接使用就行。
获取到图片数据之后,咱们就要进行保存,这部分的逻辑由LargeImageManager负责,LargeImageManager类也被设计成了单例。既然是要对数据进行保存,那么咱们确定是有选择性的保存,也就是只保存超标的图片信息,没有超标的图片,咱们就无论了。而保存的超标信息是为了向用户进行报警。
在实现该类的时候遇到了这么几个问题,首先因为咱们分别Hook了OkHttp和图片框架,因此在加载一张网络图片的时候,咱们会先收到OkHttp的回调,在这里咱们能够获得图片的文件大小信息,而后再收到图片框架的回调,获得图片所占用的内存大小信息。咱们前面提到咱们须要保存超标的图片信息,而对超标图片的定义是文件大小超标或者内存占用超标,因此咱们在OkHttp回调的时候是没办法知道内存是否超标的,由于图片框架有可能会对图片进行压缩,那么咱们在OkHttp回调时就不用判断当前图片是否保存,而是一概保存下来,将是否保存的判断延迟到图片框架回调时。在图片框架回调时,咱们就能同时拥有文件大小和内存占用的数据,若是其中之一超标咱们则保存,若是都不超标,咱们再将数据删除。
其次咱们还遇到了这样一个问题,当我使用Glide框架加载一张网络图片时,咱们假设这张图片文件大小超标,可是内存不超标,那么咱们会记录该图片的全部信息。可是在第二次启动APP时,因为Glide在磁盘中缓存了该图片,就不会再次调用OkHttp去下载图片,那么这时候咱们只能收到图片框架的回调,换句话说咱们只能获得图片所占用内存的数据,若是这时候图片内存不超标,那么咱们就会删除此图片的信息,也就不会提示用户。为了解决这个问题,咱们就必须在SD卡中保存超标图片的完整信息,这样就算图片框架从缓存中加载图片,咱们也能获得图片的文件大小信息。
咱们应该如何将超标图片的信息保存到本地呢?用SharedPreferences?仍是数据库?由于使用场景会频繁的增长,删除和修改数据,而SP每次都是全量写入,也就是说SP在每次写入数据以前都会把xml文件更名为备份文件,而后再从xml文件中读取出数据与新增数据合并再写入到新的xml文件中,若是执行成功再将备份xml文件删除,这样效率过低了。至于数据库的效率跟SP也差不了太多,并且还要防止忽然间奔溃致使数据没保存上的状况。这就要求使用的组件具备实时写入的能力,那么mmap内存映射文件正好适合这种场景,经过mmap内存映射文件,可以提供一段可供随时写入的内存块,APP只管往里面写数据,由操做系统负责将内存回写到文件,而没必要担忧crash致使数据丢失。由微信开源的MMKV就是基于mmap内存映射的key-value组件,它十分的高效,具备增量更新的能力。下面是微信团队对MMKV,SP,SQlite的对比测试数据。
单进程状况下,在华为 Mate 20 Pro 128G,Android 10手机上,每组操做重复 1k 次,结果以ms为单位,能够看见MMKV的效率很高。
使用了MMKV,就解决了图片框架从缓存加载数据时,得不到图片文件大小的问题。可是另一个问题出现了,使用MMKV之后,咱们将超标的图片数据都保存到了本地,若是超标图片以后一直未使用,那么咱们就要一直保存着吗?也就是说咱们什么时候清理MMKV保存的数据?使用LRU算法?也许可行,可是我这里使用了一个稍微简单一点的实现方式,首先咱们设置一个清理值,达到该值就开始执行清理操做,这里我将默认值设置成了20,固然这个值是能够经过咱们提供的接口进行修改的。在超标图片bean类中也增长一个记录当前图片未使用次数的字段。而后程序每次启动时会对当前启动次数加1,而且对MMKV中保存的超标图片未使用次数加1,若是图片被加载一次,超标图片中的未使用次数就重置为0。当启动次数达到清理值,那么咱们就遍历MMKV,将未使用次数到20的图片信息进行删除,再重置当前启动次数。
对于超标图片显示,这里采起了两种查看方式,一种是经过弹窗提示,另一种是经过列表展现。
这里没什么好说的,主要注意一下悬浮窗权限的问题。
在实现列表展现的时候,我纠结过列表中的数据是展现全部的超标图片呢?仍是本次启动加载到的超标图片?最后决定仍是展现本次加载到的超标图片,主要有这么几点考虑,首先若是加载全部超标图片,那么势必要从本地读取超标图片的数据,若是数据不少的话,列表就会很长,若是用户只是想看当前页面超标的图片信息,那么查找会很不方便。其次若是要加载历史的超标图片信息,涉及到一个问题,加载超标图片信息就要加载超标图片的略缩图,那么问题来了,咱们Hook了四大图片加载框架,若是咱们在加载略缩图时采用了这四大图片框架,那么就会再次收到图片信息,因为加载的是略缩图,因此图片框架确定会对图片进行压缩,那么就会更新超标图片的信息,这样就会致使因为加载了一张超标图片的略缩图致使超标图片信息被更新为未超标,从而被删除。这是咱们不但愿看见的,而只加载本次碰见的超标图片,咱们能够将本次超标的图片缓存在内存中,在列表展现的时候直接显示缓存的Bitmap对象,这样咱们就不须要使用图片加载框架,也就不存在这个问题。
到此大图监控的原理就讲解的差很少了,你们能够到个人Github上结合源码进行分析,若是以为对您有用,能够给我点一个Star,该项目后续也会继续的进行迭代。在这里要感谢滴滴开源的Dokit框架以及Hunter开源库。最后你们也能够看看字节跳动开源的ByteX库,该库是一个字节码插件开发平台,集成了不少有用的插件,更多详情能够查看ByteX的文档。