筆者是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少了很多,安裝更快。
③ V3簽名:相比于V2簽名沒有本質提升,提供了密鑰輪換的功能,并對簽名塊的大小進行了限制。
二、Zip文件格式
APK本身是ZIP格式,V2簽名的原理就是在ZIP中插入簽名塊來存儲摘要信息。ZIP文件的格式如下,其包含文件信息、中央目錄區及目錄信息3個部分。
在解析ZIP文件時,先找到最末端的目錄信息,得到中央目錄相對起始位置的偏移,隨后通過中央目錄得到各個文件的位置,再解析各個文件即可。
V2簽名機制在文件信息與中央目錄這兩部分中間添加了簽名塊描述摘要信息,將簽名塊添加在此處后,只需修改目錄信息中的中央目錄偏移即可,ZIP文件還是符合解析規范。
簽名塊通過一個個鍵值對存儲信息,它的大小為4096的倍數,其數據結構如下所示。簽名塊的鍵值對中,只有第一個鍵值對保存了真正的摘要信息,而該鍵值對的大小并不固定,為了使鍵值對的大小符合4096的倍數,一般還會有value全為0的鍵值對。
三、插入簽名信息
經過ZIP文件格式和簽名塊結構的分析,可以發現簽名塊本身是不參與簽名校驗的,因此在簽名塊中插入渠道信息即可通過V2簽名機制的校驗。
一般來說簽名塊中有value全為0的鍵值對,用于將簽名塊的大小湊整為4096的倍數,我們可以選擇將該鍵值對縮小,留出空間存放渠道信息,如下所示。如果簽名塊中沒有value為空的鍵值對或者剩余空間不夠存儲渠道信息,那么需要將簽名塊的大小再擴大4096字節,不過我目前還沒遇到這種情況。
下面詳細介紹如何在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~