1. 程式人生 > >Android熱更新技術的研究與實現(二)

Android熱更新技術的研究與實現(二)

Sophix—阿里終極熱修復方案

不過阿里作為大廠咋可能沒有個自己的熱更新框架呢,所以阿里爸爸最近還是做了一個新的熱更新框架SopHix

方案對比

巴巴再次證明我是最強的,誰都沒我厲害!!!因為我啥都支援,而且沒缺點。。簡直就是無懈可擊!

那麼我們就來專案整合下看看具體的使用效果吧! 先去建立個應用:

建立Sophix應用

獲取AppId:24582808-1,和AppSecret:da283640306b464ff68ce3b13e036a6e 以及RSA金鑰**。三個引數配置在application節點下面:

    <meta-data
        android:name="com.taobao.android.hotfix.IDSECRET"
        android:value="24582808-1" />
    <meta-data
        android:name="com.taobao.android.hotfix.APPSECRET"
        android:value="da283640306b464ff68ce3b13e036a6e" />
    <meta-data
        android:name="com.taobao.android.hotfix.RSASECRET"
        android:value="MIIEvAIBA**********" />

新增maven倉庫地址:

repositories {
    maven {
       url "http://maven.aliyun.com/nexus/content/repositories/releases"
    }
}

新增gradle座標版本依賴:

compile 'com.aliyun.ams:alicloud-android-hotfix:3.1.0'

我們測試一個簡單一點的例子:
專案結構也很簡單

專案結構

—-方法出現錯誤的Sophix熱更新實現—-

MainActivity:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        ((TextView)findViewById(R.id.tv)).setText(String.valueOf(BuildConfig.VERSION_NAME));
        findViewById(R.id.btn_click).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent intent;
                intent = new Intent(MainActivity.this,SecondActivity.class);
                startActivity(intent);
            }
        });

    }
}

其實就是有一個文字框顯示當前版本,還有一個按鈕用來跳轉到SecondActivity

SecondActivity的內容:

public class SecondActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_second);
        String  s  = null;
        findViewById(R.id.btn).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Toast.makeText(SecondActivity.this, "彈出框內容彈出錯啦!", Toast.LENGTH_SHORT).show();
            }
        });
    }
}

也很簡單,只有一個按鈕,按鈕點選之後彈出一個Toast顯示“彈出框內容彈出錯啦!”

就這樣,我們的一個上線app完成了(粗糙是粗糙了點),下面來看下效果吧(請諒解我第一次錄屏的渣渣技術,以後會做的越來越好)

bug效果

然後我們的使用者開始用了,發現一個bug!“彈出框彈出的內容是錯誤的!”,使用者可不管別的,馬上給我改好啊!此時的開發er估計心頭千萬頭草泥馬在奔騰了,求神拜佛上線不要出問題,剛上線就出問題了,“where is my 測試er!!!”不說了,趕緊修吧,最暴力的方法就是SecondActivity的Toast中彈出“彈出框內容彈正常啦!”一句程式碼搞定!bingo!
如果沒有熱更新,可能就要搞個臨時版本或者甚至釋出一個新版本,但是現在我們有了Sophix,就不需要這麼麻煩了。

首先我們去下載補丁打包工具(不得不說,做的確實比較醜。。。)

阿里補丁工具

舊包:<必填> 選擇基線包路徑(有問題的APK)。

新包:<必填> 選擇新包路徑(修復過該問題APK)。

日誌:開啟日誌輸出視窗。

高階:展開高階選項

設定:配置其他資訊。

GO!:開始生成補丁。

所以首先我們把舊包和新包新增上之後,配置好之後看看會發生什麼吧!

強制冷啟動是補丁打完後重啟才生效。

配置

正在生成補丁 補丁生成成功

時間看情況吧,因為專案本身內容比較少,所以生成補丁的速度比較快,等一下就好了。專案比較大的話估計需要等的時間長一點

我們來看看到底生成了什麼?開啟補丁生成目錄

生成的補丁檔案

這個就是我們生成的補丁檔案了,下一步補丁如何使用?
我們開啟阿里的管理控制檯,將補丁上傳到控制檯,就可以釋出了.

補丁上傳

補丁釋出

這裡有個坑,我用自己的中興手機發現在使用補丁除錯工具的時候一直獲取包名錯誤,然後就借了別人的華為手機測試然後就可以了。最後我是在模擬器上完成錄製的。

我們首先下載除錯工具來看看效果吧,首先連線應用(坑就在這裡,有的手機可能會提示包名錯誤,但是其實是沒有錯的,雖然官網給出瞭解決方案,可依舊沒有解決,不得已只能用模擬器了)

除錯工具

然後有兩種方式可以載入補丁包,一種是掃二維碼,還有一種是載入本地補丁jar包,模擬器上實在不好操作啊!!!最後我屈服了,借了同學的手機掃二維碼載入補丁包了。。。然後就會有log提示

除錯工具載入補丁包

從圖中的log提示我們可以看出首先下載了補丁包,然後打補丁完成,要求我們重啟APP,那我們就重啟唄,看到的當然就應該是補丁打好的1.1版本和Toast彈出正常啦!!

更新版本 更新Toast

當然了,目前我們還是在除錯工具上載入的補丁包,我們接下來將補丁包釋出後就可以不用除錯工具,直接開啟app就可以實現打補丁了,這樣就完成了bug的修復!

其實這麼看起來確實是非常簡單就實現了熱修復,主要我們的生成補丁工作都是交給阿里提供的工具實現了,其實我們也能看得出來,Sophix和前面介紹的AndFix很像,不同的地方是補丁檔案已經給出工具可以一鍵生成了,而且支援的東西更多了。其他比如so庫和library以及資原始檔的更新大家可以檢視官方文件瞭解。

其實Sophix主要是在阿里百川HotFix的版本上的一個更新,而HotFix又是什麼呢?

HotFix和ndFix的關係
所以阿里爸爸一直在進步著呢,知道技術存在問題就要去解決問題,這不,從Dexposed–>AndFix–>HotFix–>Sophix,技術是越來越成熟了。

下面介紹另外一個大廠的幾種熱更新方案

Qzone超級補丁 & 微信Tinker 企鵝大廠的熱更新方案

巴巴家的熱更新技術一直在發展,作為網際網路巨頭的騰訊怎甘落後,所以也是窮追不捨的幹起來!

Qzone超級補丁

因為超級補丁技術是基於DEX分包方案,使用了多DEX載入的原理,所以我先給大家簡單講下DEX載入的一些東西:

Android程式要執行需要先編譯打包成dex,之後才可以被Android虛擬機器解析執行。因此我們如果想要即時修補bug就要讓修復的程式碼被Android虛擬機器識別,如何才能讓虛擬機器認識我們修改過的程式碼呢,也就是我們需要把修改過的程式碼打包成單獨的dex。
然後接下來要做的就是如何讓虛擬機器載入我們修改過後的dex jar包中的類呢?

我們需要了解的是類載入器是如何載入類的。

在Android中 有 2種類載入器:

PathClassLoader和DexClassLoader,原始碼如下:

public class DexClassLoader extends BaseDexClassLoader {  

    public DexClassLoader(String dexPath, String optimizedDirectory,  
            String libraryPath, ClassLoader parent) {  
        super(dexPath, new File(optimizedDirectory), libraryPath, parent);  
    }  
}  

public class PathClassLoader extends BaseDexClassLoader {  

    public PathClassLoader(String dexPath, ClassLoader parent) {  
        super(dexPath, null, null, parent);  
    }  

    public PathClassLoader(String dexPath, String libraryPath,  
            ClassLoader parent) {  
        super(dexPath, null, libraryPath, parent);  
    }  
}  

這兩者的區別是:

  1. DexClassLoader:可以載入jar/apk/dex,可以從SD卡中載入未安裝的apk;

  2. PathClassLoader:要傳入系統中apk的存放Path,所以只能載入已經安裝的apk檔案。

兩個類都只是簡單的對BaseDexClassLoader做了一下封裝,具體的實現還是在父類裡。不過這裡也可以看出,PathClassLoaderoptimizedDirectory只能是null,進去BaseDexClassLoader看看這個引數是幹什麼的

public BaseDexClassLoader(String dexPath, File optimizedDirectory,
        String libraryPath, ClassLoader parent) {
    super(parent);
    this.originalPath = dexPath;
    this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}

這裡建立了一個DexPathList例項:

 public DexPathList(ClassLoader definingContext, String dexPath,
        String libraryPath, File optimizedDirectory) {
    ……
    this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory);
}

private static Element[] makeDexElements(ArrayList<File> files,
        File optimizedDirectory) {
    ArrayList<Element> elements = new ArrayList<Element>();
    for (File file : files) {
        ZipFile zip = null;
        DexFile dex = null;
        String name = file.getName();
        if (name.endsWith(DEX_SUFFIX)) {
            dex = loadDexFile(file, optimizedDirectory);
        } else if (name.endsWith(APK_SUFFIX) || name.endsWith(JAR_SUFFIX)
                || name.endsWith(ZIP_SUFFIX)) {
            zip = new ZipFile(file);
        }
        ……
        if ((zip != null) || (dex != null)) {
            elements.add(new Element(file, zip, dex));
        }
    }
    return elements.toArray(new Element[elements.size()]);
}

private static DexFile loadDexFile(File file, File optimizedDirectory)
        throws IOException {
    if (optimizedDirectory == null) {
        return new DexFile(file);
    } else {
        String optimizedPath = optimizedPathFor(file, optimizedDirectory);
        return DexFile.loadDex(file.getPath(), optimizedPath, 0);
    }
}

//**
 //* Converts a dex/jar file path and an output directory to an
 //* output file path for an associated optimized dex file.
 //
private static String optimizedPathFor(File path,
        File optimizedDirectory) {
    String fileName = path.getName();
    if (!fileName.endsWith(DEX_SUFFIX)) {
        int lastDot = fileName.lastIndexOf(".");
        if (lastDot < 0) {
            fileName += DEX_SUFFIX;
        } else {
            StringBuilder sb = new StringBuilder(lastDot + 4);
            sb.append(fileName, 0, lastDot);
            sb.append(DEX_SUFFIX);
            fileName = sb.toString();
        }
    }
    File result = new File(optimizedDirectory, fileName);
    return result.getPath();
}

我們不需要弄的特別明白,只要知道這裡optimizedDirectory是用來快取我們需要載入的dex檔案的,並建立一個DexFile物件,如果它為null,那麼會直接使用dex檔案原有的路徑來建立DexFile物件。

optimizedDirectory必須是一個內部儲存路徑,無論哪種動態載入,載入的可執行檔案一定要存放在內部儲存。DexClassLoader可以指定自己的optimizedDirectory,所以它可以載入外部的dex,因為這個dex會被複制到內部路徑的optimizedDirectory;而PathClassLoader沒有optimizedDirectory,所以它只能載入內部的dex,這些大都是存在系統中已經安裝過的apk裡面的。

上面還只是建立了類載入器的例項,其中建立了一個DexFile例項,用來儲存dex檔案,我們猜想這個例項就是用來載入類的。

Android中,ClassLoader用loadClass方法來載入我們需要的類

 public Class<?> loadClass(String className) throws ClassNotFoundException {
    return loadClass(className, false);
}

protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
    Class<?> clazz = findLoadedClass(className);
    if (clazz == null) {
        ClassNotFoundException suppressed = null;
        try {
            clazz = parent.loadClass(className, false);
        } catch (ClassNotFoundException e) {
            suppressed = e;
        }

        if (clazz == null) {
            try {
                clazz = findClass(className);
            } catch (ClassNotFoundException e) {
                e.addSuppressed(suppressed);
                throw e;
            }
        }
    }
    return clazz;
}

loadClass方法呼叫了findClass方法,而BaseDexClassLoader過載了這個方法,到BaseDexClassLoader看看

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
    Class clazz = pathList.findClass(name);
    if (clazz == null) {
        throw new ClassNotFoundException(name);
    }
    return clazz;
}

結果還是呼叫了DexPathList的findClass

public Class findClass(String name) {
    for (Element element : dexElements) {
        DexFile dex = element.dexFile;
        if (dex != null) {
            Class clazz = dex.loadClassBinaryName(name, definingContext);
            if (clazz != null) {
                return clazz;
            }
        }
    }
    return null;
}

這裡遍歷了之前所有的DexFile例項,其實也就是遍歷了所有載入過的dex檔案,再呼叫loadClassBinaryName方法一個個嘗試能不能載入想要的類。

public Class loadClassBinaryName(String name, ClassLoader loader) {
    return defineClass(name, loader, mCookie);
}
private native static Class defineClass(String name, ClassLoader loader, int cookie);

上面的類載入中DexPathList的findClass,一個classloader可以包含多個dex,其中這個集合中的物件就是所有的dex檔案,然後呼叫從頭開始遍歷所有的dex 如果在dex中找到所需要的類,那麼就直接返回,也就是說如果存在多個dex 在前一個dex中找到了需要找到的類,也就不會繼續查詢其他dex中有沒有這個類了。

dex.loadClassBinaryName(name, definingContext)在這個dex中查詢相應名字的類,之後 defineClass 把位元組碼交給虛擬機器就完成了類的載入。

也許你看到這裡會比較暈,沒關係,上面的你可以當做沒看到,直接看下面這句話吧:如果要載入一個類,就會呼叫 ClassLoader 的 findClass 方法,在dex中查詢這個類,找到後加載到記憶體

so,我們的關鍵人物就是在 findClass 的時候讓類載入找到我們修復過後的類,而不是未修復的類。
例如,比如說要修復的類名為 BugClass 我們要做的就是將這個類修改為正確的後,打包成 dex 的 jar,然後想辦法讓類載入去查詢我們打包的jar中的 BugClass 類 而不是先前的 BugClass 類,這樣,載入類的時候使用的就是我們修復過後的程式碼,而忽略掉原本的有問題的程式碼。問題又轉變到了如何讓我們自己打包的dex檔案放到原本的 dex 檔案之前,也就是把我們打包的 dex 放到 dexElements 集合的靠前的位置

這樣算是把超級補丁的原理講了一遍,應該有一個大概的認識了,而超級補丁所做的就是讓類載入器只找到我們修復完成的類!

通俗的說 也就是我們要改變的是 dexElements 中的內容,在其中新增一個 dex 而且放在靠前的位置,而 dexElements 是 PathClassLoader類中的一個成員變數。

因為Qzone超級補丁並沒有開源,在這裡只是給大家講了類載入機制來說下實現原理,具體的實現過程應該是這樣子的(圖可能是最直觀的):

替換過程

通過反射的方式獲取應用的PathdexClassloader—>PathList—>DexElements,再獲取補丁dex的DexClassloader—>PathList—>DexElements,然後通過combinArray的方法將2個DexElements合併,補丁的DexElements放在前面,然後使用合併後的DexElements作為PathdexClassloader中的DexElements,這樣在載入的時候就可以優先載入到補丁dex,從中可以載入到我們的補丁類。與我們加Multidex的做法相似,能基本保證穩定性與相容性

優勢:

  1. 沒有合成整包(和微信Tinker比起來),產物比較小,比較靈活

  2. 可以實現類替換,相容性高。(某些三星手機不起作用)

不足:

  1. 不支援即時生效,必須通過重啟才能生效。

  2. 為了實現修復這個過程,必須在應用中加入兩個 dex ! dalvikhack.dex 中只有一個類,對效能影響不大,但是對於patch.dex來說,修復的類到了一定數量,就需要花不少的時間載入。

  3. 在ART模式下,如果類修改了結構,就會出現記憶體錯亂的問題。為了解決這個問題,就必須把所有相關的呼叫類、父類子類等等全部載入到 patch.dex 中,導致補丁包異常的大,進一步增加應用啟動載入的時候,耗時更加嚴重。