寫在前面
國內(nèi)的Android開發(fā)者跟國外的不一樣奸远,發(fā)布Apk不是在谷歌應(yīng)用市場,而是在國內(nèi)各大大小小的渠道。但是由于想在Apk發(fā)布后追蹤词疼、分析和統(tǒng)計(jì)用戶數(shù)據(jù),就必須區(qū)分每個渠道包帘腹。對于聰明的程序員贰盗,當(dāng)然不會一個一個渠道包逐個出,所以就有了多渠道包生成技術(shù)阳欲。本文意在探索和實(shí)踐目前比較穩(wěn)定和常用的幾種多渠道包生成的方式舵盈。
正文
目前比較流行的多渠道包生成方案有以下三種:
- META-INF目錄添加渠道文件
- Apk文件末尾追加渠道注釋
- 針對Android7.0 新增的V2簽名方案的Apk添加渠道ID-value
下面我們逐一來探索并實(shí)踐下這三種多渠道包生成方案,找出最適合我們項(xiàng)目的出包方式球化。
方案一:META-INF目錄添加渠道文件
我們都知道秽晚,Apk文件的簽名信息是保存在META-INF
目錄下的(關(guān)于META-INF如何保存簽名信息不是本文討論的范圍,這里不討論了筒愚,有興趣的童鞋可以看下我之前的文章APK安全性自校驗(yàn))赴蝇。
對于使用V1(Jar Signature)方案簽名的Apk,校驗(yàn)時是不會對META-INF
目錄下的文件進(jìn)行校驗(yàn)的巢掺。我們正可以利用這一特性句伶,在Apk META-INF
目錄下新建一個包含渠道名稱或id的空文件劲蜻,Apk啟動時,讀取該文件來獲取渠道號考余,從而達(dá)到區(qū)分各個渠道包的作用先嬉。
這種方案簡單明了,下面我們來實(shí)踐下:
1.添加渠道文件
添加渠道文件就非常簡單了楚堤,因?yàn)锳pk實(shí)際時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有實(shí)踐,不知道如何獲取的童鞋可以看下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();
}
}
就這么簡單幾十行的代碼就能釋放我們雙手相嵌,瞬間自動地打出多個甚至幾十個渠道包了腿时!但似乎讀取渠道文件時稍稍有點(diǎn)耗時,因?yàn)橐闅v整個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 所記錄的注釋長度必須跟實(shí)際所寫入的注釋字節(jié)數(shù)相等辟躏,否則Apk文件安裝會失敗。
同樣的土全,我們來實(shí)踐下:
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.讀取渠道注釋
因?yàn)椴挥帽闅v文件绰沥,讀取渠道注釋就比方式一的渠道方式快多了,只要根據(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);
}
}
注意,上面的實(shí)例沒有考慮Apk原本存在注釋的情況奥此,如果要考慮的話弧哎,可以根據(jù)EOCD的開始標(biāo)記,值是固定為 0x06054b50
稚虎,找到這個標(biāo)記撤嫩,再相對偏移20的字節(jié)就是 Comment Length,這樣就能知道原有注釋的長度了蠢终。
寫在最后
等等序攘!開頭不是寫了三種方案,只介紹了兩種把胺鳌程奠?抱歉哈,考慮到文章篇幅祭钉,我把第三種方案的實(shí)踐另起文章來寫梦染,并且第三種方案是這次實(shí)踐的重點(diǎn)和難點(diǎn),我希望能區(qū)分開來講朴皆,盡量講得詳細(xì)和簡單點(diǎn)帕识,所以明天再更了~
難道方案三比方案二更高效嗎?其實(shí)不然遂铡,Android7.0后谷歌推出了V2(Fill APK Signature)簽名方案肮疗,正如其名,這種簽名方案是對整個Apk文件進(jìn)行簽名的扒接,校驗(yàn)時也對整個文件進(jìn)行校驗(yàn)伪货。因?yàn)榉桨敢缓头桨付菍pk文件進(jìn)行修改的,所以導(dǎo)致了在使用了V2簽名方案的Apk钾怔,方案一和方案二就不適用了碱呼!而方案三正是針對V2簽名做的處理,所以說宗侦,方案三是方案一和方案二的缺陷的補(bǔ)充吧愚臀。方案三如何操作就下篇文章講啦~
方案三已更新:Android多渠道包生成最佳實(shí)踐(二)
好了,總結(jié)下矾利。到目前為止姑裂,我們實(shí)踐了兩種方案來生成渠道包,二兩種方案都很簡單明了男旗,其中方案二即簡單又高效舶斧,雖然方案一性能也不會很差,但我們當(dāng)然選性能最好的啦察皇。所以我推薦使用方案二來實(shí)現(xiàn)多渠道包的生成茴厉。