注: 首发地址
0. 前言
如果只学理论, 不做实践, 不踩踩坑, 一般很难发现真正实践项目中的问题的, 也比较难以加深对技术的理解. 所以延续上篇 JNI 的实战 Android NDK 开发: JNI 实战篇 , 这篇主要是一些 NDK 小项目的练习, 由于这些项目网上都有 demo 介绍, 这里不会具体一步步介绍如何操作, 只记录一些个人需要注意的地方或一些主要步骤, 详细的介绍或代码可以点击里面的链接查看.
1. 文件加解密和分割合并
1.1 简介
所有文件都是二进制存储的, 无论是文本, 图片还是视频文件都是以二进制存储在磁盘中. 所以可以通过对文件进行二进制运算进行加解密. 下面用到的是比较简单的 ^ 异或运算来对文件加解密(算是一种对称加密算法)
附: 加解密算法扩展: 加解密算法 . 区块链技术指南
一般在大文件传输时, 如音视频文件, 会将文件分割后再传输, 从而提高效率. 当需要使用时, 再将分割后的文件合并即可.
而文件加解密设计到安全, 可以使用 NDK 增加反编译的难度. 另外文件的分割合并都比较耗性能, 可以放到 NDK 处理提高效率.
以下练习参考: NDK 开发基础2文件加密解密与分割合并 - 简书
效果图如图, 进入界面会拷贝两张 assets 的图片 cats.jpg 和 image.jpg 到本地 sdcard/NdkSample 目录下作为测试. 加密后的图片 cats_encypt.jpg 是无法直接查看的, 合成的图片是 image.jpeg
效果图
1.2 文件加解密
Java 代码
- public class FileUtils {
- private static final String FILE_PATH_PREFIX = Environment.getExternalStorageDirectory() + File.separator;
- private static final String FOLDER_NAME = "NdkSample" + File.separator;
- public static final String FILE_PATH = FILE_PATH_PREFIX + FOLDER_NAME;
- public static boolean fileEncrypt() {
- String normalFilePath = FILE_PATH + "cats.jpg";
- String encryptFilePath = FILE_PATH + "cats_encrypt.jpg";
- try {
- return fileEncrypt(normalFilePath, encryptFilePath);
- } catch (Exception e) {
- e.printStackTrace();
- }
- return false;
- }
- public static boolean fileDecode() {
- String encryptFilePath = FILE_PATH + "cats_encrypt.jpg";
- String decodeFilePath = FILE_PATH + "cats_decode.jpg";
- try {
- return fileDecode(encryptFilePath, decodeFilePath);
- } catch (Exception e) {
- e.printStackTrace();
- }
- return false;
- }
- private static native boolean fileEncrypt(String normalFilePath, String encryptFilePath);
- private static native boolean fileDecode(String encryptFilePath, String decodeFilePath);
- }
JNI 加密代码实现, 注意加文件读写权限
- const char *PASSWORD = "pw";
- long getFileSize(char* filePath);
- extern "C"
- JNIEXPORT jboolean JNICALL
- Java_cn_cfanr_ndksample_utils_FileUtils_fileEncrypt(JNIEnv *env, jclass type, jstring normalFilePath_,
- jstring encryptFilePath_) {
- const char *normalFilePath = env->GetStringUTFChars(normalFilePath_, 0);
- const char *encryptFilePath = env->GetStringUTFChars(encryptFilePath_, 0);
- int passwordLen = strlen(PASSWORD);
- LOGE("要加密的文件的路径 = %s , 加密后的文件的路径 = %s", normalFilePath, encryptFilePath);
- // 读文件指针
- FILE *frp = fopen(normalFilePath, "rb");
- // 写文件指针
- FILE *fwp = fopen(encryptFilePath, "wb");
- if (frp == NULL) {
- LOGE("文件不存在");
- return JNI_FALSE;
- }
- if (fwp == NULL) {
- LOGE("没有写权限");
- return JNI_FALSE;
- }
- // 边读边写边加密
- int buffer;
- int index = 0;
- while ((buffer = fgetc(frp)) != EOF) {
- // write
- fputc(buffer ^ *(PASSWORD + (index % passwordLen)), fwp); // 异或的方式加密
- index++;
- }
- // 关闭文件流
- fclose(fwp);
- fclose(frp);
- LOGE("文件加密成功");
- env->ReleaseStringUTFChars(normalFilePath_, normalFilePath);
- env->ReleaseStringUTFChars(encryptFilePath_, encryptFilePath);
- return JNI_TRUE;
- }
解密代码类似.
1.3 文件分割合并
Java 代码实现
- public static boolean fileSplit() {
- String splitFilePath = FILE_PATH + "image.jpg";
- String suffix = ".b";
- try {
- return fileSplit(splitFilePath, suffix, 4);
- } catch (Exception e) {
- e.printStackTrace();
- }
- return false;
- }
- /**
- * 文件合并
- *
- * @return
- */
- public static boolean fileMerge() {
- String splitFilePath = FILE_PATH + "image.jpg";
- String splitSuffix = ".b";
- String mergeSuffix = ".jpeg";
- try {
- return fileMerge(splitFilePath, splitSuffix, mergeSuffix, 4);
- } catch (Exception e) {
- e.printStackTrace();
- }
- return false;
- }
- /**
- * 文件分割
- *
- * @param splitFilePath 要分割文件的路径
- * @param suffix 分割文件的扩展名
- * @param fileNum 分割文件的数量
- * @return
- */
- private static native boolean fileSplit(String splitFilePath, String suffix, int fileNum);
- /**
- * 文件合并
- *
- * @param splitFilePath 分割文件的路径
- * @param splitSuffix 分割文件的扩展名
- * @param mergeSuffix 合并文件的扩展名
- * @param fileNum 分割文件的数量
- * @return
- */
- private static native boolean fileMerge(String splitFilePath, String splitSuffix, String mergeSuffix, int fileNum);
注意, 文件的分割合并需要设置文件扩展名后分割文件数量. 分割时, 分两种情况,
1)能整除的, 直接平均分;
2)不能整除的, fileSize % ( n -1), 前 n -1 个平均分, 剩余的留给最后一个;
合并时, 需要注意的是, 必须按照分割的顺序合并
其余 JNI 实现代码略, 可以到 GitHub 查看具体源码: NdkSample/native_file_handler.cpp
2. Android 增量更新
2.1 简介
所谓增量更新, 是服务器将新旧版本的 apk 做差分处理, 生成一个差分包 patch, 下发到客户端; 客户端再用 patch 包和本地的 apk 合并成新的 apk, 再安装. 很显然, 这样在一定程度上可以减少更新 apk 时消耗的流量. 目前在很多应用市场也有用到这种技术. 增量更新技术主要解决是安装包文件过大的问题.
2.2 优缺点
优点: 节省流量, 下载 apk 时, 只需要下载差分包, 不用下载完整包;
缺点:
客户端和服务端都需要加入相关的支持. 每次新版本发布, 服务器需要根据新版本对以前所有老版本生成对应的差分包, 而且还要维护不同渠道的包; 另外客户端请求时, 上传当前版本号, 服务器返回对应的差分包和新版本 apk 的 md5 值, 作为合并新 apk 后的校验; 所以整体流程会有点繁琐;
合成差分包会有点耗时 (最好用单独线程处理) 和耗内存的, 内存不足的手机或本地 apk 损坏的 apk 无法进行增量更新; 另外 apk 包之间差异比较小 (2m 以下) 时, 生成的差分包仍然有几百 k;
2.3 差分包的生成与合并
需要用工具对文件进行 diff 和 patch 处理, 一般可以通过 http://www.daemonology.net/bsdiff/ 实现
具体使用可以参考 Hongyang 的文章 Android 增量更新完全解析 是增量不是热修复 - Hongyang, 在这里就不啰嗦了
注意, 在执行 make 命令, 可能报以下错误,(以下环境都是在 Mac 上)
- bspatch.c:39:21: error: unknown type name 'u_char'; did you mean 'char'?
- static off_t offtin(u_char *buf)
- ^~~~~~
- char
可以通过在 bspatch.c 文件加上 #include <sys/types.h > 头文件(Hongyang 没说明清楚), 参考: 编译和使用 bsdiff - 木头平 - 博客园 http://www.cnblogs.com/lping/p/5833090.html
主要掌握几个命令:
执行 make 命令, 使 makefile 生成 bsdiff 和 bspatch 可执行文件;
执行
./bsdiff old.apk new.apk update.patch
, 生成差分包;
执行
./bspatch old.apk new2.apk update.patch
, 合成新 apk;
执行 md5 xxx.apk 查看 apk 的 md5 值;
2.4 服务端操作
服务端需要返回一个文件和新版本 apk md5 值给客户端.
用 2.3 生成的 bsdiff 可执行文件生成新版本 apk 和老版本的 apk 的差分包 update.patch;(可以编写个脚本处理)
使用 md5 命令查看新 apk 的 md5, 并保存, 之后需要返回给客户端;
2.5 客户端操作
主要实现是如何制造 bspatch 的 so 文件, 由于网上详细的步骤都比较完善了, 这里也不啰嗦了, 只简单说下需要注意的问题.
由于按照 AS 的默认的 CMake 建 NDK 的方式, C/C++ 的文件是在 cpp 目录的, Hongyang 的是在 jni 目录, 两者配置方式不太一样, 如果同时使用, 只会编译 CMake 的配置, 所以需要将 bspatch.c 放到 cpp 目录, 同时还需要下载 http://www.bzip.org/downloads.html 源码
详细步骤可以参考, Android 增量更新与 CMake 构建工具 - 亚特兰蒂斯 - CSDN 博客
PS: 这里的 CMakeLists.txt 是放在 cpp 目录下, 特别需要注意的是, 由于 CMakeLists.txt 目录改变了, 必须修改 C/C++ 源文件的路径, 去掉 src/main/cpp, 同时也要修改 build.gradle 的 cmake 文件的路径, 不然会编译失败
- # Sets the minimum version of CMake required to build the native library.
- cmake_minimum_required(VERSION 3.4.1)
- # 支持 - std=gnu++11
- set(CMAKE_VERBOSE_MAKEFILE on)
- set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=gnu++11 -Wall -DGLM_FORCE_SIZE_T_LENGTH")
- set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -DGLM_FORCE_RADIANS")
- # 设置生成的 so 动态库最后输出的路径
- set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${PROJECT_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
- # 添加 bzip2 目录, 为构建添加一个子路径
- set(bzip2_src_DIR ${CMAKE_SOURCE_DIR})
- add_subdirectory(${bzip2_src_DIR}/bzip2)
- add_library( native-lib
- SHARED
- # Provides a relative path to your source file(s). 注意, CMakeLists.txt 在 cpp 目录下, 此处不需要加路径前缀 src/main/cpp
- native_file_handler.cpp
- bspatch.c
- )
- find_library(log-lib log )
- target_link_libraries(native-lib ${log-lib} )
build.gradle 文件
- externalNativeBuild {
- cmake {
- path "src/main/cpp/CMakeLists.txt"
- }
- }
另外, 修改的 bspatch.c 文件增加的 JNI 代码中, 第一个参数是 so 库的名字, 注意一定要保持一致
- //......
- JNIEXPORT jint JNICALL Java_cn_cfanr_ndksample_utils_BsPatch_bspatch(JNIEnv *env, jclass jcls, jstring oldApk_,jstring newApk_, jstring patch_) {
- const char *oldApkPath = (*env)->GetStringUTFChars(env, oldApk_, 0);
- const char *newApkPath = (*env)->GetStringUTFChars(env, newApk_, 0);
- const char *patchPath = (*env)->GetStringUTFChars(env, patch_, 0);
- int argc = 4;
- char* argv[argc];
- argv[0] = "native-lib"; // 注意此处是 so 库名字
- argv[1] = oldApkPath;
- argv[2] = newApkPath;
- argv[3] = patchPath;
- jint ret = patchMethod(argc, argv);
- (*env)->ReleaseStringUTFChars(env, oldApk_, oldApkPath);
- (*env)->ReleaseStringUTFChars(env, newApk_, newApkPath);
- (*env)->ReleaseStringUTFChars(env, patch_, patchPath);
- return ret;
- }
- //......
其他代码逻辑:
1)从服务器下载差分包 update.patch 保存到本地, 并请求获取新版 apk 的 md5 值;
2)提取本地的 apk 文件;
3)使用 JNI 方法
public static native int bspatch(String oldApk, String newApk, String patch)
将 update.patch 和本地旧的 apk 合并成新的 apk;
4)校验生成的新 apk 的 md 值是否和服务器返回的一样;
5)检测新 apk 和服务器提供的一致后, 安装新的 apk 文件
不过 demo 是没有写从服务器下载差分包的逻辑的, 这里是将差分包通过 adb push patch 路径 /sdcard/NdkSample 命令放到手机来测试的
具体代码可以查看 GitHub:NdkSample/PatchUpdateActivity.java
3. Android 封装 libjpeg 库
3.1 编译 libjpeg.so 库
1. 克隆 libjpeg-trubo Android 版到本地, 并解压
Git clone Git://Git.linaro.org/people/tomgall/libjpeg-turbo/libjpeg-turbo.Git -b linaro-Android
2. 在配置好 ndk-build 环境后(具体步骤略), 开始编译 libjpeg-trubo 库
按照网上大多数教程的步骤都是执行以下命令
ndk-build APP_ABI=armeabi-v7a,armeabi
但可能由于我本地配置的版本是 ndk-14 的, 直接执行这个命令并没有奏效, 以下是我遇到的一些错误:
如果你没进入 libjpeg-turbo 目录就执行命令, 可能会报以下错误, 也就是找不到 Android.mk
- Android NDK: Your APP_BUILD_SCRIPT points to an unknown file: ./Android.mk
- /Users/cfanr/Library/Android/sdk/ndk-bundle/build/core/add-application.mk:198: *** Android NDK: Aborting... . Stop.
如果报找不到应用项目的目录, 如下:
- Android NDK: Could not find application project directory !
- Android NDK: Please define the NDK_PROJECT_PATH variable to point to it.
- /Users/cfanr/Library/Android/sdk/ndk-bundle/build/core/build-local.mk:151: *** Android NDK: Aborting . Stop.
就需要设置下 NDK_PROJECT_PATH 指定需要编译的代码的工程目录, 这里给出的是当前目录, 还有, APP_BUILD_SCRIPT 是 Android makefile 文件的路径, 如果你还有 Application.mk 文件的话, 则可以添加 NDK_APP_APPLICATION_MK=./Application.mk, 参考: Android 开发实践: 在任意目录执行 NDK 编译 - Jhuster 的专栏 http://ticktick.blog.51cto.com/823160/1428354
如果 NDK 版本过高, 可能会报以下错误,
- Android.mk:11: Extraneous text after `ifeq' directive
- Android NDK: WARNING: Unsupported source file extensions in Android.mk for module jpeg
- Android NDK: turbojpeg-mapfile
- /Users/cfanr/Library/Android/sdk/ndk-bundle/build/core/build-binary.mk:687: Android NDK: Module jpeg depends on undefined modules: cutils
- /Users/cfanr/Library/Android/sdk/ndk-bundle/build/core/build-binary.mk:700: *** Android NDK: Aborting (set APP_ALLOW_MISSING_DEPS=true to allow missing dependencies) . Stop.
所以, 我最终的解决方法是用指定的低版本的 ndk (ndk-r11c)去编译, 而不是用我在系统配置 ndk. 正确的命令, 使用指定 NDK 版本编译
~/NDK/Android-ndk-r11c/ndk-build NDK_PROJECT_PATH=. APP_BUILD_SCRIPT=./Android.mk APP_ABI=armeabi-v7a,armeabi
检测已编译成功: 编译成功后, 会在 libjpeg-turbo 生成 libs 和 obj 文件夹, 里面分别会有你设置的 ABI 类型的 libjpeg.so 库和其他生成的文件, 需要拷贝到项目中的是 libs 文件下的 libjpeg.so 库.
3.2 使用 libjpeg.so 库编写压缩图片的 native 方法
参考: Android 使用 libjpeg 实现图片压缩 - BlueBerry 的专栏 - CSDN 博客
1. 拷贝 libjpeg.so 和头文件到项目中
首先将不同 ABI 的 libjpeg.so 拷贝到项目的 libs 目录下, 再将上面下载的 libjpeg-turbo 的源码的所有头文件拷贝到 cpp/include 目录下
2. 配置 CMakeLists 文件
练习时, 要注意 CMakeLists 的配置, 不然可能会发生以下错误(博主就是因为没看清楚文章, 没配置好, 出错后, 一直搜索, 浪费不少时间 ♀)
1)如果 CMakeLists.txt 文件关联到 Android 的 Bitmap 相关库 jnigraphics, 会报以下错误, 未定义 Bitmap
- Error:(39) undefined reference to 'AndroidBitmap_getInfo'
- Error:(43) undefined reference to 'AndroidBitmap_lockPixels'
- Error:(85) undefined reference to 'AndroidBitmap_unlockPixels'
- Error:error: linker command failed with exit code 1 (use -v to see invocation)
2)如果只是添加了 libjpeg.so 库 add_library(libjpeg SHARED IMPORTED ), 未设置关联库 target_link_libraries(), 会报类似以下未定义某些属性的错误:
- Error:(99) undefined reference to 'jpeg_std_error'
- Error:(106) undefined reference to 'jpeg_CreateCompress'
- Error:(114) undefined reference to 'jpeg_stdio_dest'
- Error:(122) undefined reference to 'jpeg_set_defaults'
- Error:(126) undefined reference to 'jpeg_set_quality'
看来不熟悉理解 CMake 构建脚本还是挺容易踩坑的, 下篇会详细介绍 CMake 构建脚本的使用.
完整构建脚本如下:(如果 libjpeg.so 或头文件放置的目录和我的不一样, 下面就需要修改下)
- # Sets the minimum version of CMake required to build the native library.
- cmake_minimum_required(VERSION 3.4.1)
- # 设置生成的 so 动态库最后输出的路径
- set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${PROJECT_SOURCE_DIR}/src/main/jniLibs/${ANDROID_ABI})
- # 指定要引用的 libjpeg.so 的头文件目录
- set(LIBJPEG_INCLUDE_DIR src/main/cpp/include)
- include_directories(${LIBJPEG_INCLUDE_DIR})
- # 导入 libjpeg 动态库 SHARED; 静态库为 STATIC
- add_library(libjpeg SHARED IMPORTED)
- # 对应 so 目录, 这里为了简单设置的是绝对路径(注意要先 add_library, 再 set_target_properties)
- set_target_properties(libjpeg PROPERTIES IMPORTED_LOCATION /Users/cfanr/AndroidStudioProjects/DemoProjects/NDKSample/compress/libs/${ANDROID_ABI}/libjpeg.so)
- add_library(
- compress
- SHARED
- src/main/cpp/compress.c
- )
- find_library(graphics jnigraphics)
- find_library(log-lib log)
- target_link_libraries(compress libjpeg ${log-lib} ${graphics})
3. 编写 Java 层 native 方法
- public class ImageCompress {
- static {
- System.loadLibrary("compress");
- }
- private ImageCompress(){
- }
- public static native boolean compressBitmap(Bitmap bitmap, String dstPath, int quality, boolean isOptimize);
- }
4. 实现 JNI 逻辑
1)将 Android 的 Bitmap 解码转化为 RGB 数据;
2)为 JPEG 对象分配空间并初始化;
3)获取文件信息, 然后指定压缩数据源;
4)为压缩设定参数, 包括图像大小, 颜色空间;
5)开始压缩;
6)压缩完毕后, 释放资源;
具体代码查看: navyifanr/NdkSample: compress.c
效果图:
效果图
4. NDK 技术在 Android 的应用场景简述
4.1 首先需要了解 NDk 有什么作用和特点?
NDK 作用是 Google 提供了交叉编译工具链, 能够在 Linux 平台编译出在 ARM 平台下执行的二进制库文件;
NDK 特点:(来自: Android:JNI 与 NDK 到底是什么?- Carson_Ho 的博客 - CSDN 博客)
NDK 特点
4.2 应用场景
优化密集运算和消耗资源较大模块的性能, 如音视频解码, 图像操作等
需要提高安全性的地方, 编译成 so 库不容易被反编译; 如文件加密, 核心算法模块等;
跨平台应用的需要;
一些 Android NDK 的具体应用场景:
跨平台的音视频解码库 FFMPEG;
Android 增量更新技术;
Android 加固和防逆向技术;
一些热修复技术;
人脸识别, OpenCV 等
Android 平台的游戏开发等;
附:
C/C++ 代码被编译成库文件之后, 才能执行, 库文件分为动态库和静态库两种:
so 库文件类型
- -----src
- --------main
- ------------jniLibs
- -----------------armabi
- ---------------------libcompress.so
- -----App
- --------libs
- ------------armabi
- ------------------libcompress.so
- Android {
- //......
- sourceSets {
- main {
- jniLibs.srcDirs = ['libs']
- }
- }
- }
来源: http://www.jianshu.com/p/c32132784392