美團多渠道打包工具Walle原始碼解析

Windin發表於1970-01-01

筆者現在在負責一個新的Android專案,前期功能不太複雜,安裝包的體積小,渠道要求也較少,所以打渠道包使用Android Studio自帶的打包方法。原生方法打渠道包大約八分鐘左右就搞定了,順便可以悠閒地享受一下這種打包方式的樂趣。但是,隨著重的功能的加入和渠道的增加,原生方法打渠道包就顯得有點慢了,所以整合了美團的多渠道打包工具Walle,順便看了一下里面的實現原理。

一、概述

這一次的原理分析僅僅針對Android Signature V2 Scheme

在上一家公司的時候,筆者所在的Android團隊經歷了Android Signature V1Android Signature V2的變更,其中因為未及時從V1升級到V2而導致上線受阻,當時也緊急更換了新的多渠道打包工具來解決問題。在我自己使用多渠道打包工具時,不免對V2簽名驗證的方式有了一絲好奇,想去看看V2簽名驗證和多渠道打包的實現原理。

該文章先從安裝包V2簽名驗證入手,再從打包過程中分析Walle是怎麼繞過簽名驗證在安裝包上加入渠道資訊,最後看Walle怎麼從應用中讀取渠道資訊。在這裡我就不講Walle的使用了,建議讀者在看原理前先了解一下使用方式。

二、APK Signature Scheme v2

APK Signature Scheme v2的簽名驗證,我們先從官方一張圖入手

美團多渠道打包工具Walle原始碼解析

一般情況下,我們用到的zip格式由三個部分組成:檔案資料區+中央目錄結構+中央目錄結束標誌,分別對應上圖的Contents Of ZIP entriesCentral 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對中的ID0x7109871aID-value進行校驗,對對中的其它ID-value是不做檢驗處理的,那麼我們可以向ID-value對中新增我們自己的ID-value,即渠道資訊,這樣使安裝包可以在增加了渠道資訊的情況下通過Android的安裝包檢驗。

三、寫入渠道資訊

通過上面的分析我們得知,寫入渠道資訊需要修改安裝包,這時候肯定會想到使用gradle外掛對編譯後的安裝包檔案進行修改。如下圖所示,我們也可以看到,Walle的原始碼目錄中的plugin外掛。

美團多渠道打包工具Walle原始碼解析

通過分析plugingradle依賴,我們知道這個外掛的功能實現由pluginpayload_writerpayload_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)中建立了一個ChannelMakergradle任務物件,並把這個任務物件放在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.groovypackaging()方法中,做了檢驗操作和一堆條件判斷,最後都會呼叫以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 Blocksize,再通過Offset of start of central directory(中央目錄偏移量)size來確定APK Signing Block的起始偏移量。這時候我們知道了APK Signing Block的位置,就可以拿到ID-value對去加入渠道資訊,再將修改後的APK Signing BlockCentral DirectoryEOCD一起寫入檔案中。

這時候修改工作還沒有完成,這裡因為改動了APK Signing Block,所以在APK Signing Block後面的Central Directory起始偏移量也跟著改變了。這個起始偏移量是記錄在EOCD中的,根據EOCD結構修改Central Directory的起始偏移量後寫入工作就算完成了。

細心的朋友會發現,不是說V2簽名會保護EOCD這一區塊嗎,修改了裡面的超始偏移量還能通過校驗嗎?其實Android系統在使用V2校驗安裝包時,會把EOCDCentral Directory的起始偏移量換成APK Signing Block的偏移量再進行校驗,所以修改EOCDCentral Directory的起始偏移量不會影響到校驗。

四、讀取渠道資訊

在瞭解了Walle是如何寫入渠道資訊之後,去理解讀取渠道資訊就很簡單了。Walle先拿到安裝包檔案,再根據zip檔案結構找到APK Signing Block,從中讀取出之前寫入的渠道資訊。具體的程式碼懶懶的筆者就不帖了。

五、總結

有一部分的Coder總是能做出創新性的東西,基於他們對於技術的理解做出更加方便、靈活的工具。在通過對Walle的分析中,我們可以學到,在清楚理解了zip結構、Android安裝包檢驗原理,執行gradle plugin,就可以做出一款便於打包的工具。在這裡分享美團多渠道打包工具Walle的原理實現,希望各位看了有所收穫。

來源:https://juejin.im/post/5be91e776fb9a049c43d2ff3

相關文章