筆者如今在負責一個新的
Android
項目,前期功能不太複雜,安裝包的體積小,渠道要求也較少,因此打渠道包使用Android Studio
自帶的打包方法。原生方法打渠道包大約八分鐘左右就搞定了,順即可以清閒地享受一下這種打包方式的樂趣。可是,隨着重的功能的加入和渠道的增長,原生方法打渠道包就顯得有點慢了,因此集成了美團的多渠道打包工具Walle
,順便看了一下里面的實現原理。android
這一次的原理分析僅僅針對Android Signature V2 Scheme
。api
在上一家公司的時候,筆者所在的Android
團隊經歷了Android Signature V1
到Android Signature V2
的變動,其中由於未及時從V1
升級到V2
而致使上線受阻,當時也緊急更換了新的多渠道打包工具來解決問題。在我本身使用多渠道打包工具時,難免對V2
簽名驗證的方式有了一絲好奇,想去看看V2
簽名驗證和多渠道打包的實現原理。bash
該文章先從安裝包V2
簽名驗證入手,再從打包過程當中分析Walle
是怎麼繞過簽名驗證在安裝包上加入渠道信息,最後看Walle
怎麼從應用中讀取渠道信息。在這裏我就不講Walle
的使用了,建議讀者在看原理前先了解一下使用方式。app
APK Signature Scheme v2
的簽名驗證,咱們先從官方一張圖入手dom
通常狀況下,咱們用到的zip
格式由三個部分組成:文件數據區+中央目錄結構+中央目錄結束標誌,分別對應上圖的Contents Of ZIP entries
、Central Directory``、End of Central Directory
(下文簡稱爲EOCD
)。正如圖中After signing
所示,APK Signature Scheme v2
是在ZIP文件格式的 Central Directory
區塊所在文件位置的前面添加一個APK Signing Block
區塊,用於檢驗以上三個區塊的完整性。ide
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)。gradle
其中Android
是經過ID-value
對中的ID
爲0x7109871a
的ID-value
進行校驗,對對中的其它ID-value
是不作檢驗處理的,那麼咱們能夠向ID-value
對中添加咱們本身的ID-value
,即渠道信息,這樣使安裝包能夠在增長了渠道信息的狀況下經過Android
的安裝包檢驗。ui
經過上面的分析咱們得知,寫入渠道信息須要修改安裝包,這時候確定會想到使用gradle
插件對編譯後的安裝包文件進行修改。以下圖所示,咱們也能夠看到,Walle
的源碼目錄中的plugin插件。this
經過分析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
的原理實現,但願各位看了有所收穫。