全民 K 歌增量升级方案

时间:2022-04-25
本文章向大家介绍全民 K 歌增量升级方案,主要内容包括一、背景、二、实现原理、2、客户端:、三、实现步骤、2、解决多渠道问题、3、合成新安装包、三、小结、四、参考资料、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

  本文主要介绍一种增量升级方案。用户在升级版本时,不需要下载完整的安装包,只需下载增加的部分即可体验新版本完整功能,即节约用户流量,也减少服务器流量,并解决了多渠道问题,值得尝试。

一、背景

  随着全民K歌版本不断迭代,安装包大小也不断增大,现在每次版本更新,用户都需要下载最新版本安装包,如果使用增量更新的方式,用户每次更新只下载新版本和旧版本差异的部分,将会为用户和服务器节约大量流量。以全民K歌3.2和3.3版本为例: | 文件名 | 文件大小 | |———- | ———- | | karaoke_3.2.apk | 30.4M | | karaoke_3.3.apk | 27.6M | | 3.2_3.3.patch | 7.3M |

  3.2_3.3.patch文件是3.2和3.3版本的差异部分,大小为7.3M,如果用户使用增量升级方案,相对于下载完整的3.3版本27.6M,用户将节约20.3M。下面我将介绍如何使用用户本地已安装的版本karaoke_3.2.apk + 差异包3.2_3.3.patch生成最新版本karaoke_3.3.apk。

二、实现原理

1、服务器端:

2、客户端:

  增量更新的原理是将旧版本的apk和新版本的apk进行二进制对比,得到差异包,用户升级更新时,根据本地版本从服务器下载需要的差分包,使用本地版本+差分包生成新版apk。而差异包需要提前由服务器生成,用户在升级时,服务器根据用户当前版本下发差异包。列如:用户从全民K歌3.2版本升级到3.3版本,需要从服务器下载差异包(3.2_3.3.patch),再使用用户正在使用的全民K歌3.2版本apk(karaoke_3.2.apk),即可生成全民K歌3.3版本(karaoke_3.3.apk)。

三、实现步骤

1、生成差异包

  apk文件的差分和合并都是使用的开源的二进制比较工具 bsdiff 实现。下载的bsdiff-4.3版本中有几个文件,其中bsdiff.c用于生成差异包的源码,bspatch.c用于合成apk的源码,makefile是生成可执行文件的脚本。亲测在linux系统中,执行makefile文件,可生成一个bsdiff工具,使用该工具即可生成差异包。   在服务器端使用bsdiff工具生成差异包。其中karaoke_3.2.apk和karaoke_3.3.apk是我们的老版本和新版本安装包(都未写入渠道号)。在命令行执行./bsdiff karaoke_3.2.apk karaoke_3.3.apk 3.2_3.3.patch 命令即可生成差异包3.2_3.3.patch。

2、解决多渠道问题

(1)多渠道说明

  多渠道是指根据不同的市场打不同的安装包包,比如应用宝,安卓市场,百度市场,Google市场,360市场等等。分渠道打包目的是为了针对不同市场做出不同的一些统计,数据分析,收集用户信息。多渠道的实现通常是在生成安装包的时候,把渠道号写入安装包的渠道文件中,用户在使用app时,读取安装包的渠道文件内容,并上传服务器。例如应用宝渠道,则在安装包中有一个qua.ini文件,里面内容是YYB_D,用户在使用APP时,读取qua.ini文件内容,把YYB_D上传服务器。   由实现原理可以看出,服务器端需要两个安装包对比,然后才能生成差异包。在生成安装包的时候,不同渠道的安装包内容是不一样的(文件md5值不一样),不同渠道的新老版本生成的差异包也不一样。解决这个问题比较粗暴的方案是:每个渠道都生成一个差异包,客户端合成的时候,根据用户使用的渠道安装包下载对应渠道的差异包,再合成对应渠道最新的安装包。这时如果有50个渠道,就需要50个差异包,这个方案实现复杂,不利于差异包的维护。   这里微信团队提出了另一种实现方案:把渠道号写入安装包的注释字段。该方案不会破坏安装包,经验证,android手机可以正常安装使用。Android apk安装包是zip格式文件,在zip文件的最后有一个记录说明。格式如下:

  从表中可以看出,在文件的末尾有两个字段:Comment length和Comment,分别表示注释长度(2个字节)和注释内容(N个字节)。apk安装包打包完成后, Comment length默认为0,comment为空,我们可以把入渠道号写入comment字段。app启动后,读取Comment内容即可获取渠道号。

(2)安装包未写入渠道号时:

  从图中可以看出,Comment length=0,说明这个安装包未写入任何注释。在使用gradle编译打包生成的apk默认是没有写入任何注释信息的。

(3)安装包写入应用宝(YYB_D)渠道号时:

  从图中可以看出,Comment length=12,说明这个安装包的注释长度为12个字节。(为了方便定位渠道号,除了渠道号,在文件末尾多写了7个字节内容)12 = 5 + 2 + 5 { 5个字节渠道号(YYB_D)+ 2个字节的MAGIC字符长度说明 + 5个字节的MAGIC(!ZXK!)}。

(4)写渠道号关键代码:

// ZIP文件注释长度字段和MAGIC的字节数
static final int SHORT_LENGTH = 2;
//注释字符编码
static final String UTF_8 = "UTF-8";
// 文件最后用于定位的MAGIC字节
static final byte[] MAGIC = new byte[]{0x21, 0x5a, 0x58, 0x4b, 0x21}; //!ZXK!
//写入渠道号
public static void writeQUA(File file, String comment) throws IOException {
    byte[] data = comment.getBytes(UTF_8);
    final RandomAccessFile raf = new RandomAccessFile(file, "rw");
    //定位到文件有效内容的末尾(文件长度-注释长度)
    raf.seek(file.length() - SHORT_LENGTH);
    //写入注释字节数{注释字节数+2(MAGIC长度说明)+MAGIC长度}
    writeShort(data.length + SHORT_LENGTH + MAGIC.length, raf);
    //写入注释内容
    writeBytes(data, raf);
    //写入MAGIC字节数
    writeShort(data.length, raf);
    //写入MAGIC
    writeBytes(MAGIC, raf);
    raf.close();
}

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());
}

  在安装包Comment字段写入渠道号的方式,经过测试,并没有修改安装包的内容,用户能成功安装并且使用。

(5)读渠道号关键代码:

//读取源apk的路径
public static String getSourceApkPath(Context context, String packageName) {
    if (TextUtils.isEmpty(packageName))
        return null;
    try {
        ApplicationInfo appInfo = context.getPackageManager().getApplicationInfo(packageName, 0);
            return appInfo.sourceDir;
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        }
    return null;
}
//读取渠道号
public static String readQUA(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;
        //定位到MAGIC处
        raf.seek(index);
        //读取MAGIC
        raf.readFully(buffer);
        //判断文件末尾是否存在MAGIC字符
        if (isMagicMatched(buffer)) {
            index -= SHORT_LENGTH;
            raf.seek(index);
            //读取渠道号长度
            int length = readShort(raf);
            if (length > 0) {
                index -= length;
                raf.seek(index);
                //读取渠道号
                byte[] bytesComment = new byte[length];
                raf.readFully(bytesComment);
                return new String(bytesComment, UTF_8);
            }
        }
    } finally {
        if (raf != null) {
            raf.close();
        }
    }
    return null;
}
//判断是否存在渠道号
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;
}

读取渠道号有两个步骤:   1、获取安装包的绝对路径。Android系统在用户安装app时,会把用户安装的apk拷贝一份到/data/apk/路径下,通过getSourceApkPath 可以获取该apk的绝对路径。   2、读取渠道号。先定位到文件末尾,判断该文件是否存在写入的MAGIC字符,如果存在再读取渠道号。 这时,我们多渠道的问题也解决了。我们把代码渠道号写入apk的comment字段,也通过代码成功读取到了渠道号。

3、合成新安装包

(1)删除原APK的渠道号

  由于我们在生成差异包的时候,两个新旧版本的安装包都是没有渠道号的,而用户在应用市场下载的安装包是我们写入渠道号的安装包,所以我们要把用户正在使用的版本删除渠道号。由于/data/apk/路径,我们只有读取的权限,所以需要把删除渠道号的安装包临时保存起来。   删除渠道号的关键代码:

//删除渠道号
public static int deleteQua(File src, File dest) throws IOException{
    if(!src.exists()){
        return DELETE_QUA_FAILE_SOURCE_FILE_NOT_EXIST;
    }
    long contenLength = getContentLength(src);
    if(contenLength < 0){
        return DELETE_QUA_FAILE_QUA_NOT_EXIST;
    }
    FileInputStream in = new FileInputStream(src.getAbsolutePath());
    File file = dest;
    if(!file.exists())
        file.createNewFile();
    FileOutputStream out = new FileOutputStream(file);
    int c;
    long copyed = contenLength;
    byte buffer[] = new byte[1024];
    while ((c = in.read(buffer)) != -1) {
        if(copyed != c && c == buffer.length){
            copyed = copyed - c;
            out.write(buffer, 0, c);
        }else {
            //还原源文件,需要把最后两个字节置为0  表示apk没有注释
            buffer[(int) (copyed - 1)] = 0;
            buffer[(int) (copyed - 2)] = 0;
            out.write(buffer, 0, (int)copyed);
        }
    }
    close(in);
    close(out);
    return SUCCESS;
}

(2)合成新APK

  通过上面步骤,我们可以得到没有渠道号的临时本地安装包,并且和服务器的原始包一致,我们可以使用这个没有渠道号的安装包和已下载的差异包合成新版安装包。由于用户使用的版本可能是破解版,或者下载差异包下载不完整,所以在合成的前需要做文件一致性检验。可以比较文件的md5值,如果MD5值一致,才能进行合成,否则合成失败,直接下载完整的安装包升级。流程如下图:

  我们需要把bsdiff中的bspatch.c整合到我们C代码中,并将其编译生so供Android手机使用,其中bspatch依赖bzip2,需要自己下载依赖的c文件。 C关键代码:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <err.h>
#include <unistd.h>
#include <fcntl.h>
#include <android/log.h>
#include <jni.h>

#include "bzip2/bzlib.c"
#include "bzip2/crctable.c"
#include "bzip2/compress.c"
#include "bzip2/decompress.c"
#include "bzip2/randtable.c"
#include "bzip2/blocksort.c"
#include "bzip2/huffman.c"

#include "com_tencent_smartpatch_utils_PatchUtils.h"

static off_t offtin(u_char *buf) {
    off_t y;

    y = buf[7] & 0x7F;
    y = y * 256;
    y += buf[6];
    y = y * 256;
    y += buf[5];
    y = y * 256;
    y += buf[4];
    y = y * 256;
    y += buf[3];
    y = y * 256;
    y += buf[2];
    y = y * 256;
    y += buf[1];
    y = y * 256;
    y += buf[0];

    if (buf[7] & 0x80)
        y = -y;

    return y;
}

int applypatch(int argc, char * argv[]) {
    FILE * f, *cpf, *dpf, *epf;
    BZFILE * cpfbz2, *dpfbz2, *epfbz2;
    int cbz2err, dbz2err, ebz2err;
    int fd;
    ssize_t oldsize, newsize;
    ssize_t bzctrllen, bzdatalen;
    u_char header[32], buf[8];
    u_char *old, *new;
    off_t oldpos, newpos;
    off_t ctrl[3];
    off_t lenread;
    off_t i;

    if (argc != 4)
        errx(1, "usage: %s oldfile newfile patchfilen", argv[0]);

    /* Open patch file */
    if ((f = fopen(argv[3], "r")) == NULL)
        err(1, "fopen(%s)", argv[3]);

    /*
     File format:
     0    8    "BSDIFF40"
     8    8    X
     16    8    Y
     24    8    sizeof(newfile)
     32    X    bzip2(control block)
     32+X    Y    bzip2(diff block)
     32+X+Y    ???    bzip2(extra block)
     with control block a set of triples (x,y,z) meaning "add x bytes
     from oldfile to x bytes from the diff block; copy y bytes from the
     extra block; seek forwards in oldfile by z bytes".
     */

    /* Read header */
    if (fread(header, 1, 32, f) < 32) {
        if (feof(f))
            errx(1, "Corrupt patchn");
        err(1, "fread(%s)", argv[3]);
    }

    /* Check for appropriate magic */
    if (memcmp(header, "BSDIFF40", 8) != 0)
        errx(1, "Corrupt patchn");

    /* Read lengths from header */
    bzctrllen = offtin(header + 8);
    bzdatalen = offtin(header + 16);
    newsize = offtin(header + 24);
    if ((bzctrllen < 0) || (bzdatalen < 0) || (newsize < 0))
        errx(1, "Corrupt patchn");

    /* Close patch file and re-open it via libbzip2 at the right places */
    if (fclose(f))
        err(1, "fclose(%s)", argv[3]);
    if ((cpf = fopen(argv[3], "r")) == NULL)
        err(1, "fopen(%s)", argv[3]);
    if (fseeko(cpf, 32, SEEK_SET))
        err(1, "fseeko(%s, %lld)", argv[3], (long long) 32);
    if ((cpfbz2 = BZ2_bzReadOpen(&cbz2err, cpf, 0, 0, NULL, 0)) == NULL)
        errx(1, "BZ2_bzReadOpen, bz2err = %d", cbz2err);
    if ((dpf = fopen(argv[3], "r")) == NULL)
        err(1, "fopen(%s)", argv[3]);
    if (fseeko(dpf, 32 + bzctrllen, SEEK_SET))
        err(1, "fseeko(%s, %lld)", argv[3], (long long) (32 + bzctrllen));
    if ((dpfbz2 = BZ2_bzReadOpen(&dbz2err, dpf, 0, 0, NULL, 0)) == NULL)
        errx(1, "BZ2_bzReadOpen, bz2err = %d", dbz2err);
    if ((epf = fopen(argv[3], "r")) == NULL)
        err(1, "fopen(%s)", argv[3]);
    if (fseeko(epf, 32 + bzctrllen + bzdatalen, SEEK_SET))
        err(1, "fseeko(%s, %lld)", argv[3],
                (long long) (32 + bzctrllen + bzdatalen));
    if ((epfbz2 = BZ2_bzReadOpen(&ebz2err, epf, 0, 0, NULL, 0)) == NULL)
        errx(1, "BZ2_bzReadOpen, bz2err = %d", ebz2err);

    if (((fd = open(argv[1], O_RDONLY, 0)) < 0)
            || ((oldsize = lseek(fd, 0, SEEK_END)) == -1)
            || ((old = malloc(oldsize + 1)) == NULL)
            || (lseek(fd, 0, SEEK_SET) != 0)
            || (read(fd, old, oldsize) != oldsize) || (close(fd) == -1))
        err(1, "%s", argv[1]);
    if ((new = malloc(newsize + 1)) == NULL)
        err(1, NULL);

    oldpos = 0;
    newpos = 0;
    while (newpos < newsize) {
        /* Read control data */
        for (i = 0; i <= 2; i++) {
            lenread = BZ2_bzRead(&cbz2err, cpfbz2, buf, 8);
            if ((lenread < 8)
                    || ((cbz2err != BZ_OK) && (cbz2err != BZ_STREAM_END)))
                errx(1, "Corrupt patchn");
            ctrl[i] = offtin(buf);
        };

        /* Sanity-check */
        if (newpos + ctrl[0] > newsize)
            errx(1, "Corrupt patchn");

        /* Read diff string */
        lenread = BZ2_bzRead(&dbz2err, dpfbz2, new + newpos, ctrl[0]);
        if ((lenread < ctrl[0])
                || ((dbz2err != BZ_OK) && (dbz2err != BZ_STREAM_END)))
            errx(1, "Corrupt patchn");

        /* Add old data to diff string */
        for (i = 0; i < ctrl[0]; i++)
            if ((oldpos + i >= 0) && (oldpos + i < oldsize))
                new[newpos + i] += old[oldpos + i];

        /* Adjust pointers */
        newpos += ctrl[0];
        oldpos += ctrl[0];

        /* Sanity-check */
        if (newpos + ctrl[1] > newsize)
            errx(1, "Corrupt patchn");

        /* Read extra string */
        lenread = BZ2_bzRead(&ebz2err, epfbz2, new + newpos, ctrl[1]);
        if ((lenread < ctrl[1])
                || ((ebz2err != BZ_OK) && (ebz2err != BZ_STREAM_END)))
            errx(1, "Corrupt patchn");

        /* Adjust pointers */
        newpos += ctrl[1];
        oldpos += ctrl[2];
    };

    /* Clean up the bzip2 reads */
    BZ2_bzReadClose(&cbz2err, cpfbz2);
    BZ2_bzReadClose(&dbz2err, dpfbz2);
    BZ2_bzReadClose(&ebz2err, epfbz2);
    if (fclose(cpf) || fclose(dpf) || fclose(epf))
        err(1, "fclose(%s)", argv[3]);

    /* Write the new file */
    if (((fd = open(argv[2], O_CREAT | O_TRUNC | O_WRONLY, 0666)) < 0)
            || (write(fd, new, newsize) != newsize) || (close(fd) == -1))
        err(1, "%s", argv[2]);

    free(new);
    free(old);

    return 0;
}

JNIEXPORT jint Java_com_tencent_smartpatch_utils_PatchUtils_patch(JNIEnv *env,
        jclass obj, jstring old_apk, jstring new_apk, jstring patch) {

    char * ch[4];
    ch[0] = "bspatch";
    ch[1] = (char*) ((*env)->GetStringUTFChars(env, old_apk, 0));
    ch[2] = (char*) ((*env)->GetStringUTFChars(env, new_apk, 0));
    ch[3] = (char*) ((*env)->GetStringUTFChars(env, patch, 0));

    int ret = applypatch(4, ch);

    (*env)->ReleaseStringUTFChars(env, old_apk, ch[1]);
    (*env)->ReleaseStringUTFChars(env, new_apk, ch[2]);
    (*env)->ReleaseStringUTFChars(env, patch, ch[3]);

    return ret;
}

  java关键代码:

/**
 * apk 合成类
 */
public class PatchUtils {

    public static final String TAG = "PatchUtils";
    static {
        System.loadLibrary("apksmartpatchlibrary");
    }
    /**
     * native方法 使用路径为oldApkPath的apk与路径为patchPath的补丁包,合成新的apk,并存储于newApkPath
     * 
     * 返回:0,说明操作成功
     * 
     * @param oldApkPath 示例:/sdcard/old.apk
     * @param newApkPath 示例:/sdcard/new.apk
     * @param patchPath  示例:/sdcard/xx.patch
     * @return
     */
    public static native int patch(String oldApkPath, String newApkPath, String patchPath);
}

  至此,我们调用 PatchUtile.patch方法即可生成最新的安装包。

三、小结

  再重复一下完整过程:   1、编译打包APK(未写入渠道号)   2、服务器用新旧APK(未写入渠道号)生成差异包   3、APK写入渠道号,供用户下载使用   4、用户本地APK删除渠道号   5、根据用户使用版本下载差异包   6、用本地删除渠道号的APK+下载的差异包生成最新版本APK

四、参考资料

  1、http://www.daemonology.net/bsdiff   2、https://en.wikipedia.org/wiki/Zip_(file_format)   3、https://github.com/cundong/SmartAppUpdates   4、https://github.com/mcxiaoke/packer-ng-plugin