Android 手把手完美教你美团wally(瓦力)多渠道打包 3种方法(针对签名V1和V2都会有很详细的介绍)
?
?
APK的生成步驟:
大家從圖中可以看出,大體分為以下 7 個大步: 1、打包資源文件,生成 R.java 文件
2、處理 aidl 文件,生成相應 java 文件
3、編譯工程源代碼,生成相應 class 文件
4、轉換所有 class 文件,生成 classes.dex 文件
5、打包生成 apk
6、對 apk 文件進行簽名
7、對簽名的 apk 進行 zipalign 對其操作
說明:這里只是大致表明大致的打包編譯過程,其實如果細分每一部分還有很多細節。
結合原理和渠道的 1、2 兩種設置方法我們分別來說.
1、如果渠道信息是通過 Java 的硬編碼方式來做的,我們可以在打包之前預處理 Java 源文件,找到渠道設置關鍵字,從渠道列表中找到一個渠道設置進去即可。由于腳本這塊兒,不同的語言的實現方式不同,這里不做過多的說明。如果有需要的我可 以把自己之前 shell 寫的一段代碼分享了。
2、如果使用寫在 AndroidManifext.xml 中,這個就可以通過讀取 XML 文件的方式定位到 meta-data 并且 android:name 的值為 TDCHANNEL ID 的元節點。把這個元節點的值設置成某一個渠道即可。這里推薦大家看一下友盟開源的一個多渠道打包工具中有相關的實現細 [1]。這個過程發生在上圖中的 aapt 階段,這個階段做的事情還比較多沒有分來來說。
大致原理就是這樣,其實也很簡單~,就是在正常的 Android 打包編譯過程中進行干涉,加入一些邏輯來替換相關的渠道信息,保證打包之后的 APK 中的渠道信息各不相同。 其實這個過程是這樣的:
?
02.友盟的多渠道打包
說明:
1.什么是多渠道包?
渠道包就是要在安裝包中添加渠道信息,也就是channel,對應不同的渠道,例如:小米市場、360市場、應用寶市場等
2.為什么要提供多渠道包?
我們要在安裝包中添加不同的標識,應用在請求網絡的時候攜帶渠道信息,方便后臺做運營統計(這就是添加渠道信息的用處)。
3.實現多渠道打包的原理:
核心原理就是通過腳本修改androidManifest.xml中的mate-date內容,執行N次打包簽名操作實現多渠道打包的需求。productFlavors?
?
一般來講,這個渠道的標識會放在AndroidManifest.xml的Application的一個Metadata中。然后就可以在java中通過API獲取對應的數據了。
?
- 原理:清單文件添加渠道標簽讀取對應值。
- 打包后修改渠道值的兩種方法
- 第一種方法:
通過ApkTool進行解包,然后修改AndroidManifest中修改渠道標示,最后再通過ApkTool進行打包、簽名。 - 第二種方法:
使用AXML解析器axmleditor.jar,擁有很弱的編輯功能,工程中用來編輯二進制格式的 AndroidManifest.xml 文件.
?
最近不斷有朋友向我咨詢AndroidStudio多渠道的打包方法,今天整理一下之前積累的打包套路,寫一篇文章,手把手的教給大家。
? ? ? ?說到多渠道,這里不得不提一下友盟統計,友盟統計是大家日常開發中常用的渠道統計工具,而我們的打包方法就是基于友盟統計實施的。按照友盟官方文檔說明,渠道信息通常需要在AndroidManifest.xml中配置如下值:
?
<meta-data android:value="Channel ID" android:name="UMENG_CHANNEL"/>? ? ? ?上面的value值Channel_ID就是渠道標識。我們的期望的就是在編譯時候這個值能夠自動變化以滿足區分多渠道的需求。
友盟多渠道打包
(一)在AndroidManifest.xml里設置動態渠道變量
?
<meta-data ? ?android:name="UMENG_CHANNEL" ? ?android:value="${UMENG_CHANNEL_VALUE}" />(二)在build.gradle設置productFlavors
?
這里假定我們需要打包的渠道為酷安市場、360、小米、百度、豌豆莢 android { ? ? ?productFlavors { ? ? ? ?kuan { ? ? ? ? ? ?manifestPlaceholders = [UMENG_CHANNEL_VALUE: "kuan"] ? ? ? } ? ? ? ?xiaomi { ? ? ? ? ? ?manifestPlaceholders = [UMENG_CHANNEL_VALUE: "xiaomi"] ? ? ? } ? ? ? ?qh360 { ? ? ? ? ? ?manifestPlaceholders = [UMENG_CHANNEL_VALUE: "qh360"] ? ? ? } ? ? ? ?baidu { ? ? ? ? ? ?manifestPlaceholders = [UMENG_CHANNEL_VALUE: "baidu"] ? ? ? } ? ? ? ?wandoujia { ? ? ? ? ? ?manifestPlaceholders = [UMENG_CHANNEL_VALUE: "wandoujia"] ? ? ? } ? } ? }或者批量修改
?
android { ? ? ?productFlavors { ? ? ? ?kuan {} ? ? ? ?xiaomi {} ? ? ? ?qh360 {} ? ? ? ?baidu {} ? ? ? ?wandoujia {} ? } ? ? ?productFlavors.all { ? ? ? ?flavor -> flavor.manifestPlaceholders = [UMENG_CHANNEL_VALUE: name] ? } }? ? ? ?所謂ProductFlavors其實就是可定義的產品特性,配合 manifest merger 使用的時候就可以達成在一次編譯過程中產生多個具有自己特性配置的版本。上面這個配置的作用就是,為每個渠道包產生不同的 UMENG_CHANNEL_VALUE 的值。
?
如何實現多個 Apk 安裝在同一設備
在之前的印象中,同一個應用在同一設備上只能安裝一個,除非手動修改 AndroidManifest.xml 文件中的包名( package ),但這么做的后果就是新的應用真的是新的應用,舊版應用再也收不到更新。而現在你通過 Gradle,你可以輕松構建多個不同版本的應用,并且在同一設備上安裝使用。
這里要用到 productFlavors?,productFlavors 可以用來自定義應用構建版本,我們可以用其 applicationId 屬性來實現多個 Apk 安裝在同一設備上。
build.gradle 中部分配置代碼如下:
android {compileSdkVersion 24buildToolsVersion "24.0.1"//默認配置,所有 productFlavors 都會繼承 defaultConfig 中配置的屬性defaultConfig {//默認的 applicationId,一般與 AndroidManifest.xml 文件 package屬性相同applicationId "com.littlejie.multichannel"minSdkVersion 15targetSdkVersion 24versionCode 1versionName "1.0"}// productFlavors 定義了一個應用的自定義構建版本//一個單一的項目可以同時定義多個不同的 flavor 來改變應用的輸出。// productFlavors 這個概念是為了解決不同的版本之間的差異非常小的情況,通常用于區分同一個應用的不同渠道/客戶等,可包含少量業務功能差別。// productFlavors 中的 flavor 不能跟 buildType 中的一樣,否則會報: "ProductFlavor names cannot collide with BuildType names"productFlavors {//默認版本,不設置 applicationId ,繼承 defaultConfig 中的配置flavors_default {}//開發版本, applicationId 替換為 com.littlejie.multichannel.devflavors_dev {applicationId "com.littlejie.multichannel.dev"}//發布版本, applicationId 替換為 com.littlejie.multichannel.releaseflavors_release {applicationId "com.littlejie.multichannel.release"}} }來源:?https://www.cnblogs.com/travellife/p/Gradle-shi-xian-Android-duo-qu-dao-ding-zhi-hua-da.html
?
6、缺點:?
這樣的打包方式效率比較低下,如果是幾十個包還可以應付,打一個包快的話需要十幾秒,慢的話需要幾分鐘不等,跟機器性能很有關系。
?
這種方式缺點非常明顯,大部分情況下打渠道包只是為了修改一個渠道號,這種方式完全是殺雞用牛刀,非常耗費時間。而且很多時候是要混淆代碼的,如果多次打包就會生成多個mapping文件,在進行錯誤信息的查看時,不同渠道使用不同的mapping文件,想想就酸爽。
、美團多渠道打包
整個APK(ZIP文件格式)會被分為以下四個區塊:
- Contents of ZIP entries(from offset 0 until the start of APK Signing Block)
- APK Signing Block
- ZIP Central Directory
-
ZIP End of Central Directory
?
apk-sections.png
這個是V2簽名包的APK包格式,新的應用簽名方案有著良好的向后兼容性,能完全兼容低于Android 7.0(Nougat)的版本。對比舊簽名方案,它有更快的驗證速度和更安全的保護。
區塊1、3、4都是受保護區塊,不允許修改保護區塊。美團打包的方式,是在2區塊內寫入ID-value的擴展信息(渠道信息),并保存到APK中。這樣,每打一個渠道包只需復制一個APK,然后在APK中添加一個ID-value即可,這種打包方式速度非???#xff0c;對一個30M大小的APK包只需要100多毫秒(包含文件復制時間)就能生成一個渠道包,而在運行時獲取渠道信息只需要大約幾毫秒的時間。
?
原理:
原理很簡單,就是將渠道信息存放在APK文件的注釋字段中。美團的打包方式非??焖?#xff0c;打渠道包幾乎就只是進行一次copy apk文件。
?
把一個Android應用包當作zip文件包進行解壓,然后發現在簽名生成的目錄下(META-INF)添加一個空文件不需要重新簽名。利用這個機制,該文件的文件名就是渠道名。這種方式不需要重新簽名等步驟,非常高效,但是貌似在Android7.0之后,Google為了增強簽名的安全性,采用了新的簽名規則,不是針對每個文件來進行數字編碼,而是對zip包文件結構編碼簽名后產生一個唯一的數據叫做apk signing block。如果修改了zip文件的任何模塊的內容,APK Signing Block都會發生改變,從而無法再繞過簽名機制。
- 1、直接將apk文件解壓縮,然后在META-INF中間中添加以渠道名命名的空文件。
- 2、代碼中讀取該文件名作為渠道名。
- 該種方式不需要重新對apk進行簽名,操作簡單,也可將添加渠道文件的步驟做成Python腳本。實現自動化添加
- 優缺點:需要將apk解壓縮,如果APK包較大則解壓縮所需時間較長。
- 使用遇到的問題:使用愛加密加密過后,再按此種方式進行渠道包的添加,會導致在7.0及以上手機上無法安裝
- 解決辦法:先分渠道打多個包,然后提交愛加密進行加密。
?
在打包之前我們需要了解一下Android studio 在打包簽名過程中V1簽名和V2簽名的一個區別
這里可以看到:v1簽名是對jar進行簽名,V2簽名是對整個apk簽名:官方介紹就是:v2簽名是在整個APK文件的二進制內容上計算和驗證的,v1是在歸檔文件中解壓縮文件內容。
二者簽名所產生的結果:?
v1:在v1中只對未壓縮的文件內容進行了驗證,所以在APK簽名之后可以進行很多修改——文件可以移動,甚至可以重新壓縮。即可以對簽名后的文件在進行處理?
v2:v2簽名驗證了歸檔中的所有字節,而不是單獨的ZIP條目,如果您在構建過程中有任何定制任務,包括篡改或處理APK文件,請確保禁用它們,否則您可能會使v2簽名失效,從而使您的APKs與Android 7.0和以上版本不兼容。
?
google官方最后也說了:一個APK可以同時由v1和v2簽名同時簽署,所以它仍然可以向后兼容以前的Android版本
?
這里開發經驗表示 :?
一定可行的方案: 只使用 v1 方案?
不一定可行的方案:同時使用 v1 和 v2 方案?
對 7.0 以下一定不行的方案:只使用 v2 方案
?
1, 如果要支持 Android 7.0 以下版本,那么盡量同時選擇兩種簽?
名方式,但是一旦遇到簽名問題,可以只使用 v1 簽名方案?
2,如果需要對簽名后的信息做處理修改,那就使用v1簽名方案?
3,如果最后遇到各種不同的問題,可以不勾選v1和v2,直接打包簽名。
方法:
首先你需要去下載相關的工具:?
詳細步驟:
方法一:Python開發環境的安裝,美團打包工具(地址1或地址2任選其一既可)
1、將要打包的apk放到PythonTool中?
2、在PythonTool/info/channel.txt中寫入需要的渠道,一個渠道占一行?
3、雙擊執行PythonTool/MultiChannelBuildTool.py文件(需要Python環境),就會生成渠道包?
4、獲取渠道信息:將JavaUtil文件中的ChannelUtil.java拷貝到工程,調用ChannelUtil.getChannel即可獲取渠道.
?
步驟:
1.第一步 :配置build.gradle
在位于項目的根目錄 build.gradle 文件中添加Walle Gradle插件的依賴, 如下:
第二步:創建channel,如下?txt文件
第三部:接下來就是打渠道包了。在這里我們用Android studio 的Terminal來進行打包:(Terminal 下 運行一句命令:gradlew clean assembleReleaseChannels)
?
方法二:
第一步:直接將PackerNg作為Utils拷貝到項目中。
package com.yshr.util;import java.io.BufferedReader; import java.io.DataInput; import java.io.DataOutput; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.FileReader; import java.io.IOException; import java.io.RandomAccessFile; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.MappedByteBuffer; import java.nio.channels.FileChannel; import java.nio.channels.FileChannel.MapMode; import java.util.ArrayList; import java.util.Arrays; import java.util.List;public final class PackerNg {private static final String TAG = PackerNg.class.getSimpleName();private static final String EMPTY_STRING = "";private static String sCachedMarket;public static String getMarket(final Object context) {return getMarket(context, EMPTY_STRING);}public static synchronized String getMarket(final Object context, final String defaultValue) {if (sCachedMarket == null) {sCachedMarket = getMarketInternal(context, defaultValue).market;}return sCachedMarket;}public static MarketInfo getMarketInfo(final Object context) {return getMarketInfo(context, EMPTY_STRING);}public static synchronized MarketInfo getMarketInfo(final Object context, final String defaultValue) {return getMarketInternal(context, defaultValue);}private static MarketInfo getMarketInternal(final Object context, final String defaultValue) {String market;Exception error;try {final String sourceDir = Helper.getSourceDir(context);market = Helper.readMarket(new File(sourceDir));error = null;} catch (Exception e) {market = null;error = e;}return new MarketInfo(market == null ? defaultValue : market, error);}public static class MarketInfo {public final String market;public final Exception error;public MarketInfo(final String market, final Exception error) {this.market = market;this.error = error;}@Overridepublic String toString() {return "MarketInfo{" +"market='" + market + '\'' +", error=" + error +'}';}}public static class Helper {static final String UTF_8 = "UTF-8";static final int ZIP_COMMENT_MAX_LENGTH = 65535;static final int SHORT_LENGTH = 2;static final byte[] MAGIC = new byte[]{0x21, 0x5a, 0x58, 0x4b, 0x21}; //!ZXK!// for android codeprivate static String getSourceDir(final Object context)throws ClassNotFoundException,InvocationTargetException,IllegalAccessException,NoSuchFieldException,NoSuchMethodException {final Class<?> contextClass = Class.forName("android.content.Context");final Class<?> applicationInfoClass = Class.forName("android.content.pm.ApplicationInfo");final Method getApplicationInfoMethod = contextClass.getMethod("getApplicationInfo");final Object appInfo = getApplicationInfoMethod.invoke(context);// try ApplicationInfo.publicSourceDirfinal Field publicSourceDirField = applicationInfoClass.getField("publicSourceDir");String sourceDir = (String) publicSourceDirField.get(appInfo);if (sourceDir == null) {// try ApplicationInfo.sourceDirfinal Field sourceDirField = applicationInfoClass.getField("sourceDir");sourceDir = (String) sourceDirField.get(appInfo);}if (sourceDir == null) {// try Context.getPackageCodePath()final Method getPackageCodePathMethod = contextClass.getMethod("getPackageCodePath");sourceDir = (String) getPackageCodePathMethod.invoke(context);}return sourceDir;}private static boolean isMagicMatched(byte[] buffer) {if (buffer.length != MAGIC.length) {return false;}for (int i = 0; i < MAGIC.length; ++i) {if (buffer[i] != MAGIC[i]) {return false;}}return true;}private static void writeBytes(byte[] data, DataOutput out) throws IOException {out.write(data);}private static void writeShort(int i, DataOutput out) throws IOException {ByteBuffer bb = ByteBuffer.allocate(SHORT_LENGTH).order(ByteOrder.LITTLE_ENDIAN);bb.putShort((short) i);out.write(bb.array());}private static short readShort(DataInput input) throws IOException {byte[] buf = new byte[SHORT_LENGTH];input.readFully(buf);ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);return bb.getShort(0);}public static void writeZipComment(File file, String comment) throws IOException {if (hasZipCommentMagic(file)) {throw new IllegalStateException("zip comment already exists, ignore.");}// {@see java.util.zip.ZipOutputStream.writeEND}byte[] data = comment.getBytes(UTF_8);final RandomAccessFile raf = new RandomAccessFile(file, "rw");raf.seek(file.length() - SHORT_LENGTH);// write zip comment length// (content field length + length field length + magic field length)writeShort(data.length + SHORT_LENGTH + MAGIC.length, raf);// write contentwriteBytes(data, raf);// write content lengthwriteShort(data.length, raf);// write magic byteswriteBytes(MAGIC, raf);raf.close();}public static boolean hasZipCommentMagic(File file) throws IOException {RandomAccessFile raf = null;try {raf = new RandomAccessFile(file, "r");long index = raf.length();byte[] buffer = new byte[MAGIC.length];index -= MAGIC.length;// read magic bytesraf.seek(index);raf.readFully(buffer);// check magic bytes matchedreturn isMagicMatched(buffer);} finally {if (raf != null) {raf.close();}}}public static String readZipComment(File file) throws IOException {RandomAccessFile raf = null;try {raf = new RandomAccessFile(file, "r");long index = raf.length();byte[] buffer = new byte[MAGIC.length];index -= MAGIC.length;// read magic bytesraf.seek(index);raf.readFully(buffer);// if magic bytes matchedif (isMagicMatched(buffer)) {index -= SHORT_LENGTH;raf.seek(index);// read content length fieldint length = readShort(raf);if (length > 0) {index -= length;raf.seek(index);// read content bytesbyte[] bytesComment = new byte[length];raf.readFully(bytesComment);return new String(bytesComment, UTF_8);} else {throw new IOException("zip comment content not found");}} else {throw new IOException("zip comment magic bytes not found");}} finally {if (raf != null) {raf.close();}}}private static String readZipCommentMmp(File file) throws IOException {final int mappedSize = 10240;final long fz = file.length();RandomAccessFile raf = null;MappedByteBuffer map = null;try {raf = new RandomAccessFile(file, "r");map = raf.getChannel().map(MapMode.READ_ONLY, fz - mappedSize, mappedSize);map.order(ByteOrder.LITTLE_ENDIAN);int index = mappedSize;byte[] buffer = new byte[MAGIC.length];index -= MAGIC.length;// read magic bytesmap.position(index);map.get(buffer);// if magic bytes matchedif (isMagicMatched(buffer)) {index -= SHORT_LENGTH;map.position(index);// read content length fieldint length = map.getShort();if (length > 0) {index -= length;map.position(index);// read content bytesbyte[] bytesComment = new byte[length];map.get(bytesComment);return new String(bytesComment, UTF_8);}}} finally {if (map != null) {map.clear();}if (raf != null) {raf.close();}}return null;}public static void writeMarket(final File file, final String market) throws IOException {writeZipComment(file, market);}public static String readMarket(final File file) throws IOException {return readZipComment(file);}public static boolean verifyMarket(final File file, final String market) throws IOException {return market.equals(readMarket(file));}public static void println(String msg) {System.out.println(TAG + ": " + msg);}public static List<String> parseMarkets(final File file) throws IOException {final List<String> markets = new ArrayList<String>();FileReader fr = new FileReader(file);BufferedReader br = new BufferedReader(fr);String line = null;int lineNo = 1;while ((line = br.readLine()) != null) {String parts[] = line.split("#");if (parts.length > 0) {final String market = parts[0].trim();if (market.length() > 0) {markets.add(market);} else {println("skip invalid market line " + lineNo + ":'" + line + "'");}} else {println("skip invalid market line" + lineNo + ":'" + line + "'");}++lineNo;}br.close();fr.close();return markets;} }第二步:創建一個保存渠道包名的txt文件,可以放在項目主目錄下:比如命名market.txt
渠道名可以按照需求隨便添加
anzhi
baidu
huawei
legend
letv
meizu
oppo
qq
PC
sougou
UC
update
update1
vivo
wandoujia
woshangdian
xiaomi
第三步:ChannelUtil這個工具類是用于取出文件里的渠道名
package com.yshr.util;import android.content.Context; import android.text.TextUtils;import com.ztx.shudu.supermarket.app.App; import com.ztx.shudu.supermarket.app.Constants; import com.ztx.shudu.supermarket.model.prefs.ImplPreferencesHelper;public class ChannelUtil {private static String mChannel;/*** 返回市場。 如果獲取失敗返回""** @param context* @return*/public static String getChannel(Context context) {return getChannel(context, "default"); // return getChannel(context, "sjzs360");}/*** 返回市場。 如果獲取失敗返回defaultChannel** @param context* @param defaultChannel* @return*/public static String getChannel(Context context, String defaultChannel) {//內存中獲取if (!TextUtils.isEmpty(mChannel)) {return mChannel;}//sp中獲取mChannel = getChannelBySharedPreferences(context);if (!TextUtils.isEmpty(mChannel)) {return mChannel;}mChannel = PackerNg.getMarket(context);if (!TextUtils.isEmpty(mChannel)) {//保存sp中備用saveChannelBySharedPreferences(context, mChannel);return mChannel;}//全部獲取失敗return defaultChannel;}/*** 本地保存channel & 對應版本號** @param context* @param channel*/private static void saveChannelBySharedPreferences(Context context, String channel) { // SharedPreferencesUtil.getInstance(context).applyString(Constants.Companion.getSUPERMARKET_CHANNEL(), channel);App.instance.getSharedPreferences(ImplPreferencesHelper.Companion.getSHAREDPREFERENCES_NAME(), Context.MODE_PRIVATE).edit().putString(Constants.Companion.getSUPERMARKET_CHANNEL(), "").apply();}}優缺點:
優點:?
這種打包方式速度非常快,900多個渠道不到一分鐘就能打完
缺點:?
1、google現在已經修改了新的簽名規則,若使用新的簽名規則則無法使用(老的無所謂)。?
2.加固的apk會出現什么問題。
3、一些不法的渠道商很容易通過工具修改渠道,如果一個渠道商,通過網絡劫持和篡改渠道的組合方式來獲取暴利,對于程序開發者來說可能會存在著巨大的經濟損失
?
360多渠道打包
- 利用的是Zip文件“可以添加comment(摘要)”的數據結構特點,在文件的末尾寫入任意數據,而不用重新解壓zip文件
- apk文件就是zip文件格式;
- 注釋的讀取即渠道標識的讀取
- 不需要對apk文件解壓縮和重新簽名即可完成多渠道自動打包,高效速度快,無兼容性問題
總結
以上是生活随笔為你收集整理的Android 手把手完美教你美团wally(瓦力)多渠道打包 3种方法(针对签名V1和V2都会有很详细的介绍)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 百度人脸识别sdk-转
- 下一篇: domoticz 链接 百度云物联网MQ