經過ASM實現大圖監控

1.背景

最近看滴滴開源的Dokit框架中有一個大圖監控的功能,能夠對圖片的文件大小和所佔用的內存大小設置一個閾值,當圖片超過該值的時候進行提示。這個功能對於咱們在作APK體積壓縮,內存管理的時候仍是頗有用的,好比當咱們要從後臺返回的鏈接中加載一張圖片,這張圖片的大小咱們是不知道的,雖然如今你們都使用Glide等三方 圖片加載框架,框架會自動對圖片進行壓縮,可是依然會出現壓縮後所佔內存超過預期的狀況。這時候咱們能夠在開發、測試和預生產階段使用大圖監控來識別出那些超標的圖片。java

2.需求

在討論如何作以前,咱們必須明確咱們要作什麼。該大圖監控框架我以爲應該實現如下功能:android

  1. 能對圖片的文件大小和所佔用的內存大小設置閾值,超過其中之一則報警。
  2. 可以獲得超標圖片的詳細信息,包括當前文件大小,所佔用內存,圖片分辨率,圖片的略縮圖,圖片的加載地址,view的尺寸。
  3. 可以經過彈窗或者列表的方式查看當前超標的圖片信息。
  4. 不管是本地加載圖片仍是網絡加載圖片都可以進行監控。

3.實現思路

​ 要實現對圖片文件大小和所佔內存的監控,那麼咱們就得先知道圖片的文件大小和加載該圖片所耗費的內存。目前加載圖片通常都使用第三方框架,因此能夠對經常使用的圖片加載框架進行Hook,這裏主要對主流的四種圖片加載框架進行Hook操做。git

  1. Glidegithub

  2. Picasso算法

  3. Fresco數據庫

  4. Image Loaderapi

​ 以從網絡加載一張圖片舉例,當使用圖片框架加載一張網絡圖片時,會使用OkHttp或者是HttpUrlconnection去下載該圖片,這時候咱們就能獲得圖片文件的大小。當圖片框架將圖片文件構形成Bitmap對象之後,咱們又能獲得其所佔用的內存,這樣咱們就同時的獲得了圖片的文件大小和所佔用的內存。那麼這裏咱們也必須對OkHttp和HttpUrlconnection進行Hook。緩存

​ 既然要對三方框架進行Hook操做,那麼咱們如何進行Hook呢?在選擇Hook的實現方案時,我對如下幾種方案進行了調研。服務器

  1. 反射+動態代理微信

  2. ASM

  3. AspectJ

  4. ByteBuddy

​ 首先反射+動態代理 只能在程序運行時進行,這樣會影響效率,因此暫不考慮。其餘三種方案都可以在編譯期進行字節碼插樁,ASM直接操縱字節碼,閱讀起來不那麼友好。AspectJ之前用過,常常出一些莫名其妙的問題,體驗不是很好。ByteBuddy 封裝了ASM,聽說效率很高,並且使用JAVA編寫,代碼可讀性好,只是網上的資料太少了,大部分都是那麼幾篇文章再轉發。因此這裏最後選擇了ASM實現。

有了ASM進行字節碼插入,那何時將咱們編寫好的字節碼插入到第三方框架中呢?

​ 咱們從Apk打包流程圖中能夠看到,在生成dex文件以前,咱們能夠獲取到本項目和第三方庫的class文件,那麼咱們是否能夠在此處將咱們編寫的字節碼插入呢?答案是確定的,咱們在谷歌官網上找到這麼一個界面- Transform Api

​ 網頁上講從Android Gralde插件1.5.0版本開始,添加了Transform API,來容許第三方插件在通過編譯的class文件轉換爲dex文件以前對其進行操做。Gradle會按照如下順序執行轉換:JaCoCo->第三方插件->ProGuard。其中第三方插件的執行順序與第三方插件添加順序一致,而且第三方插件沒法經過Api控制轉換的執行順序。

​ 有了Transform API +ASM咱們就可以將咱們本身編寫的字節碼插入到第三方框架的class文件中,從而在編譯器完成插樁。

4.具體實現

​ 如今咱們已經決定了用ASM在編譯期經過Transform API進行插樁。那麼具體該怎麼實現呢?咱們回想一下咱們須要實現的功能,咱們要對圖片進行監控,爲了監控咱們要獲取圖片的數據,獲得數據後發現超標圖片咱們要給與提示。這意味着有兩部分功能,一部分負責經過插樁獲取數據,另一部分負責顯示超標數據。因而整個大圖監控項目咱們採用Gradle自定義插件+Android Library的形式。

  1. largeimage-plugin:自定義Gradle插件,主要負責將咱們編寫的字節碼插入到class文件。
  2. largeimage:Andriod Library,主要負責將獲取到的圖片數據進行過濾,保存超標圖片而且以彈窗或者列表的形式呈現給用戶。

​ 如何建立Gralde插件項目在這裏就很少說了,網上有不少教程。網上的大多數教程會告訴你把插件項目名稱改成buildSrc,這樣作有不少好處,尤爲是在代碼編寫階段,能夠採用如下這種形式進行測試

apply plugin:org.zzy.largeimage.LargeImageMonitorPlugin
複製代碼

不須要每次編寫完成之後發佈到maven倉庫,插件項目修改之後,會直接在使用模塊體現出來。

​ 在這裏筆者自建了本地maven庫,而且爲了名稱上的統一,並無將插件項目的名稱改成buildSrc,這兩種形式均可以,你們能夠根據自身的狀況來使用。

4.1 插件端

​ 若是在編譯期存在不少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);
    }
}
複製代碼

該類主要作了三件事:

  1. 判斷當前是不是Release變體,若是是的話就不進行字節碼插樁。緣由很簡單,對超標圖片的監控儘可能在開發和測試階段處理完,不要帶到線上。
  2. 獲取自定義擴展,好比我須要增長一個插樁開關標識,來控制是否進行字節碼加強。
  3. 將自定義Transform進行註冊。

在代碼中能夠看見,咱們註冊了三個自定義Transform,由於咱們同時要對圖片加載框架和網絡請求庫進行插樁。

  1. LargeImageTransform:主要負責對Glide,Picasso,Fresco,ImageLoader進行字節碼操做。
  2. OkHttpTransform:主要負責對OkHttp進行字節碼操做。
  3. UrlConnectionTransform:主要負責對UrlConnection進行字節碼操做。
4.1.1 Hook圖片加載庫

​ 因爲使用了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();
}
複製代碼

從這段代碼中咱們能夠知道幾點:

  1. requestListeners是一個List。
  2. 回調方法onResourceReady中有咱們所須要的全部數據。

這樣一來咱們只須要在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中。

4.1.2 Hook OkHttp

​ 咱們前面說到,當咱們使用圖片框架加載一張網絡圖片時,圖片框架會先從網絡將圖片下載,而後再加載。以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中。

4.1.3 Hook HttpUrlConnection

​ 可能不少人會以爲,如今還有人用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中。

4.2 Library端

Library端主要完成這麼幾件事:

1.負責初始化並接收用戶的配置。

2.從框架的回調中獲得所需的數據。

3.對超標的圖片數據進行保存。

4.對超標的圖片進行展現。

4.2.1 初始化與配置

​ 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 (可選)
複製代碼
4.2.2 獲取數據

​ 當咱們在插件端將字節碼插入到框架之後,框架會自動回調咱們自定義的方法,在這些方法中就能夠獲取到圖片的數據,因此關於這一塊沒什麼好說的,都比較簡單,無非就是獲取到數據之後調用相關類的方法保存數據,並不作過多的業務處理。這裏值得一說的是,在HttpUrlConnection進行Hook時,咱們提到要自定義HttpUrlConnection而且使用OkHttp來實現,這部分的實現不用咱們本身來完成,在OkHttp3.14版本以前有提供一個叫ObsoleteUrlFactory的類,已經幫咱們實現好了,只是從3.14版本之後該類被去掉了,咱們只須要把這個類拷貝過來直接使用就行。

4.2.3 保存數據

​ 獲取到圖片數據之後,咱們就要進行保存,這部分的邏輯由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的圖片信息進行刪除,再重置當前啓動次數。

4.2.4 超標圖片顯示

​ 對於超標圖片顯示,這裏採起了兩種查看方式,一種是經過彈窗提示,另一種是經過列表展現。

img

img

這裏沒什麼好說的,主要注意一下懸浮窗權限的問題。

​ 在實現列表展現的時候,我糾結過列表中的數據是展現全部的超標圖片呢?仍是本次啓動加載到的超標圖片?最後決定仍是展現本次加載到的超標圖片,主要有這麼幾點考慮,首先若是加載全部超標圖片,那麼勢必要從本地讀取超標圖片的數據,若是數據不少的話,列表就會很長,若是用戶只是想看當前頁面超標的圖片信息,那麼查找會很不方便。其次若是要加載歷史的超標圖片信息,涉及到一個問題,加載超標圖片信息就要加載超標圖片的略縮圖,那麼問題來了,咱們Hook了四大圖片加載框架,若是咱們在加載略縮圖時採用了這四大圖片框架,那麼就會再次收到圖片信息,因爲加載的是略縮圖,因此圖片框架確定會對圖片進行壓縮,那麼就會更新超標圖片的信息,這樣就會致使因爲加載了一張超標圖片的略縮圖致使超標圖片信息被更新爲未超標,從而被刪除。這是咱們不但願看見的,而只加載本次碰見的超標圖片,咱們能夠將本次超標的圖片緩存在內存中,在列表展現的時候直接顯示緩存的Bitmap對象,這樣咱們就不須要使用圖片加載框架,也就不存在這個問題。

5.寫在最後

​ 到此大圖監控的原理就講解的差很少了,你們能夠到個人Github上結合源碼進行分析,若是以爲對您有用,能夠給我點一個Star,該項目後續也會繼續的進行迭代。在這裏要感謝滴滴開源的Dokit框架以及Hunter開源庫。最後你們也能夠看看字節跳動開源的ByteX庫,該庫是一個字節碼插件開發平臺,集成了不少有用的插件,更多詳情能夠查看ByteX的文檔。

相關文章
相關標籤/搜索