Android—V2簽名下多渠道快速打包方案

筆者是MIUI系統應用組的開發,之前發布APP時只有應用商店這一個渠道,因此只需給應用商店提供一個APK即可。不過最近應用開發了一個外發版本,該版本有廣告、push等多個下載渠道,為了統計各渠道的日活、轉化率等信息,需要進行多渠道打包,目前騰訊的VasDolly和美團的Walle這兩個框架都實現了V2簽名下的多渠道快速打包,但是項目并不希望引入第三方庫,因此選擇獨立開發。

一、多渠道打包現狀

1. Android自帶多渠道打包

在Manifest的application標簽下添加meta-data標簽,表示這是一個渠道號信息的占位符。

<meta-data
    android:name="channel"
    android:value="${APP_CHANNEL}"/>

隨后在build.gradle中進行如下配置,buildTypes中的release模塊指明使用signingConfigs中的release打包配置,在productFlavors中定義了shop和push兩個渠道。

android {
    signingConfigs {
        release {
            storeFile file('/Users/....../default.jks')
            storePassword '123456'
            keyAlias 'default'
            keyPassword '123456'
            v1SigningEnabled true
            v2SigningEnabled true
        }
    }

    buildTypes {
        debug {
            ......
        }
        release {
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
            signingConfig signingConfigs.release
        }
    }

    productFlavors{
        shop {
            manifestPlaceholders = [APP_CHANNEL:"shop"]
        }
        push {
            manifestPlaceholders = [APP_CHANNEL:"push"]
        }
    }
}

配置完后,在終端使用./gradlew assembleRelease命令即可在app/build文件夾下生成所有渠道的apk包。如果只需要打某個渠道的包,如push渠道,可以使用./gradlew assemblePushRelease命令。

該方式下,各渠道包的打包耗時都是一樣的,對于大型項目來說,一個渠道包需要四五分鐘的打包時間,在渠道較多的情況下,該打包方式效率低下。

2. 解壓&重簽名

使用該打包方式時需要一個初始APK,將其解壓后,刪除之前的簽名信息,添加渠道信息后再壓縮簽名即可。相比于第一種方式,該打包方式在效率上有所提升,但是壓縮/簽名也是比較耗時的。

3. 在APK中插入信息

該方式直接在APK文件中插入渠道信息,即使有幾百個渠道,在有初始APK的情況下,也只需要幾秒鐘的時間。該打包方式的關鍵點在于,如何在插入額外信息后,APK還能通過Android簽名的校驗。

先來了解一下Android的簽名機制:為了保證APK發布后不被第三方篡改,在打包時,簽名機制會對文件提取摘要并存入APK中。在安裝時,系統會提取文件的摘要,并將其與之前存入APK中的摘要信息對比,只有這兩個摘要完全一致才安裝成功。

Android目前提供了V1、V2、V3三種簽名技術,這里簡單介紹下,詳細見參考[3]
① V1簽名:V1簽名是針對Jar的簽名技術,在Android7之前使用。簽名時會對所有的class文件與資源文件提取摘要,隨后新建MATE-INF文件夾并將摘要存入其中,而META-INF文件夾本身不參與簽名校驗。
上面提到,快速多渠道打包的關鍵在于添加額外信息后APK還能通過簽名校驗,由于V1簽名下META-INF文件夾中的內容不參與簽名校驗,因此只需在META-INF下增加一個文件描述渠道號即可。

② V2簽名:Android7及以上版本使用V2簽名技術,相比于V1簽名技術,V2簽名在效率和安全性上有了較大提升。使用V1簽名的APK在安裝時需要對比所有class文件和資源文件的摘要,效率較低;而使用V2簽名打包時,會在生成初始APK后對它的每1M提取摘要,再將摘要塊插入到APK中,也就是下圖的APK Signing Block。因此在安裝時,需要對比的摘要數量比V1少了很多,安裝更快。

V2簽名機制.png

③ V3簽名:相比于V2簽名沒有本質提升,提供了密鑰輪換的功能,并對簽名塊的大小進行了限制。

二、Zip文件格式

APK本身是ZIP格式,V2簽名的原理就是在ZIP中插入簽名塊來存儲摘要信息。ZIP文件的格式如下,其包含文件信息、中央目錄區及目錄信息3個部分。

ZIP文件格式.png

在解析ZIP文件時,先找到最末端的目錄信息,得到中央目錄相對起始位置的偏移,隨后通過中央目錄得到各個文件的位置,再解析各個文件即可。

V2簽名機制在文件信息與中央目錄這兩部分中間添加了簽名塊描述摘要信息,將簽名塊添加在此處后,只需修改目錄信息中的中央目錄偏移即可,ZIP文件還是符合解析規范。

簽名前后ZIP文件格式.png

簽名塊通過一個個鍵值對存儲信息,它的大小為4096的倍數,其數據結構如下所示。簽名塊的鍵值對中,只有第一個鍵值對保存了真正的摘要信息,而該鍵值對的大小并不固定,為了使鍵值對的大小符合4096的倍數,一般還會有value全為0的鍵值對。

簽名塊數據結構.png

三、插入簽名信息

經過ZIP文件格式和簽名塊結構的分析,可以發現簽名塊本身是不參與簽名校驗的,因此在簽名塊中插入渠道信息即可通過V2簽名機制的校驗。

一般來說簽名塊中有value全為0的鍵值對,用于將簽名塊的大小湊整為4096的倍數,我們可以選擇將該鍵值對縮小,留出空間存放渠道信息,如下所示。如果簽名塊中沒有value為空的鍵值對或者剩余空間不夠存儲渠道信息,那么需要將簽名塊的大小再擴大4096字節,不過我目前還沒遇到這種情況。

插入渠道信息示意.jpg

下面詳細介紹如何在V2/V3簽名的APK中插入渠道信息。

第一步:判斷當前APK是否使用了V2/V3簽名,可以通過ZIP末端的目錄信息定位到中央目錄,而簽名塊位于中央目錄前,如果中央目錄前16字節為"APK Sig Block 42",則該APK使用了V2/V3簽名,相關代碼如下。

        ......
        zipFile = new ZipFile(apkPath)
        String zipComment = zipFile.getComment()
        int commentLength = 0
        if (zipComment != null && zipComment.length() > 0) {
            commentLength = zipComment.getBytes().length
        }
        File file = new File(apkPath)
        long fileLength = file.length()
        // 獲取zip中央目錄結束標記,以小端模式讀取
        byte[] centralEndSignBytes = readReserveData(
                file, fileLength - 22 - commentLength, 4)
        int centralEndSign = ByteBuffer.wrap(centralEndSignBytes).getInt()
        if (centralEndSign != 0x06054b50) {
            println("zip中央目錄結束標記錯誤!!!!!!!!!!!!!!!!!")
            return
        }
        long eoCdrLength = commentLength + 22
        long eoCdrOffset = file.length() - eoCdrLength
        // 中央目錄區的偏移量保存在 EoCDR 開始位置 16 字節處, 一共 4 字節
        long pointer = eoCdrOffset + 16
        // 獲取中央目錄偏移,以小端模式讀取
        byte[] pointerBuffer = readReserveData(file, pointer, 4)
        int centralDirectoryOffset = ByteBuffer.wrap(pointerBuffer).getInt()
        // 讀取字符串,不用逆置
        byte[] buffer = readDataByOffset(file, centralDirectoryOffset - 16, 16)
        String checkV2Signature = new String(buffer, StandardCharsets.US_ASCII)
        if (!checkV2Signature.equals(SIGNATURE_MAGIC_NUMBER)) {
            println("當前未使用V2簽名!!!!!!!!!!!!!!!!!!!!!!!")
            return
        }

這里有個值得注意的地方,readReserveData()方法讀取了某個地址后的一段數據,隨后將其逆置了,這是因為數字、ID相關的內容讀取到內存后是使用小端法存儲的。而readDataByOffset()讀取出內容后并未逆置,因此讀出字符串不用逆置。這兩個方法如下所示。

byte[] readDataByOffset(File file, long offset, int length) throws Exception {
    InputStream is = new FileInputStream(file)
    is.skip(offset)
    byte[] buffer = new byte[length]
    is.read(buffer, 0, length)
    is.close()
    return buffer
}

byte[] readReserveData(File file, long offset, int length) throws Exception {
    byte[] buffer = readDataByOffset(file, offset, length)
    reserveByteArray(buffer)
    return buffer
}

第二步:遍歷簽名塊中的鍵值對,找到最后一個鍵值對,并判斷該鍵值對的value是否全部為空。只有該鍵值對的value全為空,并且有足夠的空間,才能真正地插入渠道信息,遍歷鍵值對和判斷value是否為空的方法如下。

/**
 * 檢查簽名塊中的鍵值對信息, 尋找可以插入渠道信息的地方
 * 這里選擇獲取最后一個鍵值對的地址, 該鍵值對一般全是0
 */
def checkKeyValues(File file, long signBlockStart, long signBlockEnd) throws Exception {
    long curKvOffset = signBlockStart + 8
    long lastKvOffset
    while (true) {
        lastKvOffset = curKvOffset
        byte[] kvSizeBytes = readReserveData(file, curKvOffset, 8)
        long kvSize = ByteBuffer.wrap(kvSizeBytes).getLong()
        byte[] idBuffer = readReserveData(file, curKvOffset + 8, 4)
        int id = ByteBuffer.wrap(idBuffer).getInt()
        // CHANNEL_KV_ID為渠道號信息的key,如果它存在表示之前已經插入了渠道信息
        if (id == CHANNEL_KV_ID) {
            int channelSize = (int) (kvSize - 4)
            byte[] channelBytes = readDataByOffset(file, curKvOffset + 12, channelSize)
            String channelString = new String(channelBytes, StandardCharsets.US_ASCII)
            println("channelString: " + channelString)
            return 0
        }
        curKvOffset = curKvOffset + 8 + kvSize
        if (curKvOffset >= signBlockEnd) {
            break
        }
    }
    return lastKvOffset
}

/**
 * 檢查某個KV的值是否為空, 如果為空才能插入自己的信息
 */
boolean checkIfSingleKvEmpty(File file, long offset) throws Exception {
    boolean result = true
    byte[] kvSizeBytes = readReserveData(file, offset, 8)
    long kvSize = ByteBuffer.wrap(kvSizeBytes).getLong()
    byte[] bytes = readDataByOffset(file, offset + 12, (int) (kvSize - 4))
    for (byte b : bytes) {
        if (b != 0) {
            result = false
            break
        }
    }
    return result
}

第三步:修改空鍵值對的大小,并插入一個新的鍵值對存儲渠道信息,其相關代碼如下,其中insertOrOverrideBytes()方法用于覆蓋原數據寫入,具體代碼見第五章代碼。

/**
 * 在簽名塊中插入渠道信息
 * @param lastKvOffset 簽名塊中最后一個KV的地址偏移, 需要在該KV中插入簽名信息
 * @param signBlockEnd 簽名塊末尾地址
 */
def insertChannelInfo(String channel, File file, String filePath,
                                      long lastKvOffset, long signBlockEnd) throws Exception {
    byte[] channelBytes = channel.getBytes()
    byte[] channelInfo = buildKeyValue(CHANNEL_KV_ID, channelBytes)
    byte[] lastKvSize = readReserveData(file, lastKvOffset, 8)
    long size = ByteBuffer.wrap(lastKvSize).getLong()
    long newSize = size - channelInfo.length
    byte[] newLastKvSizeBytes = toLittleEndianBytes(newSize, 8)
    insertOrOverrideBytes(filePath, lastKvOffset, newLastKvSizeBytes, true)
    insertOrOverrideBytes(filePath, signBlockEnd - channelInfo.length, channelInfo, true)
}

/**
 * 構建要插入簽名塊的鍵值對字節數組
 */
static byte[] buildKeyValue(int key, byte[] value) {
    byte[] keyBytes = toLittleEndianBytes(key, 4)
    long kvSize = 4 + value.length
    byte[] kvSizeBytes = toLittleEndianBytes(kvSize, 8)

    byte[] result = new byte[8 + 4 + value.length]
    System.arraycopy(kvSizeBytes, 0, result, 0, 8)
    System.arraycopy(keyBytes, 0, result, 8, 4)
    System.arraycopy(value, 0, result, 12, value.length)
    return result
}

/**
 * 將數字轉化為小端模式的字節
 * @param size 數字是4字節還是8字節
 */
static byte[] toLittleEndianBytes(long num, int size) {
    byte[] result = new byte[size]
    long t = num
    for (int i = size - 1; i >= 0; i--) {
        result[i] = (byte) (t % 256)
        t /= 256
    }
    // 由于是小端模式, 需要將結果逆置
    reserveByteArray(result)
    return result
}

四、項目集成

下面介紹如何將多渠道打包集成到項目中,我們可以通過flavor.properties文件描述所需的所有渠道號,打包的具體步驟如下:
① 讀取flavor.properties,得到所有渠道號
② 復制渠道號對應數量的初始APK并重命名,初始APK就是./gradlew assembleRelease的輸出
③ 根據第三章的內容,在各個APK中插入對應的渠道信息

Android項目通過gradle構建,可以將多渠道打包封裝為一個gradle task,命名為assembleFlavor。由于多渠道打包需要用assembleRelease命令的輸出作為初始APK,因此assembleFlavor任務依賴assembleRelease任務。隨后將具體多渠道打包的邏輯封裝到flavor.gradle文件中,并對外提供一個打包的方法供assembleFlavor調用即可。
build.gradle(:app)文件如下所示。

apply plugin: 'com.android.application'
apply from : "flavor.gradle" // 當前gradle文件依賴flavor.gradle文件

......

task assembleFlavor {
    // assembleFlavor任務依賴assembleRelease任務
    dependsOn(":app:assembleRelease") 
    doLast {
        // assembleFlavor任務實際調用flavor.gradle中的assembleFlavorApk方法
        assembleFlavorApk() 
    }
}

隨后在命令行運行./gradlew assembleFlavor即可在build文件夾下生成配置文件中對應的渠道包。

五、Demo源碼

源碼見github,其中flavor.gradle使用了一些Groovy的特性,不過Java與Groovy是完全兼容的,使用Java編寫也完全可以,歡迎給我的項目star~

六、參考

  1. Android 動態寫入信息到 APK
  2. 深入理解Android之Gradle
  3. Android | 他山之石,可以攻玉!一篇文章看懂 v1/v2/v3 簽名機制
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,797評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,179評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,628評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,642評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,444評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,948評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,040評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,185評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,717評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,794評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,418評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,414評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,750評論 2 370

推薦閱讀更多精彩內容