寫在前面
國內(nèi)的Android開發(fā)者跟國外的不一樣,發(fā)布Apk不是在谷歌應(yīng)用市場,而是在國內(nèi)各大大小小的渠道。但是由于想在Apk發(fā)布后追蹤、分析和統(tǒng)計用戶數(shù)據(jù),就必須區(qū)分每個渠道包。對于聰明的程序員,當(dāng)然不會一個一個渠道包逐個出,所以就有了多渠道包生成技術(shù)。本文意在探索和實踐目前比較穩(wěn)定和常用的幾種多渠道包生成的方式。
正文
目前比較流行的多渠道包生成方案有以下三種:
- META-INF目錄添加渠道文件
- Apk文件末尾追加渠道注釋
- 針對Android7.0 新增的V2簽名方案的Apk添加渠道ID-value
下面我們逐一來探索并實踐下這三種多渠道包生成方案,找出最適合我們項目的出包方式。
方案一:META-INF目錄添加渠道文件
我們都知道,Apk文件的簽名信息是保存在META-INF目錄下的(關(guān)于META-INF如何保存簽名信息不是本文討論的范圍,這里不討論了,有興趣的童鞋可以看下我之前的文章APK安全性自校驗)。
對于使用V1(Jar Signature)方案簽名的Apk,校驗時是不會對META-INF目錄下的文件進(jìn)行校驗的。我們正可以利用這一特性,在Apk META-INF目錄下新建一個包含渠道名稱或id的空文件,Apk啟動時,讀取該文件來獲取渠道號,從而達(dá)到區(qū)分各個渠道包的作用。
這種方案簡單明了,下面我們來實踐下:
1.添加渠道文件
添加渠道文件就非常簡單了,因為Apk實際時zip文件,對于Java來說,使用ZipFile、ZipEntry、ZipOutputStream 等類很簡單就能操作zip文件,往zip文件添加文件再簡單不過:
private static final String META_INF_PATH = "META-INF" + File.separator;
private static final String CHANNEL_PREFIX = "channel_";
private static final String CHANNEL_PATH = META_INF_PATH + CHANNEL_PREFIX;
public static void addChannelFile(ZipOutputStream zos, String channel, String channelId)
throws IOException {
// Add Channel file to META-INF
ZipEntry emptyChannelFile = new ZipEntry(CHANNEL_PATH + channel + "_" + channelId);
zos.putNextEntry(emptyChannelFile);
zos.closeEntry();
}
2.讀取渠道文件
讀文件也同樣簡單,只需遍歷Apk文件,找到我們添加的渠道文件就好:
public static String getChannelByMetaInf(File apkFile) {
if (apkFile == null || !apkFile.exists()) return "";
String channel = "";
try {
ZipFile zipFile = new ZipFile(apkFile);
Enumeration<? extends ZipEntry> entries = zipFile.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
String name = entry.getName();
if (name == null || name.trim().length() == 0 || !name.startsWith(META_INF_PATH)) {
continue;
}
name = name.replace(META_INF_PATH, "");
if (name.startsWith(CHANNEL_PREFIX)) {
channel = name.replace(CHANNEL_PREFIX, "");
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
return channel;
}
或者有童鞋會問,讀渠道文件是程序在跑時讀的,我們手機(jī)如何拿到Apk文件,總不能要用戶手機(jī)都保留一個Apk文件吧?如果有這疑問的童鞋,可能不知道手機(jī)上安裝的應(yīng)用都會保留應(yīng)用的Apk的,并且安卓也提供了Api,只需簡單幾行代碼就能獲取,這里不貼代碼了,文末的demo有實踐,不知道如何獲取的童鞋可以看下demo。
3.生成多個渠道包
生成渠道包就簡單不過了,寫一個腳本,根據(jù)渠道配置文件,讀取所需的渠道,再復(fù)制多個原Apk文件作為渠道包,最后往渠道包添加渠道文件就可以了:
public static void addChannelToApk(ZipFile apkFile) {
if (apkFile == null) throw new NullPointerException("Apk file can not be null");
Map<String, String> channels = getAllChannels();
Set<String> channelSet = channels.keySet();
String srcApkName = apkFile.getName().replace(".apk", "");
srcApkName = srcApkName.substring(srcApkName.lastIndexOf(File.separator));
for (String channel : channelSet) {
String channelId = channels.get(channel);
ZipOutputStream zos = null;
try {
File channelFile = new File(BUILD_DIR,
srcApkName + "_" + channel + "_" + channelId + ".apk");
if (channelFile.exists()) {
channelFile.delete();
}
FileUtils.createNewFile(channelFile);
zos = new ZipOutputStream(new FileOutputStream(channelFile));
copyApkFile(apkFile, zos);
MetaInfProcessor.addChannelFile(zos, channel, channelId);
} catch (IOException e) {
e.printStackTrace();
} finally {
IOUtils.closeQuietly(zos);
}
}
IOUtils.closeQuietly(apkFile);
}
private static void copyApkFile(ZipFile src, ZipOutputStream zos) throws IOException {
Enumeration<? extends ZipEntry> entries = src.entries();
while (entries.hasMoreElements()) {
ZipEntry zipEntry = entries.nextElement();
ZipEntry copyZipEntry = new ZipEntry(zipEntry.getName());
zos.putNextEntry(copyZipEntry);
if (!zipEntry.isDirectory()) {
InputStream in = src.getInputStream(zipEntry);
int len;
byte[] buffer = new byte[8 * 1024];
while ((len = in.read(buffer)) != -1) {
zos.write(buffer, 0, len);
}
}
zos.closeEntry();
}
}
就這么簡單幾十行的代碼就能釋放我們雙手,瞬間自動地打出多個甚至幾十個渠道包了!但似乎讀取渠道文件時稍稍有點耗時,因為要遍歷整個Apk文件,如果文件一大,性能可能就不太理想了,有沒更好的方法?答案肯定是有的,我們接下來看看第二種方案。
方案二:Apk文件末尾追加渠道注釋
在探索這個方案前,你需要了解zip文件的格式,大家可以參考下文章 ZIP文件格式分析。內(nèi)容很多,記不???沒關(guān)系,該方案你只需關(guān)注zip文件的末尾的格式 End of central directory record (EOCD):
| Offset | Bytes | Desctiption |
|---|---|---|
| 0 | 4 | End of central directory signature = 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 |
| 10 | 2 | Total number of entries in the central directory |
| 12 | 4 | Size of central directory (bytes) |
| 16 | 2 | Offset of start of central directory with respect to the starting disk number |
| 20 | 2 | Comment length(n) |
| 22 | n | Comment |
zip文件末尾的字節(jié) Comment 就是其注釋。我們知道,代碼的注釋是不會影響程序的,它只是為代碼添加說明。zip的注釋同樣如此,它并不會影響zip的結(jié)構(gòu),在注釋了寫入字節(jié),對Apk文件不會有任何影響,也即能正常安裝。
基于此特性,我們就可以在zip的注釋塊里動手了,可以在注釋里寫入我們的渠道信息,來區(qū)分每個渠道包。但需要注意的是:Comment Length 所記錄的注釋長度必須跟實際所寫入的注釋字節(jié)數(shù)相等,否則Apk文件安裝會失敗。
同樣的,我們來實踐下:
1.追加渠道注釋
追加注釋很簡單,就是在文件末寫入數(shù)據(jù)而已。但我們要有一定的格式,來標(biāo)識是我們自己寫的注釋,并且能保證我們能正確地讀取渠道號。為了簡單起見,我demo里使用的格式也很簡單:
| Offset | Bytes | Desctiption |
|---|---|---|
| 0 | n | Json格式的渠道信息 |
| n | 2 | 渠道信息的字節(jié)數(shù) |
| n+2 | 3 | 魔數(shù) ”LEO“,標(biāo)記作用 |
寫入注釋同樣很簡單,只要注意要更新 Comment Length 的字節(jié)數(shù)就可以了:
public static void writeFileComment(File apkFile, String data) {
if (apkFile == null) throw new NullPointerException("Apk file can not be null");
if (!apkFile.exists()) throw new IllegalArgumentException("Apk file is not found");
int length = data.length();
if (length > Short.MAX_VALUE) throw new IllegalArgumentException("Size out of range: " + length);
RandomAccessFile accessFile = null;
try {
accessFile = new RandomAccessFile(apkFile, "rw");
long index = accessFile.length();
index -= 2; // 2 = FCL
accessFile.seek(index);
short dataLen = (short) length;
int tempLength = dataLen + BYTE_DATA_LEN + COMMENT_MAGIC.length();
if (tempLength > Short.MAX_VALUE) throw new IllegalArgumentException("Size out of range: " + tempLength);
short fcl = (short) tempLength;
// Write FCL
ByteBuffer byteBuffer = ByteBuffer.allocate(Short.BYTES);
byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
byteBuffer.putShort(fcl);
byteBuffer.flip();
accessFile.write(byteBuffer.array());
// Write data
accessFile.write(data.getBytes(CHARSET));
// Write data len
byteBuffer = ByteBuffer.allocate(Short.BYTES);
byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
byteBuffer.putShort(dataLen);
byteBuffer.flip();
accessFile.write(byteBuffer.array());
// Write flag
accessFile.write(COMMENT_MAGIC.getBytes(CHARSET));
} catch (IOException e) {
e.printStackTrace();
} finally {
IOUtils.closeQuietly(accessFile);
}
}
2.讀取渠道注釋
因為不用遍歷文件,讀取渠道注釋就比方式一的渠道方式快多了,只要根據(jù)我們自己寫入文件的注釋格式,從文件末逆著讀就可以了(嘻嘻,這你知道我們?yōu)楹卧趯懭胱⑨寱r需要寫入我們渠道信息的長度了吧~)。好,看代碼:
public static String readFileComment(File apkFile) {
if (apkFile == null) throw new NullPointerException("Apk file can not be null");
if (!apkFile.exists()) throw new IllegalArgumentException("Apk file is not found");
RandomAccessFile accessFile = null;
try {
accessFile = new RandomAccessFile(apkFile, "r");
FileChannel fileChannel = accessFile.getChannel();
long index = accessFile.length();
// Read flag
index -= COMMENT_MAGIC.length();
fileChannel.position(index);
ByteBuffer byteBuffer = ByteBuffer.allocate(COMMENT_MAGIC.length());
fileChannel.read(byteBuffer);
byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
if (!new String(byteBuffer.array(), CHARSET).equals(COMMENT_MAGIC)) {
return "";
}
// Read dataLen
index -= BYTE_DATA_LEN;
fileChannel.position(index);
byteBuffer = ByteBuffer.allocate(Short.BYTES);
fileChannel.read(byteBuffer);
byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
short dataLen = byteBuffer.getShort(0);
// Read data
index -= dataLen;
fileChannel.position(index);
byteBuffer = ByteBuffer.allocate(dataLen);
fileChannel.read(byteBuffer);
byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
return new String(byteBuffer.array(), CHARSET);
} catch (IOException e) {
e.printStackTrace();
} finally {
IOUtils.closeQuietly(accessFile);
}
return "";
}
3.生成多個渠道包
這部分就跟方式一的差不多了,只是處理的方式不同而已,就不多說了:
public static void addChannelToApk(File apkFile) {
if (apkFile == null) throw new NullPointerException("Apk file can not be null");
Map<String, String> channels = getAllChannels();
Set<String> channelSet = channels.keySet();
String srcApkName = apkFile.getName().replace(".apk", "");
InputStream in = null;
OutputStream out = null;
for (String channel : channelSet) {
String channelId = channels.get(channel);
String jsonStr = "{" +
"\"channel\":" + "\"" + channel + "\"," +
"\"channel_id\":" + "\"" + channelId + "\"" +
"}";
try {
File channelFile = new File(BUILD_DIR,
srcApkName + "_" + channel + "_" + channelId + ".apk");
if (channelFile.exists()) {
channelFile.delete();
}
FileUtils.createNewFile(channelFile);
in = new FileInputStream(apkFile);
out = new FileOutputStream(channelFile);
copyApkFile(in, out);
FileCommentProcessor.writeFileComment(channelFile, jsonStr);
} catch (IOException e) {
e.printStackTrace();
} finally {
IOUtils.closeQuietly(in);
IOUtils.closeQuietly(out);
}
}
}
private static void copyApkFile(InputStream in, OutputStream out) throws IOException {
byte[] buffer = new byte[4 * 1024];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
注意,上面的實例沒有考慮Apk原本存在注釋的情況,如果要考慮的話,可以根據(jù)EOCD的開始標(biāo)記,值是固定為 0x06054b50,找到這個標(biāo)記,再相對偏移20的字節(jié)就是 Comment Length,這樣就能知道原有注釋的長度了。
寫在最后
等等!開頭不是寫了三種方案,只介紹了兩種啊?抱歉哈,考慮到文章篇幅,我把第三種方案的實踐另起文章來寫,并且第三種方案是這次實踐的重點和難點,我希望能區(qū)分開來講,盡量講得詳細(xì)和簡單點,所以明天再更了~
難道方案三比方案二更高效嗎?其實不然,Android7.0后谷歌推出了V2(Fill APK Signature)簽名方案,正如其名,這種簽名方案是對整個Apk文件進(jìn)行簽名的,校驗時也對整個文件進(jìn)行校驗。因為方案一和方案二是對Apk文件進(jìn)行修改的,所以導(dǎo)致了在使用了V2簽名方案的Apk,方案一和方案二就不適用了!而方案三正是針對V2簽名做的處理,所以說,方案三是方案一和方案二的缺陷的補充吧。方案三如何操作就下篇文章講啦~
方案三已更新:Android多渠道包生成最佳實踐(二)
好了,總結(jié)下。到目前為止,我們實踐了兩種方案來生成渠道包,二兩種方案都很簡單明了,其中方案二即簡單又高效,雖然方案一性能也不會很差,但我們當(dāng)然選性能最好的啦。所以我推薦使用方案二來實現(xiàn)多渠道包的生成。
DEMO
