淺談Android熱更新的前因後果

語言: CN / TW / HK

作者:Petterp 連結:https://juejin.cn/post/6968827359197659166

什麼是熱修復?它可以幫我解決什麼問題?

其實簡單來說,熱修復就是一種動態載入技術,比如你線上某個產品此時出現了bug:

傳統流程:debug->測試->釋出新版 ->使用者安裝(各平臺稽核時間不一,而且使用者需要手動下載或者更新) 整合熱修復情況下:dubug->測試->推送補丁->自動下載補丁修復 (使用者不知情況,自動下載補丁並修復)

對比下來,我們不難發現,傳統流程存在這幾大弊端:

  1. 發版代價大

  2. 使用者下載安裝的成本過高

  3. bug修復不及時,取決於各平臺的稽核時間等等

熱修復產生背景?

  • app發版成本高

  • 用H5整合某些經常變動的業務邏輯,但這種方案需要學習成本,而且對於無法轉為H5形式的程式碼仍舊是無法修復;

  • Instant Run

上面三個原因中,我們主要來談一下 Instant Run:

Android Studio2.0時,新增了一個 Instant Run的功能,而各大廠的熱修復方案,在程式碼,資源等方面的實現都是很大程度上參考了Instant Run的程式碼。所以可以說 Instant Run 是推進Android 熱修復的主因。

那Instant Run內部是如何做到這一點呢?

  1. 構建一個新的 AssetManager(資源管理框架),並通過反射呼叫這個 addAssetPath,把這個完整的新資源加入到 AssetManager中,這樣就得到了一個含有所有新資源的 AssetManager.

  2. 找到所有之前引用到原有AssetManager的地方,通過反射,把引用出替換為新的AssetManager.

參考自 <深入探索Android熱修復技術原理>

熱修復的原理是什麼?

我們都知道熱修復都相當於動態載入,那麼動態載入到底動態在哪裡了呢。

說到這個就躲不過一個關鍵點 ClassLoader(類載入器) ,所以我們先從Java開始。

我們都知道Java的類載入器有四種,分別為:

  • Bootstarp ClassLoader

  • Extension ClassLoader

  • App ClassLoader 載入應用ClassLoader

  • Custom ClassLoader 載入自己的class檔案

類載入過程如下:

過程:載入-連線(驗證-準備-解析)-初始化

  1. 載入 將類的資訊(位元組碼)從檔案中獲取並載入到JVM的記憶體中

  2. 連線 驗證:檢查讀入的結構是否符合JVM規範 準備:分配一個結構來儲存類的資訊 解析:將類的常量池中的所有引用改變成直接引用

  3. 初始化 執行靜態初始化程式,把靜態變數初始化成指定的值

其中用到的三個主要機制:

  1. 雙親委託機制

  2. 全盤負責機制

  3. 快取機制

其實後面的兩個機制都是主要從雙親委託機制延續而來。

在說明了Java 的ClassLoader之後,我們接下來開始Android的ClassLoader,不同於Java的是,Java中的ClassLoader可以載入 jar 檔案和 Class檔案,而Android中載入的是Dex檔案,這就需要重新設計相關的ClassLoader類。所以Android 的ClassLoader 我們會說的詳細一點

原始碼解析

在這裡,順便提一下,這裡貼的程式碼版本是Android 9.0,在8.0以後,PathClassLoader和DexClassLoader並沒有什麼區別,因為唯一的一個區別引數 optimizedDirectory已經被廢棄。

首先是 loadClass,也就是我們類載入的核心方法方法:

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
        // First, check if the class has already been loaded
        //查詢當前類是否被載入過
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                if (parent != null) {
                  //檢視父載入器是否載入過
                    c = parent.loadClass(name, false);
                } else {
                  //如果沒有載入過,呼叫根載入器載入,雙親委託模式的實現
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }
      
      //找到根載入器依然為null,只能自己載入了
            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                c = findClass(name);
            }
        }
        return c;
}

這裡有個問題,JVM雙親委託機制可以被打破嗎?先保留疑問。

我們主要去看他的 findClass方法

protected Class<?> findClass(String name) throws ClassNotFoundException {
    throw new ClassNotFoundException(name);
}

這個方法是一個null實現,也就是需要我們開發者自己去做。

從上面基礎我們知道,在Android中,是有 PathClassLoader和 DexClassLoader,而它們又都繼承與 BaseDexClassLoader,而這個BaseDexClassLoader又繼承與 ClassLoader,並將findClass方法交給子類自己實現,所以我們從它的兩個子類 PathClassLoader和 DexClassLoader入手,看看它們是怎麼處理的。

這裡礙於Android Studio無法檢視相關具體實現原始碼,所以我們從原始碼網站上查詢:

PathClassLoader

public class PathClassLoader extends BaseDexClassLoader {
    
    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }
  
   // dexPath: 需要載入的檔案列表,檔案可以是包含了 classes.dex 的 JAR/APK/ZIP,也可以直接使用 classes.dex 檔案,多個檔案用 “:” 分割
 // librarySearchPath: 存放需要載入的 native 庫的目錄
 // parent: 父 ClassLoader
    public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}

由註釋看可以發現PathClassLoader被用來載入本地檔案系統上的檔案或目錄,因為它呼叫的 BaseDexClassLoader的第二個引數為null,即未傳入優化後的Dex檔案。

注意:Android 8.0之後,BaseClassLoader第二個引數為(optimizedDirectory)為null,所以DexClassLoader與PathClassLoader並無區別

DexClassLoader

public class DexClassLoader extends BaseDexClassLoader {
   // dexPath: 需要載入的檔案列表,檔案可以是包含了 classes.dex 的 JAR/APK/ZIP,也可以直接使用 classes.dex 檔案,多個檔案用 “:” 分割
 // optimizedDirectory: 存放優化後的 dex,可以為空
 // librarySearchPath: 存放需要載入的 native 庫的目錄
 // parent: 父 ClassLoader  
  public DexClassLoader(String dexPath, String optimizedDirectory,
            String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}

DexClassLoader用來載入jar、apk,其實還包括zip檔案或者直接載入dex檔案,它可以被用來執行未安裝的程式碼或者未被應用載入過的程式碼,也就是我們修復過的程式碼。

注意:Android 8.0之後,BaseClassLoader第二個引數為(optimizedDirectory)為null,所以DexClassLoader與PathClassLoader並無區別

從上面我們可以看到,它們都繼承於BaseDexClassLoader,並且它們真正的實現行為都是呼叫的父類方法,所以我們來看一下BaseDexClassLoader.

BaseDexClassLoader

public class BaseDexClassLoader extends ClassLoader {

  private static volatile Reporter reporter = null;
 
  //核心關注點
   private final DexPathList pathList;

 BaseDexClassLoader 建構函式有四個引數,含義如下:

 // dexPath: 需要載入的檔案列表,檔案可以是包含了 classes.dex 的 JAR/APK/ZIP,也可以直接使用 classes.dex 檔案,多個檔案用 “:” 分割
 // optimizedDirectory: 存放優化後的 dex,可以為空
 // librarySearchPath: 存放需要載入的 native 庫的目錄
 // parent: 父 ClassLoader
   public BaseDexClassLoader(String dexPath, File optimizedDirectory,
           String librarySearchPath, ClassLoader parent) {
       //classloader,dex路徑,目錄列表,內部資料夾
       this(dexPath, optimizedDirectory, librarySearchPath, parent, false);
   }


   public BaseDexClassLoader(String dexPath, File optimizedDirectory,
           String librarySearchPath, ClassLoader parent, boolean isTrusted) {
       super(parent);
       this.pathList = new DexPathList(this, dexPath, librarySearchPath, null, isTrusted);

       if (reporter != null) {
           reportClassLoaderChain();
       }
   }
  
  ...
 
    public BaseDexClassLoader(ByteBuffer[] dexFiles, ClassLoader parent) {
        // TODO We should support giving this a library search path maybe.
        super(parent);
        this.pathList = new DexPathList(this, dexFiles);
    }
 
  //核心方法
    @Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
     //異常處理
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
     //這裡也只是一箇中轉,關注點在 DexPathList
        Class c = pathList.findClass(name, suppressedExceptions);
        if (c == null) {
            ClassNotFoundException cnfe = new ClassNotFoundException(
                    "Didn't find class \"" + name + "\" on path: " + pathList);
            for (Throwable t : suppressedExceptions) {
                cnfe.addSuppressed(t);
            }
            throw cnfe;
        }
        return c;
    }

 
  ...
}

從上面我們可以發現,BaseDexClassLoader其實也不是主要處理的類,所以我們繼續去查詢 DexPathList.

DexPathList

final class DexPathList {
  //檔案字尾
 private static final String DEX_SUFFIX = ".dex";
 private static final String zipSeparator = "!/";

** class definition context */
private final ClassLoader definingContext;

//內部類 Element
private Element[] dexElements;

public DexPathList(ClassLoader definingContext, String dexPath,
        String librarySearchPath, File optimizedDirectory) {
    this(definingContext, dexPath, librarySearchPath, optimizedDirectory, false);
}

DexPathList(ClassLoader definingContext, String dexPath,
        String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
    if (definingContext == null) {
        throw new NullPointerException("definingContext == null");
    }

    if (dexPath == null) {
        throw new NullPointerException("dexPath == null");
    }

    if (optimizedDirectory != null) {
        if (!optimizedDirectory.exists())  {
            throw new IllegalArgumentException(
                    "optimizedDirectory doesn't exist: "
                    + optimizedDirectory);
        }

        if (!(optimizedDirectory.canRead()
                        && optimizedDirectory.canWrite())) {
            throw new IllegalArgumentException(
                    "optimizedDirectory not readable/writable: "
                    + optimizedDirectory);
        }
    }

    this.definingContext = definingContext;

    ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
    // save dexPath for BaseDexClassLoader
   //我們關注這個 makeDexElements 方法
    this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                                       suppressedExceptions, definingContext, isTrusted);
    this.nativeLibraryDirectories = splitPaths(librarySearchPath, false);
    this.systemNativeLibraryDirectories =
            splitPaths(System.getProperty("java.library.path"), true);
    List<File> allNativeLibraryDirectories = new ArrayList<>(nativeLibraryDirectories);
    allNativeLibraryDirectories.addAll(systemNativeLibraryDirectories);

    this.nativeLibraryPathElements = makePathElements(allNativeLibraryDirectories);

    if (suppressedExceptions.size() > 0) {
        this.dexElementsSuppressedExceptions =
            suppressedExceptions.toArray(new IOException[suppressedExceptions.size()]);
    } else {
        dexElementsSuppressedExceptions = null;
    }
}
  
  
  
static class Element {
 //dex檔案為null時表示 jar/dex.jar檔案
 private final File path;
 
 //android虛擬機器檔案在Android中的一個具體實現
 private final DexFile dexFile;

 private ClassPathURLStreamHandler urlHandler;
 private boolean initialized;

 /**
  * Element encapsulates a dex file. This may be a plain dex file (in which case dexZipPath
  * should be null), or a jar (in which case dexZipPath should denote the zip file).
  */
 public Element(DexFile dexFile, File dexZipPath) {
     this.dexFile = dexFile;
     this.path = dexZipPath;
 }

 public Element(DexFile dexFile) {
     this.dexFile = dexFile;
     this.path = null;
 }

 public Element(File path) {
   this.path = path;
   this.dexFile = null;
 }
 
 public Class<?> findClass(String name, ClassLoader definingContext,
               List<Throwable> suppressed) {
        //核心點,DexFile
           return dexFile != null ? dexFile.loadClassBinaryName(name, definingContext, suppressed)
                   : null;
       }
  
 /**
  * Constructor for a bit of backwards compatibility. Some apps use reflection into
  * internal APIs. Warn, and emulate old behavior if we can. See b/33399341.
  *
  * @deprecated The Element class has been split. Use new Element constructors for
  *             classes and resources, and NativeLibraryElement for the library
  *             search path.
  */
 @Deprecated
 public Element(File dir, boolean isDirectory, File zip, DexFile dexFile) {
     System.err.println("Warning: Using deprecated Element constructor. Do not use internal"
             + " APIs, this constructor will be removed in the future.");
     if (dir != null && (zip != null || dexFile != null)) {
         throw new IllegalArgumentException("Using dir and zip|dexFile no longer"
                 + " supported.");
     }
     if (isDirectory && (zip != null || dexFile != null)) {
         throw new IllegalArgumentException("Unsupported argument combination.");
     }
     if (dir != null) {
         this.path = dir;
         this.dexFile = null;
     } else {
         this.path = zip;
         this.dexFile = dexFile;
     }
 }
  ...
}

  
  
 ...
//主要作用就是將 我們指定路徑中所有檔案轉化為DexFile,同時存到Eelement陣列中
//為什麼要這樣做?目的就是為了讓findClass去實現
private static Element[] makeDexElements(List<File> files, File optimizedDirectory,
  List<IOException> suppressedExceptions, ClassLoader loader, boolean isTrusted) {
  Element[] elements = new Element[files.size()];
  int elementsPos = 0;
  //遍歷所有檔案
  for (File file : files) {
      if (file.isDirectory()) {
          //如果存在資料夾,查詢資料夾內部查詢
          elements[elementsPos++] = new Element(file);
        //如果是檔案
      } else if (file.isFile()) {
          String name = file.getName();
          DexFile dex = null;
        //判斷是否是dex檔案
          if (name.endsWith(DEX_SUFFIX)) {
              // Raw dex file (not inside a zip/jar).
              try {
                 //建立一個DexFile
                  dex = loadDexFile(file, optimizedDirectory, loader, elements);
                  if (dex != null) {
                      elements[elementsPos++] = new Element(dex, null);
                  }
              } catch (IOException suppressed) {
                  System.logE("Unable to load dex file: " + file, suppressed);
                  suppressedExceptions.add(suppressed);
              }
          } else {
              try {
                  dex = loadDexFile(file, optimizedDirectory, loader, elements);
              } catch (IOException suppressed) {
                  /*
                   * IOException might get thrown "legitimately" by the DexFile constructor if
                   * the zip file turns out to be resource-only (that is, no classes.dex file
                   * in it).
                   * Let dex == null and hang on to the exception to add to the tea-leaves for
                   * when findClass returns null.
                   */
                  suppressedExceptions.add(suppressed);
              }

              if (dex == null) {
                  elements[elementsPos++] = new Element(file);
              } else {
                  elements[elementsPos++] = new Element(dex, file);
              }
          }
          if (dex != null && isTrusted) {
            dex.setTrusted();
          }
      } else {
          System.logW("ClassLoader referenced unknown path: " + file);
      }
  }
  if (elementsPos != elements.length) {
      elements = Arrays.copyOf(elements, elementsPos);
  }
  return elements;
}
  
  ---
 private static DexFile loadDexFile(File file, File optimizedDirectory, ClassLoader loader,
Element[] elements)throws IOException {
     //判斷可複製資料夾是否為null
     if (optimizedDirectory == null) {
         return new DexFile(file, loader, elements);
     } else {
        //如果不為null,則進行解壓後再建立
         String optimizedPath = optimizedPathFor(file, optimizedDirectory);
         return DexFile.loadDex(file.getPath(), optimizedPath, 0, loader, elements);
     }
 }
  
  -----
public Class<?> findClass(String name, List<Throwable> suppressed) {
    //遍歷初始化好的DexFile陣列,並由Element呼叫 findClass方法去生成
    for (Element element : dexElements) {
       //
        Class<?> clazz = element.findClass(name, definingContext, suppressed);
        if (clazz != null) {
            return clazz;
        }
    }

    if (dexElementsSuppressedExceptions != null) {
        suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
    }
    return null;
}

上面的程式碼有點複雜,我摘取了其中一部分我們需要關注的點,便於我們進行分析:

在BaseDexClassLoader中,我們發現最終載入類的是由 DexPathList 來進行的,所以我們進入了 DexPathList 這個類中,我們可以發現 在初始化的時候,有一個關鍵方法需要我們注意 makeDexElements。而這個方法的主要作用就是將 我們指定路徑中所有檔案轉化為 DexFile ,同時存到 Eelement 陣列中。

而最開始呼叫的 DexPathList中的findClass() 反而是由Element 呼叫的 findClass方法,而Emement的findClass方法中實際上又是 DexFile 呼叫的 loadClassBinaryName 方法,所以帶著這個疑問,我們進入 DexFile這個類一查究竟。

DexFile

public final class DexFile {
*
 If close is called, mCookie becomes null but the internal cookie is preserved if the close
 failed so that we can free resources in the finalizer.
/
@ReachabilitySensitive
private Object mCookie;

private Object mInternalCookie;
private final String mFileName;
...
DxFile(String fileName, ClassLoader loader, DexPathList.Element[] elements) throws IOException {
     mCookie = openDexFile(fileName, null, 0, loader, elements);
     mInternalCookie = mCookie;
     mFileName = fileName;
     //System.out.println("DEX FILE cookie is " + mCookie + " fileName=" + fileName);
 }
  
 //關注點在這裡
public Class loadClassBinaryName(String name, ClassLoader loader, List<Throwable> suppressed) {
     return defineClass(name, loader, mCookie, this, suppressed);
 }

//
 private static Class defineClass(String name, ClassLoader loader, Object cookie,
                                  DexFile dexFile, List<Throwable> suppressed) {
     Class result = null;
     try {
       //這裡呼叫了一個 JNI層方法
         result = defineClassNative(name, loader, cookie, dexFile);
     } catch (NoClassDefFoundError e) {
         if (suppressed != null) {
             suppressed.add(e);
         }
     } catch (ClassNotFoundException e) {
         if (suppressed != null) {
             suppressed.add(e);
         }
     }
     return result;
 }

  private static native Class defineClassNative(String name, ClassLoader loader, Object cookie,
                                               DexFile dexFile)
         throws ClassNotFoundException, NoClassDefFoundError;

我們從 loadClassBinaryName 方法中發現,呼叫了 defineClass 方法,最終又呼叫了 defineClassNative 方法,而 defineClassNative 方法是一個JNI層的方法,所以我們無法得知具體如何。但是我們思考一下,從開始的 BaseDexClassLoader一直到現在的 DexFile,我們一直從入口找到了最底下,不難猜測,這個 defineClassNative 方法內部就是 C/C++幫助我們以位元組碼或者別的生成我們需要的 dex檔案,這也是最難的地方所在。

最後我們再用一張圖來總結一下Android 中類載入的過程。

在瞭解完上面的知識之後,我們來總結一下,Android中熱修復的原理?

Android中既然已經有了DexClassLoader和 PathClassLoader,那麼我在載入過程中直接替換我自己的Dex檔案不就可以了,也就是先載入我自己的Dex檔案不就行了,這樣不就實現了熱修復。

熱修復的注意事項

有了熱修復,我們就可以為所欲為了嗎?

開始講騷話:

並不是,熱修復受限於各種機型裝置,而且也有失敗的可能性,所以我們開發者,對於補丁包同樣也要抱有敬畏之心。

對於熱修復同樣也由於嚴格的過程,但是我們日常開發至少要保證以下幾點:

debug-> 打補丁包->開發裝置測試->灰度下發(條件下發)->全量下發

下面針對我開發中遇到的問題,給出解決方案。

熱修復與多渠道

多渠道打包使用 美團 的一鍵打包方案。補丁包的話,其實並不會影響,因為補丁包一般改動的程式碼相同,但前提是需要保證我們每個渠道基準包沒問題。如果改動程式碼有區別,那就需要針對這個渠道單獨打補了。

自動化構建與熱修復

Android開發一般集成了 Jenkins 或者別的自動化打包工具,我們一般基準包都在 app/build/bakApk目錄下,所以我們可以通過編寫 shell 命令,在jenkins中打包時,將生成的基準包移動到一個特定的資料夾即可。tinker,Sophix都是支援伺服器後臺的,所以我們也可以通過自動化構建工具上傳補丁包,如果相應的熱修復框架不支援伺服器管理的話,那麼可以將補丁包上傳的指定的資料夾,然後我們app開啟時,訪問我們的伺服器介面下拉最新的補丁包,然後在service中合成。不過 Tinker(bugly) , Sophix 都是支援後臺管理,所以具體使用那種方案我們自行選擇。

關於熱修復的到這裡就基本寫完了,散散落落居然寫了這麼多,其實難的不是熱修復,而是Android中類載入的過程及一些基礎相關知識,理解了這些,我們才能真正明白那些優秀的框架到底是怎樣去修復的。

如果本文有幫到你的地方,不勝榮幸。如果有什麼地方有錯誤或者疑問,也歡迎大家提出。

掃描二維碼

獲取更多精彩

Android補給站

點個 在看 你最好看