轉載請註明出處:http://blog.csdn.net/llew2011/article/details/78540911
我們在開發中經常用到一些優秀的第三方庫,比如okhttp,glide,butterknife等。這些庫不僅提高了開發效率而且避免踩坑,假如在應用中這些開源庫出現了bug,我們隨時可以從GitHub下載源碼進行bug修改。但是項目中使用的庫不是開源的並且該庫又存在bug,由於沒有源碼也就無法進行bug的修復,一般做法就是給非開源庫的作者或組織反饋bug等他們進行修復,如果他們修復的及時還好說,一旦他們更新不及時就會給我們APP造成不好影響(比如用戶流失)……這篇文章我就給小夥伴們講解一下如何自定義Gradle Plugin來徹底解決第三方Jar包中的bug(*^__^*) ……
入職新公司以來一直把精力放在了新公司原有項目的重構上,與其說是重構還不如說是重寫了一遍,重構期間整個Android團隊壓力還是蠻大的,一方面在開發新需求另一方面在進行項目重構,經過幾個月的辛勞重構後,新項目順利上線了(在這裏對整個團隊表示感謝),重構後的新版本上線後我一直在Bugly上關注着它的穩定情況,尤其是崩潰率。從Bugly的日誌上我發現有幾個crash從舊版本到重構後的新版本一直存在着,且這些crash都是發生在第三方Jar包中的,其中一個crash日誌如下所示:
日誌信息清楚的表明該crash是用戶沒有授權導致的,由於我們APP集成了企鵝廠家的直播SDK,在用戶退出直播間後都會調用SDK的退出直播間方法,也就是說崩潰發生在鵝廠的SDK內部,儘管我們在進入APP中都有向用戶申請READ_PHONE_STATE權限,但還是存在部分用戶不予授權的情況,以上crash就是用戶沒有授權導致的。因此我把這個問題反饋給了鵝廠的SDK研發組那裏,並問他們能不能做下兼容(就是添加權限的判斷),他們很快給予了回覆說沒法做兼容並給了個解決辦法:如果用戶沒授權就給用戶提示然後強制退出APP。如果按照鵝廠的解決辦法那就代表着用戶不授予權限就不能使用我們APP了,這顯然是不可接受的,因爲這樣很容易造成用戶流失……
既然鵝廠那邊不願意解決該問題,那我自己來解決!!!在解決該問題前我們先看看他們SDK中NetworkHelp類下的getAPInfo()方法和getMobileAPInfo()方法實現邏輯是什麼樣的,因爲從Bugly日誌看崩潰前調用了NetworkHelp中的這倆方法。反編譯NetworkHelp.class文件,核心代碼如下所示:
public class NetworkHelp {
protected static NetworkHelp.APInfo getAPInfo(Context var0) {
NetworkHelp.APInfo var1 = new NetworkHelp.APInfo();
if(var0 == null) {
QLog.e("NetworkHelp", 0, "getAPInfo initial context is null");
return var1;
} else {
ConnectivityManager var2 = (ConnectivityManager)var0.getSystemService("connectivity");
NetworkInfo var3 = var2.getActiveNetworkInfo();
if(var3 != null && var3.isConnected()) {
switch(var3.getType()) {
case 0:
// 在這裏調用了getMobileAPInfo()方法
var1 = getMobileAPInfo(var0, var3.getSubtype());
break;
// 省略相關代碼......
}
return var1;
}
}
private static NetworkHelp.APInfo getMobileAPInfo(Context var0, int var1) {
TelephonyManager var2 = (TelephonyManager)var0.getSystemService("phone");
NetworkHelp.MobileCarrier var3 = NetworkHelp.MobileCarrier.UNKNOWN;
String var4 = var2.getSubscriberId(); // getSubscriberId()內部拋的異常
// 省略相關代碼...
NetworkHelp.APInfo var5 = new NetworkHelp.APInfo();
// 省略相關代碼...
return var5;
}
public static class APInfo {
public int apType;
public String apName;
public APInfo() {// 當創建該對象的時候,屬性會賦予默認值
this.apType = NetworkHelp.APType.AP_UNKNOWN.value();
this.apName = "AP_UNKNOWN";
}
}
// 省略相關代碼...
}
根據反編譯的NetworkHelp源碼可以看出在getAPInfo()方法中首先判斷Context類型的參數var0是否爲null,如果爲null則直接返回APInfo對象,而APInfo對象在創建的時候會把內部屬性賦予默認值,也就是說如果我們在getAPInfo()方法中想辦法讓參數var0爲null不就可以避免那些沒有授權的用戶發生崩潰了麼?這是一個Hook點,簡單粗暴(*^__^*) ……我們繼續往下讀getAPInfo()的代碼,如果參數var0不爲空且網絡是OK的,就會走到getMobileInfo()方法中,在getMobileInfo()方法中調用了TelephoneManager的getSubscriberId()方法,而getSubscriberId()方法內部執行過程中會做權限校驗,在未授權的情況下會拋出異常,因此只要在getMobileInfo()方法中調用getSubscriberId()前添加判斷是否有權限的代碼塊就OK了,這又是一個Hook點(*^__^*)
……根據以上兩個Hook點,我們對NetworkHelp做Hook後的代碼應該是如下的樣子:public class NetworkHelp {
protected static NetworkHelp.APInfo getAPInfo(Context var0) {
// Hook點1,添加如下一句代碼,簡單粗暴
var0 = null;
NetworkHelp.APInfo var1 = new NetworkHelp.APInfo();
if(var0 == null) {
QLog.e("NetworkHelp", 0, "getAPInfo initial context is null");
return var1;
} else {
// 省略相關代碼...
return var1;
}
}
private static NetworkHelp.APInfo getMobileAPInfo(Context var0, int var1) {
// Hook點2,考慮SDK的感受,讓他們拿到網絡信息(*^__^*)
if (PackageManager.PERMISSION_GRANTED != ContextCompat.checkSelfPermission(var0, Manifest.permission.READ_PHONE_STATE)) {
return new NetworkHelp.APInfo();
}
TelephonyManager var2 = (TelephonyManager)var0.getSystemService("phone");
NetworkHelp.MobileCarrier var3 = NetworkHelp.MobileCarrier.UNKNOWN;
String var4 = var2.getSubscriberId(); // getSubscriberId()內部拋的異常
// 省略相關代碼...
return var5;
}
// 省略相關代碼...
}
好了,清楚了對NetworkHelp的Hook點後,接下來就是考慮如何對NetworkHelp.class類進行修改了,要修改class文件就要清楚JVM指令,因爲class文件最後都是通過類加載器加載運行在JVM上的,只有清楚JVM指定才能按照JVM規範來修改class文件。好在這個世界總有那麼一些神一般存在的大神,對於class文件的修改操作(添加方法,修改方法、字段等)已經有位日本的大學教授封裝好了面向Java API編程的字節碼操作庫Javassist,該庫目前已收錄於jboss開源項目中。使用Javassist庫就可以避免和JVM指令打交道,從而讓修改class文件變得簡單Happy起來。【注意:】這篇文章僅僅介紹Javassist的簡單用法,後續文章將介紹它的詳細用法以及帶領小夥伴來讀一下Javassist的源碼,敬請期待……
有了操作class文件的Javassist庫,就可以對class文件進行修改了,修改流程是:首先解壓第三方Jar文件,拿到要修復的class文件,其次利用Javassist庫進行class文件修改,修改完打包成Jar文件後覆蓋原有的Jar文件,之後項目打包也就直接把修復過的Jar文件打包進去了。這個流程理論上是沒有問題的,但是當我們引用的第三方Jar包是通過Gradle來配置的話就會存在冗餘工作的問題,例如ConstraintLayout包:
compile 'com.android.support.constraint:constraint-layout:1.0.2'
Gradle文件中添加如上配置後,它就會從JCenter倉庫下載相應配置的Jar文件保存在以版本號命名的本地文件夾中,若按照上述流程進行class文件的修復是沒有問題的,但是當ConstraintLayout進行了版本升級,這個時候Gradle就又會下載最新包到本地,這個時候我們又得從新執行以上的修復流程,這樣十分繁瑣,作爲程序員一定要記住:能用機器去做的就堅決不要讓人去做,自動化一切可以自動化的。所以我們的目標的是write once, run anywhere,無論依賴的Jar包今後升級與否,只寫一遍代碼就能統統搞定(*^__^*) ……
既然要實現一次編碼就能滿足今後所依賴的Jar文件升級與否的功能,我們就要在項目打包成APK前找到一個節點,這個節點一定是在Jar包被轉換成Dex文件前,因爲在Gradle打包流程中一旦Jar包被轉換成Dex文件後,我們再對Jar文件進行處理就已經失去意義了,好在Gradle plugin在1.5.0版本後給我們提供了API Transform,該Transform的作用如官方所說:
Starting with 1.5.0-beta1, the Gradle plugin includes a Transform API allowing 3rd party plugins to manipulate compiled class files before they are converted to dex files. (The API existed in 1.4.0-beta2 but it's been completely revamped in 1.5.0-beta1)
也就是說,Gradle Plugin 在1.5.0之後的版本中,Gradle Plugin提供了Transform API,該Transform API 允許第三方插件在class文件被轉換成Dex文件之前有機會處理到這些class文件。
現在我們有了對class處理的Javassist庫,也找到了對class文件的處理時機,接下來就是自定義Gradle Plugin來實現對Jar文件的修復了。首先在我們項目中創建一個Android Library Module取名爲plugin,該plugin就是用來開發Gradle插件的。然後清空plugin下的其他文件,只保留build.gradle和src/main目錄,在build.gradle中添加如下配置:
apply plugin: 'groovy'
repositories {
jcenter()
}
dependencies {
compile gradleApi()
compile localGroovy()
compile 'com.android.tools.build:gradle:2.3.3'
compile 'org.javassist:javassist:3.20.0-GA'
}
添加以上配置後同步一下代碼就OK了,這段配置代碼主要是聲明plugin插件使用的Gradle SDK和Groovy SDK並添加gradle和javassist API的依賴。
然後進入plugin目錄下的main目錄創建groovy目錄,因爲Gradle插件是基於Groovy語法的,因此我們開發的插件相當於一個Groovy項目,所以需要在main目錄下創建groovy目錄。這時plugin目錄如下所示:
由於groovy是基於JVM的DSL語言,在groovy中能完美調用Java API。因此創建groovy文件和創建Java文件是類似的,在groovy包下新建包名:com.llew.bytecode.fix.plugin,然後在plugin包下新建BytecodeFixPlugin.groovy文件,因爲要創建Gradle插件就必須要實現Gradle包中的org.gradle.api.Plugin接口,所以BytecodeFixPlugin.groovy內容如下所示:
package com.llew.bytecode.fix.plugin;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
public class BytecodeFixPlugin implements Plugin<Project> {
@Override
void apply(Project project) {
println "this is a gradle plugin, (*^__^*)……"
}
}
插件定義好之後我們要告訴Gradle哪一個類是我們定義的插件類,因此需要在main目錄下創建resources目錄,然後在resources目錄下創建META-INF目錄,接着在META-INF目錄下創建gradle-plugins目錄,gradle-plugins目錄是自定義Gradle插件的必備目錄,然後在該目錄下創建一個properties文件,文件名爲com.llew.bytecode.fix.properties,這個文件名是有技巧的,當起完名字後如果要使用插件,就可以這樣:apply
plugin 'com.llew.bytecode.fix';起完名字後還不可以使用該插件,還要告訴Gradle自定義插件的具體實現類是哪一個,在com.llew.bytecode.fix.properties文件中添加如下內容:implementation-class=com.llew.bytecode.fix.plugin.BytecodeFixPlugin
這樣就告訴了Gradle插件的實現類是com.llew.bytecode.fix.plugin.BytecodeFixPlugin,定義完了以上配置後,還需要把插件打包到Maven倉庫後纔可以使用,爲了簡單起見,我們直接把插件打包到本地Maven倉庫,在plugin的build.gradle中完整配置如下:apply plugin: 'groovy'
apply plugin: 'maven'
repositories {
jcenter()
mavenCentral()
}
dependencies {
compile gradleApi()
compile localGroovy()
compile 'com.android.tools.build:gradle:2.3.3'
}
group = 'com.llew.bytecode.fix'
version = '1.0.0'
uploadArchives {
repositories {
mavenDeployer {
repository(url: uri("../repository"))
}
}
}
配置好plugin的build.gradle後就是進行打包了,這時候點擊Android Studio的gradle工具在plugin下有一個uploadArchives Task,這時候點擊運行該task,就會在plugin的同級目錄下生成一個repository文件夾,該文件夾就是plugin的倉庫,如下圖所示:
plugin打包到本地倉庫後就可以在主項目的build.gradle中使用我們自定義的插件了,在根目錄的build.gradle文件中添加Maven的本地依賴,代碼如下所示:
buildscript {
repositories {
jcenter()
maven {// 添加Maven的本地依賴
url uri('./repository')
}
}
dependencies {
classpath 'com.android.tools.build:gradle:2.3.3'
// 添加如下配置,格式爲:groupName:moduleName:version
classpath 'com.llew.bytecode.fix:plugin:1.0.0'
}
}
在根目錄的build.gradle配置完成之後,就可以只用我們自定義的插件了,在主項目的build.gradle文件末尾中添加如下依賴:apply plugin: 'com.llew.bytecode.fix'
主項目的build.gradle配置完成後重新clean下代碼,重新clean下代碼,重新clean下代碼,重要的事情說三遍,然後點擊Android
Studio的make project按鈕,Gradle輸出窗口就會打印如下日誌:
經過一系列的操作,我們自定義的插件終於可以使用了,頓時感覺好Happy呀,哈哈,接下來就可以在我們自定義的插件BytecodeFixPlugin中注入Transform了,創建BytecodeFixTransform並繼承Transform類,然後重寫相關方法,代碼如下:
package com.llew.bytecode.fix.transform
public class BytecodeFixTransform extends Transform {
private static final String DEFAULT_NAME = "BytecodeFixTransform"
BytecodeFixTransform() {
}
@Override
public String getName() {
return DEFAULT_NAME
}
@Override
public Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS
}
@Override
public Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT
}
@Override
public boolean isIncremental() {
return false
}
@Override
public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
}
}
BytecodeFixTransform重寫了部分方法,相關方法解釋如下:
- getName()
該方法表示當前Transform在task列表中的名字,返回值最終經過一系列的拼接,具體拼接實現在TransformManager的getTaskNamePrefix()方法中,拼接格式:transform${InputType1}And${InputType2}And${InputTypeN}And${name}For${flavor}${BuildType} - getInputTypes()
該方法表示指定輸入類型,這裏我們指定CONTENT_CLASS類型 - getScopes()
該方法表示當前Transform的作用範圍,這裏我們指定SCOPE_FULL_PROJECT - isIncremental()
該方法表示當前Transform是否支持增量編譯 - transform()
該方法是重點,它接收上一個Transform的輸出,並把處理後的結果作爲下一個Transform的輸入,如下所示:
創建完BytecodeFixTransform後需要把它添加到Android的編譯流程中,修改BytecodeFixPlugin的apply()方法,如下所示:
@Override
void apply(Project project) {
def android = project.extensions.findByType(AppExtension.class)
def versionName = android.defaultConfig.versionName
android.registerTransform(new BytecodeFixTransform(project, versionName))
}
現在BytecodeFixTransform已經添加到打包流程中了,如果運行項目會失敗的,因爲在整個運行流程中上一個Transform的輸入進入了BytecodeFixTransform的transfrom()方法中,但該方法沒有輸出,所以要在transform()方法中做資源的輸出,代碼如下所示:@Override
public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
if (null == transformInvocation) {
throw new IllegalArgumentException("transformInvocation is null !!!")
}
Collection<TransformInput> inputs = transformInvocation.inputs
if (null == inputs) {
throw new IllegalArgumentException("TransformInput is null !!!")
}
TransformOutputProvider outputProvider = transformInvocation.outputProvider;
if (null == outputProvider) {
throw new IllegalArgumentException("TransformInput is null !!!")
}
for (TransformInput input : inputs) {
if (null == input) continue;
// 把項目中的class文件拷貝到指定目錄
for (DirectoryInput directoryInput : input.directoryInputs) {
if (directoryInput) {
if (null != directoryInput.file && directoryInput.file.exists()) {
// directoryInput.file就是我們項目中編譯過的class文件
// 獲取指定目錄,固定寫法
File dest = outputProvider.getContentLocation(directoryInput.getName(), directoryInput.getContentTypes(), directoryInput.getScopes(), Format.DIRECTORY);
FileUtils.copyDirectory(directoryInput.file, dest);
}
}
}
// 把依賴的第三方Jar文件拷貝到指定目錄
for (JarInput jarInput : input.jarInputs) {
if (jarInput) {
if (jarInput.file && jarInput.file.exists()) {
String jarName = jarInput.name;
String md5Name = DigestUtils.md5Hex(jarInput.file.absolutePath);
if (jarName.endsWith(".jar")) {
jarName = jarName.substring(0, jarName.length() - 4);
}
// jarInput.file文件就是依賴的一些第三方Jar包,修復第三方的Jar包就是在這裏進行處理的
// 獲取指定目錄,固定寫法
File dest = outputProvider.getContentLocation(DigestUtils.md5Hex(jarName + md5Name), jarInput.contentTypes, jarInput.scopes, Format.JAR);
if (dest) {
if (dest.parentFile) {
if (!dest.parentFile.exists()) {
dest.parentFile.mkdirs();
}
}
if (!dest.exists()) {
dest.createNewFile();
}
FileUtils.copyFile(jarInput.file, dest);
}
}
}
}
}
}
在transform()方法添加輸出後,項目就可以運行起來了。目前transform()方法僅僅是把輸入文件集合拷貝到目的文件夾中,而這些輸入文件集合和輸出文件路徑是通過TransformInput和TransformOutputProvider提供的。我們修改第三方Jar包的時機就是在這裏進行的,先把輸入進來的第三方Jar包文件做修改,修改之後就直接把修改過的Jar包拷貝進目標文件夾中就行了,實現思路就是這樣,很簡單,有木有(*^__^*) ……
到這裏我們自定義Gradle Plugin已經實現了,接下來就是實現對Jar包文件內容的修改,由於篇幅原因,我把對Jar包文件的內容修復講解放在了下篇文章Android 源碼系列之<十八>自定義Gradle Plugin,優雅的解決第三方Jar包中的bug<中>中,點擊鏈接可跳轉閱讀。
插件已開源GitHub:https://github.com/llew2011/BytecodeFixer;下篇文章有講解詳細用法