背景:目前項目在打渠道包的時候,采用的是AndroidManifest.xml配置渠道號,上線前一個個構建出來,全部構建完成耗時長達一個小時,這對于追求高效的工程師來講是無法忍受的。當前有很多公司開源了多渠道打包的方案,如美團的walle,騰訊的VasDolly,還有技術達人個人研究的packer-ng-plugin,今天就來探索下多渠道打包的奧秘。
在了解多渠道打包之前,需要先了解下android的簽名方式,這樣才能知己知彼百戰(zhàn)不殆!
APK 簽名方案
Android 支持兩種應用簽名方案,一種是基于 JAR 簽名的方案(v1 方案),另一種是 Android Nougat (Android 7.0) 中引入的 APK 簽名方案 v2(v2 方案)。
為了最大限度地提高兼容性,應同時采用 v1 和 v2 這兩種方案對應用進行簽名。與只通過 v1 方案簽名的應用相比,通過 v2 方案簽名的應用能夠更快速地安裝到 Android Nougat 及更高版本的設備上。更低版本的 Android 平臺會忽略 v2 簽名,這就需要應用包含 v1 簽名。
JAR 簽名(v1 方案)
從一開始,APK 簽名就是 Android 的一個有機部分。該方案基于簽名的JAR。
v1 簽名不保護 APK 的某些部分,例如 ZIP 元數(shù)據(jù)。APK 驗證程序需要處理大量不可信(尚未經(jīng)過驗證)的數(shù)據(jù)結構,然后會舍棄不受簽名保護的數(shù)據(jù)。這會導致相當大的受攻擊面。此外,APK 驗證程序必須解壓所有已壓縮的條目,而這需要花費更多時間和內(nèi)存。為了解決這些問題,Android 7.0 中引入了 APK 簽名方案 v2。
APK 簽名方案 v2(v2 方案)
Android 7.0 中引入了 APK 簽名方案 v2(v2 方案)。該方案會對 APK 的內(nèi)容進行哈希處理和簽名,然后將生成的“APK 簽名分塊”插入到 APK 中。
多渠道打包方案
Gradle插件模式
- 在AndroidManifest.xml中添加渠道信息:
<meta-data
android:name="UMENG_CHANNEL"
android:value="${UMENG_CHANNEL_VALUE}"></meta-data>
- 2.通過Gradle Plugin提供的productFlavors標簽,添加渠道信息:
productFlavors.all { flavor ->
flavor.manifestPlaceholders = [UMENG_CHANNEL_VALUE: name]
}
flavorDimensions 'channel'
productFlavors {
"yingyongbao" {
dimension "channel"
}
"_360" {
dimension "channel"
}
Gradle編譯生成多渠道包時,會用不同的渠道信息替換AndroidManifest.xml中的占位符。在代碼中,也就可以直接讀取AndroidManifest.xml中的渠道信息了。
ApplicationInfo appInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(),
PackageManager.GET_META_DATA);
int channelId = appInfo.metaData.getInt("UMENG_CHANNEL");
當前方案存在的問題,在開頭也說了,現(xiàn)在總結一下(引用VasDolly原文)
1.每生成一個渠道包,都要重新執(zhí)行一遍構建流程,效率太低,浪費時間,不太適合多渠道包的場景。
2.Gradle會為每個渠道包生成一個不同的BuildConfig.java類,記錄渠道信息,導致每個渠道包的DEX的CRC值都不同。一般情況下,這是沒有影響的。但是如果你使用了微信的Tinker熱補丁方案,那么就需要為不同的渠道包打不同的補丁,這完全是不可以接受的。(因為Tinker是通過對比基礎包APK和新包APK生成差分補丁,然后再把補丁和基礎包APK一起合成新包APK。這就要求用于生成差分補丁的基礎包DEX和用于合成新包的基礎包DEX是完全一致的,即:每一個基礎渠道包的DEX文件是完全一致的,不然就會合成失?。?/p>
有沒有可能,在不改變基礎包的情況下,快速的打出多渠道包呢?
APK本身也是個zip壓縮包,多渠道打包是根據(jù)zip包的文件格式來切入的,所以可以先來看下zip包的文件格式。
[local file header 1]
[local file header 1]
[file data 1]
[data descriptor 1]
.
.
.
[local file header n]
[file data n]
[data descriptor n]
[archive decryption header] (EFS)
[archive extra data record] (EFS)
[central directory]
[zip64 end of central directory record]
[zip64 end of central directory locator]
[end of central directory record]
zip主要由三部分組成:
| 壓縮源文件數(shù)據(jù)區(qū) | 中央目錄 | 目錄結束 |
|---|---|---|
| local file header + file data + data descriptor | central directory | end of central directory record |
| 記錄了文件名、壓縮算法、壓縮前后的文件大小、修改時間、CRC32值等 | 包含了多個central directory file header(和第一部分的local file header一一對應),每個中央目錄文件頭主要記錄了壓縮算法、注釋信息、對應local file header的偏移量等 | 主要記錄了中央目錄大小、偏移量和ZIP注釋信息等 |
因基于v1方案的多渠道打包,會在目錄結束部分做文章,這里把End of central directory record(EOCD)結構詳細結構描述下。
目錄結束標識存在于整個歸檔包的結尾,用于標記壓縮的目錄數(shù)據(jù)的結束。每個壓縮文件必須有且只有一個EOCD記錄。
| Offset | Bytes | Description | 譯 |
|---|---|---|---|
| 0 | 4 | End of central directory signature = 0x06054b50 | 核心目錄結束標記(0x06054b50) |
| 4 | 2 | Number of this disk | 當前磁盤編號 |
| 6 | 2 | number of the disk with the start of the central directory | 核心目錄開始位置的磁盤編號 |
| 8 | 2 | total number of entries in the central directory on this disk | 該磁盤上所記錄的核心目錄數(shù)量 |
| 10 | 2 | total number of entries in the central directory | 核心目錄結構總數(shù) |
| 12 | 2 | Size of central directory (bytes) | 核心目錄的大小 |
| 16 | 4 | offset of start of central directory with respect to the starting disk number | 核心目錄開始位置相對于archive開始的位移 |
| 20 | 2 | .ZIP file comment length(n) | 注釋長度 |
| 22 | n | .ZIP Comment | 注釋內(nèi)容 |
有了以上對zip文件格式的認知,來看下android簽名方案以及如何從簽名方案下手來做多渠道打包。
基于V1簽名的多渠道打包方案
根據(jù)之前的V1簽名和校驗機制可知,V1簽名只會檢驗第一部分的所有壓縮文件,而不理會后兩部分內(nèi)容。因此,只要把渠道信息寫入到后兩塊內(nèi)容就可以通過V1校驗,而EOCD的注釋字段無疑是最好的選擇。
上面也說過,apk實際上就是普通的zip,在一個zip文件的最后允許寫入N個字符的注釋,zip末尾兩個部分:2字節(jié)的的注釋長度+N個字節(jié)的注釋。
那么,我們只要把簽名內(nèi)容作為注釋寫入,再修改2字節(jié)的注釋長度即可。
那么我們怎么知道一個apk有沒有寫入這個渠道信息呢?
我們可以在文件文件末尾寫入一個特殊的字符串,當我們讀取文件末尾為這個特殊的字符串,即可認為該apk寫入了渠道信息。該特殊字符串稱之為魔數(shù)
最終的渠道信息為:
渠道字符串+渠道字符串長度+魔數(shù)
在APK文件的注釋字段,添加渠道信息。
整個方案包括以下幾步:
1.復制APK
2.找到EOCD數(shù)據(jù)塊
3.修改注釋長度
4.添加渠道信息
5.添加渠道信息長度
6.添加魔數(shù)
接下來看如何從apk讀取這個渠道信息呢?
根據(jù)上面的了解,添加魔數(shù)的好處是方便從后向前讀取數(shù)據(jù),定位渠道信息。 因此,讀取渠道信息包括以下幾步:
1.定位到魔數(shù)
2.向前讀兩個字節(jié),確定渠道信息的長度LEN
3.繼續(xù)向前讀LEN字節(jié),就是渠道信息了。
用二進制編輯器打開打包好的Apk,看末尾的幾個字節(jié),如圖:

對這圖可以分析如下:
- 首先讀取8個字節(jié),對應一個特殊字符串“l(fā)tlovezh”
- 往前兩個字節(jié)為02 00,對應渠道信息長度,實際值為2.
- 再往前讀取2個字節(jié)為63 31,對照ASCII表,即可知為c1
讀取到渠道信息為:c1。
到這,渠道讀寫方案基本完了,再此不再深入,有時間在結合代碼分析。
基于V2簽名的多渠道打包方案
使用 APK 簽名方案 v2 進行簽名時,會在 APK 文件中插入一個 APK 簽名分塊,該分塊位于“ZIP 中央目錄”部分之前并緊鄰該部分。在“APK 簽名分塊”內(nèi),v2 簽名和簽名者身份信息會存儲在 APK 簽名方案 v2 分塊中。

上圖中,簽名前和簽名后的 APK
APK 簽名方案 v2 是在 Android 7.0 (Nougat) 中引入的。為了使 APK 可在 Android 6.0 (Marshmallow) 及更低版本的設備上安裝,應先使用 JAR 簽名功能對 APK 進行簽名,然后再使用 v2 方案對其進行簽名。
APK 簽名分塊
為了保持與當前 APK 格式向后兼容,v2 及更高版本的 APK 簽名會存儲在“APK 簽名分塊”內(nèi),該分塊是為了支持 APK 簽名方案 v2 而引入的一個新容器。在 APK 文件中,“APK 簽名分塊”位于“ZIP 中央目錄”(位于文件末尾)之前并緊鄰該部分。
該分塊包含多個“ID-值”對,所采用的封裝方式有助于更輕松地在 APK 中找到該分塊。APK 的 v2 簽名會存儲為一個“ID-值”對,其中 ID 為 0x7109871a。
為了保護 APK 內(nèi)容,APK 包含以下 4 個部分:
ZIP 條目的內(nèi)容(從偏移量 0 處開始一直到“APK 簽名分塊”的起始位置)
APK 簽名分塊
ZIP 中央目錄
ZIP 中央目錄結尾

上圖中,簽名后的各個 APK 部分
APK 簽名方案 v2 負責保護第 1、3、4 部分的完整性,以及第 2 部分包含的“APK 簽名方案 v2 分塊”中的 signed data 分塊的完整性。
第 1、3 和 4 部分的完整性通過其內(nèi)容的一個或多個摘要來保護,這些摘要存儲在 signed data 分塊中,而這些分塊則通過一個或多個簽名來保護。
第 1、3 和 4 部分的摘要采用以下計算方式,類似于兩級 Merkle 樹。 每個部分都會被拆分成多個大小為 1 MB(220 個字節(jié))的連續(xù)塊。每個部分的最后一個塊可能會短一些。每個塊的摘要均通過字節(jié) 0xa5 的連接、塊的長度(采用小端字節(jié)序的 uint32 值,以字節(jié)數(shù)計)和塊的內(nèi)容進行計算。頂級摘要通過字節(jié) 0x5a 的連接、塊數(shù)(采用小端字節(jié)序的 uint32 值)以及塊的摘要的連接(按照塊在 APK 中顯示的順序)進行計算。摘要以分塊方式計算,以便通過并行處理來加快計算速度。

上圖中 APK 摘要
由于第 4 部分(ZIP 中央目錄結尾)包含“ZIP 中央目錄”的偏移量,因此該部分的保護比較復雜。當“APK 簽名分塊”的大小發(fā)生變化(例如,添加了新簽名)時,偏移量也會隨之改變。因此,在通過“ZIP 中央目錄結尾”計算摘要時,必須將包含“ZIP 中央目錄”偏移量的字段視為包含“APK 簽名分塊”的偏移量。
V2校驗流程
在 Android 7.0 中,可以根據(jù) APK 簽名方案 v2(v2 方案)或 JAR 簽名(v1 方案)驗證 APK。更低版本的平臺會忽略 v2 簽名,僅驗證 v1 簽名。

上圖 中APK 簽名驗證過程(新步驟以紅色顯示)
APK 簽名方案 v2 驗證
- 找到“APK 簽名分塊”并驗證以下內(nèi)容:
- “APK 簽名分塊”的兩個大小字段包含相同的值。
- “ZIP 中央目錄結尾”緊跟在“ZIP 中央目錄”記錄后面。
- “ZIP 中央目錄結尾”之后沒有任何數(shù)據(jù)。
- 找到“APK 簽名分塊”中的第一個“APK 簽名方案 v2 分塊”。如果 v2 分塊存在,則繼續(xù)執(zhí)行第 3 步。否則,回退至使用 v1 方案驗證 APK。
- 對“APK 簽名方案 v2 分塊”中的每個
signer執(zhí)行以下操作:- 從
signatures中選擇安全系數(shù)最高的受支持signature algorithm ID。安全系數(shù)排序取決于各個實現(xiàn)/平臺版本。 - 使用
public key并對照signed data驗證signatures中對應的signature。(現(xiàn)在可以安全地解析signed data了。) - 驗證
digests和signatures中的簽名算法 ID 列表(有序列表)是否相同。(這是為了防止刪除/添加簽名。) - 使用簽名算法所用的同一種摘要算法計算 APK 內(nèi)容的摘要。
- 驗證計算出的摘要是否與
digests中對應的digest相同。 - 驗證
certificates中第一個certificate的 SubjectPublicKeyInfo 是否與public key相同。
- 從
- 如果找到了至少一個
signer,并且對于每個找到的signer,第 3 步都取得了成功,APK 驗證將會成功。
注意:如果第 3 步或第 4 步失敗了,則不得使用 v1 方案驗證 APK。
基于V2簽名的多渠道打包方案
在看一個問題,V2簽名是怎么保證APK不被篡改的?
首先,如果破壞者修改了APK文件的任何部分(簽名塊本身除外),那么APK的數(shù)據(jù)摘要就和“MF”數(shù)據(jù)塊中記錄的數(shù)據(jù)摘要不一致,導致校驗失敗。 其次,如果破壞者同時修改了“MF”數(shù)據(jù)塊中的數(shù)據(jù)摘要,那么“MF”數(shù)據(jù)塊的數(shù)字簽名就和“SF”數(shù)據(jù)塊中記錄的數(shù)字簽名不一致,導致校驗失敗。 然后,如果破壞者使用自己的私鑰去加密生成“SF”數(shù)據(jù)塊,那么使用開發(fā)者的公鑰去解密“SF”數(shù)據(jù)塊中的數(shù)字簽名就會失??; 最后,更進一步,若破壞者甚至替換了開發(fā)者公鑰,那么使用數(shù)字證書中的公鑰校驗簽名塊中的公鑰就會失敗,這也正是數(shù)字證書的作用。
綜上所述,任何對APK的修改,在安裝時都會失敗,除非對APK重新簽名。但是相同包名,不同簽名的APK也是不能同時安裝的。
基于V2簽名的多渠道打包方案
在上節(jié)V2簽名的校驗流程中,有一個很重要的細節(jié):Android系統(tǒng)只會關注ID為0x7109871a的V2簽名塊,并且忽略其他的ID-Value,同時V2簽名只會保護APK本身,不包含簽名塊,因此,k添加一個ID-Value,存儲渠道信息,即解決了基于V2簽名的多渠道打包問題。
方案包括以下幾步:
找到APK的EOCD塊
找到APK簽名塊
獲取已有的ID-Value Pair
添加包含渠道信息的ID-Value
基于所有的ID-Value生成新的簽名塊
修改EOCD的中央目錄的偏移量(上面已介紹過:修改EOCD的中央目錄偏移量,不會導致數(shù)據(jù)摘要校驗失敗)
用新的簽名塊替代舊的簽名塊,生成帶有渠道信息的APK
實際上,除了渠道信息,我們可以在APK簽名塊中添加任何輔助信息。
多渠道包的強校驗
那么如何保證通過這些方案生成的渠道包,能夠在所有Android平臺上正確安裝呢?
Google提供了一個同時支持V1和V2簽名和校驗的工具:apksig。它包括一個apksigner命令行和一個apksig類庫。其中前者就是Android SDK build-tools下面的命令行工具。而我們正是借助后面的apksig來進行渠道包強校驗,它可以保證渠道包在apk Minsdk ~ Maxsdk之間都校驗通過。
至此,多渠道打包方案基本看完了,還有其他方案,暫未分析,有興趣的可以自行研究。

參考文獻:
APK 簽名方案 v2
應用簽名
VasDolly
walle
帶你了解騰訊開源的多渠道打包技術 VasDolly源碼解析