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 24
    buildToolsVersion "24.0.1"

    //默认配置,所有 productFlavors 都会继承 defaultConfig 中配置的属性
    defaultConfig {
        //默认的 applicationId,一般与 AndroidManifest.xml 文件 package属性相同
        applicationId "com.littlejie.multichannel"
        minSdkVersion 15
        targetSdkVersion 24
        versionCode 1
        versionName "1.0"
    }

    // productFlavors 定义了一个应用的自定义构建版本
    //一个单一的项目可以同时定义多个不同的 flavor 来改变应用的输出。
    // productFlavors 这个概念是为了解决不同的版本之间的差异非常小的情况,通常用于区分同一个应用的不同渠道/客户等,可包含少量业务功能差别。
    // productFlavors 中的 flavor 不能跟 buildType 中的一样,否则会报: "ProductFlavor names cannot collide with BuildType names"
    productFlavors {

        //默认版本,不设置 applicationId ,继承 defaultConfig 中的配置
        flavors_default {
        }

        //开发版本, applicationId 替换为 com.littlejie.multichannel.dev
        flavors_dev {
            applicationId "com.littlejie.multichannel.dev"
        }

        //发布版本, applicationId 替换为 com.littlejie.multichannel.release
        flavors_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即可,这种打包方式速度非常快,对一个30M大小的APK包只需要100多毫秒(包含文件复制时间)就能生成一个渠道包,而在运行时获取渠道信息只需要大约几毫秒的时间。

 

原理:

原理很简单,就是将渠道信息存放在APK文件的注释字段中。美团的打包方式非常快速,打渠道包几乎就只是进行一次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;
        }

        @Override
        public 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 code
        private 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.publicSourceDir
            final Field publicSourceDirField = applicationInfoClass.getField("publicSourceDir");
            String sourceDir = (String) publicSourceDirField.get(appInfo);
            if (sourceDir == null) {
                // try ApplicationInfo.sourceDir
                final 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 content
            writeBytes(data, raf);
            // write content length
            writeShort(data.length, raf);
            // write magic bytes
            writeBytes(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 bytes
                raf.seek(index);
                raf.readFully(buffer);
                // check magic bytes matched
                return 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 bytes
                raf.seek(index);
                raf.readFully(buffer);
                // if magic bytes matched
                if (isMagicMatched(buffer)) {
                    index -= SHORT_LENGTH;
                    raf.seek(index);
                    // read content length field
                    int length = readShort(raf);
                    if (length > 0) {
                        index -= length;
                        raf.seek(index);
                        // read content bytes
                        byte[] 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 bytes
                map.position(index);
                map.get(buffer);
                // if magic bytes matched
                if (isMagicMatched(buffer)) {
                    index -= SHORT_LENGTH;
                    map.position(index);
                    // read content length field
                    int length = map.getShort();
                    if (length > 0) {
                        index -= length;
                        map.position(index);
                        // read content bytes
                        byte[] 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文件解压缩和重新签名即可完成多渠道自动打包,高效速度快,无兼容性问题
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值