이번 포스팅에서는 로컬 환경에 NDK를 설치 · 안드로이드 프로젝트에 세팅하고,
JNI를 사용하여 C언어로 작성된 코드를 안드로이드 환경에서 호출해본다.
NDK 설치 · 안드로이드 프로젝트에 세팅
✅ 안드로이드 스튜디오에서 NDK 설치
[Tools] → [SDK Manager] → [Android SDK] → [SDK Tools] 탭에 진입한다.
그리고 NDK를 찾아서 원하는 버전을 고르고 [Apply] 해준다.
위에 적힌 [Android SDK Location]에서 설정된 경로를 확인해줘야 한다.
예제에서는 현시점 가장 최신 버전 28.0.12674087을 설치했다.
설치가 끝나면 [Finish] 눌러준다.
✅ NDK 설치 확인
아까 말했던 경로를 직접 찾아가면 [ndk] → [설치한 버전명] 폴더가 생성되어 있다.
NDK가 잘 설치된 모습이다.
✅안드로이드 스튜디오 내 프로젝트에 NDK 경로 세팅
이제 안드로이드 스튜디오에 해당 경로를 세팅해줘야 한다.
[File] → [Project Structure] → [SDK Location] 탭에 진입한다.
2번째에 있는 Android NDK location에 경로를 세팅하면 된다.
하지만 간혹 위 처럼 경로를 입력할 수 없는 현상이 발생한다.
당황하지 말고 [Gradle Scripts] → [local.properties] 에 진입해준다.
그리고 위와 같이 NDK 경로를 직접 입력해주자.
다시 [File] → [Project Structure] → [SDK Location] 탭에 진입하면,
조금 전에 직접 입력했던 경로가 채워져있는 것을 확인할 수 있다.
그리고 그래들 파일에도 NDK 버전을 정의해주자.
build.gradle.kt
android {
...
ndkVersion = "28.0.12674087"
}
설치한 NDK 버전과 동일하도록 작성해주면 된다.
사실 이 부분까지는 굳이 안해도 된다.
필자처럼 NDK 버전을 여러개 설치하는 등 삽질을 했다면...
NDK 빌드 환경을 통일해주는 과정이 필요할 뿐이다.
✅환경 변수 세팅
NDK를 빌드하기 위해 환경 변수에 NDK 경로를 추가해야 한다.
[제어판] → [환경 변수] 진입하여 Path를 더블클릭한다.
그리고 위 처럼 NDK 경로를 추가해준다.
이제 NDK 설치 및 경로 설정이 끝났다.
JNI 예제
✅Java 파일 세팅
JNI 예제라서 Java 파일을 세팅한다고 표현했지만,
Java와 100% 호환을 자랑하는 Kotlin으로 예제를 작성한다.
MainActivity.kt
class MainActivity {
external fun getNativeTestText(): String
override fun onCreate() {
...
System.loadLibrary("nativetest-lib")
bind.textview.text = getNativeTestText()
}
}
유심히 봐야할 부분은 2가지다.
● System.loadLibrary()
네이티브 함수를 사용하기 전에, 네이티브 라이브러리를 먼저 로드해야 한다.
아직 생성하지 않았지만, 이제 곧 NDK 빌드를 통해 .so 파일을(공유 라이브러리) 생성할 것이다.
● external fun getNativeTestText(): String
코틀린에서는 external 키워드로 네이티브 함수를 선언할 수 있다.
네이티브 코드에서 반환받을 타입을 명시해주고, 함수명을 정확히 기억해야 한다.
이 함수명을 C 코드에서 동일하게 작성해줘야하기 때문이다.
그리고 TextView를 하나 만들어서 setText() 해줄 예정이다.
✅JNI 패키지 세팅
안드로이드 스튜디와 좌측탭을 [Project] 형식으로 변경 후,
[main] 패키지 하위로 [jni] 폴더를 만들어주자.
그리고 [jni] 폴더에 다음과 같이 3가지 파일을 생성해줄 것이다.
- nativetest-lib.c
- Android.mk
- Application.mk
nativetest-lib.c
#include <jni.h>
JNIEXPORT jstring JNICALL Java_com_example_testapp_MainActivity_getNativeTestText(JNIEnv *env, jobject obj) {
return (*env)->NewStringUTF(env, "네이티브 함수 호출!");
}
단순히 문자열 데이터 하나를 반환하는 함수인데,
JNI, C 공부가 부족한 사람에겐 생소한 문법이 많이 보인다.
간단히라도 풀이해보자.
● #include <jni.h>
JNI 관련 함수와 데이터 타입을 사용하기 위한 헤더 파일 include
● 엄청 긴(?) 함수명
- JNIEXPORT : 함수가 네이티브 라이브러리에서 외부로 공개됨을 의미 (JNI 함수에서는 필수)
- jstring : 함수의 반환 타입. Java의 String을 JNI에서는 jstring으로 사용
- JNICALL : JNI 함수 호출 규칙 (JNI 함수에서는 필수)
- Java_<패키지명>_<클래스명>_<함수명> : JNI 문법 상의 고정된 함수명이다.
반드시 Java 파일에서 작성한 함수명을 동일하게 작성해줘야 한다.
- 파라미터 : JNI 함수에 반드시 포함되는 2개의 파라미터
- JNIEnv : JNI 환경을 나타내는 포인터이며, 이 포인터로 객체 생성, 데이터 변환 등의 작업 수행
- jobject : 이 함수를 호출한 Java 객체를 나타낸다.
상황에 따라 위 2개의 파라미터를 사용하지 않을 수도 있다.
● 반환 문자열
C의 문자열(const char*)은 Java(String)의 문자열과 형식이 다르다.
그렇기에 (*env)->NewStringUTF(env, "문자열") 문법을 사용하여
Java에서 인식할 수 있는 jstring 객체로 변환 후 반환해야 한다.
Android.mk
# 소스 파일의 위치 지정
LOCAL_PATH := $(call my-dir)
# LOCAL 값 중복 제거
include $(CLEAR_VARS)
# 모듈 이름 지정
LOCAL_MODULE := nativetest-lib
# 소스 파일 지정
LOCAL_SRC_FILES := nativetest-lib.c
# 공유 라이브러리로 빌드
include $(BUILD_SHARED_LIBRARY)
빌드할 네이티브 라이브러리를 정의하는 파일이다.
각 줄마다 주석을 작성했으니 추가적인 설명은 생략한다.
Application.mk
APP_ABI := all
여러 CPU를 지원하기 위해 작성하는 파일이다.
특정 CPU 아키텍처만 지원하려면 다음과 같이 명시할 수 있다.
APP_ABI := armeabi-v7a arm64-v8a
● 참고
arm64-v8a : ARM 64비트 아키텍처
armeabi-v7a : ARM 32비트 아키텍처
x86 : Intel 32비트 아키텍처
x86_64 : Intel 64비트 아키텍처
그리고 그래들에 다음과 같이 Android.mk 파일을 명시해준다.
build.gradle.kts
android {
...
externalNativeBuild {
ndkBuild {
path("src/main/jni/Android.mk")
}
}
}
✅so 파일 생성 (NDK 빌드)
이제 터미널을 열고 프로젝트의 app/src/main/jni 폴더로 이동 후,
ndk-build
입력해준다.
빌드가 완료되면 자동으로 libs 폴더가 생성되며,
그 하위에 다음과 같이 공유 라이브러리 파일들이 생성된다.
Application.mk 파일에서 APP_ABI := all 로 작성했기 때문에,
다양한 CPU 아키텍처마다 폴더가 생성되었음을 볼 수 있다.
✅결과
이제 앱을 실행해보면..!
JNI를 사용하여 C로 작성한 함수를 호출하는데 성공했다.
번외 : 간단한 성능 테스트
네이티브 언어의 성능이 얼마나 좋길래 이렇게까지 해야하는지...
궁금해졌기 때문에 간단한 연산을 진행해보고 싶다.
C와 Kotlin으로 단순 반복문을 10만번 정도 돌려보고 속도 차이를 확인해보자.
nativetest-lib.c
JNIEXPORT jint JNICALL Java_com_itxai_testapp_MainActivity_getNativeSum(JNIEnv *env, jobject obj, jint num1, jint num2) {
int value = 0;
for (int i = 0; i < 100000; i++) {
value += num1 + num2;
}
return value;
}
int 타입을 반환할 것이기에 반환타입에 jint 작성해주고,
기본 파라미터 2개(JNIEnv, jobject)를 제외하고도 추가로 파라미터 2개를 작성해줬다.
MainActivity.kt
external fun getNativeSum(num1: Int, num2: Int): Int
private fun getSumValueInNative(num1: Int, num2: Int) {
Log.d(TAG, "C - 시작 시간: ${System.currentTimeMillis()}")
Log.d(TAG, "C - 결과 값: ${getNativeSum(num1, num2)}")
Log.d(TAG, "C - 종료 시간: ${System.currentTimeMillis()}")
}
private fun getSumValue(num1: Int, num2: Int) {
Log.d(TAG, "Java - 시작 시간: ${System.currentTimeMillis()}")
var value = 0
for (i in 0 until 100000) {
value += num1 + num2
}
Log.d(TAG, "Java - 결과 값: $value")
Log.d(TAG, "Java - 종료 시간: ${System.currentTimeMillis()}")
}
override fun onCreate() {
...
System.loadLibrary("nativetest-lib")
getSumValueInNative(3, 6)
getSumValue(3, 6)
}
네이티브 함수에 파라미터를 넣어서 호출하고,
Kotlin으로 작성한 반복문이 포함된 함수도 동일한 파라미터를 넣어서 호출했다.
결과)
10만번의 반복문 연산 속도 측정 결과는 다음과 같다.
C : 1ms
Java : 11ms
C가 압도하는 모습을 보여준다.
생각보다 차이가 심해서 놀람..
이번 포스팅에서는 NDK와 JNI를 직접 다뤄보았다.
NDK와 JNI를 폭 넓게 사용하려면 C언어 공부도...해야할 것..같다.
'Android (안드로이드)' 카테고리의 다른 글
[Android] 안드로이드 스튜디오 단축키 정리 (Windows) / + 소소한 크롬 단축키 정리 (4) | 2024.12.10 |
---|---|
[Android] NDK / JNI (3) - CMake 방식 적용 (1) | 2024.12.09 |
[Android] NDK / JNI (1) (2) | 2024.12.04 |
[Android] 안드로이드 환경에서 C언어 사용 이유? / Java · C 성능 차이점 (5) | 2024.12.02 |
[Android] libvlc - RTSP 스트리밍 연결 (7) | 2024.11.29 |