美團多渠道打包工具Walle源碼解析

筆者如今在負責一個新的Android項目,前期功能不太複雜,安裝包的體積小,渠道要求也較少,因此打渠道包使用Android Studio自帶的打包方法。原生方法打渠道包大約八分鐘左右就搞定了,順即可以清閒地享受一下這種打包方式的樂趣。可是,隨着重的功能的加入和渠道的增長,原生方法打渠道包就顯得有點慢了,因此集成了美團的多渠道打包工具Walle,順便看了一下里面的實現原理。android

1、概述

這一次的原理分析僅僅針對Android Signature V2 Schemeapi

在上一家公司的時候,筆者所在的Android團隊經歷了Android Signature V1Android Signature V2的變動,其中由於未及時從V1升級到V2而致使上線受阻,當時也緊急更換了新的多渠道打包工具來解決問題。在我本身使用多渠道打包工具時,難免對V2簽名驗證的方式有了一絲好奇,想去看看V2簽名驗證和多渠道打包的實現原理。bash

該文章先從安裝包V2簽名驗證入手,再從打包過程當中分析Walle是怎麼繞過簽名驗證在安裝包上加入渠道信息,最後看Walle怎麼從應用中讀取渠道信息。在這裏我就不講Walle的使用了,建議讀者在看原理前先了解一下使用方式。app

2、APK Signature Scheme v2

APK Signature Scheme v2的簽名驗證,咱們先從官方一張圖入手dom

通常狀況下,咱們用到的zip格式由三個部分組成:文件數據區+中央目錄結構+中央目錄結束標誌,分別對應上圖的Contents Of ZIP entriesCentral Directory``、End of Central Directory(下文簡稱爲EOCD)。正如圖中After signing所示,APK Signature Scheme v2是在ZIP文件格式的 Central Directory 區塊所在文件位置的前面添加一個APK Signing Block區塊,用於檢驗以上三個區塊的完整性。ide

APK Signing Block區塊的構成是這樣的工具

偏移 字節數 描述
@+0 8 這個Block的長度(本字段的長度不計算在內)
@+8 n 一組ID-value
@-24 8 這個Block的長度(和第一個字段同樣值)
@-16 16 魔數 「APK Sig Block 42」

區塊2中APK Signing Block是由這幾部分組成:2個用來標示這個區塊長度的8字節 + 這個區塊的魔數 + 這個區塊所承載的數據(ID-value)。gradle

其中Android是經過ID-value對中的ID0x7109871aID-value進行校驗,對對中的其它ID-value是不作檢驗處理的,那麼咱們能夠向ID-value對中添加咱們本身的ID-value,即渠道信息,這樣使安裝包能夠在增長了渠道信息的狀況下經過Android的安裝包檢驗。ui

3、寫入渠道信息

經過上面的分析咱們得知,寫入渠道信息須要修改安裝包,這時候確定會想到使用gradle插件對編譯後的安裝包文件進行修改。以下圖所示,咱們也能夠看到,Walle的源碼目錄中的plugin插件。this

經過分析plugingradle依賴,咱們知道這個插件的功能實現由pluginpayload_writerpayload_reader三個模塊構成。咱們先看實現了org.gradle.api.Plugin<Project>GradlePlugin類。拋開異常檢查和配置相關的代碼,咱們從主功能代碼開始看。

@Override
    void apply(Project project) {
    ...
        applyExtension(project);
        applyTask(project);
    }
    
    void applyTask(Project project) {
        project.afterEvaluate {
            project.android.applicationVariants.all { BaseVariant variant ->
                ...
                ChannelMaker channelMaker = project.tasks.create("assemble${variantName}Channels", ChannelMaker);
                channelMaker.targetProject = project;
                channelMaker.variant = variant;
                channelMaker.setup();

                channelMaker.dependsOn variant.assemble;
            }
        }
    }
複製代碼

在gradle腳本運行時會調用實現了org.gradle.api.Plugin<Project>接口的類的void apply(Project project)方法,咱們從該方法開始跟蹤。這裏主要調用了applyTask(project)。而applyTask(project)中建立了一個ChannelMakergradle任務對象,並把這個任務對象放在assemble任務(即完成了打包任務)後,可見Walle是經過ChannelMaker保存渠道信息的。接下來,咱們便看ChannelMaker這個groovy文件。

@TaskAction
    public void packaging() {
        ...
            checkV2Signature(apkFile)
        ...
            if (targetProject.hasProperty(PROPERTY_CHANNEL_LIST)) {
        ...
                channelList.each { channel ->
                    generateChannelApk(apkFile, channelOutputFolder, nameVariantMap, channel, extraInfo, null)
                }
            } else if (targetProject.hasProperty(PROPERTY_CONFIG_FILE)) {
        ...
                generateChannelApkByConfigFile(configFile, apkFile, channelOutputFolder, nameVariantMap)
            } else if (targetProject.hasProperty(PROPERTY_CHANNEL_FILE)) {
        ...
                generateChannelApkByChannelFile(channelFile, apkFile, channelOutputFolder, nameVariantMap)
            } else if (extension.configFile instanceof File) {
        ...
                generateChannelApkByConfigFile(extension.configFile, apkFile, channelOutputFolder, nameVariantMap)
            } else if (extension.channelFile instanceof File) {
        ...
                generateChannelApkByChannelFile(extension.channelFile, apkFile, channelOutputFolder, nameVariantMap)
            }
        }
        ...
    }
複製代碼

ChannelMaker.groovypackaging()方法中,作了檢驗操做和一堆條件判斷,最後都會調用以generateChannel爲開頭命名的方法。至於判斷了什麼,咱們不要在乎這些細節。這些名字以generateChannel開頭的方法最後都會調用到generateChannelApk(),看代碼:

def generateChannelApk(File apkFile, File channelOutputFolder, Map nameVariantMap, channel, extraInfo, alias) {
        ...
        ChannelWriter.put(channelApkFile, channel, extraInfo)
        ...
    }
複製代碼

這個方法中比較關鍵的一段代碼是ChannelWriter.put(channelApkFile, channel, extraInfo)即傳入文件地址、渠道信息、extra信息後交由ChannelWriter完成寫入工做。

ChannelWriter封裝在由payload_writer模塊中,裏面封裝了方法調用。其中void put(final File apkFile, final String channel, final Map<String, String> extraInfo)間接調用了void putRaw(final File apkFile, final String string, final boolean lowMemory)

public static void putRaw(final File apkFile, final String string, final boolean lowMemory) throws IOException, SignatureNotFoundException {
        PayloadWriter.put(apkFile, ApkUtil.APK_CHANNEL_BLOCK_ID, string, lowMemory);
    }
複製代碼

這時調用進入了PayloadWriter類,渠道信息寫入的關鍵代碼便在這裏面。這裏從void put(final File apkFile, final int id, final ByteBuffer buffer, final boolean lowMemory)調用到void putAll(final File apkFile, final Map<Integer, ByteBuffer> idValues, final boolean lowMemory)

public static void putAll(final File apkFile, final Map<Integer, ByteBuffer> idValues, final boolean lowMemory) throws IOException, SignatureNotFoundException {
        handleApkSigningBlock(apkFile, new ApkSigningBlockHandler() {
            @Override
            public ApkSigningBlock handle(final Map<Integer, ByteBuffer> originIdValues) {
                if (idValues != null && !idValues.isEmpty()) {
                    originIdValues.putAll(idValues);
                }
                final ApkSigningBlock apkSigningBlock = new ApkSigningBlock();
                final Set<Map.Entry<Integer, ByteBuffer>> entrySet = originIdValues.entrySet();
                for (Map.Entry<Integer, ByteBuffer> entry : entrySet) {
                    final ApkSigningPayload payload = new ApkSigningPayload(entry.getKey(), entry.getValue());
                    apkSigningBlock.addPayload(payload);
                }
                return apkSigningBlock;
            }
        }, lowMemory);
    }
複製代碼

void putAll()中調用了handleApkSigningBlock(),顧名思義,這個方法是處理APK Signing Block的,將渠道信息寫入Block中。

static void handleApkSigningBlock(final File apkFile, final ApkSigningBlockHandler handler, final boolean lowMemory) throws IOException, SignatureNotFoundException {
        RandomAccessFile fIn = null;
        FileChannel fileChannel = null;
        try {
            // 由安裝包路徑構建一個RandomAccessFile對象,用於自由訪問文件位置
            fIn = new RandomAccessFile(apkFile, "rw");
            // 獲取fileChannel,經過fileChannel寫文件
            fileChannel = fIn.getChannel();
            // 獲取zip文件的comment長度
            final long commentLength = ApkUtil.getCommentLength(fileChannel);
            // 找到Central Directory的初始偏移量
            final long centralDirStartOffset = ApkUtil.findCentralDirStartOffset(fileChannel, commentLength);
            // 找到APK Signing Block
            final Pair<ByteBuffer, Long> apkSigningBlockAndOffset = ApkUtil.findApkSigningBlock(fileChannel, centralDirStartOffset);
            final ByteBuffer apkSigningBlock2 = apkSigningBlockAndOffset.getFirst();
            final long apkSigningBlockOffset = apkSigningBlockAndOffset.getSecond();
            // 找到APK Signature Scheme v2的ID-value
            final Map<Integer, ByteBuffer> originIdValues = ApkUtil.findIdValues(apkSigningBlock2);
            // 找到V2簽名信息
            final ByteBuffer apkSignatureSchemeV2Block = originIdValues.get(ApkUtil.APK_SIGNATURE_SCHEME_V2_BLOCK_ID);
            // 校驗簽名信息是否存在
            if (apkSignatureSchemeV2Block == null) {
                throw new IOException(
                        "No APK Signature Scheme v2 block in APK Signing Block");
            }

            final ApkSigningBlock apkSigningBlock = handler.handle(originIdValues);

            if (apkSigningBlockOffset != 0 && centralDirStartOffset != 0) {

                // read CentralDir
                fIn.seek(centralDirStartOffset);

                byte[] centralDirBytes = null;
                File tempCentralBytesFile = null;
                // read CentralDir
                ...
                    centralDirBytes = new byte[(int) (fileChannel.size() - centralDirStartOffset)];
                    fIn.read(centralDirBytes);
                ...

                //update apk sign
                fileChannel.position(apkSigningBlockOffset);
                final long length = apkSigningBlock.writeApkSigningBlock(fIn);

                // update CentralDir
                ...
                    // store CentralDir
                    fIn.write(centralDirBytes);
                ...
                // update length
                fIn.setLength(fIn.getFilePointer());

                // update CentralDir Offset

                // End of central directory record (EOCD)
                // Offset     Bytes     Description[23]
                // 0            4       End of central directory signature = 0x06054b50
                // 4            2       Number of this disk
                // 6            2       Disk where central directory starts
                // 8            2       Number of central directory records on this disk
                // 10           2       Total number of central directory records
                // 12           4       Size of central directory (bytes)
                // 16           4       Offset of start of central directory, relative to start of archive
                // 20           2       Comment length (n)
                // 22           n       Comment
                
                // 定位到EOCD中Offset of start of central directory,即central directory中央目錄的超始位置
                fIn.seek(fileChannel.size() - commentLength - 6);
                // 6 = 2(Comment length) + 4 (Offset of start of central directory, relative to start of archive)
                final ByteBuffer temp = ByteBuffer.allocate(4);
                temp.order(ByteOrder.LITTLE_ENDIAN);
                // 寫入修改APK Signing Block以後的central directory中央目錄的超始位置
                temp.putInt((int) (centralDirStartOffset + length + 8 - (centralDirStartOffset - apkSigningBlockOffset)));
                // 8 = size of block in bytes (excluding this field) (uint64)
                temp.flip();
                fIn.write(temp.array());
        ...
複製代碼

好了,寫入渠道信息的代碼大體上都在這裏了,結合上面的代碼和註釋咱們來作一下分析。上文咱們提到,經過往APK Signing Block寫入渠道信息完成多渠道打包,這裏簡要地說明一下流程。咱們是這樣從安裝包中找到APK Signing Block的:

zip結構中的EOCD出發,根據EOCD結構定位到Offset of start of central directory(中央目錄偏移量),經過中央目錄偏移量找到中央目錄的位置。由於APK Signing Block是在中央目錄以前,因此咱們能夠從中央目錄偏移量往前找到APK Signing Blocksize,再經過Offset of start of central directory(中央目錄偏移量) - size來肯定APK Signing Block的起始偏移量。這時候咱們知道了APK Signing Block的位置,就能夠拿到ID-value對去加入渠道信息,再將修改後的APK Signing BlockCentral DirectoryEOCD一塊兒寫入文件中。

這時候修改工做尚未完成,這裏由於改動了APK Signing Block,因此在APK Signing Block後面的Central Directory起始偏移量也跟着改變了。這個起始偏移量是記錄在EOCD中的,根據EOCD結構修改Central Directory的起始偏移量後寫入工做就算完成了。

細心的朋友會發現,不是說V2簽名會保護EOCD這一區塊嗎,修改了裏面的超始偏移量還能經過校驗嗎?其實Android系統在使用V2校驗安裝包時,會把EOCDCentral Directory的起始偏移量換成APK Signing Block的偏移量再進行校驗,因此修改EOCDCentral Directory的起始偏移量不會影響到校驗。

4、讀取渠道信息

在瞭解了Walle是如何寫入渠道信息以後,去理解讀取渠道信息就很簡單了。Walle先拿到安裝包文件,再根據zip文件結構找到APK Signing Block,從中讀取出以前寫入的渠道信息。具體的代碼懶懶的筆者就不帖了。

5、總結

有一部分的Coder老是能作出創新性的東西,基於他們對於技術的理解作出更加方便、靈活的工具。在經過對Walle的分析中,咱們能夠學到,在清楚理解了zip結構、Android安裝包檢驗原理,運行gradle plugin,就能夠作出一款便於打包的工具。在這裏分享美團多渠道打包工具Walle的原理實現,但願各位看了有所收穫。

相關文章
相關標籤/搜索