參考資料
https://mp.weixin.qq.com/s?__biz=MzI1MTA1MzM2Nw==&mid=400118620&idx=1&sn=b4fdd5055731290eef12ad0d17f39d4a&scene=1&srcid=1106Imu9ZgwybID13e7y2nEi#wechat_redirect
https://github.com/dodola/HotFix
http://blog.csdn.net/lmj623565791/article/details/49883661
http://www.cnblogs.com/alibaichuan/p/5863616.html
http://blog.csdn.net/huachao1001/article/details/51819972
什麼是熱修複?
1.正常開發流程
從流程來看,傳統的開發流程存在很多弊端:
- 重新釋出版本代價太大
- 使用者下載下傳安裝成本太高
- BUG修複不及時,使用者體驗太差
2.熱修複開發流程
熱修複的開發流程顯得更加靈活,優勢很多:
- 無需重新發版,實時高效熱修複
- 使用者無感覺修複,無需下載下傳新的應用,代價小
- 修複成功率高,把損失降到最低
前言
熱修複作為當下熱門的技術,在業界内比較著名的有阿裡巴巴的AndFix、Dexposed,騰訊QQ空間的熱更新檔動态修複和微信的Tinker。本文基于QQ空間的熱更新檔動态修複方案。
關于Gradle 1.5版本以前對HotFix的分析文章,網上有很多,推薦鴻洋大大的部落格
http://blog.csdn.net/lmj623565791/article/details/49883661
但在Gradle 1.5開始,preDex和Dex這兩個task已經消失,取而代之的是com.android.build.api.transform.Transfrom,這也就導緻了目前的Gradle版本有很多問題要處理。
Google Transform API
http://tools.android.com/tech-docs/new-build-system/transform-api
本文旨在讓以前從來沒有接觸過HotFix的同學從零學習,而不需切換到Gradle 1.5以前的版本。
需要掌握的知識
- Groovy語言:掌握基本文法和閉包即可
- Gradle插件開發:掌握目前項目插件開發即可
- Javassist:掌握基本動态改變類結構方法即可
- Dex分包
針對Groovy語言和Javassist相關方法介紹,網上有很多相關資料,在這裡不再重複。本文隻需掌握Groovy基本文法,閉包以及Javassist動态改變類結構方法即可。
Javassist API
http://jboss-javassist.github.io/javassist/html/index.html
其餘知識,下面做一個簡單介紹。
Gradle插件開發
關于Gradle插件開發,本文就建立目前項目插件做一個簡單總結。
- File->New->New Module->選擇Phone Module或Android Library->将Application Name及Module Name改為buildsrc。注意這裡如果要建立目前項目插件,必須改為buildsrc,其餘名稱均不可。
- 将該子產品除src/main及build.gradle外檔案全部删除,将build.gradle内内容清空。
- 在main目錄下建立groovy檔案夾。
- 在groovy檔案夾下建立包,并建立檔案,命名為HotFixInjectPlugin.groovy
- 修改build.gradle檔案如下
apply plugin: 'groovy'
dependencies {
compile gradleApi()
compile localGroovy()
}
repositories {
jcenter()
}
建立的HotFixInjectPlugin.groovy需要實作Plugin接口,代碼如下。
package zjm.cst.dhu.plugin
import org.gradle.api.Plugin
import org.gradle.api.Project
public class HotFixInjectPlugin implements Plugin<Project> {
@Override
void apply(Project project) {
println("--->Apply HotFixInjectPlugin")
}
}
在App的build.gradle中加入
當App應用該插件時即會調用apply方法。
注意,若更改插件子產品中内容,請先clean工程後再make工程
Dex分包
關于Dex分包基本概念與原理,網上有很多相關資料,這裡就不再談了,但具體實作大多是Gradle 1.5版本以前的。而Gradle 1.5以後版本,由于dex任務取消,導緻之前在build.gradle中控制分包參數失效,而替換的則是dexOptions參數。
dexOptions {
javaMaxHeapSize "4g"
preDexLibraries = false
additionalParameters = ['--multi-dex', '--main-dex-list=' + project.rootDir.absolutePath + '/app/maindexlist.txt', '--minimal-main-dex','--set-max-idx-number=30000']
}
其中maindexlist.txt檔案為最先加載class清單。将\app\build\intermediates\multi-dex\debug目錄下的maindexlist.txt複制到app目錄下,并在其中加入我們希望分到主dex的class檔案即可。
HotFix原理
關于HotFix基本原理,QQ空間團隊的文章已有介紹
https://mp.weixin.qq.com/s?__biz=MzI1MTA1MzM2Nw==&mid=400118620&idx=1&sn=b4fdd5055731290eef12ad0d17f39d4a&scene=1&srcid=1106Imu9ZgwybID13e7y2nEi#wechat_redirect
這裡我總結一下HotFix的原理。
- 類加載器中存放dex的數組位置位于ClassLoader中DexPathList執行個體 pathList中。具體為DexPathList類中dexElements數組。關于對ClassLoader及DexPathList源碼研究,後面在使用反射機制插入dex時也會用到。
BaseDexClassLoader源碼
http://androidxref.com/4.4.2_r1/xref/libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java
DexPathList源碼
http://androidxref.com/4.4.2_r1/xref/libcore/dalvik/src/main/java/dalvik/system/DexPathList.java#splitLibraryPath
- 系統在dex校驗過程中,若clazz與static方法/private方法/構造函數等直接引用到的類在同一個dex中時,會被打上CLASS_ISPREVERIFIED标記。但由于我們需要在dexElements數組頭插入更新檔的dex,插入後,與它直接引用到的類不在同一個dex,導緻驗證時會報錯。是以我們需要在clazz的構造函數中直接引用與它不在同一個dex的類以防止打上标記。
- 在所有類構造函數中加入System.out.println(AntilazyLoad.class),我們通過javassist擷取并修改每個類的構造器方式,向其添加該語句。
- 我們需要在class轉換為dex檔案前增加該語句。我們通過自定義Gradle插件,并注冊自定義Transform,執行javassist相關任務。
- 由于所有類都在構造函數中需要AntilazyLoad.class,是以我們需要在dexElements數組頭優先插入AntilazyLoad.class單獨打包的hack_dex.jar。
下面給出我畫的HotFix基本的流程圖,針對一些關鍵步驟,我下面具體分析。
自定義Transform
上面已經講了如何自定義Gradle插件,那麼我們隻需在apply方法中注冊Transform即可。
@Override
void apply(Project project) {
def android = project.extensions.findByType(AppExtension)
android.registerTransform(new HotFixInjectTransform(project))
}
關鍵點在于如何自定義Transform。
我們先看一下Transform的過程
其實很簡單Transform就是将一個輸入處理,最後輸出的過程,那麼我們自定義Transform其實就是做處理的過程。
我們先像之前那樣在插件子產品中建立HotFixInjectTransform.groovy。這裡直接貼出代碼,注釋已經很明白了。
package zjm.cst.dhu.plugin
import com.android.build.api.transform.*
import com.android.build.gradle.internal.pipeline.TransformManager
import org.apache.commons.codec.digest.DigestUtils
import org.apache.commons.io.FileUtils
import org.gradle.api.Project
public class HotFixInjectTransform extends Transform {
Project project
// 構造函數,我們将Project儲存下來備用
public HotFixInjectTransform(Project project) {
this.project = project
}
// 設定我們自定義的Transform對應的Task名稱
@Override
String getName() {
return "HotFixInjectTransform"
}
// 指定輸入的類型,通過這裡的設定,可以指定我們要處理的檔案類型
//這樣確定其他類型的檔案不會傳入
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS
}
// 指定Transform的作用範圍
@Override
Set<QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT
}
@Override
boolean isIncremental() {
return false
}
@Override
void transform(Context context, Collection<TransformInput> inputs,
Collection<TransformInput> referencedInputs,
TransformOutputProvider outputProvider, boolean isIncremental)
throws IOException, TransformException, InterruptedException {
// Transform的inputs有兩種類型,一種是目錄,一種是jar包,要分開周遊
inputs.each { TransformInput input ->
//對類型為“檔案夾”的input進行周遊
input.directoryInputs.each { DirectoryInput directoryInput ->
//檔案夾裡面包含的是我們手寫的類以及R.class、BuildConfig.class以及R$XXX.class等
HotFixInjectProcess.injectDir(directoryInput.file.absolutePath, "zjm\\cst\\dhu\\hotfix")
// 擷取output目錄
def dest = outputProvider.getContentLocation(directoryInput.name,
directoryInput.contentTypes, directoryInput.scopes,
Format.DIRECTORY)
// 将input的目錄複制到output指定目錄
FileUtils.copyDirectory(directoryInput.file, dest)
}
//對類型為jar檔案的input進行周遊
input.jarInputs.each { JarInput jarInput ->
//jar檔案一般是第三方依賴庫jar檔案
// 重命名輸出檔案(同目錄copyFile會沖突)
def jarName = jarInput.name
def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
if (jarName.endsWith(".jar")) {
jarName = jarName.substring(, jarName.length() - )
}
//生成輸出路徑
def dest = outputProvider.getContentLocation(jarName + md5Name,
jarInput.contentTypes, jarInput.scopes, Format.JAR)
//将輸入内容複制到輸出
FileUtils.copyFile(jarInput.file, dest)
}
}
}
}
HotFixInjectProcess其實就是我們具體處理目錄類型class的類。
與之前一樣,建立HotFixInjectProcess.groovy。直接貼出代碼。
package zjm.cst.dhu.plugin
import javassist.ClassPool
import javassist.CtClass
import javassist.CtConstructor
public class HotFixInjectProcess {
private static ClassPool pool = ClassPool.getDefault()
static String injectStr = "System.out.println(zjm.cst.dhu.hotfix.hack.AntilazyLoad.class); ";
public static void injectDir(String path, String packageName) {
pool.appendClassPath(path)
File dir = new File(path)
if (dir.isDirectory()) {
dir.eachFileRecurse { File file ->
String filePath = file.absolutePath
//確定目前檔案是class檔案,并且不是系統自動生成的class檔案
if (filePath.endsWith(".class")
&& !filePath.contains('R$')
&& !filePath.contains('R.class')
&& !filePath.contains("BuildConfig.class")) {
// 判斷目前目錄是否是在我們的應用包裡面
int index = filePath.indexOf(packageName);
boolean isMyPackage = index != -;
if (isMyPackage) {
int end = filePath.length() - // .class = 6
String className = filePath.substring(index, end).replace('\\', '.').replace('/', '.')
//開始修改class檔案
CtClass c = pool.getCtClass(className)
//若已加載則修改
if (c.isFrozen()) {
c.defrost()
}
//擷取預設構造器
CtConstructor[] cts = c.getDeclaredConstructors()
pool.importPackage("android.util.Log");
if (cts == null || cts.length == ) {
//手動建立一個構造函數
CtConstructor constructor = new CtConstructor(new CtClass[], c)
constructor.insertBeforeBody(injectStr)
c.addConstructor(constructor)
} else {
cts[].insertBeforeBody(injectStr)
}
c.writeFile(path)
c.detach()
}
}
}
}
}
}
這裡我們就運用到了javassist,擷取該包下所有class的預設構造器,并添加注入語句。
到這裡,自定義Transform就結束啦。
執行完transform後,我們就需要插入hack_dex.jar,那麼我們就遇到了一個問題,如何将AntilazyLoad.java轉化為hack_dex.jar。
java代碼打包成獨立Dex包
将java代碼打包成獨立的Dex包,我們需要兩步。第一步,将java代碼編譯生成class檔案,第二步,将class檔案通過dx工具進行打包轉化。
将java代碼編譯生成class檔案很簡單,我們build一下項目,然後在app/build/intermediates/classes/debug中就可以找到編譯好的class檔案。
将class轉化打包成jar,我們需要用到build-tools中的dx.bat指令。
首先,我們先将class一般打包。
然後使用我們在Android SDK,build-tools檔案夾下的dx.bat指令
這樣我們就獲得了hack_dex.jar包
我們用相同的方法把需要打更新檔的class打包成patch_dex.jar。
插入jar包
我們之前已經擷取到了需要的兩個包,一個hack,一個patch。這裡我就插入hack包做一個例子。
//加載hack.AntilazyLoad包
//在私有目錄下建立hack_dex.jar
File dexPath = new File(getDir("dex", Context.MODE_PRIVATE), "hack_dex.jar");
try {
Utils.prepareDex(this.getApplicationContext(), dexPath, "hack_dex.jar");
} catch (IOException e) {
e.printStackTrace();
}
HotFix.patch(this, dexPath.getAbsolutePath(), "zjm.cst.dhu.hotfix.hack.AntilazyLoad");
這就是Application中onCreate方法,插入hack包的代碼。
首先,我們将更新檔包放入assets資源檔案夾中,然後在App私有目錄下建立該名稱的JAR。
接着,就是Util.prepareDex(),将assets中檔案寫入該目錄。代碼如下。
//讀取assets檔案夾下檔案并寫入新路徑
public static boolean prepareDex(Context context, File dexInternalStoragePath, String dex_file) throws IOException {
BufferedInputStream bis = null;
OutputStream dexWriter = null;
bis = new BufferedInputStream(context.getAssets().open(dex_file));
dexWriter = new BufferedOutputStream(new FileOutputStream(dexInternalStoragePath));
byte[] buf = new byte[BUF_SIZE];
int len;
while ((len = bis.read(buf, , BUF_SIZE)) > ) {
dexWriter.write(buf, , len);
}
dexWriter.close();
bis.close();
return true;
}
最後就是HotFix.patch(),這個就是具體插入的方法,通過反射機制,修改相關屬性。比較複雜,我們單獨拿出來看。
public static void patch(Context context, String patchDexFile, String patchClassName) {
if (patchDexFile != null && new File(patchDexFile).exists()) {
try {
if (hasLexClassLoader()) {
//阿裡類構造器
injectInAliyunOs(context, patchDexFile, patchClassName);
} else if (hasDexClassLoader()) {
//高于API 14 類構造器
injectAboveEqualApiLevel14(context, patchDexFile, patchClassName);
} else {
//低于API 14 類構造器
injectBelowApiLevel14(context, patchDexFile, patchClassName);
}
} catch (Throwable th) {
}
}
}
首先判斷檔案是否存在,接着三個判斷語句,我們這裡隻針對高于API 14的類構造器進行講解。
進入方法的前提是
private static boolean hasDexClassLoader() {
try {
Class.forName("dalvik.system.BaseDexClassLoader");
return true;
} catch (ClassNotFoundException e) {
return false;
}
}
判斷是否存在BaseDexClassLoader。這個類是不是看起來很眼熟?沒錯,就是之前我們在HotFix原理中所講到的類。在原理中,我提到了插入dex,實際上是将dex插入到BaseDexClassLoader類中DexPathList執行個體 pathList的dexElements數組前。那麼接下來的injectAboveEqualApiLevel14方法其實也就是這個過程。
我們來看這個方法。
private static void injectAboveEqualApiLevel14(Context context, String str, String str2)
throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
Object a = combineArray(getDexElements(getPathList(pathClassLoader)),
getDexElements(getPathList(
//用私有目錄下檔案初始化新類構造器
new DexClassLoader(str, context.getDir("dex", ).getAbsolutePath(), str, context.getClassLoader()))));
Object a2 = getPathList(pathClassLoader);
//替換dexElements屬性
setField(a2, a2.getClass(), "dexElements", a);
pathClassLoader.loadClass(str2);
}
其實就是将新的dex檔案放在數組頭,剩餘的dex放在尾部,形成一個新的對象。并用這個對象替換原對象的dexElements屬性即可。
我們來看這其中用到的剩餘方法。
//獲得BaseDexClassLoader的pathlist
private static Object getPathList(Object obj) throws ClassNotFoundException, NoSuchFieldException,
IllegalAccessException {
return getField(obj, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
}
//獲得該對象的dexElements
private static Object getDexElements(Object obj) throws NoSuchFieldException, IllegalAccessException {
return getField(obj, obj.getClass(), "dexElements");
}
//擷取該對象obj的str屬性
private static Object getField(Object obj, Class cls, String str)
throws NoSuchFieldException, IllegalAccessException {
Field declaredField = cls.getDeclaredField(str);
declaredField.setAccessible(true);
return declaredField.get(obj);
}
//向該對象obj的str屬性設定新值
private static void setField(Object obj, Class cls, String str, Object obj2)
throws NoSuchFieldException, IllegalAccessException {
Field declaredField = cls.getDeclaredField(str);
declaredField.setAccessible(true);
declaredField.set(obj, obj2);
}
//将obj2插入到obj前
private static Object combineArray(Object obj, Object obj2) {
Class componentType = obj2.getClass().getComponentType();
int length = Array.getLength(obj2);
int length2 = Array.getLength(obj) + length;
Object newInstance = Array.newInstance(componentType, length2);
for (int i = ; i < length2; i++) {
if (i < length) {
Array.set(newInstance, i, Array.get(obj2, i));
} else {
Array.set(newInstance, i, Array.get(obj, i - length));
}
}
return newInstance;
}
就像我剛才所講,運用java的反射機制,将dexElements屬性替換。
實踐
我們先建立一個Activity,TextView文本被設定為is a test。然後執行個體化一個空對象
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.layout_test);
TextView tv=(TextView) findViewById(R.id.text);
tv.setText("is a test");
System.out.println("==========start===============");
new Test();
System.out.println("==========end===============");
}
但是呢,我們這裡隻插入System.out.println(zjm.cst.dhu.hotfix.hack.AntilazyLoad.class);,并且把hack_dex.jar插入到頭部。但不把更新檔包插入。我們看一下結果
已經成功把System.out.println(zjm.cst.dhu.hotfix.hack.AntilazyLoad.class);插入進去啦。
這時候我們修改一下Activity,把TextView文本内容設定為fix Success。然後生成更新檔包,插入更新檔。看一下結果。
更新檔成功打入啦。
源碼位址:
https://github.com/CMonoceros/HotFix