Android SDK的輕量級熱修更新

需求

最近要做sdk的熱更新
因為需求方的sdk其實是jar包,只有class文件,沒有資源文件,所以此文只針對class文件更新

首先羅列下一個輕量級更新框架的功能最小邊界:

  • 需要配置文件描述更新包
  • 更新包需要在線下載,并檢驗包的完整性
  • 只針對特定版本
  • 針對特定渠道
  • 補丁包的版本控制

調(diào)研

市面上比較流行的熱更新有Tinker、QZone、AndFix、Sophix、Robust、Dexposed這些大家都很熟悉,但絕大部分是針對app的
先回顧下class文件是如何實現(xiàn)熱修復(fù)的:

概念:

DexClassLoader和PathClassLoader

回顧下Android中Classloader的知識
DexClassLoaderPathClassLoader都是繼承自BaseDexClassLoader
看下Android10的代碼

public class DexClassLoader extends BaseDexClassLoader {
    /**
     * ...
     */
    public DexClassLoader(String dexPath, String optimizedDirectory,
            String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}

public class PathClassLoader extends BaseDexClassLoader {
    public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}

可見這兩個類沒有本質(zhì)上區(qū)別,網(wǎng)上說的optimizedDirectory在Api26之后已廢棄
只不過PathClassLoader多了構(gòu)造函數(shù)參數(shù)sharedLibraryLoaders,可以加載系統(tǒng)類

ClassLoader

在Java的ClassLoader中:

 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
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    c = findClass(name);
                }
            }
            return c;
    }

findLoadedClass是保證加載的類不會被加載
findBootstrapClassOrNull返回的是空
所以調(diào)用的findClass方法

BaseDexClassLoader的findClass方法:

 @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // First, check whether the class is present in our shared libraries.
        if (sharedLibraryLoaders != null) {
            for (ClassLoader loader : sharedLibraryLoaders) {
                try {
                    return loader.loadClass(name);
                } catch (ClassNotFoundException ignored) {
                }
            }
        }
        // Check whether the class in question is present in the dexPath that
        // this classloader operates on.
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        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;
    }

可以由傳進(jìn)來的sharedLibraryLoaders來加載,但DexClassLoader的sharedLibraryLoaders為空
是交由DexPathList類來處理實現(xiàn)findClass

DexPathList

 DexPathList(ClassLoader definingContext, String dexPath,
            String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
       ...
        this.definingContext = definingContext;

        ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
        // save dexPath for BaseDexClassLoader
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                                           suppressedExceptions, definingContext, isTrusted);

        //native相關(guān)
        this.nativeLibraryDirectories = splitPaths(librarySearchPath, false);
        this.systemNativeLibraryDirectories =
                splitPaths(System.getProperty("java.library.path"), true);
        this.nativeLibraryPathElements = makePathElements(getAllNativeLibraryDirectories());
        ...
    }

通過Element[]數(shù)組來存儲dex,makeDexElements這個方法:

 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;
              if (name.endsWith(DEX_SUFFIX)) {
                  // Raw dex file (not inside a zip/jar).
                  try {
                      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) {
                      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;
    }

DexPathList在構(gòu)造函數(shù)通過makeDexElements方法,生成Element[]數(shù)組
在BaseDexClassLoader中調(diào)用的是DexPathList的findClass方法:

public Class<?> findClass(String name, List<Throwable> suppressed) {
        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;
    }

很清楚的看到element是按順序取出
也就是說兩個element如果有相同的class,第二個就不會加載,如果將目標(biāo)dex包插隊到列隊最前面,則可以實現(xiàn)目標(biāo)dex包中的class替換掉其他dex中的class
熱修復(fù)的實現(xiàn):

  1. DexClassLoader加載目標(biāo)dex
  2. 將目標(biāo)的和系統(tǒng)的 dexElements 進(jìn)行合并
  3. 賦值給系統(tǒng)的 pathList
 public void loadCustomDex(Context mContext){
      //遍歷所有修復(fù)的dex
      File fileDir = yourfilepath;
      File[] listFiles = fileDir.listFiles();
      for (File f : listFiles){
          if(isValid(f)){
              dexList.add(f);
          }
      }
      //合并與應(yīng)用
      PathClassLoader pathClassLoader = (PathClassLoader) mContext.getClassLoader();
      for (File f : dexList){
          //加載指定的dex文件。
          DexClassLoader dexClassLoader = new DexClassLoader(f.getAbsolutePath(),null,null,pathClassLoader);
          //合并
          Object dexObj = getPathList(dexClassLoader);
          Object pathObj = getPathList(pathClassLoader);
          Object dexElementsList = getDexElements(dexObj);
          Object pathElementsList = getDexElements(pathObj);
          Object newElements = combineArray(dexElementsList,pathElementsList);
          //給PathList里面的dexElements賦值
          Object pathList = getPathList(pathClassLoader);
          setField(pathList,pathList.getClass(),"dexElements",newElements);
      }
  }
 
    
private Object getPathList(Object obj) throws Exception {
        return reflectField(obj,Class.forName("dalvik.system.BaseDexClassLoader"),"pathList");
}

private Object getDexElements(Object obj) throws Exception {
        return reflectField(obj,obj.getClass(),"dexElements");
}

private static Object combineArray(Object array1, Object array2) {
        Class<?> localClass = array1.getClass().getComponentType();
        int i = Array.getLength(array1);
        int j = i + Array.getLength(array2);
        Object result = Array.newInstance(localClass, j);
        for (int k = 0; k < j; ++k) {
            if (k < i) {
                Array.set(result, k, Array.get(array1, k));
            } else {
                Array.set(result, k, Array.get(array2, k - i));
            }
        }
        return result;
    }

方案:

上述的熱更新實現(xiàn)方式有兩個問題需要解決:

  • app中可以在Application中的attachBaseContext中去做插入dex,SDK的類是不知道何時被調(diào)用的
  • ISPREVERIFIED問題

如果一個類的static方法,private方法,override方法以及構(gòu)造函數(shù)中引用了其他類,而且這些類都屬于同一個dex文件,此時該類就會被打上CLASS_ISPREVERIFIED
如果在運行時被打上CLASS_ISPREVERIFIED的類引用了其他dex的類,就會報錯

解決ISPREVERIFIED問題,常用的就是“插裝”(往字節(jié)碼里插入自定義的字節(jié)碼),我們只要讓所有類都引用其他dex中的某個類就可以了:

  1. 當(dāng)安裝apk的時候,classes.dex內(nèi)的類都會引用一個在不相同dex中的XX類,這樣就防止了類被打上CLASS_ISPREVERIFIED的標(biāo)志了,只要沒被打上這個標(biāo)志的類都可以進(jìn)行打補丁操作
  2. 我們需要在源碼編譯成字節(jié)碼之后,在字節(jié)碼中進(jìn)行插入操作。對字節(jié)碼進(jìn)行操作的框架有很多,但是比較常用的則是ASM和javaassist

那么有沒有簡單點的方法:

  1. 將工程拆分,保證要進(jìn)行更換的工程與其他工程沒有直接引用(都用反射)
  2. 對要進(jìn)行更換的工程采用全量替換方法,考慮到sdk體積比較小,此方法最簡單

配置文件

對應(yīng)需求中的最小邊界,定義了如下的配置文件:

{
    "checksum": {
        "TYPE": "md5",
        "value": "20d81614ab8ac44b3185af0f5e8a96b2"
    },
    "channel": "abcdefgh",
    "version": "1.0.0",
    "subVersion": 1,
    "package": "http://robinfjb.github.io/dex.jar",
    "className": "robin.sdk.sdk_impl2.ServiceImpl"
}
  1. checksum是對包完整性的驗證
  2. channel是渠道,在sdk一般用appkey作為渠道標(biāo)識
  3. version目標(biāo)版本,由于sdk的定制化比較多,不同版本的代碼功能都不一樣,所以只針對某個版本更新
  4. subVersion補丁版本,只有新的補丁才會應(yīng)用
  5. package包的下載地址
  6. className新包的入口類名,后面會講到

工程

我們選擇類似類加載方案
將整個sdk拆分成 業(yè)務(wù)實現(xiàn),更新模塊,對外api 三個模塊

  • 業(yè)務(wù)實現(xiàn):sdk-impl , 更新則只要更新業(yè)務(wù)實現(xiàn)模塊
  • 更新模塊:sdk-dynamic , 實現(xiàn)熱更新下載,應(yīng)用
  • 對外api:sdk,sdk對外暴露的類
  • 代理: sdk-proxy 為了避免sdk-implsdk的依賴,用單獨工程維護(hù)兩個模塊之間的接口
  • 公共:sdk-common 放一些基礎(chǔ)類,比如log之類

代碼

命名

補丁類可以命名為packagename+version
比如業(yè)務(wù)包名:robin.sdk.sdk_impl
補丁包名則為:robin.sdk.sdk_impl1

關(guān)鍵代碼:

代理:

例子中使用了CS結(jié)構(gòu),我們將service代理:

public interface ServiceProxy {
    void onCreate(Context var1);

    int onStartCommand(Intent var1, int var2, int var3);

    IBinder onBind(Intent var1);

    boolean onUnBind(Intent var1);

    void onDestroy();
}

對外的service中實現(xiàn):

public final class RobinService extends Service {
    @Override
    public void onCreate() {
        try {
            context = getApplicationContext();
            proxy = serviceLoad();
            proxy.onCreate(this);
            checkUpdate();
        } catch (Throwable e) {
        }
    }
    
    @Override
    public int onStartCommand(Intent var1, int var2, int var3) {
        return proxy.onStartCommand(var1, var2, var3);
    }

    @Override
    public IBinder onBind(Intent var1) {
        return proxy.onBind(var1);
    }

    @Override
    public boolean onUnbind(Intent var1) {
        return proxy.onUnBind(var1);
    }

    @Override
    public void onDestroy() {
        proxy.onDestroy();
    }
}

然后在sdk-impl中實現(xiàn)代理接口:

public class ServiceImpl implements ServiceProxy {
}

這個類就是我們需要更換的類

下載補丁

在程序入口(Service onCreate)方法, 開啟下載
先下載配置文件,下載完成后:

DyInfo dyInfo = new DyInfo(new JSONObject(response));
if (checkDyInfo(context, dyInfo)) {//檢查配置文件有效性
    LogUtil.e(UPDATE_TAG, "downloadDyJar");
    downloadDyJar(dyInfo, context);
}

checkDyInfo檢查當(dāng)前包是否需要下載與應(yīng)用補丁
幾個關(guān)鍵檢測:

  • 檢測key是否等于當(dāng)前key,說明是針對某個key發(fā)的補丁,如果為空說明是全部渠道都應(yīng)用補丁
  • 檢測SDK版本是否和配置文件中一致,補丁只針對某個版本
  • 檢測補丁版本,在補丁應(yīng)用成功后會記錄應(yīng)用的補丁版本,只有新的補丁版本大于已應(yīng)用的,補丁才會下載與生效

下載完成后:檢查文件完整性與保存配置信息

if (!checkJarMd5(context, dyInfo.checksumValue)) {
    LogUtil.e(UPDATE_TAG, "checkJarMd5 fail");
    deleteFailjar(context);
} else {
    //保存jar信息
    SpUtil.setDyInfo(context, dyInfo);
    LogUtil.e(UPDATE_TAG, "checkJarMd5 success");
}

應(yīng)用補丁

        try {
            DyInfo dyInfo = SpUtil.getDyInfo(context);
            if (UpdateManager.checkDyInfo(context, dyInfo) && checkJar(dyInfo, usingJar)) {
                DexClassLoader dexClassLoader = new DexClassLoader(usingJar.getAbsolutePath(),
                        context.getCacheDir().getAbsolutePath(), null, context.getClassLoader());
                Class libclass = dexClassLoader.loadClass(dyInfo.className);
                lib = (ServiceProxy) libclass.newInstance();
                SpUtil.setPatchVersion(context, dyInfo.subVersion);
                LogUtil.e(DYNAMIC_TAG, "動態(tài)包已加載成功 ");
            }
        } catch (Throwable throwable) {
            LogUtil.e(DYNAMIC_TAG, "動態(tài)包加載異常:" + throwable.getLocalizedMessage());
        }
        if(lib == null) {
            try {
                Class libclass = context.getClassLoader().loadClass("robin.sdk.sdk_impl.ServiceImpl");
                lib = (ServiceProxy) libclass.newInstance();
            } catch (Throwable throwable) {
                LogUtil.e(DYNAMIC_TAG, "正常包加載異常:" + throwable.getLocalizedMessage());
            }
        }
  • 讀出上一次下載的配置文件與補丁包,進(jìn)行校驗
  • DexClassLoader加載目標(biāo)usingJar(data/user/0/packagename/file/robin/dex.jar)的包
  • DexClassLoader加載包中的類ServiceImpl(類名比如:robin.sdk.sdk_impl1.ServiceImpl
  • 如果未加載到,則加載默認(rèn)包中的類robin.sdk.sdk_impl.ServiceImpl

腳本

sdk

Android studio的assembleXXX往往打的都是aar包,我們需要一個jar包的gradle task:

task makeSdkJar(type: Jar) {
   //指定生成的jar名
    baseName 'sdk'
    //從哪里打包class文件
    from('build/intermediates/javac/release/classes/')
    from('../sdk-proxy/build/intermediates/javac/release/classes/')
    from('../sdk-common/build/intermediates/javac/release/classes/')
    from('../sdk-dynamic/build/intermediates/javac/release/classes/')
    from('../sdk-impl/build/intermediates/javac/release/classes/')
}
makeSdkJar.dependsOn(clean, 'compileReleaseJavaWithJavac')

注意:由于gradle版本不同,intermediates目錄的路徑可能會有變化

打出sdk.jar包,然后進(jìn)行混淆

task _proguardJar(dependsOn: makeSdkJar, type: proguard.gradle.ProGuardTask) {
    String inJar = makeSdkJar.archivePath.getAbsolutePath()
    println("正在混淆jar...path= " + inJar)

    injars inJar
    outjars "build/libs/proguard.jar"
    configuration "$rootDir/sdk/proguard-rules.pro"
}

proguard-rules.pro中,需要keep住所有需要反射的類

-keep public class robin.sdk.*.ServiceImpl {*;}
-keep class robin.sdk.*.ServiceImpl$* {*;}

對于sdk對外的類,也需要keep:

-keep public class robin.sdk.hotfix.RobinClient {*;}
-keep class robin.sdk.hotfix.RobinClient$* {*;}

補丁

這里采用全量打包方式,將sdk的jar包解壓,去除hotfix,proxy,sdk_common,service_dynamic目錄:

//打patch任務(wù)
task renameJar(type: Copy) {
    from 'build/libs/'
    include 'proguard.jar'
    destinationDir file('build/libs/')
    rename 'proguard.jar', "classes.zip"
}

task upzip(dependsOn: renameJar, type: Copy) {
    def zipFile = file('build/libs/classes.zip')
    def outputDir = file("build/libs/unzip")
    from zipTree(zipFile)
    into outputDir
}

task _patchProguardJar(dependsOn: upzip, type: Jar) {
//指定生成的jar名
    baseName 'patch'
    from('build/libs/unzip/')
    exclude('robin/sdk/hotfix')
    exclude('robin/sdk/proxy')
    exclude('robin/sdk/sdk_common')
    exclude('robin/sdk/service_dynamic')
    doLast {
        delete('build/libs/unzip')
        delete('build/libs/classes.zip')
    }
}

將jar包轉(zhuǎn)dex方便DexClassLoader加載:

task _jarToDex(type: Exec) {
    commandLine 'cmd'
    doFirst {
        //jar文件對象
        def srcFile = file("/build/libs/hot.jar")
        //需要生成的dex文件對象
        def desFile = file(srcFile.parent + "/" + "dex.jar")
        workingDir srcFile.parent
        //拼接dx.bat執(zhí)行的參數(shù)
        def list = []
        list.add("/c")
        list.add("dx")
        list.add("--dex")
        list.add("--output")
        list.add(desFile)
        list.add(srcFile)
        args list
    }
}

測試驗證

使用_proguardJar task打包sdk.jar文件,放到測試app中:
app的gradle依賴:

implementation files("libs/sdk.jar")

首次啟動:

日志如下:

image

此日志為CLient和Service直接的交互,可見Service為robin.sdk.sdk_impl.ServiceImpl,為原版的Service
image

此日志為ServiceImpl中類的引用,目前的類為robin.sdk.sdk_impl.a(已混淆)

Service啟動后會滿足條件自動下載補丁包;


image

第二次啟動:

日志如下:
加載動態(tài)包后的:

image

原來的robin.sdk.sdk_impl.a已替換為robin.sdk.sdk_impl2.a
image

Service也替換成功

工程代碼:

https://github.com/robinfjb/Android_SDK_Hotfix

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容