前言
工作要求将一个C++老项目的函数用ndk打包成库给安卓同事的java程序调用。
这个任务我debuff拉满:
- 自己之前从来没接触过安卓开发,问了老板为什么不让安卓开发来干,老板说安卓开发不懂c++,公司就我一个是懂c++的。。。
- 项目开发年限超过十年,只在32位系统编译过,一些32位可以通过的代码到了64位就不行了,很多的库多少有些兼容问题
- 项目开发环境全程断网,给开发带来诸多不便
感谢智谱和GPT4,最后花了几天还是摸清了一条路出来,不然可能过了一周都搞不清android.mk要怎么写。
不废话,笔记如下:
知识点记录:
NDK:Native Development Kit,是 Android 的一个工具开发包。NDK 可以看做是 Android 中实现 JNI 的一种手段(另一种是CMake),通过 NDK,还可以打包 C/C++ 动态库,并自动打包进 APK/AAR 中。我们可以到安卓官网下载NDK,可以直接执行NDK命令,也可以集成NDK到AndroidStudio中编译C/C++文件。
JNI:Java Native Interface,即 Java 本地接口。使得 Java 与本地其他类型语言(如 C、C++)交互。也就是在 Java 中调用 C/C++ 代码,或者在 C/C++ 中调用 Java 代码。JNI 是 Java 的,和 Android 无关。
.SO文件:so文件是Linux下的程序函数库,即编译好的可以供其他程序使用的代码和数据。一般来说.so文件就是常说的动态链接库, 都是C或C++编译出来的。与Java比较就是:它通常是用的Class文件(字节码)。Linux下的.so文件时不能直接运行的,一般来讲,.so文件称为共享库。
Android.mk :文件位于项目 jni/ 目录的子目录中,用于向编译系统描述源文件和共享库。它实际上是编译系统解析一次或多次的微小 GNU makefile 片段。Android.mk 文件用于定义 Application.mk、编译系统和环境变量所未定义的项目范围设置。它还可替换特定模块的项目范围设置。Android.mk 的语法支持将源文件分组为模块。模块是静态库、共享库或独立的可执行文件。
开发环境
OS:Windows 11
Android Studio版本:2021.3.1.17 Download link
NDK: r26d download link
开始操作
编译安卓so库
首先得搞清楚ndk-build怎么使用,这东西本质是对clang的封装, android.mk就相当于ndk的makefile,这里用一个c++的四则运算来讲。
创建这么个项目:
PS D:\MyCodeBase\ndk_test> ls
目录: D:\MyCodeBase\ndk_test
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2024/7/13 17:34 594 android.mk
-a---- 2024/7/13 17:29 71 application.mk
-a---- 2024/7/13 17:27 838 mycal.cpp
-a---- 2024/7/13 17:27 520 mycal.h
c++的四则运算类实现如下,我这里参考公司的实现方式,一个亮点是使用了工厂模式,头文件类只放基类,基类内全是纯虚函数(接口),实现在源文件里另有子类处理,头文件只提供creator创建实现类,返回基类指针,通过基类指针来操作接口方法。
// mycal.h
#ifndef MYCAL_H
#define MYCAL_H
#include <iostream>
#include <string>
// 运算接口基类
class Calculator {
public:
virtual ~Calculator() = default;
virtual double add(double a, double b) const = 0;
virtual double sub(double a, double b) const = 0;
virtual double mul(double a, double b) const = 0;
virtual double div(double a, double b) const = 0;
};
// 非类成员工厂函数声明
Calculator* createCalculator();
#endif // MYCAL_H
// mycal.cpp
#include "mycal.h"
#include <stdexcept>
// 实现Calculator接口的具体类
class CalculatorImpl : public Calculator {
public:
double add(double a, double b) const override {
return a + b;
}
double sub(double a, double b) const override {
return a - b;
}
double mul(double a, double b) const override {
return a * b;
}
double div(double a, double b) const override {
if (b != 0.0) {
return a / b;
} else {
throw std::runtime_error("Division by zero.");
}
}
};
// 工厂函数实现
Calculator* createCalculator() {
return new CalculatorImpl();
}
ndk-build通过android.mk和application.mk来控制编译,android.mk已经提过,什么是application.mk? 其实是另一个脚本,专门用于控制编译平台相关的东西,比如android平台版本,ABI架构,我在这个例子中指定平台为android-24,输出x86, x86_64, arm64-v8a, armeabi-v7a四种架构的so库。
#application.mk
APP_PLATFORM := android-24
APP_ABI := x86 x86_64 arm64-v8a armeabi-v7a
# android.mk
# build: ndk-build NDK_PROJECT_PATH="./" APP_BUILD_SCRIPT="./android.mk" NDK_APPLICATION_MK="./application.mk" V=1
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := mycal #模块名
LOCAL_SRC_FILES := mycal.cpp #源文件路径
LOCAL_C_INCLUDES := $(LOCAL_PATH) #指定头文件搜索路径
LOCAL_CPPFLAGS := -fexceptions #启用throw错误,默认不启用会保存
LOCAL_LDLIBS := -llog -lc++_shared #指定链接库, 其中c++_shared对于c++文件编译必须要加上,否则会报错
include $(BUILD_SHARED_LIBRARY)
ndk-build编译:
ndk-build NDK_PROJECT_PATH="./" APP_BUILD_SCRIPT="./android.mk" NDK_APPLICATION_MK="./application.mk" V=1
参数讲解:
NDK_PROJECT_PATH
:指定ndk项目的根路径
APP_BUILD_SCRIPT
:指定android.mk路径
NDK_APPLICATION_MK:
指定application.mk路径
V=1
:输出clang的编译日志,默认不输出
ndk-build的更多选项可见:ndk-build官方使用说明
命令编译成功后,应该会在目录下出现libs和objs两个文件夹,其中libs存在指定的各个架构下的so库:
PS D:\MyCodeBase\ndk_test> tree /F
卷 New Volume 的文件夹 PATH 列表
卷序列号为 6AC4-31C7
D:.
│ android.mk
│ application.mk
│ main.cpp
│ mycal.cpp
│ mycal.h
│
├─libs
│ ├─arm64-v8a
│ │ libmycal.so
│ │
│ ├─armeabi-v7a
│ │ libmycal.so
│ │
│ ├─x86
│ │ libmycal.so
│ │
│ └─x86_64
│ libmycal.so
│
└─obj
....
自此android程序需要的so库编译完成。
android studio的jni编程
打开android studio,新建一个native c++项目MyCalLib:
在MyCalLib项目中,将ndk_test项目的libs文件夹拷贝到对应的libs文件夹(更老版本可能是jniLibs,不过我这个创建就有libs), 并将mycal.h拷贝到cpp文件夹,接着修改CMakeLists.txt将so库引入:
cmake_minimum_required(VERSION 3.18.1)
# Declares and names the project.
project("mycallib")
find_library(log-lib log)
find_library(c++_shared-lib c++_shared)
# 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.
set(my_lib_path ${CMAKE_SOURCE_DIR}/../../../libs)
add_library(mycal SHARED IMPORTED)
set_target_properties(mycal PROPERTIES IMPORTED_LOCATION ${my_lib_path}/${ANDROID_ABI}/libmycal.so)
add_library( # Sets the name of the library.
mycallib
# Sets the library as a shared library.
SHARED
# Provides a relative path to your source file(s).
native-lib.cpp
)
# 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.
mycallib
# Links the target library to the log library
# included in the NDK.
${log-lib}
${c++_shared-lib}
mycal
)
然后在MainActitivity.java声明jni接口:
public native void calInit();
public native double add(double a, double b);
public native double sub(double a, double b);
public native double mul(double a, double b);
public native double div(double a, double b);
在native-lib.cpp实现这些jni接口,jni的命名方式为Java_包名_类名_方法名
,不过AS下应该能自动生成函数签名。
#include <jni.h>
#include <string>
#include "mycal.h"
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_mycallib_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
Calculator* cal;
extern "C"
JNIEXPORT void JNICALL
Java_com_example_mycallib_MainActivity_calInit(JNIEnv *env, jobject thiz) {
cal = createCalculator();
}
extern "C"
JNIEXPORT jdouble JNICALL
Java_com_example_mycallib_MainActivity_add(JNIEnv *env, jobject thiz, jdouble a, jdouble b) {
return cal->add(a, b);
}
extern "C"
JNIEXPORT jdouble JNICALL
Java_com_example_mycallib_MainActivity_sub(JNIEnv *env, jobject thiz, jdouble a, jdouble b) {
return cal->sub(a, b);
}
extern "C"
JNIEXPORT jdouble JNICALL
Java_com_example_mycallib_MainActivity_mul(JNIEnv *env, jobject thiz, jdouble a, jdouble b) {
return cal->mul(a, b);
}
extern "C"
JNIEXPORT jdouble JNICALL
Java_com_example_mycallib_MainActivity_div(JNIEnv *env, jobject thiz, jdouble a, jdouble b) {
return cal->div(a, b);
}
函数的生成结果需要在机子上测试,测试界面activity_main.xml准备四个文本框,分别显示四则运算的运算结果:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/sample_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_add"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.554" />
<TextView
android:id="@+id/tv_sub"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.639" />
<TextView
android:id="@+id/tv_mul"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.502"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.683" />
<TextView
android:id="@+id/tv_div"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.744" />
</androidx.constraintlayout.widget.ConstraintLayout>
MainActivity.java测试代码:
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
calInit();
// Example of a call to a native method
TextView tv_add = binding.tvAdd;
TextView tv_sub = binding.tvSub;
TextView tv_mul = binding.tvMul;
TextView tv_div = binding.tvDiv;
double val = add(5,6);
tv_add.setText(String.valueOf(val));
val = sub(11,10);
tv_sub.setText(String.valueOf(val));
val = mul(11, 10);
tv_mul.setText(String.valueOf(val));
val = div(12,7);
tv_div.setText(String.valueOf(val));
}
自此一切准备工作完毕,Device Manager>Create device创建一个安卓虚拟机,这里指定系统为x86,API 24。
创建成功后build run, 如果运行成功,虚拟机应该会打开一个app,里面是activity_main.xml上测试代码的输出结果。
坑点记录
-
android studio不能用的太新,一开始直接官网下的最新的android studio koala 2024.1来弄,结果发现网上很多教程(包括AI回答)都停留在安卓的老版本,很多项目设置找不到,ndk路径设置找不到(后来才知道谷歌2021年出于多余理由把ndk选项从AS中移除了),这个版本用的gradle 8.0,build.gradle一些cmake和ndk的设置放到上面根本build不了,寒。。。最后请了安卓同事给我弄了这个老版本,gradle7.4,build.gradle的一些设置缺失果然就没了。
-
android.mk编译的第三方so库如果有引入c++_shared.so库,AS中cmake版本太低是不会识别编译选项的,届时运行会在Logcat上报找不到库的错误,而升级cmake的提示,在有中间文件生成时是不会提醒的。就因为这个不会提示,我从下午4点一直被硬控到第二天下午3点。
解决方法:Tools>SDK Manager>SDK Tools, 找到CMake, 下载3.19以上的版本,可勾选Show Package Details看到版本信息。
参考
https://developer.android.com/ndk/guides/android_mk?hl=zh-cn
ndk-build官方使用说明