前提
寫這篇文章的目的也是為了了解android源碼及hack技術,讀了這篇文章相信你也可以了解到Dalvik的工作流程,apk的生成過程,及build.gradle中plugin中ApplicationPlugin的Task有哪些,如何通過hack技術來完成hotfix。有興趣的同學也可以看看groovy如何編寫Plugin,及如何優化dex來讓優化app
熱修復需要注意的幾個問題
- 如何進行hack來達到熱修復
- hack操作中需要使用哪些class
- apk是生成的生命周期
- gradle build 腳本(groovy)
我們先了解這些問題后再進行具體的操作步驟,來個循序漸進。問題接下來會一一的詳解
如何進行hack來達到熱修復
為什么會有熱修復這個東西呢?大家都知道如果我們的線上的app 由于某種原因crash?我們這時候不能怨測試沒測好,后臺接口有變化什么的,這不是解決問題的最終方式!要是以前我們肯定就是把重新上傳app到各大渠道,從新上線,這個過程嚴重的影響到我們的用戶體驗非常不好,而且很耗時!作為程序員如何通過代碼進行線上修復crash bug。。。呢?所以有了熱修復這個功能 bat 每家都有自己的開源熱修復庫?我這里就講一下如何通過反射的方式來實現修復功能吧!也就是通過DexClassLoader。如果大家對其他的開源庫想要了解的話可以通過一下傳送門
AndFix
tinker
HotFix
Robust
我這里也就講一下Dex的方式修復
hack操作中需要使用哪些class
- 顧名思義DexClassLoader這個必須要用到的
- javaassist用于代碼的打樁(就是class文件代碼的植入,這里不詳解了)
- groovy一個android plugin插件開發語言 底下會提及到
apk是生成的生命周期
我們的項目如何在編譯的時候變成apk呢?
- 第一步當然是把我們的資源文件生成R.Java文件了
- 處理AIDL文件,生成對應的.java文件(當然,有很多工程沒有用到AIDL,那這個過程就可以省了)
- 編譯Java文件,生成對應的.class文件
- 把.class文件轉化成Davik VM支持的.dex文件
- 打包生成未簽名的.apk文件
- 對未簽名.apk文件進行簽名
- 對簽名后的.apk文件進行對齊處理(不進行對齊處理是不能發布到Google Market的)
我感覺還是貼圖比較靠譜不然看文字沒有感覺
這就是一個apk編譯所走的生命周期,但是我們的build腳本到底走了哪些任務呢。如果想看的話可以在我們module中的build.gradle 加入如下代碼 即可在console中看到相應的任務
tasks.whenTaskAdded { task ->
println(task.name+"===")
}
這個就是我們apkbuild的時候的每一個task。既然知道了這些task 那我們如何才能知道這些task到底在后臺做了些什么呢?
gradle build 腳本(groovy)
時常見到卻不知道他在干嘛的一句代碼,apply plugin: 'com.android.application'如果我們把com.android.application代替為com.android.library,那我們的build目錄下的output那就是aar包了。組件化開發會用到這樣的切換想了解的可以看看(組件化)
想要了解這句話干嘛的那你必須的知道這個開發語言groovy,他是支持android studio的。我們可以自定義我們想要的插件,在編譯的時候進行一些好玩的操作。這里面可以定義許多task,回歸正題,這句代碼到底干了哪些事情呢,那我們就必須的了解這個源碼想要了解的同學可以看看這里就不多說了,這里面有我們build中的所有task
app啟動過程
以上了解了這么多,接下來就要進入正題了!現在說app的啟動過程,過程就不細說了,因為經歷了很多復雜的過程,我就說一下與DexClassLoader有關的事情吧。
app每次啟動fork一個進程但同時也會同樣會分配一個dalvik虛擬機供這個app運行,在dalvik中每次運行都需要讀取apk里面的dex文件,這樣會耗費很多cpu資源,然后采用odex,把dex文件優化成odex文件,那么odex操作給我們熱修復帶來了哪些問題呢?我們先把這個問題記錄下來,之后會分析具體原因。啟動先說到這?。?!
如何進行熱修復
- 通過上面了解了app啟動過程中每次都要通過dvm來加載dex文件。
- 同樣大家也知道了dex文件是由 .java->.class->dex 一步一步轉化來的
了解了上面的兩個重要的東西,熱修復就是每次在我們app啟動的時候加載我們自己的patch.dex文件而不是加載就是我們修復的dex文件,這樣就可以達到熱修復了(這時大概會有很多同學困惑,dvm怎么知道就用我們的patch.dex而不用之前的呢?好問題 讓老夫徐徐道來)
動態加載patch.dex
- 在 Android 中,App 安裝到手機后,apk 里面的 class.dex 中的 class 均是通過 PathClassLoader 來加載的。
- DexClassLoader 可以用來加載 SD 卡上加載包含 class.dex 的 .jar 和 .apk 文件
- DexClassLoader 和 PathClassLoader 的基類 BaseDexClassLoader 查找 class 是通過其內部的 DexPathList pathList 來查找的
- DexPathList 內部有一個 Element[] dexElements 數組,其 findClass() 方法(源碼如下)的實現就是遍歷該數組,查找 class ,一旦找到需要的類,就直接返回,停止遍歷:
public Class findClass(String name, List<Throwable> suppressed) {
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
通過上面的步驟是不是知道了我們app每次啟動一個class是如何找到類的呢?現在知道了吧,DexClassLoader -> DexPathList -> Element[]
好的 現在應該有一些系統的了解了,通過上面的步驟可以知道 每次查找類都是通過Element[]中查找的。如果找到就會return 而不會繼續找!這時候嘿嘿嘿我們知道了他是如何findclass 的那我們就可以悄悄的干些壞事了(這里會有一些同學會懵逼,Element[]是什么鬼)
Element[]是什么鬼
我們在每次創建DexClassLoader時他的構造函數是這樣的
public DexClassLoader(String dexPath, String optimizedDirectory,
String libraryPath, ClassLoader parent) {
super(dexPath, new File(optimizedDirectory), libraryPath, parent);
}
從源碼可以看出第一個參數顧名思義是dex路徑,第二個呢可以看看源碼,第二個要傳一個路徑dex優化后odex的路徑。第三個呢就是父類嗎,直接getClassLoader()就好那么我們看看他的父類拿這些參數干了些什么
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String libraryPath, ClassLoader parent) {
super(parent);
this.originalPath = dexPath;
this.pathList =
new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}
父類也就做了一些初始化操作。最主要的是初始化了DexPathList這個類,然后我們看看BaseDexClassLoader里面的findClass做了些什么呢源碼如下
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Class clazz = pathList.findClass(name);
if (clazz == null) {
throw new ClassNotFoundException(name);
}
return clazz;
}
看到了嗎通過我們構造函數初始化的DexPathList來查找的,上面我們已經貼了DexPathList內部findclass的他是通過Element[]來拿到的。接下來我們來看看DexPathList的構造函數
public DexPathList(ClassLoader definingContext, String dexPath,
String libraryPath, File optimizedDirectory) {
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;
this.dexElements =
makeDexElements(splitDexPath(dexPath), optimizedDirectory);
this.nativeLibraryDirectories = splitLibraryPath(libraryPath);
}
好了這就是他的源碼了可看到在夠著函數中有一個很重要的一步就是對(makeDexElements這個方法)Element[]初始化 說了這么多終于到這個地方了 這是什么鬼,進入這個方法來看一下
private static Element[] makeDexElements(ArrayList<File> files,
File optimizedDirectory) {
ArrayList<Element> elements = new ArrayList<Element>();
/*
* Open all files and load the (direct or contained) dex files
* up front.
*/
for (File file : files) {
ZipFile zip = null;
DexFile dex = null;
String name = file.getName();
if (name.endsWith(DEX_SUFFIX)) {
// Raw dex file (not inside a zip/jar).
try {
dex = loadDexFile(file, optimizedDirectory);
} catch (IOException ex) {
System.logE("Unable to load dex file: " + file, ex);
}
} else if (name.endsWith(APK_SUFFIX) || name.endsWith(JAR_SUFFIX)
|| name.endsWith(ZIP_SUFFIX)) {
try {
zip = new ZipFile(file);
} catch (IOException ex) {
/*
* Note: ZipException (a subclass of IOException)
* might get thrown by the ZipFile constructor
* (e.g. if the file isn't actually a zip/jar
* file).
*/
System.logE("Unable to open zip file: " + file, ex);
}
try {
dex = loadDexFile(file, optimizedDirectory);
} catch (IOException ignored) {
/*
* 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). Safe to just ignore
* the exception here, and let dex == null.
*/
}
} else {
System.logW("Unknown file type for: " + file);
}
if ((zip != null) || (dex != null)) {
elements.add(new Element(file, zip, dex));
}
}
return elements.toArray(new Element[elements.size()]);
}
代碼有點多哈...不要著急 我來慢慢說,首選呢構造函數傳進來了一個file數組不論是jar文件還是apk文件我們在這一步都是吧他們轉換成dex文件相當于做了一個操作把patch.jar 改成patch.dex然后轉存到最初我們傳進來的那個optimizedDirectory文件夾下。然后我們的Element這個類是個靜態內部類,可以看看下面的源碼的構造函數
public Element(File file, ZipFile zipFile, DexFile dexFile) {
this.file = file;
this.zipFile = zipFile;
this.dexFile = dexFile;
}
可以看到他傳入了這些參數。好了現在知道了這是什么鬼了吧,一系列的源碼恐怕會看的頭暈腦脹的吧。反正知道了Element就是存儲我們dex文件的每次findclass的時候從這里面取得,知道這個就行了。
插入我們需要的加載的patch.dex
現在知道往哪里插入我們的dex文件了吧,只要在我們app啟動的時候,把我們的dex文件加載到Element[]數組最前面就行了,每次findclass的時候肯定先查找我們的dex了。這樣不就可以達到熱修復了嗎!
- 第一步創建一個我們的DexClassLoader 把我們的patch.dex(或.jar)文件傳進去
- 第二步通過反射拿到我們創建的DexClassLoader里面的DexPathList里面的Element[]
- 拿到apk的DexClassLoader(getClassLoader()這個方法就可以拿到)然后同樣反射的方式拿到DexPathList里面的Element[]。
- 最關鍵的一部就是把我們patch的Element[]和apk的Element[]合并在一起然后通過反射修改apk里面的Element[](別合并錯了,要把我們的數據插入最前面)
- 以上步驟要在Application生命周期中的attachBaseContext進行執行不然,在onCreate里面執行的話app就已經初始化好了
這里我就用Nuva熱修復的代碼來舉例吧,他這邊寫的很詳細的 git傳送門哈哈哈博主已棄坑 放棄維護了
DexClassLoader dexClassLoader = new DexClassLoader(dexPath, defaultDexOptPath, dexPath, getPathClassLoader());
Object baseDexElements = getDexElements(getPathList(getPathClassLoader()));
Object newDexElements = getDexElements(getPathList(dexClassLoader));
Object allDexElements = combineArray(newDexElements, baseDexElements);
Object pathList = getPathList(getPathClassLoader());
ReflectionUtils.setField(pathList, pathList.getClass(), "dexElements", allDexElements);
這就是我所說的那四步。
大功告成是不是很帶勁吧。md終于把我們的dex文件插入了。嘿嘿嘿黑科技啊,熱修復原來如此簡單。別高興的太早。接下來重點來了
坑1(CLASS_ISPREVERIFIED)預定義
這時候你運行項目的時候會發現app 掛了 哈哈哈 真是日了狗了,不出意外的話會報一下錯誤class ref in pre-verified class resolved to unexpected implementation 這個就是上面所說的odex操作帶來的麻煩。
出問題嗎?當然要慢慢解決了。先了解一下odex吧
- 在apk安裝的時候系統會將dex文件優化成odex文件,在優化的過程中會涉及一個預校驗的過程
- 如果一個類的static方法,private方法,override方法以及構造函數中引用了其他類,而且這些類都屬于同一個dex文件,此時該類就會被打上CLASS_ISPREVERIFIED
- 如果在運行時被打上CLASS_ISPREVERIFIED的類引用了其他dex的類,就會報錯
- 所以你的類中引用另一個dex的類就會出現上文中的問題
- 正常的分包方案會保證相關類被打入同一個dex文件
- 想要使得patch可以被正常加載,就必須保證類不會被打上CLASS_ISPREVERIFIED標記。而要實現這個目的就必須要在分完包后的class中植入對其他dex文件中類的引用
- 要在已經編譯完成后的類中植入對其他類的引用,就需要操作字節碼,慣用的方案是插樁。常見的工具有javaassist,asm等
這時候大家就要了解這個了javaassist,一個代碼植入庫,幾個簡單的api大家看看都會
/**
* 植入代碼
* @param buildDir 是項目的build class目錄,就是我們需要注入的class所在地
* @param lib 這個是hackdex的目錄,就是AntilazyLoad類的class文件所在地
*/
public static void process(String buildDir, String lib) {
System.out.println(buildDir)
println(lib);
ClassPool classes = ClassPool.getDefault()
classes.appendClassPath(buildDir)
classes.appendClassPath(lib)
// 將需要關聯的類的構造方法中插入引用代碼
CtClass c = classes.getCtClass("cn.jiajixin.nuwasample.Hello.Hello")
if (c.isFrozen()) {
c.defrost()
}
println("====添加構造方法====")
def constructor = c.getConstructors()[0];
constructor.insertBefore("System.out.println(com.cuieney.hookdex.AntilazyLoad.class);")
c.writeFile(buildDir)
}
如果我們想不被打上標記就只能這樣了,就是通過這個方法,讓現在這個類Hello在當dex里面引用其他的dex文件里面的AntilazyLoad.class簡單的說就是對其他dex文件有依賴就不會被打上標記。
那么我們這個代碼段改在哪里運行呢。好問題?。?!這也是重點。不知道老鐵們還記得上面的代碼嗎。apk的生成過程的生命周期,就是在build的是那幾個步驟。我們需要在.class文件編程成.dex文件前 進行代碼植入。這樣是不是很完美呢。那我們從哪里下手呢。當然是我們的build.gradle文件下手,我們編譯項目的時候每次是不是都是在這里進行操作的
這里要用到一個新的姿勢哦(不對是知識哈哈哈)groovy這個語言plugin插件語言。我們原生的android studio 是對groovy支持的。在我們的項目中創建一個buildsrc項目,一定要這個名字。然后我們在項目中創建一個類patch.groovy 目錄結構如下不用的都刪了。
然后我們在我們的app的build.gradle里面做一下操作
task ('processWithJavassist') << {
String classPath = file('build/intermediates/classes/debug')//項目編譯class所在目錄
com.cuieney.groovy.PatchClass.process(classPath, project(':hookdex').buildDir
.absolutePath + '/intermediates/classes/debug')//第二個參數是hackdex的class所在目錄
}
這個是執行代碼植入操作project(':hookdex')這個使我們植入的類的module
但是我么這個task你得保證在.class 到 .dex文件之間操作,我們怎么保證呢?接下來見證奇跡的時候到了在build.g里在添加如下代碼
applicationVariants.all { variant ->
variant.dex.dependsOn << processWithJavassist //在執行dx命令之前將代碼打入到class中
}
這樣就完成了我們的代碼植入操作 哈哈哈哈 牛逼不牛逼不
but 你在植入代碼之前一定要把我們的植入的類的dex提前插入到Element[]里面不然 會報找不到這個類的。 然后在只要真正的patch.dex 我們的補丁。
補丁制作
- 將class文件打入一個jar包中 jar cvf path.jar xxxx.java
- 將jar包轉換成dex的jar包 dx --dex --output=path_dex.jar path.jar
- 用adb將你的path_dex.jar push到你的dexpath中。每次app啟動吧這個補丁打入就好
坑2 以上代碼植入在高版本的gradle不行
包以下錯誤Gradle1.40 里TransformAPI無法打包的情況,只兼容Gradle1.3-Gradle2.1.0版本
哈哈哈我也沒則,目前RocooFix這個項目博主通過一種新的方式進行了代碼植入(之前我們通過植入代碼來完成避免打上標志,他則是反其道而行,PatchClassLoader每次加載apk里面的dex時,把標志去了這樣也可以防止出現之前那種crash 只能說牛逼牛逼,里面代碼還在研究...)有興趣的同學可以看一下。
ending
說了這么多,其實網上這種帖子很多,自己只是想系統的整理一下,其實在這個過程自己學到了很多,不論是源碼還是各方面的擴展知識吧,對自己都有很大的提升,不論老鐵們看沒看玩,希望這次分享給大家帶來的知識的提升。 stay hungry stay foolish
下一篇文章
手把手教你寫熱修復(HOTFIX)