Android多渠道包生成最佳實(shí)踐(一)

寫在前面

國內(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)多渠道包的生成茴厉。


DEMO

MCRelease

Demo項(xiàng)目結(jié)構(gòu)說明.png
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子矾缓,更是在濱河造成了極大的恐慌怀酷,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,839評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件而账,死亡現(xiàn)場離奇詭異胰坟,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)泞辐,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,543評論 2 382
  • 文/潘曉璐 我一進(jìn)店門笔横,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人咐吼,你說我怎么就攤上這事吹缔。” “怎么了锯茄?”我有些...
    開封第一講書人閱讀 153,116評論 0 344
  • 文/不壞的土叔 我叫張陵厢塘,是天一觀的道長。 經(jīng)常有香客問我肌幽,道長晚碾,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,371評論 1 279
  • 正文 為了忘掉前任喂急,我火速辦了婚禮格嘁,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘廊移。我一直安慰自己糕簿,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,384評論 5 374
  • 文/花漫 我一把揭開白布狡孔。 她就那樣靜靜地躺著懂诗,像睡著了一般。 火紅的嫁衣襯著肌膚如雪苗膝。 梳的紋絲不亂的頭發(fā)上殃恒,一...
    開封第一講書人閱讀 49,111評論 1 285
  • 那天,我揣著相機(jī)與錄音荚醒,去河邊找鬼芋类。 笑死,一個胖子當(dāng)著我的面吹牛界阁,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播胖喳,決...
    沈念sama閱讀 38,416評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼泡躯,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起较剃,我...
    開封第一講書人閱讀 37,053評論 0 259
  • 序言:老撾萬榮一對情侶失蹤咕别,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后写穴,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體惰拱,經(jīng)...
    沈念sama閱讀 43,558評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,007評論 2 325
  • 正文 我和宋清朗相戀三年啊送,在試婚紗的時候發(fā)現(xiàn)自己被綠了偿短。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,117評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡馋没,死狀恐怖昔逗,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情篷朵,我是刑警寧澤勾怒,帶...
    沈念sama閱讀 33,756評論 4 324
  • 正文 年R本政府宣布,位于F島的核電站声旺,受9級特大地震影響笔链,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜腮猖,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,324評論 3 307
  • 文/蒙蒙 一鉴扫、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧缚够,春花似錦幔妨、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,315評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至雏吭,卻和暖如春锁施,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背杖们。 一陣腳步聲響...
    開封第一講書人閱讀 31,539評論 1 262
  • 我被黑心中介騙來泰國打工悉抵, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人摘完。 一個月前我還...
    沈念sama閱讀 45,578評論 2 355
  • 正文 我出身青樓姥饰,卻偏偏與公主長得像,于是被迫代替她去往敵國和親孝治。 傳聞我的和親對象是個殘疾皇子列粪,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,877評論 2 345

推薦閱讀更多精彩內(nèi)容

  • 關(guān)于作者: 李濤审磁,騰訊Android工程師,14年加入騰訊SNG增值產(chǎn)品部岂座,期間主要負(fù)責(zé)手Q動漫态蒂、企鵝電競等項(xiàng)目的...
    稻草人_3e17閱讀 3,595評論 0 10
  • 一鸳址、前言 Hi瘩蚪,大家好,我是承香墨影氯质! 當(dāng)我們需要發(fā)布一款 App 到應(yīng)用市場的時候募舟,一般需要我們針對不同的市場生...
    承香墨影閱讀 1,092評論 0 13
  • Android市場的渠道分散已不是什么新鮮事,但如何高效打包仍是令許多開發(fā)者頭疼的問題闻察。本篇文章著重介紹了目前最新...
    _曾胖子閱讀 1,916評論 1 10
  • 目錄一拱礁、Python打包及優(yōu)化(美團(tuán)多渠道打包)二、Gradle打包三辕漂、其他打包方案:修改Zip文件的commen...
    守望君閱讀 5,675評論 4 17
  • [TOC] 打包流程 前言 我們每一個產(chǎn)品中一般都是由一位同事來負(fù)責(zé)打包工作的呢灶,其他同學(xué)一般是不需要關(guān)心具體的流程...
    鐘金寶閱讀 1,619評論 0 5