Android教程網
  1. 首頁
  2. Android 技術
  3. Android 手機
  4. Android 系統教程
  5. Android 游戲
 Android教程網 >> Android技術 >> 關於Android編程 >> 聊聊Android 熱修復Nuwa有哪些坑

聊聊Android 熱修復Nuwa有哪些坑

編輯:關於Android編程

然後我說了Nuwa有坑,有人就問Nuwa到底有哪些坑,這篇文章對自己在Nuwa上走過的坑做一個總結,如果你遇到了其他坑,歡迎留言,我會統一加到文章中去。當然有些也不算是Nuwa的坑,算是ClassLoader這種方式進行熱修復暴露出來的問題吧。

坑一、混淆有哪些坑

excludeClass沒有參考混淆產物mapping.txt,導致無法exclude掉一些不需要處理的類

在不混淆的情況下,Nuwa在這一方面是沒有什麼問題的,但是一旦混淆了,有些類你不想讓他注入字節碼,它卻注入了,這是為什麼呢,原因是Nuwa處理的是混淆後的jar,混淆後的jar包名和類名發生了變化,你再使用配置進去的excludeClass是無法主動不進行字節碼注入處理的,除非你加進去的是混淆後的類名,但是在沒混淆前,我們是根本不知道混淆後的類名的,有人說,我可以先混淆一遍,混淆完了查看一下mapping文件,找到對應的混淆後的類名,加到excludeClass中去,可以是可以,難道你不覺得蛋疼嗎,而且這樣也很有可能出現差錯。那麼有沒有更好的方法呢?當然有。

混淆後在outputs目錄下會產生一個mapping.txt文件,我們能不能解析這個文件,將混淆後的類還原為原來的類名呢,這個文件的大致內容就像下面這樣。

android.support.graphics.drawable.AnimatedVectorDrawableCompat$1 -> android.support.a.a.c:
    android.support.graphics.drawable.AnimatedVectorDrawableCompat this$0 -> a
    629:629:void (android.support.graphics.drawable.AnimatedVectorDrawableCompat) -> 
    632:633:void invalidateDrawable(android.graphics.drawable.Drawable) -> invalidateDrawable
    637:638:void scheduleDrawable(android.graphics.drawable.Drawable,java.lang.Runnable,long) -> scheduleDrawable
    642:643:void unscheduleDrawable(android.graphics.drawable.Drawable,java.lang.Runnable) -> unscheduleDrawable
android.support.graphics.drawable.AnimatedVectorDrawableCompat$AnimatedVectorDrawableCompatState -> android.support.a.a.d:
    int mChangingConfigurations -> a
    android.support.graphics.drawable.VectorDrawableCompat mVectorDrawable -> b
    java.util.ArrayList mAnimators -> c
    android.support.v4.util.ArrayMap mTargetNameMap -> d
    473:503:void (android.content.Context,android.support.graphics.drawable.AnimatedVectorDrawableCompat$AnimatedVectorDrawableCompatState,android.graphics.drawable.Drawable$Callback,android.content.res.Resources) -> 
    507:507:android.graphics.drawable.Drawable newDrawable() -> newDrawable
    512:512:android.graphics.drawable.Drawable newDrawable(android.content.res.Resources) -> newDrawable
    517:517:int getChangingConfigurations() -> getChangingConfigurations
android.support.graphics.drawable.AnimatedVectorDrawableCompat$AnimatedVectorDrawableDelegateState -> android.support.a.a.e:
    android.graphics.drawable.Drawable$ConstantState mDelegateState -> a
    424:426:void (android.graphics.drawable.Drawable$ConstantState) -> 
    430:434:android.graphics.drawable.Drawable newDrawable() -> newDrawable
    439:443:android.graphics.drawable.Drawable newDrawable(android.content.res.Resources) -> newDrawable
    448:452:android.graphics.drawable.Drawable newDrawable(android.content.res.Resources,android.content.res.Resources$Theme) -> newDrawable
    457:457:boolean canApplyTheme() -> canApplyTheme
    462:462:int getChangingConfigurations() -> getChangingConfigurations

仔細觀察一下,還是挺有規律的,第一行是原始類名對應的混淆類名,中間用->分割,之後是原始變量名對應的混淆後的變量名,還是用->分割,但是開頭縮進了四個空格。最後是方法的混淆,最前面是方法的行數,使用:分割,兩個數字分割後再跟一個:,後面就是原始方法名對應的混淆方法名,也是使用->分割。方法和變量都是有類型的。你是不是想到怎麼解析了,沒錯,正則表達式,別急著寫代碼,在寫代碼之前我們先看看有沒有造好的輪子可以用用,在github上搜一下proguard,沒結果。。。再換個關鍵字,retrace,為什麼是retrace呢,因為proguard自帶了一個腳本叫retrace,可以從混淆後的異常信息還原為原始類的異常信息。結果出來了,在code中選擇java,第一頁的最後一條就是。這裡我把這個倉庫fork到自己的倉庫中去了,見地址https://github.com/lizhangqu/retrace

當然不能完完全全的直接用,其實我們用得到的就三個類,一個是ClassMapping.java,一個是MethodMapping.java,還有一個是Retrace.java,至於如何改造,靠你自己了,源碼都擺在你面前了你還不會改造?改造後的結果就是傳入混淆後的全類名,返回原始的全類目,這樣跟excludeClass進行對比就能正確處理了。

沒有被修改的類被卻被打進了patch,為什麼?

我們修改了一個復雜一點的類,准備打patch了,發現被打進patch的類怎樣不是一個,還包含了一大堆其他的類,為什麼呢?打修復包時利用正式包的mapping,修復bug,修改了原先的類,改變的類會改變,但有些類沒有改變也會因為混淆的關系產生變化(混淆會剔除一些無用的方法,打修復包時那些無用的方法可能會加上),這就造成了有些類沒有修改,但也會出現在修復包中。當然這種情況出現的概率還是挺大的,但是出現的類的數量就不一定了,有多了一個的,也有多了一坨的。。。。怎樣解決。。。無解,多就多了呗。。。最多也就是patch包大小變大了。只能盡量避免這種情況的發生,比如打修復包的時候不要修改原有的縮進,一不小心手賤重新進行格式化,可能原來的代碼沒有格式化,你這麼一格式化,整個類都發生了變化,包括這個類的內部類,這樣patch的類的數量就會爆增。所以打patch的時候應該盡可能的減少代碼的改動。

坑二、Application直接引用的類無法打Patch

為什麼會出現這種現象?

出現這種現象的原因是Application類我們沒有引用hack.apk,為什麼不引用呢,因為在加載Application類之前我們還沒加載hack.apk,引用了就會報找不到類的異常,於是這個類不能打,並且在加載hack.apk前用的類都不能引用hack.apk。於是就導致了Application類被打上了那個標記進行了校驗。然後Application直接引用的類就無法打patch了,一打patch就會報那個異常Class ref in pre-verified class resolved to unexpected implementation

如何解決這個問題?

直接引用的類不能打patch,但是間接引用的可以打呀,把直接引用的類改成間接引用就ok了,怎麼做呢?新建一個中間類,比如PatchUtil,裡面有一個init方法,入參是Application,把原來在Application中的邏輯全都轉移到PatchUtil中去,然後Application引用PatchUtil類進行調用,最終將一大推直接引用的類變成了間接引用,同時PatchUtil變成了直接引用的類,於是原來一大坨不能打patch的類變成了一個類不能打patch,還是值得的。

坑三、字節碼注入的坑

注入失敗的原因是什麼(混淆和私有構造函數)?

如果代碼不混淆,字節碼注入是沒問題的,所以這個原因還是混淆導致的,混淆之後,很多類沒有了< init >,或者< init > 變成了 < clinit >,為什麼會這樣呢,我估計是剔除了無用方法導致的。還有一個特殊的情況就是私有構造函數,比如單例的情況下就存在只有一個私有構造函數,私有構造函數字節碼中沒有 < init >,甚至更絕,也沒有 < clinit >,這種情況是百分百注入不進去的,而Nuwa的邏輯是判斷name是不是等於< init >並且在構造函數的末尾。但是實際測試情況是絕大多數的類混淆之後字節碼中都沒有< init >或者< clinit >。

如何去解決注入失敗的問題?

能不能插一個成員變量呢,實際測試結果是不能。。。具體原因我也不清楚。那麼沒有構造函數就給它插一個構造函數,但是卻又不能顯示的插一個構造函數,因為這種情況也可能是有問題的,比如原來就有一個私有構造函數,你再插一個公有構造函數,肯定是有問題的,於是就演變成了給它插一段靜態初始化的代碼就可以了,在這段代碼中直接引用Hack.class。就像這樣子

static{
    System.out.println(com.package.Hack.class);
}

至於這段代碼怎麼插。。。我表示用asm插我真的不會插,所以我把Nuwa插字節碼的那段代碼從使用asm插字節碼替換成了用javassist插,至於怎麼插,見後文。

如何修改注入的字節碼使其找不到類也不會報錯

Nuwa原來的字節碼注入是在構造函數中注入一段這樣的代碼

System.out.println(Hack.class);

這段代碼有什麼問題呢,仔細用腦子想一下,萬一有些類在加載Hack.class之前就使用了,並且我們一不小心給他注入了這段代碼,那麼程序運行就會立馬crash,於是,我們想能不能不讓這段代碼執行呢,答案是可能的,通過一個if語句,讓它永遠進不去這個if語句就可以了,下面是一種方式,當然你完全可以使用其他類似的代碼

if(Boolean.FALSE.booleanValue()){
    System.out.println(Hack.class)
}

這樣這段代碼就永遠不會被執行,即使提取使用了某個不應該使用的類,程序也不會crash,最多是控制台輸出一條log,說這個引用的類找不到。而實際測試結果是,即使報了這個log,也還是能打patch的。

坑四、不支持gradle 1.5以上

如何解決?
Hook方式解決
hook的方式和1.2.3是一樣的,只不過hook 1.5的gradle比1.2.3的處理要簡單許多,具體實現可以見這個實現 AndHotFix hook的task的名字叫transfromClassesWidthDexForRelease或者transfromClassesWidthDexForDebug,在這個task之前執行我們的注入操作就可以了。 使用transform api解決
除了hook,還可以使用gradle1.5的新的api來解決,也就是transform接口,具體實現可以參考我前面的一篇文章 Android 熱修復使用Gradle Plugin1.5改造Nuwa插件,這種方式有一個缺點,我們處理的類是沒有被混淆前的類,處理完後打patch的時候需要在代碼中根據配置文件以及mapping文件進行一次代碼級別的混淆操作,當然這個操作也是全自動的,用代碼來進行混淆即可,缺點是一個類的內部類都會被打進patch。所以也不是特別合適,反倒hook的方式更加靈活。

坑五、patch包沒有進行簽名校驗

如何防止patch包被非法篡改?
patch包在app端的校驗是必須的,因此校驗的前提是對patch進行簽名,如何簽名呢?可以參考攜程的打包腳本 https://github.com/CtripMobile/DynamicAPK,裡面有對apk進行sign和zipalign的腳本,拿來稍微修改一下就可以使用了。下面是我修改後的腳本
public static signedApk(Logger logger, def variant, File apkFile) {
    if (!apkFile.exists())
        return;

    def signingConfigs = variant.getSigningConfig()
    if (signingConfigs == null) {
        logger.error "no need to sign"
        return;
    }


    def args = [JavaEnvUtils.getJdkExecutable('jarsigner'),
                '-verbose',
                '-sigalg', 'MD5withRSA',
                '-digestalg', 'SHA1',
                '-keystore', signingConfigs.storeFile,
                '-keypass', signingConfigs.keyPassword,
                '-storepass', signingConfigs.storePassword,
                apkFile.absolutePath,
                signingConfigs.keyAlias]

    def proc = args.execute()
}


public static zipalign(Project project, File apkFile) {
    if (apkFile.exists()) {
        def sdkDir
        Properties properties = new Properties()
        File localProps = project.rootProject.file("local.properties")
        if (localProps.exists()) {
            properties.load(localProps.newDataInputStream())
            sdkDir = properties.getProperty("sdk.dir")
        } else {
            sdkDir = System.getenv("ANDROID_HOME")
        }
        if (sdkDir) {
            def cmdExt = Os.isFamily(Os.FAMILY_WINDOWS) ? '.exe' : ''
            File dest = new File("${apkFile.absolutePath}.zipalign");
            def argv = []
            argv << '-f'    //overwrite existing outfile.zip
            // argv << '-z'    //recompress using Zopfli
            argv << '-v'    //verbose output
            argv << '4'     //alignment in bytes, e.g. '4' provides 32-bit alignment
            argv << apkFile.absolutePath

            argv << dest.absolutePath  //output

            project.exec {
                commandLine "${sdkDir}/build-tools/${project.android.buildToolsVersion}/zipalign${cmdExt}"
                args argv
            }

            if (apkFile.exists()) {
                apkFile.delete()
            }
            dest.renameTo(apkFile)
        } else {
            throw new InvalidUserDataException('$ANDROID_HOME is not defined')
        }
    }
}

然後客戶端需要做的就是根據這個patch的前面和當前app的簽名進行校驗即可。

坑六、ASM字節碼注入的維護成本高

這個不能算是Nuwa的坑,只不過Nuwa使用了ASM來進行注入字節碼,ASM的可讀性實在是太差,對於不懂字節碼的人來說有一定的難度,所以必須提高代碼的可讀性,降低維護成本。

如何降低維護成本

替換asm為javassist,相對asm來說,javassist在性能上可能差一點,但是在可讀性上,那絕對是對開發人員友好的,因為寫的就是java代碼。下面我們來演示一下注入之前說的那段代碼

if(Boolean.FALSE.booleanValue()){
    System.out.println(Hack.class)
}
ClassPool classPool = ClassPool.getDefault();
//這裡動態生成Hack類,插入到classpatch中,因為javassist生成字節碼需要依賴這個類,這裡采用動態生成
CtClass hackClass = classPool.makeClass("com.lizhangqu.hack.Hack")
byte[] hackBytes = hackClass.toBytecode()
hackClass.defrost()
classPool.insertClassPath(new ByteArrayClassPath("com.weidian.hack.Hack", hackBytes))

Nuwa原來注入字節碼的函數原型是這樣的

private static byte[] referHackWhenInit(InputStream inputStream) {
}

入參是InputStream,返回值是字節碼的byte數組,我們不改變函數原型,編寫這個注入函數

private
    static byte[] referHackByJavassistWhenInit(ClassPool classPool, InputStream inputStream) {
        CtClass clazz = classPool.makeClass(inputStream)
        CtConstructor ctConstructor = clazz.makeClassInitializer()
        ctConstructor.insertAfter("if(Boolean.FALSE.booleanValue()){System.out.println(com.weidian.hack.Hack.class);}")
        def bytes = clazz.toBytecode()
        clazz.defrost()
        return bytes
    }

入參多了一個ClassPool參數,這個參數就是前面的那個ClassPool,裡面包含了Hack這個類。這裡面的關鍵是makeClassInitializer函數,這個函數的作用就是生成一段靜態初始化的代碼,如果不存在的話會新建一個,存在的話就返回,然後我們在這個最後面插入一段字節碼,即

if(Boolean.FALSE.booleanValue()){System.out.println(com.lizhangqu.hack.Hack.class);}

插入完成後轉換成字節數組,記得調用defrost方法進行解凍,否則會有異常。最終生產的代碼就是這樣的。

static{
    if(Boolean.FALSE.booleanValue(){
        System.out.println(com.lizhangqu.hack.Hack.class);
    }
}

坑七、Android各版本的兼容性如何

在Android5.0與6.0上兼容性表現得如何?
實際情況下,我測了三個系統版本,即4.4,5.0,6.0,實際測試結果怎麼樣呢,三個系統版本打patch都是沒有問題的,唯一需要特殊處理的系統可能是6.0,為什麼是6.0呢,因為6.0多了一個運行時權限申請。

Android 6.0 動態權限申請的坑

為什麼這是個坑呢,因為測試的時候我是把patch放到sdcard根目錄進行測試的,這種情況下,對應6.0的系統來說,讀寫sdcard除了需要在manifest文件中進行聲明之外,還需要動態申請權限,因為對於用戶來說,讀寫sdcard屬於危險權限,需要用戶主動授權,所以6.0的系統,如果你的patch在sdcard,你可能需要加入類似這樣的申請權限的代碼

int permission = ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE);
if (permission != PackageManager.PERMISSION_GRANTED) {
    Log.e("TAG", "未授權");
    ActivityCompat.requestPermissions(this,
            new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
            100);
}

之後只要用戶授權了,就能正常的打patch了。

以上就是最近我遇到的一些坑的簡述以及簡單的給出了解決思路,如果你遇到了其他坑,歡迎留言。

  1. 上一頁:
  2. 下一頁:
熱門文章
閱讀排行版
Copyright © Android教程網 All Rights Reserved