Fork me on GitHub

Android 增量更新(使用CMake编译)

前言

近期公司的 App 版本迭代比较快,经常发新版本,但实际上就我自己来说,手机里的 App 不到不能用的时候我是不会更新的,我们前期的解决方案就是强制更新,不更新不让用,以前我们用户少的时候还能这么干,用户量上去还这么干,基本等于劝退那些不怎么愿意更新的用户。所以近期研究的技术点是 热修复 与 增量更新。热修复可以不用因为更改 Bug 而专门发布新版本,增量更新可以让用户在不得不更新的时候可以少花一些流量。热更新最近的接入测试流程都过的差不多了,比我想象的要顺利一些,不过我接入的是 Tinker,操作起来还是挺麻烦的,同事去看 Sophix 了,Sophix 是无侵入的接入,感觉打包什么的操作应该会简单很多。回归正题,本文是参考了很多文章最后经过实践得出的,因为一些文章的时间比较久远,我在实践的时候也碰到了很多问题,查资料也不太好查,最后想了想还是用 CMake 去编译,最后跑通了整个流程。

简介

增量更新实际上就是使用工具比对新老 apk,得出一个补丁,用户的是老版本的 apk,在下载完增量包之后与自身 apk 合成一个新的apk,然后再次安装(是的,需要重新安装,所以不是热修复,做不到无感)。整个过程实际上我们都是使用的现成的轮子,客户端需要做的就是:

  • 获得用户当前应用的 apk
  • 获取增量包
  • 将老 apk 与增量包合并生成新 apk
  • 合并完成后安装

其中生成增量包与合并是难点,但是并不需要我们自己去实现,已经有工具替我们实现了。就是 bsdiff 和 bzip,当然我在看 鸿洋大神 的这篇文章的时候,bzip 下载已经凉了,那个页面我也实在没看懂在哪下,最后是在鸿洋大神的代码仓库里复制出来的。

生成增量包

首先去下载工具:

下载完之后解压,使用终端(我是 Mac)进入解压后的文件夹,执行:

1
$ make

make 命令会读入所有的 Makefile 并执行,但是在执行目录下的 Makefile 会报错:

1
Makefile:13: *** missing separator.  Stop.

我搜到的解决方法是在倒数第一行和第三行加 TAB,我在网上搜到的资料说没有缩进会识别成条件判断符,有缩进会认为他是一个普通的 shell 脚本,当然到底是为什么,我这里并没有深追下去了。

继续执行 make 命令,会生成一个 bsdiff 的文件,之后会报错,我们这里只需要这个,没有生成的那个是合并老版本与增量包的东西。我们这里并不需要,因为合成新包的步骤是在客户端完成的。这里生成 bsdiff 就暂时告一段落,之后我们会利用这个工具来生成增量包,接下来在项目中引入 bzip,用于合成新的 apk。

集成合并工具

首先新建一个项目,勾上支持 Kotlin 和 C++,之后都选默认的,创建好项目之后是这样的:

目录

之前说了 bzip 这玩意我没弄懂怎么下源码,于是去鸿洋大神的 demo 里 copy 了一下,当然,这里我会放上我的 demo 地址,毕竟你是在看我的文,用我的 demo 不过分。将之前下载 bsdiff 中的 bspatch.c 拷贝到项目的 cpp 文件夹中,再将整个 bzip 拷贝到 cpp 文件夹中,由于我们这里是使用 CMake 来编译,所以需要在自动生成的 CMakeLists.txt 中加入我们的配置,这里把我写的配置直接整个的放上来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html

# Sets the minimum version of CMake required to build the native library.

cmake_minimum_required(VERSION 3.4.1)

include_directories(src/main/cpp/bzip2/bzlib.h
src/main/cpp/bzip2/bzlib_private.h
)

# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.

add_library( # Sets the name of the library.
native-lib

# Sets the library as a shared library.
SHARED

# Provides a relative path to your source file(s).
src/main/cpp/native-lib.cpp
src/main/cpp/bspatch.c
src/main/cpp/bzip2/blocksort.c
src/main/cpp/bzip2/bzip2.c
src/main/cpp/bzip2/bzip2recover.c
src/main/cpp/bzip2/bzlib.c
src/main/cpp/bzip2/compress.c
src/main/cpp/bzip2/crctable.c
src/main/cpp/bzip2/decompress.c
src/main/cpp/bzip2/dlltest.c
src/main/cpp/bzip2/huffman.c
src/main/cpp/bzip2/mk251.c
src/main/cpp/bzip2/randtable.c
src/main/cpp/bzip2/spewG.c
src/main/cpp/bzip2/unzcrash.c)

# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.

find_library( # Sets the name of the path variable.
log-lib

# Specifies the name of the NDK library that
# you want CMake to locate.
log )

# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.

target_link_libraries( # Specifies the target library.
native-lib

# Links the target library to the log library
# included in the NDK.
${log-lib} )

这里需要在 bspatch.c 中加入我们的一些东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
JNIEXPORT jint
JNICALL Java_com_xiasuhuei321_incrementapk2_MainActivity_bspatch
(JNIEnv *env, jclass cls,
jstring old, jstring new, jstring patch) {
int argc = 4;
char *argv[argc];
argv[0] = "bspatch";
argv[1] = (char *) ((*env)->GetStringUTFChars(env, old, 0));
argv[2] = (char *) ((*env)->GetStringUTFChars(env, new, 0));
argv[3] = (char *) ((*env)->GetStringUTFChars(env, patch, 0));


int ret = patchMethod(argc, argv);

(*env)->ReleaseStringUTFChars(env, old, argv[1]);
(*env)->ReleaseStringUTFChars(env, new, argv[2]);
(*env)->ReleaseStringUTFChars(env, patch, argv[3]);
return ret;
}

这里的 Java_com_xiasuhuei321_incrementapk2_MainActivity_bspatch 是有一定命名规律的,用过 jni 的同学应该知道这玩意可以通过 javah -d 的命令来为带有 native 的 class 文件生成 .h 的头文件。这里不深究,总是就是我在这个包底下的 MainActivity 里写了一个 native(kotin 中是 extern)方法叫 bspatch,方法里拿到了传入的三个参数,调用了 bspatch 的 patchMethod,最后释放了字符串资源。这里的方法名一定要写对,不然运行的时候会报错,大意是没有人实现你的 native 方法。接下来看 MainActivity 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
package com.xiasuhuei321.incrementapk2

import android.Manifest
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.support.v4.content.FileProvider
import android.support.v7.app.AppCompatActivity
import com.tbruyelle.rxpermissions2.RxPermissions
import kotlinx.android.synthetic.main.activity_main.*
import java.io.File

class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)


RxPermissions(this).request(Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE)
.subscribe{
}


val destApk = File(Environment.getExternalStorageDirectory(), "dest.apk")
val patch = File(Environment.getExternalStorageDirectory(), "xxx/PATCH.patch")

patchApkBtn.setOnClickListener {
Thread {
bspatch(applicationInfo.sourceDir, destApk.absolutePath, patch.absolutePath)
runOnUiThread { installApk(destApk) }
}.start()
}
}

/**
* A native method that is implemented by the 'native-lib' native library,
* which is packaged with this application.
*/
external fun stringFromJNI(): String

external fun bspatch(oldPth: String, newPath: String, path: String): Int

companion object {

// Used to load the 'native-lib' library on application startup.
init {
System.loadLibrary("native-lib")
}
}

private fun installApk(file: File) {
try {
val f = file
// val f = File("sdcard/remeet/apk/remeet.apk")
val intent = Intent(Intent.ACTION_VIEW)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val apkUri = FileProvider.getUriForFile(this, "${applicationInfo.packageName}.installapk.provider", f)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
} else {
intent.setDataAndType(Uri.fromFile(f), "application/vnd.android.package-archive")
}

startActivity(intent)
} catch (e: Exception) {
} finally {

}
}
}

比较简单,界面一共就一个按钮和一个文字,文字显示版本(我自己乱填的),按钮点击会触发合并补丁的事件。当然,因为我自己的手机是8.0的,我要看效果是能简单适配一下了,各位如果代码跑不起来可以看看我的清单文件申请的权限,和对于7.0的适配。然后自己这里打两个不同的包,一个是 old 一个是 new ,之后进入 bsdiff 的目录,执行命令:

1
$ ./bsdiff old.apk new.apk PATCH.patch

之后将这个 PATCH.patch 用 adb push 命令推到自己在代码中指定的目录,就可以开始尝试合成补丁了~

最后是跑起来了:

跑起来了

当然,这只是一个 Demo,需要你自己去完善整个流程,我这里只是演示了合成补丁包并重新安装的部分。