美團多渠道打包工具Walle原始碼解析
筆者現在在負責一個新的
Android
專案,前期功能不太複雜,安裝包的體積小,渠道要求也較少,所以打渠道包使用Android Studio
自帶的打包方法。原生方法打渠道包大約八分鐘左右就搞定了,順便可以悠閒地享受一下這種打包方式的樂趣。但是,隨著重的功能的加入和渠道的增加,原生方法打渠道包就顯示有點慢了,所以集成了美團的多渠道打包工具Walle
,順便看了一下里面的實現原理。
一、概述
這一次的原理分析僅僅針對Android Signature V2 Scheme
。
在上一家公司的時候,筆者所在的Android
團隊經歷了Android Signature V1
到Android Signature V2
V1
升級到V2
而導致上線受阻,當時也緊急更換了新的多渠道打包工具來解決問題。在我自己使用多渠道打包工具時,不免對V2
簽名驗證的方式有了一絲好奇,想去看看V2
簽名驗證和多渠道打包的實現原理。
該文章先從安裝包V2
簽名驗證入手,再從打包過程中分析Walle
是怎麼繞過簽名驗證在安裝包上加入渠道資訊,最後看Walle
怎麼從應用中讀取渠道資訊。在這裡我就不講Walle
的使用了,建議讀者在看原理前先了解一下使用方式。
二、APK Signature Scheme v2
APK Signature Scheme v2
的簽名驗證,我們先從官方一張圖入手
一般情況下,我們用到的zip
Contents Of ZIP entries
、Central Directory``、End of Central Directory
(下文簡稱為EOCD
)。正如圖中After signing
所示,APK Signature Scheme v2
是在ZIP檔案格式的 Central Directory
區塊所在檔案位置的前面新增一個APK Signing Block
區塊,用於檢驗以上三個區塊的完整性。
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)。
其中Android
是通過ID-value
對中的ID
為0x7109871a
的ID-value
進行校驗,對對中的其它ID-value
是不做檢驗處理的,那麼我們可以向ID-value
對中新增我們自己的ID-value
,即渠道資訊,這樣使安裝包可以在增加了渠道資訊的情況下通過Android
的安裝包檢驗。
三、寫入渠道資訊
通過上面的分析我們得知,寫入渠道資訊需要修改安裝包,這時候肯定會想到使用gradle
外掛對編譯後的安裝包檔案進行修改。如下圖所示,我們也可以看到,Walle
的原始碼目錄中的plugin外掛。
通過分析plugin
的gradle
依賴,我們知道這個外掛的功能實現由plugin
、payload_writer
、payload_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)
中建立了一個ChannelMaker
的gradle
任務物件,並把這個任務物件放在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.groovy
的packaging()
方法中,做了檢驗操作和一堆條件判斷,最後都會呼叫以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 Block
的size
,再通過Offset of start of central directory(中央目錄偏移量)
- size
來確定APK Signing Block
的起始偏移量。這時候我們知道了APK Signing Block
的位置,就可以拿到ID-value
對去加入渠道資訊,再將修改後的APK Signing Block
和Central Directory
同EOCD
一起寫入檔案中。
這時候修改工作還沒有完成,這裡因為改動了APK Signing Block
,所以在APK Signing Block
後面的Central Directory
起始偏移量也跟著改變了。這個起始偏移量是記錄在EOCD
中的,根據EOCD結構修改Central Directory
的起始偏移量後寫入工作就算完成了。
細心的朋友會發現,不是說V2
簽名會保護EOCD
這一區塊嗎,修改了裡面的超始偏移量還能通過校驗嗎?其實Android
系統在使用V2
校驗安裝包時,會把EOCD
的Central Directory
的起始偏移量換成APK Signing Block
的偏移量再進行校驗,所以修改EOCD
中Central Directory
的起始偏移量不會影響到校驗。
四、讀取渠道資訊
在瞭解了Walle
是如何寫入渠道資訊之後,去理解讀取渠道資訊就很簡單了。Walle
先拿到安裝包檔案,再根據zip
檔案結構找到APK Signing Block
,從中讀取出之前寫入的渠道資訊。具體的程式碼懶懶的筆者就不帖了。
五、總結
有一部分的Coder
總是能做出創新性的東西,基於他們對於技術的理解做出更加方便、靈活的工具。在通過對Walle
的分析中,我們可以學到,在清楚理解了zip
結構、Android
安裝包檢驗原理,執行gradle plugin
,就可以做出一款便於打包的工具。在這裡分享美團多渠道打包工具Walle
的原理實現,希望各位看了有所收穫。