概述
当时红橙的视频讲解就差不多90分钟,但是真正自己做出来热更新的demo还是花了八九个晚上,期间遇到各种各样的问题,什么叫台上一分钟 台下十年功是深有体会了。
本节会涉及一部分NDK的知识 推荐阅读Android官方的NDK简介
https://developer.android.com/ndk/guides
上一节我们学习了如何在Linux下使用bspath和bsdiff 这次我们学习一下如何在Android设备上使用该第三方库
我们的热更新的一般流程是这样的
当然我们这里只是写个demo 大致了解热更新的原理,所以会省掉很多步骤 比如查询是否需要更新新版本,比如下载patch等
我们接着需要设计服务端和客户端 本来我其实想把客户端的部分省掉的 直接使用Linux系统编译patch写demo的结果真正使用的时候 在bspatch.c中报错 “Corrupt patch” 猜测可能是Linux的文件编码与Android设备的不同 他们的patch不是通用的。只能老老实实的搭建服务端。
由于要写NDK的代码 需要预先安装CMake NDK。
服务端搭建
1.1在src/main/创建jni文件夹
我们需要下载bspatch的源代码 并将其放到该目录,另外 在上一篇中 我们知道该第三方框架依赖了biz2的库 因此我们也要将其下载下来。在如下链接可以下载bspath和bzip2的源码
https://src.fedoraproject.org/lookaside/pkgs/bsdiff/bsdiff-4.3.tar.gz/e6d812394f0e0ecc8d5df255aa1db22a/
https://www.sourceware.org/bzip2/downloads.html
下载完毕后 将两个压缩包里面的.c和.h文件都拷贝到jni的目录下
1.2.创建mk文件
在jni目录创建如下两个文件
Android.mk
#注释部分解释来自 https://developer.android.com/ndk/guides/android_mk
LOCAL_PATH := $(call my-dir) # 此变量表示源文件在开发树中的位置 my-dir 将返回Android.mk所在当前目录
# CLEAR_VARS 变量,其值由构建系统提供 LEAR_VARS 变量指向一个特殊的 GNU Makefile,后者会为您清除许多 LOCAL_XXX 变量
include $(CLEAR_VARS)
# LOCAL_MODULE 变量存储您要构建的模块的名称 每个模块名称必须唯一,且不含任何空格 如下会生成名为 libnative-lib.so
LOCAL_MODULE :=bsdiff
# 列举源文件,以空格分隔多个文件
LOCAL_SRC_FILES :=bsdiff.c
# 此变量列出了在构建共享库或可执行文件时使用的额外链接器标记
LOCAL_LDLIBS := -ljnigraphics -llog
# BUILD_SHARED_LIBRARY 变量指向一个 GNU Makefile 脚本,该脚本会收集您自最近 include 以来在 LOCAL_XXX 变量中定义的所有信息。此脚本确定要构建的内容以及构建方式
include $(BUILD_SHARED_LIBRARY)
Application.mk
APP_ABI := armeabi-v7a armeabi x86_64 x86 #表示 编译目标 ABI(应用二进制接口)
APP_PLATFORM := android-9
1.3.修改moudle的build.gradle
plugins {id 'com.android.application'
}android {compileSdkVersion 30buildToolsVersion "30.0.3"// 指定ndk路径ndkPath "20.0.5594570"defaultConfig {applicationId "com.example.hotupdatediff"minSdkVersion 24targetSdkVersion 30versionCode 1versionName "1.0"testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"// ndk 编译生成.so文件ndk {moduleName "bspatch" //生成的so名字abiFilters "arm64-v8a", "armeabi-v7a", "x86", "x86_64" //输出指定abi体系结构下的so库}}buildTypes {release {minifyEnabled falseproguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'}}sourceSets {main {jni.srcDirs = []// 设置禁止gradle生成Android.mkjniLibs.srcDirs = ['src/main/libs']}}externalNativeBuild {ndkBuild {// 指定mk文件路径path "src/main/jni/Android.mk"}}compileOptions {sourceCompatibility JavaVersion.VERSION_1_8targetCompatibility JavaVersion.VERSION_1_8}
}dependencies {implementation 'androidx.appcompat:appcompat:1.3.0'implementation 'com.google.android.material:material:1.3.0'implementation 'androidx.constraintlayout:constraintlayout:2.0.4'testImplementation 'junit:junit:4.+'androidTestImplementation 'androidx.test.ext:junit:1.1.2'androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}
1.4.创建jni方法
在MainActivity同级目录创建DiffUtils
public class DiffUtils {static {System.loadLibrary("bsdiff");}/*** @param oldApkPath 旧版本的apk* @param newApkPath 合并后新的apk路径* @param patchPath 差分包路径*/public static native void diff(String oldApkPath, String newApkPath, String patchPath);
}
1.5 生成头文件
cd到能找到对应文件的目录 以便生成头文件
D:\testDarren\learn_darren_eassy_joke\hotupdatediff\src\main> javah -d jni -classpath ./java com.example.hotupdatediff.DiffUtils
PS: 如果遇到如下报错:
ndk-build.cmd finished with non-zero exit value 2
需要找到下载的ndk 里面的ndk-build.cmd
编辑该文件
@echo off
%~dp0\build\ndk-build.cmd %*
删除上面的第二行
最后将生成的.h文件移动到上面创建的jni目录
1.6 轻微修改bsdiff.c
在头部加上依赖文件(尝试编译的时候会报各种错 一般都是缺少依赖 一个个添加吧)
#include "crctable.c"
#include "compress.c"
#include "decompress.c"
#include "randtable.c"
#include "blocksort.c"
#include "huffman.c"
#include "bzlib.c"
#include "bzlib.h"
#include "com_example_hotupdatediff_DiffUtils.h"
滑到该文件的最下面的方法 修改main方法为diff
在该文件的最后添加一个方法 该方法是jni方法的具体实现 实际调用了c文件的diff方法
JNIEXPORT void JNICALL
Java_com_example_hotupdatediff_DiffUtils_diff(JNIEnv *env, jclass clazz, jstring old_apk_path,jstring new_apk_path, jstring patch_path) {// 1.封装参数int argc = 4;char *argv[4];// 1.1 转换 jstring -> char*char *old_pak_cstr = (char *) (*env)->GetStringUTFChars(env, old_apk_path, NULL);char *new_apk_cstr = (char *) (*env)->GetStringUTFChars(env, new_apk_path, NULL);char *patch_cstr = (char *) (*env)->GetStringUTFChars(env, patch_path, NULL);// 第0的位置随便给argv[0] = "diff";argv[1] = old_pak_cstr;argv[2] = new_apk_cstr;argv[3] = patch_cstr;// 2.调用上面的方法 int argc,char * argv[]diff(argc, argv);// 3.释放资源(*env)->ReleaseStringUTFChars(env, old_apk_path, old_pak_cstr);(*env)->ReleaseStringUTFChars(env, new_apk_path, new_apk_cstr);(*env)->ReleaseStringUTFChars(env, patch_path, patch_cstr);
}
1.7 在Java文件中调用该方法
public class MainActivity extends AppCompatActivity {private static int READ_EXTERNAL_STORAGE_REQUEST_CODE = 1;private static int WRITE_EXTERNAL_STORAGE_REQUEST_CODE = 2;private String mPatchPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)+ File.separator + "1_2.patch";private String mNewApkPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)+ File.separator + "2.0.apk";private String mOldApkPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)+ File.separator + "1.0.apk";@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);if (!checkPermission(READ_EXTERNAL_STORAGE)) {requestPermission(this, READ_EXTERNAL_STORAGE, READ_EXTERNAL_STORAGE_REQUEST_CODE);}if (!checkPermission(WRITE_EXTERNAL_STORAGE)) {requestPermission(this, WRITE_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE_REQUEST_CODE);}}public void requestPermission(Activity activity, String permission, int requestCode) {//第一次申请权限被拒后每次进入activity就会调用,或者用户之前允许了,之后又在设置中去掉了该权限if (ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)) {Toast.makeText(activity, permission + " permission needed. Please allow in App Settings for additional functionality.", Toast.LENGTH_LONG).show();} else {ActivityCompat.requestPermissions(activity, new String[]{permission}, requestCode);}}public boolean checkPermission(String permission) {int result = ContextCompat.checkSelfPermission(getApplicationContext(), permission);return result == PackageManager.PERMISSION_GRANTED;}public void diff(View view) {if (!new File(mOldApkPath).exists()) {Toast.makeText(MainActivity.this, "hotUpdateDiff: mOldApkPath doesn't exist", Toast.LENGTH_SHORT).show();return;}if (!new File(mNewApkPath).exists()) {Toast.makeText(MainActivity.this, "hotUpdateDiff: mNewApkPath doesn't exist", Toast.LENGTH_SHORT).show();return;}// 下面是一个耗时操作 为了示意简便 就不开异步操作了(实际就是懒)DiffUtils.diff(mOldApkPath, mNewApkPath, mPatchPath);}
}
我们看到上面的代码中申请了读写外部存储的权限 因为需要读取1.0.apk与2.0.apk并生成1_2.apk 因此需要读写权限
另外还要再清单文件中声明
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /><uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
至此 服务端搭建完毕 可以尝试编译一下 查看生成的apk中是否有so文件
如果存在 基本就算成功了。
客户端搭建
客户端建立的步骤和服务端基本类似 有些许地方有些不同
2.1在src/main/创建jni文件夹
这一步和服务端操作完全一致
2.2.创建mk文件
基本和服务端一致 除了Android.mk中构建的模块从bsdiff修改为bspatch
2.3.修改moudle的build.gradle
这一步也是基本和服务端一致 主要差别是配置了release相关的编译
因为之后生成的apk需要调用FileProvider的相关方法安装apk
在Android 8.0以后(不清楚是不是更早版本)Android不支持直接安装debug版本的apk(adb install可以)
试图安装debug版本的apk会提示apk not installed 因此需要编译release版本的apk
关于签名文件.jks的生成 可以参考
https://blog.csdn.net/u011109881/article/details/113948590
plugins {id 'com.android.application'
}android {signingConfigs {release {storeFile file('..\\joke.jks')storePassword '12345678'keyAlias 'joke'keyPassword '12345678'}}compileSdkVersion 30buildToolsVersion "30.0.3"// 指定ndk路径ndkPath "20.0.5594570"defaultConfig {applicationId "com.example.hotupdate"minSdkVersion 24targetSdkVersion 30versionCode 1versionName "1.0"testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"// ndk 编译生成.so文件ndk {moduleName "bspatch" //生成的so名字abiFilters "arm64-v8a", "armeabi-v7a", "x86", "x86_64" //输出指定abi体系结构下的so库}}buildTypes {release {minifyEnabled falseproguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'signingConfig signingConfigs.release}}sourceSets {main {jni.srcDirs = []// 设置禁止gradle生成Android.mkjniLibs.srcDirs = ['src/main/libs']}}
// 以下注释部分代码不生效 可能低版本gradle是生效的 至少gralde 6.5 无效
// task ndkBuild(type: Exec) {//设置新的so的生成目录
// commandLine "C:\\codebase\\android-sdk_60_windows\\ndk\\20.0.5594570\\ndk-build.cmd",
// 'NDK_PROJECT_PATH=build/aaa/ndk',
// 'NDK_LIBS_OUT=libs'
// 'APP_BUILD_SCRIPT=src/main/jni/Android.mk'
// 'NDK_APPLICATION_MK=src/main/jni/Application.mk'
// }
// tasks.withType(JavaCompile) {
// compileTask -> compileTask.dependsOn ndkBuild
// }// 上面注释的部分 用下面的替代externalNativeBuild {ndkBuild {// 指定mk文件路径path "src/main/jni/Android.mk"}}compileOptions {sourceCompatibility JavaVersion.VERSION_1_8targetCompatibility JavaVersion.VERSION_1_8}
}dependencies {implementation 'androidx.appcompat:appcompat:1.3.0'implementation 'com.google.android.material:material:1.3.0'implementation 'androidx.constraintlayout:constraintlayout:2.0.4'testImplementation 'junit:junit:4.+'androidTestImplementation 'androidx.test.ext:junit:1.1.2'androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}
2.4.创建jni方法
在MainActivity同级目录创建PatchUtils
public class PatchUtils {static {System.loadLibrary("bspatch");}/*** @param oldApkPath 旧版本的apk* @param newApkPath 合并后新的apk路径* @param patchPath 差分包路径*/public static native void combine(String oldApkPath, String newApkPath, String patchPath);
}
2.5 生成头文件
cd到能找到对应文件的目录 以便生成头文件
D:\testDarren\learn_darren_eassy_joke\hotupdate\src\main> javah -d jni -classpath ./java com.example.hotupdate.PatchUtils
最后将生成的.h文件移动到上面创建的jni目录
2.6 轻微修改bspatch.c
在头部加上依赖文件
#include "crctable.c"
#include "compress.c"
#include "decompress.c"
#include "randtable.c"
#include "blocksort.c"
#include "huffman.c"
#include "bzlib.c"
#include "com_example_hotupdate_PatchUtils.h"
滑到该文件的最下面的方法 修改main方法为combine
在该文件的最后添加一个方法 该方法是jni方法的具体实现 实际调用了c文件的combine方法 该方法也和上面的十分类似
JNIEXPORT void JNICALL Java_com_example_hotupdate_PatchUtils_combine(JNIEnv *env, jclass jclz, jstring old_pak_path, jstring new_apk_path, jstring patch_path) {// 1.封装参数int argc = 4;char *argv[4];// 1.1 转换 jstring -> char*char *old_pak_cstr = (char *) (*env)->GetStringUTFChars(env, old_pak_path, NULL);char *new_apk_cstr = (char *) (*env)->GetStringUTFChars(env, new_apk_path, NULL);char *patch_cstr = (char *) (*env)->GetStringUTFChars(env, patch_path, NULL);// 第0的位置随便给argv[0] = "combine";argv[1] = old_pak_cstr;argv[2] = new_apk_cstr;argv[3] = patch_cstr;// 2.调用上面的方法 int argc,char * argv[]combine(argc, argv);// 3.释放资源(*env)->ReleaseStringUTFChars(env, old_pak_path, old_pak_cstr);(*env)->ReleaseStringUTFChars(env, new_apk_path, new_apk_cstr);(*env)->ReleaseStringUTFChars(env, patch_path, patch_cstr);
}
2.7 在Java文件中调用该方法
public class MainActivity extends AppCompatActivity {private static int READ_EXTERNAL_STORAGE_REQUEST_CODE = 1;private static int WRITE_EXTERNAL_STORAGE_REQUEST_CODE = 2;private static final String TAG = "hjcai";private String mPatchPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)+ File.separator + "1_2.patch";private String mNewApkPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)+ File.separator + "new.apk";protected void hotUpdate() {// 1.使用当前版本查询是否需要更新// 2.如果需要更新版本// a 部分更新 下载patch// b 下载全部apk// 3.下载完差分包之后, 调用native方法去合并生成新的apk// 是一个耗时操作 开线程, Handler, AsyncTask 这里就不开线程了// 获取本地的getPackageResourcePath()apk路径if (!new File(mPatchPath).exists()) {Log.e(TAG, "hotUpdate: mPatchPath doesn't exist");return;}// 本地apk路径怎么来,已经被安装了 1.0String oldApkPath = getPackageResourcePath();//获取当前apk的路径// 需要申请sdcard读写权限PatchUtils.combine(oldApkPath, mNewApkPath, mPatchPath);// 4.删除patchdelFile(mPatchPath);Log.e(TAG, "hotUpdate: patch deleted");// 5.需要校验签名 就是获取本地apk的签名,与我们新版本的apk作对比// 6.安装最新版本
// Android 6.0左右安装apk的方法
// Intent intent = new Intent(Intent.ACTION_VIEW);
// intent.setDataAndType(Uri.fromFile(new File(mNewApkPath)),
// "application/vnd.android.package-archive");
// startActivity(intent);installAPK();Log.e(TAG, "hotUpdate: installed APK");}private void installAPK() {//File fileApkToInstall = new File(getExternalFilesDir("Download"), mNewApkPath);File fileApkToInstall = new File(mNewApkPath);if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {//final Uri uri = Uri.parse("file://" + mNewApkPath);Uri apkUri = FileProvider.getUriForFile(MainActivity.this, BuildConfig.APPLICATION_ID + ".provider", fileApkToInstall);Intent intent = new Intent(Intent.ACTION_INSTALL_PACKAGE);intent.setData(apkUri);intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);MainActivity.this.startActivity(intent);} else {Uri apkUri = Uri.fromFile(fileApkToInstall);Intent intent = new Intent(Intent.ACTION_VIEW);intent.setDataAndType(apkUri, "application/vnd.android.package-archive");intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);MainActivity.this.startActivity(intent);}}@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);if (!checkPermission(READ_EXTERNAL_STORAGE)) {requestPermission(this, READ_EXTERNAL_STORAGE, READ_EXTERNAL_STORAGE_REQUEST_CODE);}if (!checkPermission(WRITE_EXTERNAL_STORAGE)) {requestPermission(this, WRITE_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE_REQUEST_CODE);}}public void requestPermission(Activity activity, String permission, int requestCode) {//第一次申请权限被拒后每次进入activity就会调用,或者用户之前允许了,之后又在设置中去掉了该权限if (ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)) {Toast.makeText(activity, permission + " permission needed. Please allow in App Settings for additional functionality.", Toast.LENGTH_LONG).show();} else {ActivityCompat.requestPermissions(activity, new String[]{permission}, requestCode);}}public boolean checkPermission(String permission) {int result = ContextCompat.checkSelfPermission(getApplicationContext(), permission);return result == PackageManager.PERMISSION_GRANTED;}public static boolean delFile(String filePathAndName) {File file = new File(filePathAndName);if (file.exists() && file.isFile()) {if (file.delete()) {Log.e("hjcai", "删除单个文件" + filePathAndName + "成功!");return true;} else {Log.e("hjcai", "删除单个文件" + filePathAndName + "失败!");return false;}} else {Log.e("hjcai", "删除单个文件失败:" + filePathAndName + "不存在!");return false;}}public void update(View view) {hotUpdate();}
}
上面最主要的工作是获取当前apk 在读取patch所在的文件 两者合并 在download目录生成new.apk
生成apk后 再调用fileprovider的方法install apk
当然也要申请读写外部存储的权限 以及安装apk的权限
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /><uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /><uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
2.8 配置file provider
在清单文件配置
<providerandroid:name="androidx.core.content.FileProvider"android:authorities="${applicationId}.provider"android:exported="false"android:grantUriPermissions="true"><meta-dataandroid:name="android.support.FILE_PROVIDER_PATHS"android:resource="@xml/provider_paths" /></provider>
在xml中创建provider_paths
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android"><external-pathname="/storage/emulated/0"path="." />
</paths>
最终效果演示
编译客户端生成1.0版本
稍稍修改客户端生成2.0版本
安装1.0 版本
adb install -r -t .\1.0.apk
adb push .\2.0.apk /storage/emulated/0/Download
adb push .\1.0.apk /storage/emulated/0/Download
运行服务端生成patch
在/storage/emulated/0/Download生成1_2.patch
重新打开客户端1.0版本 提示升级apk 确认升级 升级完毕重新打开发现是2.0版本了
动图:
完整代码:
服务端
https://github.com/caihuijian/learn_darren_eassy_joke/tree/main/hotupdatediff
客户端
https://github.com/caihuijian/learn_darren_eassy_joke/tree/main/hotupdate