帶你瞭解騰訊開源的多渠道打包技術 VasDolly原始碼解析

鴻洋發表於2018-04-19

本文已在我的公眾號hongyangAndroid原創釋出。

一、概要

大家應該都清楚,大家上線app,需要上線各種平臺,比如:小米,華為,百度等等等等,我們多數稱之為渠道,如果發的渠道多,可能有上百個渠道。

針對每個渠道,我們希望可以獲取各個渠道的一些獨立的統計資訊,比如:下載量等。

那麼,如何區分各個渠道呢?

我們需要一個特性的識別符號與該渠道對應,這個識別符號肯定是要包含在apk中的。那麼,我們就要針對每個渠道包去設定一個特定的識別符號,然後打一個特定的apk。

這個過程可以手動去完成,每次修改一個字串,然後手動打包。大家都清楚打包是一個相當耗時的過程,要是打幾百個渠道包,這種枯燥重複的任務,當然不是我們所能容忍的。

當然,我們會想到,這樣的需求,官方肯定有解決方案。沒錯,Gradle Plugin為我們提供了一個自動化的方案,我們可以利用佔位符,然後在build.gradle中去配置多個渠道資訊,這樣就可以將枯燥重複的任務自動化了。

這樣的方式最大的問題,就是效率問題,每個渠道包,都要執行一遍構建流程。

自動化了,時間依然過長,還是不能忍。

接下來就是尋找高效率的方案了。

因為本文是原始碼解析,就不饒彎子了~~

目前針對 V1(Android N開始推出了V2),快速的方案,主要有:

  1. 美團Android自動化之旅—生成渠道包

    主要利用修改apk的目錄META-INF中新增空檔案,由於不需要重新簽名,操作非常快。

  2. 利用zip檔案中的comment的欄位,例如VasDolly

後面在解析原始碼時,會詳細說明方式2。

自Android N之後,Google建議使用V2來做簽名,因為這樣更加安全(對整個apk檔案進行hash校驗,無法修改apk資訊),安裝速度也更加高效(無需解析校驗單個檔案,v1需要單個檔案校驗hash)。

美團對此動作非常快,立馬推出了:

其原理是利用v2的方式在做簽名時,在apk中插入了一個簽名塊(安裝時校驗apk的hash不包含此塊),該快中允許插入一些key-value對,於是將簽名插在該區域。

當然,騰訊的VasDolly採取的也是相同的方案。

本文,為VasDolly的原始碼解析,即會詳細分析:

  1. 針對v1簽名方式,利用zip的comment區域
  2. 針對v2簽名方式,利用apk中的簽名塊中插入key-value

本文不涉及v1,v2具體的簽名方式,以及安裝時的校驗流程,這些內容在:

一文中,說的非常詳細。

本文重點是原始碼的解析。

二、接入VasDolly

其實,接入非常簡單,而且readme寫的非常詳細。

但是為了文章的完整性,簡單陳述一下。

根目錄build.gradle

buildscript {
    dependencies {
        classpath 'com.leon.channel:plugin:1.1.7'
    }
}
複製程式碼

app的build.gradle

apply plugin: 'channel'

android {
    signingConfigs {
        release {
            storeFile file(RELEASE_STORE_FILE)
            storePassword RELEASE_STORE_PASSWORD
            keyAlias RELEASE_KEY_ALIAS
            keyPassword RELEASE_KEY_PASSWORD
            v1SigningEnabled true
            v2SigningEnabled false
        }
    }
    buildTypes {
        release {
            signingConfig signingConfigs.release
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }

    channel{
        //指定渠道檔案
        channelFile = file("/Users/zhanghongyang01/git-repo/learn/VasDollyTest/channel.txt")
        //多渠道包的輸出目錄,預設為new File(project.buildDir,"channel")
        baseOutputDir = new File(project.buildDir,"channel")
        //多渠道包的命名規則,預設為:${appName}-${versionName}-${versionCode}-${flavorName}-${buildType}
        apkNameFormat ='${appName}-${versionName}-${versionCode}-${flavorName}-${buildType}'
        //快速模式:生成渠道包時不進行校驗(速度可以提升10倍以上)
        isFastMode = true
    }
}

dependencies {
    api 'com.leon.channel:helper:1.1.7'
}

複製程式碼

首先要apply plugin,然後在android的閉包下寫入channel相關資訊。

channel中需要制定一個channel.txt檔案,其中每行程式碼一個渠道:

c1
c2
c3
複製程式碼

dependencies中的依賴主要是為了獲取渠道號的輔助類,畢竟你寫入渠道資訊的地方這麼奇怪,肯定要提供API進行讀取渠道號。

注意:我們在signingConfigs的release中配置的是:v1SigningEnabled=truev2SigningEnabled=false,先看V1方式的快速渠道包。

在Terminal皮膚執行./gradlew channelRelease執行完成後,即可在app/build/channel/release下看到:

release
    ├── app-1.0-1-c1-release.apk
    ├── app-1.0-1-c2-release.apk
    └── app-1.0-1-c3-release.apk
複製程式碼

注意:本文主要用於講解原始碼,如果只需接入,儘可能檢視github文件。

三、V1的渠道讀取與寫入

首先我們需要知道對於V1的簽名,渠道資訊寫在哪?

這裡直接白話說明一下,我們的apk實際上就是普通的zip,在一個zip檔案的最後允許寫入N個字元的註釋,我們關注的zip末尾兩個部分:

2位元組的的註釋長度+N個位元組的註釋。

那麼,我們只要把簽名內容作為註釋寫入,再修改2位元組的註釋長度即可。

現在需要考慮的是我們怎麼知道一個apk有沒有寫入這個渠道資訊呢,需要有一個判斷的標準:

這時候,魔數這個概念產生了,我們可以在檔案檔案末尾寫入一個特殊的字串,當我們讀取檔案末尾為這個特殊的字串,即可認為該apk寫入了渠道資訊。

很多檔案型別起始部分都包含特性的魔數用於區分檔案型別。

最終的渠道資訊為:

渠道字串+渠道字串長度+魔數

3.1 讀取

有了上面的分析,讀取就簡單了:

  1. 拿到本地的apk檔案
  2. 讀取固定位元組與預定義魔數做比對
  3. 然後再往前讀取兩個位元組為渠道資訊長度
  4. 再根據這個長度往前讀取對應位元組,即可取出渠道資訊。

在看原始碼之前,我們也可以使用二進位制編輯器開啟打包好的Apk,看末尾的幾個位元組,如圖:

帶你瞭解騰訊開源的多渠道打包技術 VasDolly原始碼解析

我們們逆著看:

  1. 首先讀取8個位元組,對應一個特殊字串“ltlovezh”
  2. 往前兩個位元組為02 00,對應渠道資訊長度,實際值為2.
  3. 再往前讀取2個位元組為63 31,對照ASCII表,即可知為c1

這樣我們就讀取除了渠道資訊為:c1。

這麼看程式碼也不復雜,最後看一眼程式碼吧:

程式碼中通過ChannelReaderUtil.getChannel獲取渠道資訊:

public static String getChannel(Context context) {
    if (mChannelCache == null) {
        String channel = getChannelByV2(context);
        if (channel == null) {
            channel = getChannelByV1(context);
        }
        mChannelCache = channel;
    }

    return mChannelCache;
}
複製程式碼

我們只看v1,根據呼叫流程,最終會到:

V1SchemeUtil.readChannel方法:

public static String readChannel(File file) throws Exception {
    RandomAccessFile raf = null;
    try {
        raf = new RandomAccessFile(file, "r");
        long index = raf.length();
        byte[] buffer = new byte[ChannelConstants.V1_MAGIC.length];
        index -= ChannelConstants.V1_MAGIC.length;
        raf.seek(index);
        raf.readFully(buffer);
        // whether magic bytes matched
        if (isV1MagicMatch(buffer)) {
            index -= ChannelConstants.SHORT_LENGTH;
            raf.seek(index);
            // read channel length field
            int length = readShort(raf);
            if (length > 0) {
                index -= length;
                raf.seek(index);
                // read channel bytes
                byte[] bytesComment = new byte[length];
                raf.readFully(bytesComment);
                return new String(bytesComment, ChannelConstants.CONTENT_CHARSET);
            } else {
                throw new Exception("zip channel info not found");
            }
        } else {
            throw new Exception("zip v1 magic not found");
        }
    } finally {
        if (raf != null) {
            raf.close();
        }
    }
}
複製程式碼

使用了RandomAccessFile,可以很方便的使用seek指定到具體的位元組處。注意第一次seek的目標是length - magic.length,即對應我們的讀取魔數,讀取到比對是否相同。

如果相同,再往前讀取SHORT_LENGTH = 2個位元組,讀取為short型別,即為渠道資訊所佔據的位元組數。

再往前對去對應的長度,轉化為String,即為渠道資訊,與我們前面的分析一模一樣。

ok,讀取始終是簡單的。

後面還要看如何寫入以及如何自動化。

3.2 寫入v1渠道資訊

寫入渠道資訊,先思考下,有個apk,需要寫入渠道資訊,需要幾步:

  1. 找到合適的寫入位置
  2. 寫入渠道資訊、寫入長度、寫入魔數

好像唯一的難點就是找到合適的位置。

但是找到這個合適的位置,又涉及到zip檔案的格式內容了。

大致講解下:

zip的末尾有一個資料庫,這個資料塊我們叫做EOCD塊,分為4個部分:

  1. 4位元組,固定值0x06054b50
  2. 16個位元組,不在乎其細節
  3. 2個位元組,註釋長度
  4. N個位元組,註釋內容

知道這個規律後,我們就可以通過匹配1中固定值來確定對應區域,然後seek到註釋處。

可能99.99%的apk預設是不包含註釋內容的,所以直接往前seek 22個位元組,讀取4個位元組做下匹配即可。

但是如果已經包含了註釋內容,就比較難辦了。很多時候,我們會正向從頭開始按協議讀取zip檔案格式,直至到達目標區域。

不過VasDolly的做法是,從檔案末尾seek 22 ~ 檔案size - 22,逐一匹配。

我們簡單看下程式碼:

public static void writeChannel(File file, String channel) throws Exception {

    byte[] comment = channel.getBytes(ChannelConstants.CONTENT_CHARSET);
    Pair<ByteBuffer, Long> eocdAndOffsetInFile = getEocd(file);
    
    if (eocdAndOffsetInFile.getFirst().remaining() == ZipUtils.ZIP_EOCD_REC_MIN_SIZE) {
        System.out.println("file : " + file.getAbsolutePath() + " , has no comment");

        RandomAccessFile raf = new RandomAccessFile(file, "rw");
        //1.locate comment length field
        raf.seek(file.length() - ChannelConstants.SHORT_LENGTH);
        //2.write zip comment length (content field length + length field length + magic field length)
        writeShort(comment.length + ChannelConstants.SHORT_LENGTH + ChannelConstants.V1_MAGIC.length, raf);
        //3.write content
        raf.write(comment);
        //4.write content length
        writeShort(comment.length, raf);
        //5. write magic bytes
        raf.write(ChannelConstants.V1_MAGIC);
        raf.close();
    } else {
        System.out.println("file : " + file.getAbsolutePath() + " , has comment");
        if (containV1Magic(file)) {
            try {
                String existChannel = readChannel(file);
                if (existChannel != null){
                    file.delete();
                    throw new ChannelExistException("file : " + file.getAbsolutePath() + " has a channel : " + existChannel + ", only ignore");
                }
            }catch (Exception e){
                e.printStackTrace();
            }
        }

        int existCommentLength = ZipUtils.getUnsignedInt16(eocdAndOffsetInFile.getFirst(), ZipUtils.ZIP_EOCD_REC_MIN_SIZE - ChannelConstants.SHORT_LENGTH);
        int newCommentLength = existCommentLength + comment.length + ChannelConstants.SHORT_LENGTH + ChannelConstants.V1_MAGIC.length;
        RandomAccessFile raf = new RandomAccessFile(file, "rw");
        //1.locate comment length field
        raf.seek(eocdAndOffsetInFile.getSecond() + ZipUtils.ZIP_EOCD_REC_MIN_SIZE - ChannelConstants.SHORT_LENGTH);
        //2.write zip comment length (existCommentLength + content field length + length field length + magic field length)
        writeShort(newCommentLength, raf);
        //3.locate where channel should begin
        raf.seek(eocdAndOffsetInFile.getSecond() + ZipUtils.ZIP_EOCD_REC_MIN_SIZE + existCommentLength);
        //4.write content
        raf.write(comment);
        //5.write content length
        writeShort(comment.length, raf);
        //6.write magic bytes
        raf.write(ChannelConstants.V1_MAGIC);
        raf.close();

    }
}
複製程式碼

getEocd(file)的的返回值是Pair<ByteBuffer, Long>,多數情況下first為EOCD塊起始位置到結束後的內容;second為EOCD塊起始位置。

if為apk本身無comment的情況,這種方式屬於大多數情況,從檔案末尾,移動2位元組,該2位元組為註釋長度,然後組裝註釋內容,重新計算註釋長度,重新寫入註釋長度,再寫入註釋內容,最後寫入MAGIC魔數。

else即為本身存在comment的情況,首先讀取原有註釋長度,然後根據渠道等資訊計算出先的註釋長度,寫入。

3.3 gradle自動化

最後我們看下,是如何做到輸入./gradle channelRelease就實現所有渠道包的生成呢。

這裡主要就是解析gradle plugin了,如果你還沒有自定義過plugin,非常值得參考。

程式碼主要在VasDolly/plugin這個module.

入口程式碼為ApkChannelPackagePlugin的apply方法。

主要程式碼:

project.afterEvaluate {
    project.android.applicationVariants.all { variant ->
        def variantOutput = variant.outputs.first();
        def dirName = variant.dirName;
        def variantName = variant.name.capitalize();
        Task channelTask = project.task("channel${variantName}", type: ApkChannelPackageTask) {
            mVariant = variant;
            mChannelExtension = mChannelConfigurationExtension;
            mOutputDir = new File(mChannelConfigurationExtension.baseOutputDir, dirName)
            mChannelList = mChanneInfolList
            dependsOn variant.assemble
        }
    }
}
複製程式碼

為每個variantName新增了一個task,並且依賴於variant.assemble

也就是說,當我們執行./gradlew channelRelease時,會先執行assemble,然後對產物apk做後續操作。

重點看這個Task,ApkChannelPackageTask

執行程式碼為:

@TaskAction
public void channel() {
    //1.check all params
    checkParameter();
    //2.check signingConfig , determine channel package mode
    checkSigningConfig()
    //3.generate channel apk
    generateChannelApk();
}
複製程式碼

註釋也比較清晰,首先channelFile、baseOutputDir等相關引數。接下來校驗signingConfig中v2SigningEnabled與v1SigningEnabled,確定使用V1還是V2 mode,我們上文中將v2SigningEnabled設定為了false,所以這裡為V1_MODE。

最後就是生成渠道apk了:

void generateV1ChannelApk() {
	 // 省略了一些程式碼
    mChannelList.each { channel ->
        String apkChannelName = getChannelApkName(channel)
        println "generateV1ChannelApk , channel = ${channel} , apkChannelName = ${apkChannelName}"
        File destFile = new File(mOutputDir, apkChannelName)
        copyTo(mBaseApk, destFile)
        V1SchemeUtil.writeChannel(destFile, channel)
        if (!mChannelExtension.isFastMode){
            //1. verify channel info
            if (V1SchemeUtil.verifyChannel(destFile, channel)) {
                println("generateV1ChannelApk , ${destFile} add channel success")
            } else {
                throw new GradleException("generateV1ChannelApk , ${destFile} add channel failure")
            }
            //2. verify v1 signature
            if (VerifyApk.verifyV1Signature(destFile)) {
                println "generateV1ChannelApk , after add channel , apk ${destFile} v1 verify success"
            } else {
                throw new GradleException("generateV1ChannelApk , after add channel , apk ${destFile} v1 verify failure")
            }
        }
    }

    println("------ ${project.name}:${name} generate v1 channel apk , end ------")
}
複製程式碼

很簡單,遍歷channelList,然後呼叫V1SchemeUtil.writeChannel,該方法即我們上文解析過的方法。

如果fastMode設定為false,還會讀取出渠道再做一次強校驗;以及會通過apksig做對簽名進行校驗。

ok,到這裡我們就完全剖析了基於V1的快速簽名的全過程。

接下來我們看基於v2的快速簽名方案。

四、基於V2的快速簽名方案

關於V2簽名的產生原因,原理以及安裝時的校驗過程可以參考 VasDolly實現原理

我這裡就拋開細節,儘可能讓大家能明白整個過程,v2簽名的原理可以簡單理解為:

  1. 我們的apk其實是個zip,我們可以理解為3塊:塊1+塊2+塊3
  2. 簽名讓我們的apk變成了4部分:塊1+簽名塊+塊2+塊3

在這個簽名塊的某個區域,允許我們寫一些key-value對,我們就將渠道資訊寫在這個地方。

這裡有一個問題,v2不是說是對整個apk進行校驗嗎?為什麼還能夠讓我們在apk中插入這樣的資訊呢?

因為在校驗過程中,對於簽名塊是不校驗的(細節上由於我們插入了簽名塊,某些偏移量會變化,但是在校驗前,Android系統會先重置偏移量),而我們的渠道資訊剛好寫在這個簽名塊中。

好了,細節一會看程式碼。

4.1 讀取渠道資訊

寫入渠道資訊,根據我們上述的分析,流程應該大致如下:

  1. 找到簽名塊
  2. 找到簽名塊中的key-value的地方
  3. 讀取出所有的key-value,找到我們特定的key對應的渠道資訊

這裡我們不按照整個程式碼流程走了,太長了,一會看幾段關鍵程式碼。

4.1.1 如何找到簽名塊

我們的apk現在格式是這樣的:

塊1+簽名塊+塊2+塊3

其中塊3稱之為EOCD,現在必須要展示下其內部的資料結構了:

帶你瞭解騰訊開源的多渠道打包技術 VasDolly原始碼解析

圖片來自:參考

在V1的相關程式碼中,我們已經可以定位到EOCD的位置了,然後往下16個位元組即可拿到Offset of start of central directory即為塊2開始的位置,也為簽名塊末尾的位置。

塊2 再往前,就可以獲取到我們的 簽名塊了。

我們先看一段程式碼,定位到 塊2 的開始位置。

# V2SchemeUtil
public static ByteBuffer getApkSigningBlock(File channelFile) throws ApkSignatureSchemeV2Verifier.SignatureNotFoundException, IOException {
 
    RandomAccessFile apk = new RandomAccessFile(channelFile, "r");
    //1.find the EOCD
    Pair<ByteBuffer, Long> eocdAndOffsetInFile = ApkSignatureSchemeV2Verifier.getEocd(apk);
    ByteBuffer eocd = eocdAndOffsetInFile.getFirst();
    long eocdOffset = eocdAndOffsetInFile.getSecond();

    if (ZipUtils.isZip64EndOfCentralDirectoryLocatorPresent(apk, eocdOffset)) {
        throw new ApkSignatureSchemeV2Verifier.SignatureNotFoundException("ZIP64 APK not supported");
    }

    //2.find the APK Signing Block. The block immediately precedes the Central Directory.
    long centralDirOffset = ApkSignatureSchemeV2Verifier.getCentralDirOffset(eocd, eocdOffset);//通過eocd找到中央目錄的偏移量
    //3. find the apk V2 signature block
    Pair<ByteBuffer, Long> apkSignatureBlock =
            ApkSignatureSchemeV2Verifier.findApkSigningBlock(apk, centralDirOffset);//找到V2簽名塊的內容和偏移量

    return apkSignatureBlock.getFirst();
}

複製程式碼

首先發現EOCD塊,這個前面我們已經分析了。

然後尋找到簽名塊的位置,上面我們已經分析了只要往下移動16位元組即可到達簽名塊末尾 ,那麼看下ApkSignatureSchemeV2Verifier.getCentralDirOffset程式碼,最終呼叫:

public static long getZipEocdCentralDirectoryOffset(ByteBuffer zipEndOfCentralDirectory) {
    assertByteOrderLittleEndian(zipEndOfCentralDirectory);
    return getUnsignedInt32(
            zipEndOfCentralDirectory,
            zipEndOfCentralDirectory.position() + 16);
}
複製程式碼

到這裡我們已經可以到達簽名塊末尾了。

我們繼續看findApkSigningBlock找到V2簽名塊的內容和偏移量:

public static Pair<ByteBuffer, Long> findApkSigningBlock(
        RandomAccessFile apk, long centralDirOffset)
        throws IOException, SignatureNotFoundException {

    ByteBuffer footer = ByteBuffer.allocate(24);
    footer.order(ByteOrder.LITTLE_ENDIAN);
    apk.seek(centralDirOffset - footer.capacity());
    apk.readFully(footer.array(), footer.arrayOffset(), footer.capacity());
    if ((footer.getLong(8) != APK_SIG_BLOCK_MAGIC_LO)
            || (footer.getLong(16) != APK_SIG_BLOCK_MAGIC_HI)) {
        throw new SignatureNotFoundException(
                "No APK Signing Block before ZIP Central Directory");
    }

    // Read and compare size fields
    long apkSigBlockSizeInFooter = footer.getLong(0);

    int totalSize = (int) (apkSigBlockSizeInFooter + 8);
    long apkSigBlockOffset = centralDirOffset - totalSize;

    ByteBuffer apkSigBlock = ByteBuffer.allocate(totalSize);
    apkSigBlock.order(ByteOrder.LITTLE_ENDIAN);
    apk.seek(apkSigBlockOffset);
    apk.readFully(apkSigBlock.array(), apkSigBlock.arrayOffset(), apkSigBlock.capacity());

    return Pair.create(apkSigBlock, apkSigBlockOffset);
}
複製程式碼

這裡我們需要介紹下簽名塊相關資訊了:

帶你瞭解騰訊開源的多渠道打包技術 VasDolly原始碼解析

圖片來自:參考

中間的不包含此8位元組,值得是該ID-VALUE的size值不包含此8位元組。

首先往前讀取24個位元組,即讀取了簽名塊大小64bits+魔數128bits;然後會魔數資訊與實際的魔數對比。

接下來讀取8個位元組為apkSigBlockSizeInFooter,即簽名塊大小。

然後+8加上上圖頂部的8個位元組。

最後將整個簽名塊讀取到ByteBuffer中返回。

此時我們已經有了簽名塊的所有資料了。

接下來我們要讀取這個簽名塊中所有的key-value對!

# V2SchemeUtil
public static Map<Integer, ByteBuffer> getAllIdValue(ByteBuffer apkSchemeBlock) {
    ApkSignatureSchemeV2Verifier.checkByteOrderLittleEndian(apkSchemeBlock);

    ByteBuffer pairs = ApkSignatureSchemeV2Verifier.sliceFromTo(apkSchemeBlock, 8, apkSchemeBlock.capacity() - 24);
    Map<Integer, ByteBuffer> idValues = new LinkedHashMap<Integer, ByteBuffer>(); // keep order
    int entryCount = 0;
    while (pairs.hasRemaining()) {
        entryCount++;
        
        long lenLong = pairs.getLong();
        
        int len = (int) lenLong;
        int nextEntryPos = pairs.position() + len;
        
        int id = pairs.getInt();
        idValues.put(id, ApkSignatureSchemeV2Verifier.getByteBuffer(pairs, len - 4));//4 is length of id
        if (id == ApkSignatureSchemeV2Verifier.APK_SIGNATURE_SCHEME_V2_BLOCK_ID) {
            System.out.println("find V2 signature block Id : " + ApkSignatureSchemeV2Verifier.APK_SIGNATURE_SCHEME_V2_BLOCK_ID);
        }
        pairs.position(nextEntryPos);
    }


    return idValues;
}

複製程式碼

首先讀取8到capacity() - 24中的內容,即所有的id-value集合。

然後進入while迴圈,讀取一個個key-value存入idValues,我們看下迴圈體內:

  1. pairs.getLong,讀取8個位元組,即此id-value塊的size
  2. 然後pairs.getInt,讀取4個位元組,即可得到id
  3. size - 4 中包含的內容即為value

如此迴圈,得到所有的idValues。

有了所有的idValues,然後根據特定的id,即可獲取我們的渠道資訊了。

即:

# ChannelReader
public static String getChannel(File channelFile) {
    System.out.println("try to read channel info from apk : " + channelFile.getAbsolutePath());
    return IdValueReader.getStringValueById(channelFile, ChannelConstants.CHANNEL_BLOCK_ID);
}
複製程式碼

這樣我們就走通了讀取的邏輯。

我替大家總結下:

  1. 根據zip的格式,先定位到EOCD的開始位置
  2. 然後根據EOCD中的內容定位到簽名塊末尾
  3. 然後根據簽名塊中的資料格式,逐一讀取出id-values
  4. 我們的渠道資訊與一個特點的id對映,讀取出即可

4.2 寫入渠道資訊

先思考下,現在要正視的是,目前到我們這裡已經是v2簽名打出的包了。那麼我們應該找到簽名塊中的id-values部分,把我們的渠道資訊插入進去。

大致的方式可以為:

  1. 讀取出塊1,簽名塊,塊2,EOCD
  2. 在簽名塊中插入渠道資訊
  3. 回寫塊1,簽名塊,塊2,EOCD

4.2.1 讀取出相關資訊

# V2SchemeUtil
public static ApkSectionInfo getApkSectionInfo(File baseApk) {
    RandomAccessFile apk = new RandomAccessFile(baseApk, "r");
    //1.find the EOCD and offset
    Pair<ByteBuffer, Long> eocdAndOffsetInFile = ApkSignatureSchemeV2Verifier.getEocd(apk);
    ByteBuffer eocd = eocdAndOffsetInFile.getFirst();
    long eocdOffset = eocdAndOffsetInFile.getSecond();

    //2.find the APK Signing Block. The block immediately precedes the Central Directory.
    long centralDirOffset = ApkSignatureSchemeV2Verifier.getCentralDirOffset(eocd, eocdOffset);//通過eocd找到中央目錄的偏移量
    Pair<ByteBuffer, Long> apkSchemeV2Block =
            ApkSignatureSchemeV2Verifier.findApkSigningBlock(apk, centralDirOffset);//找到V2簽名塊的內容和偏移量

    //3.find the centralDir
    Pair<ByteBuffer, Long> centralDir = findCentralDir(apk, centralDirOffset, (int) (eocdOffset - centralDirOffset));
    //4.find the contentEntry
    Pair<ByteBuffer, Long> contentEntry = findContentEntry(apk, (int) apkSchemeV2Block.getSecond().longValue());

    ApkSectionInfo apkSectionInfo = new ApkSectionInfo();
    apkSectionInfo.mContentEntry = contentEntry;
    apkSectionInfo.mSchemeV2Block = apkSchemeV2Block;
    apkSectionInfo.mCentralDir = centralDir;
    apkSectionInfo.mEocd = eocdAndOffsetInFile;

    System.out.println("baseApk : " + baseApk.getAbsolutePath() + " , ApkSectionInfo = " + apkSectionInfo);
    return apkSectionInfo;
}
複製程式碼
  1. 首先讀取出EOCD,這個程式碼見過多次了。
  2. 然後根據EOCD讀取到中間目錄的偏移量(塊2)。
  3. 將中間目錄完整的內容讀取出來,
  4. 讀取出塊1

全部都儲存到apkSectionInfo中。

目前我們將整個apk按區域讀取出來了。

4.2.2 簽名塊中插入渠道資訊

# ChannelWriter
public static void addChannel(ApkSectionInfo apkSectionInfo, File destApk, String channel)  {
    byte[] buffer = channel.getBytes(ChannelConstants.CONTENT_CHARSET);
    ByteBuffer channelByteBuffer = ByteBuffer.wrap(buffer);
    //apk中所有位元組都是小端模式
    channelByteBuffer.order(ByteOrder.LITTLE_ENDIAN);

    IdValueWriter.addIdValue(apkSectionInfo, destApk, ChannelConstants.CHANNEL_BLOCK_ID, channelByteBuffer);
}
複製程式碼

將渠道字串與特定的渠道id準備好,呼叫addIdValue

# IdValueWriter
public static void addIdValue(ApkSectionInfo apkSectionInfo, File destApk, int id, ByteBuffer valueBuffer)  {
    Map<Integer, ByteBuffer> idValueMap = new LinkedHashMap<>();
    idValueMap.put(id, valueBuffer);
    addIdValueByteBufferMap(apkSectionInfo, destApk, idValueMap);
}
複製程式碼

繼續:


public static void addIdValueByteBufferMap(ApkSectionInfo apkSectionInfo, File destApk, Map<Integer, ByteBuffer> idValueMap) {

    Map<Integer, ByteBuffer> existentIdValueMap = V2SchemeUtil.getAllIdValue(apkSectionInfo.mSchemeV2Block.getFirst());
    
    existentIdValueMap.putAll(idValueMap);

    ByteBuffer newApkSigningBlock = V2SchemeUtil.generateApkSigningBlock(existentIdValueMap);

    ByteBuffer contentEntry = apkSectionInfo.mContentEntry.getFirst();
    ByteBuffer centralDir = apkSectionInfo.mCentralDir.getFirst();
    ByteBuffer eocd = apkSectionInfo.mEocd.getFirst();
    long centralDirOffset = apkSectionInfo.mCentralDir.getSecond();
    //update the offset of centralDir
    centralDirOffset += (newApkSigningBlock.remaining() - apkSectionInfo.mSchemeV2Block.getFirst().remaining());
    ZipUtils.setZipEocdCentralDirectoryOffset(eocd, centralDirOffset);//修改了apkSectionInfo中eocd的原始資料

    RandomAccessFile fIn = new RandomAccessFile(destApk, "rw");
    long apkLength = contentEntry.remaining() + newApkSigningBlock.remaining() + centralDir.remaining() + eocd.remaining();
    fIn.seek(0l);
    //1. write real content Entry block
    fIn.write(contentEntry.array(), contentEntry.arrayOffset() + contentEntry.position(), contentEntry.remaining());
    //2. write new apk v2 scheme block
    fIn.write(newApkSigningBlock.array(), newApkSigningBlock.arrayOffset() + newApkSigningBlock.position(), newApkSigningBlock.remaining());
    //3. write central dir block
    fIn.write(centralDir.array(), centralDir.arrayOffset() + centralDir.position(), centralDir.remaining());
    //4. write eocd block
    fIn.write(eocd.array(), eocd.arrayOffset() + eocd.position(), eocd.remaining());
    fIn.setLength(apkLength);
    System.out.println("addIdValueByteBufferMap , after add channel , new apk is " + destApk.getAbsolutePath() + " , length = " + apkLength);
}


複製程式碼

首先讀取出原本的id-values,程式碼我們前面已經分析過,與我們要新增的id-value放到一個map中。

然後呼叫V2SchemeUtil.generateApkSigningBlock重新生成一個新的簽名塊,這裡不看了,其實就是根據上圖的位元組描述,很容易生成。

再根據新的簽名塊,和之前的中間目錄偏移量,計算出新的偏移量,調整EOCD中的相關值。

最後,通過RandomAccessFile重新寫入:

  1. 塊1
  2. 新的簽名塊
  3. 中間目錄塊
  4. EOCD塊

完工!

關於V2的gradle部分與V1部分基本一致,不再贅述。

最後,對於文中的塊1+簽名塊+塊2+塊3,主要是為了方便理解,大家可以再去了解下zip檔案格式,對應到專業的術語上去。

參考


支援我的話可以關注下我的公眾號和網站,每天都會推送新知識~

掃一掃關注我的微信公眾號:hongyangAndroid
帶你瞭解騰訊開源的多渠道打包技術 VasDolly原始碼解析

相關文章