安卓应用如何直接调用原生库:让 Java/Kotlin “敲门”进入 C/C++ 的工坊

想象你的 Android App 是一座繁忙的城市。

  • Kotlin/Java 写的业务层,是城市的办公楼:流程清晰、办事方便,适合做 UI、网络、业务逻辑。
  • C/C++ 写的原生库,是城市边缘的一座重工业工坊:机器轰鸣、效率极高,擅长干“重活”——音视频编解码、图像处理、加密、数学运算、AI 推理、游戏物理。

但办公楼和工坊语言不通:
一个说“JVM 的普通话”,一个说“CPU 的方言”。
**JNI(Java Native Interface)**就是那扇“传话窗口”,而 NDK 是给你建工坊、修道路、装吊车的一整套工具。

所谓“安卓应用直接调用原生库”,本质就是:

在 APK 中打包 .so(共享库),在运行时由系统加载它,然后用 JNI 在 Kotlin/Java 与 C/C++ 之间传递数据、调用函数。

下面我们按“修路—建窗—运货—验收”的顺序,搭一套从 0 到可上线的调用体系。


一、先把概念摆平:我们说的“原生库”一般是 .so

在 Android 上,最常见的原生库形式是 ELF 共享库

  • libxxx.so:共享库文件
  • 放在 APK 的特定目录:lib/<abi>/libxxx.so
  • 由系统动态链接器加载(dlopen 机制)

1.1 ABI:同一座工坊要给不同“口音”的工人准备不同工具

Android 设备 CPU 架构不同,需要不同 ABI 的 .so

  • arm64-v8a(主流)
  • armeabi-v7a(一些老设备)
  • x86_64(模拟器常见)
  • x86(更老)

同一个库通常要产出多份:每个 ABI 一份 .so


二、两条主路线:JNI(最常见) vs 直接 dlopen(高级但小众)

把“调用原生库”分成两种玩法:

2.1 路线 A:JNI(官方正统路线,99% 项目用它)

  • Kotlin/Java 声明 external 方法
  • C/C++ 实现 JNI 函数
  • Java 调用像普通函数一样调用 native 方法

优点:标准、稳定、工具链完善
缺点:需要写 JNI glue code(“胶水层”)

2.2 路线 B:dlopen/dlsym 动态加载(像自己拿钥匙开工坊门)

  • 你可以在 native 层 dlopen("libxxx.so")
  • dlsym 找函数指针再调用

优点:可按需加载、插件化
缺点:更底层,容易踩 ABI/符号/安全策略坑;Java 层仍通常通过 JNI 进入 native

结论:如果你不是做插件化/热插拔,先选 JNI。


三、最常见工程做法:Kotlin/Java 调 C/C++(JNI)一步步走

3.1 先在 Java/Kotlin 写“敲门口令”:external + System.loadLibrary

就像你告诉门卫:“我一会要进工坊找某个师傅干活。”

object NativeBridge {
    init {
        System.loadLibrary("mylib") // 对应 libmylib.so
    }

    external fun add(a: Int, b: Int): Int
}

调用:

val r = NativeBridge.add(1, 2)

注意:loadLibrary 里写的是逻辑名 mylib,文件名是 libmylib.so

3.2 再在 C/C++ 写“接线员”:JNI 函数实现

你需要一个 native 文件,例如 native-lib.cpp

#include <jni.h>

extern "C"
JNIEXPORT jint JNICALL
Java_com_example_app_NativeBridge_add(JNIEnv* env, jobject thiz, jint a, jint b) {
    return a + b;
}

这里的方法名像一串“暗号”:
Java_包名_类名_方法名
它要与 Kotlin/Java 的声明匹配。

3.3 用 CMake 把工坊搭起来:生成 .so

CMakeLists.txt 里:

cmake_minimum_required(VERSION 3.22.1)
project("myapp")

add_library(mylib SHARED native-lib.cpp)

find_library(log-lib log)
target_link_libraries(mylib ${log-lib})

Gradle(Kotlin DSL 举例)里启用 externalNativeBuild:

android {
    defaultConfig {
        ndk {
            abiFilters += listOf("arm64-v8a", "armeabi-v7a")
        }
        externalNativeBuild {
            cmake {
                cppFlags += "-std=c++17"
            }
        }
    }

    externalNativeBuild {
        cmake {
            path = file("src/main/cpp/CMakeLists.txt")
        }
    }
}

这样构建时 Gradle 会帮你产出对应 ABI 的 libmylib.so 并打进 APK。


四、另一种常见方式:你已经有第三方 .so,我只想“直接用”

有时候你拿到的是别人给的 libfoo.so(可能还带头文件 .h),你只想在 App 中调用它。

这就像工坊已经建好了,你只需要:

  1. 把工坊搬进城市(把 .so 放进工程并打包)
  2. 在 JNI 胶水层里“转接电话”(调用库函数)
  3. Java/Kotlin 通过胶水层调用

4.1 如何把现成 .so 放进 APK?

把文件放到:

app/src/main/jniLibs/<abi>/libfoo.so

例如:

  • app/src/main/jniLibs/arm64-v8a/libfoo.so
  • app/src/main/jniLibs/armeabi-v7a/libfoo.so

Gradle 默认会把 jniLibs 打包进去。

4.2 CMake 如何链接已有 .so

在 CMake 中把它当作“导入库”:

add_library(foo SHARED IMPORTED)
set_target_properties(foo PROPERTIES
    IMPORTED_LOCATION
    ${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libfoo.so)

add_library(mylib SHARED native-lib.cpp)
target_link_libraries(mylib foo)

然后在 native-lib.cpp#include "foo.h" 并调用 foo 的 API(前提是你有头文件和正确的函数声明)。

4.3 Java/Kotlin 依旧只需要 load 你的桥接库

通常你只 loadLibrary("mylib"),它会间接依赖 libfoo.so
但如果依赖关系复杂,可能需要显式 loadLibrary("foo") 再 load mylib(视链接方式而定)。


五、数据怎么过桥?——“运货”才是 JNI 的难点

调用 native 最难的不在“能不能调用”,而在“怎么安全高效地传数据”。

把 Java/Kotlin 到 C++ 的数据传递想象成过海关:

  • 你能带什么(类型支持)
  • 你怎么申报(转换)
  • 你带的货要不要打包(拷贝)
  • 你离开时要不要把箱子带回去(释放引用)

5.1 基础类型:最省事

  • intjint
  • longjlong
  • float/doublejfloat/jdouble
  • booleanjboolean

5.2 字符串:像“需要翻译的文件”

Java 字符串是 UTF-16,C++ 常用 UTF-8 char*。JNI 提供:

  • GetStringUTFChars(转 UTF-8,可能拷贝)
  • 用完必须 ReleaseStringUTFChars

示意:

JNIEXPORT void JNICALL
Java_xxx_print(JNIEnv* env, jobject, jstring s) {
    const char* c = env->GetStringUTFChars(s, nullptr);
    // use c
    env->ReleaseStringUTFChars(s, c);
}

5.3 数组/ByteBuffer:性能关键

如果你要传图像、音频、模型输入等大块数据,尽量用:

  • byte[]:简单但可能拷贝
  • DirectByteBuffer:可零拷贝拿到指针(常用于高性能)

DirectByteBuffer 像“集装箱直达码头”:减少搬运。


六、库加载与依赖:为什么有时会崩在 UnsatisfiedLinkError

这类错误像“门没开、钥匙不对、工坊不在”。

常见原因清单:

  1. ABI 不匹配:你只打了 arm64,设备是 armeabi-v7a(或反过来)
  2. 库名不对:loadLibrary(“mylib”) 但实际是 libMyLib.so 或没打包
  3. 依赖库缺失:libmylib.so 依赖 libfoo.so,但 foo 没放进 APK
  4. 符号找不到:链接时没把某个函数导出/名称被 C++ name mangling 改了
  5. Android 版本限制:某些旧 NDK API 或使用了不允许的系统私有库

排查建议:

  • adb logcat 看具体缺哪个 so 或符号
  • readelf -d libmylib.so 看依赖项(NEEDED)
  • 确认 APK 内 lib/<abi>/ 是否真的有对应文件

七、线程与回调:原生层能不能“反过来调用 Java”?能,但要按规矩办

有时你希望 native 在某个事件发生时回调 Java,比如:

  • 音频解码完一帧
  • 网络库收到了数据
  • 计算完成通知 UI

可以做到,但要注意:

  • native 线程如果不是从 Java 进来的,需要 AttachCurrentThread 才能用 JNIEnv
  • 回调要避免频繁跨 JNI(会有开销)
  • UI 操作要切回主线程(Android 的基本法)

把它理解为:工坊里的人要打电话回办公楼,必须先去总机登记分机号。


八、为什么有些项目更喜欢用 JNA/反射来“调用原生库”?在 Android 上通常不推荐

JNA(Java Native Access)在桌面 Java 生态较常见,但在 Android 上:

  • 体积与性能成本更高
  • 兼容性与安全策略更复杂
  • 大部分 Android 项目仍以 JNI 为主

所以如果你目标是可控、可上线、可维护:JNI 是主路。


九、发布与安全:把工坊“合法运营”的注意事项

  1. 只带必要 ABI:减少包体(arm64-v8a 通常必带)
  2. 符号与崩溃定位:保留 unstripped so 或单独保留符号文件用于 native 崩溃解析
  3. 混淆与安全:Java 层 Proguard/R8 + native 层符号剥离(但要留调试符号映射)
  4. 第三方库许可证:很多 .so 涉及开源协议,需合规
  5. 隐私合规:原生库如果访问设备信息/权限,依旧受 Android 隐私政策约束

十、把整个流程一句话串起来(记忆版)

Kotlin/Java 用 System.loadLibrary.so 载入内存 → 用 external 声明门口的“窗口” → C/C++ 用 JNI 实现这个窗口并在里面调用真正的原生库函数 → 通过 CMake/Gradle 把各 ABI 的 .so 打包进 APK → 运行时按 ABI 自动选对库加载执行。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

你一身傲骨怎能输

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值