안드로이드 네이티브 계산기 앱 개발 - 2
1. 네이티브 연동
저번 글에서 만든 UI에 네이티브 코드를 연동해보겠다.
안드로이드에서 네이티브 빌드 방식은 CMake와 ndk-build 방식으로 2가지가 있다.
1.1 CMake
Android studio에서 공식적으로 권장하는 방식이며 최신 프로젝트에서는 대부분 이 방식을 사용한다.
또한, 프로젝트 내 CMakeLists.txt 파일을 사용해 라이브러리를 빌드하는 방식으로 연동된다.
특징은 다음과 같다.
- 호환성: Android Studio와 긴밀히 통합되어 있으며, 강력한 디버깅 및 설정 도구를 제공
- 유연성: 복잡한 프로젝트를 쉽게 관리할 수 있고, 외부 라이브러리 통합이 용이
- 다중 플랫폼 지원: CMake는 Android 외에도 다른 플랫폼(Windows, Linux 등)을 지원
1.2 ndk-build
Android NDK에서 제공하는 ndk-build 도구를 사용하여 네이티브 코드를 빌드한다.
전통적인 방식으로 기존 프로젝트에서 많이 사용되었다.
특징은 다음과 같다.
- 전통적인 방식: 오래된 NDK 프로젝트에서 널리 사용
- 상대적으로 단순함: 설정 파일이 간단하며, 특정 상황에서는 빠르게 빌드 가능
- 유연성 부족: 대규모 프로젝트나 복잡한 설정 관리에 한계가 있음
최신 방식인 CMake를 이용해 네이티브 코드를 빌드해보겠다.
1. project 단위에서 main 하위에 cpp 디렉터리를 생성한다.

2. cpp 디렉터리 안에 CMakeLists.txt 파일을 생성한다.
CMakeLists.txt 는 외부 모듈 연동 정보 및 설정을 작성하다 파일이다.
- cmake_minimum_required : CMake 버전 지정
- project : 프로젝트 이름 선언
- add_library : 작성된 모듈 파일 컴파일하여 라이브러리 생성 => .so 파일
- add_executable : 실행 가능한 바이너리 생성

3. cpp 디렉터리 안에 "native-lib.c"으로 모듈을 하나 생성한다.


4. 네이티브 연동을 위해 CMakeLists.txt 파일을 작성해준다.
cmake_minimum_required(VERSION 3.22.1) // CMake 버전 지정
project("[프로젝트명]") // 프로젝트 명 지정
add_library(
calculator-lib // 생성할 라이브러리 파일 이름
SHARED
native-lib.c) // 작성된 모듈 파일 컴파일
5. build.gradle 에 CMake 정보를 입력해주고 Sync 시킨다.
android {
....
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
}
이제 모듈 코드를 짜보자
나는 입력 받은 문자열을 후위표기식으로 변환 후 스택을 통해 연산을 진행 시키도록 작성하였다.
stack에 type으로 DOUBLE과 CHAR을 넣어준 이유는 연산과정에서 asc(43)이 '+'로 인식되어 숫자 43과 같이 +, -, *, %, / 연산이 수행되지 않는 버그가 발생해서 피연산자와 연산자를 구분하기 위해 추가하였다.
#include <jni.h>
#include <string.h>
#include <stdlib.h>
#include <ctype.h>
#include <math.h>
#define STACK_SIZE 100
typedef struct {
enum { DOUBLE, CHAR } type;
union {
double doubleValue;
char charValue;
} data;
} StackElement;
typedef struct {
StackElement elements[STACK_SIZE];
int top;
} Stack;
void init_Stack(Stack *stack) {
stack->top = -1;
}
void pushDouble (Stack *stack, double value) {
stack->elements[++stack->top].type = DOUBLE;
stack->elements[stack->top].data.doubleValue = value;
}
void pushChar (Stack *stack, char value) {
stack->elements[++stack->top].type = CHAR;
stack->elements[stack->top].data.charValue = value;
}
double add(double a, double b) {
return a + b;
}
double sub(double a, double b) {
return b - a;
}
double multiply(double a, double b) {
return a * b;
}
double divide(double a, double b) {
return b / a;
}
double mod(double a, double b) {
return fmod(b, a);
}
int operator_Priority(char op) {
if (op == 'x' || op == '/' || op == '%') return 2;
else if (op == '+' || op == '-') return 1;
return 0;
}
void inToPost(const char* expression, Stack *stack) {
char convert_str[100] = "";
char operator_Stack[100];
int rator_top = -1;
char *endptr;
int i = 0, len = strlen(expression);
while (i < len) {
char ch = expression[i];
int j = i;
if (isdigit(ch) || ch == '.') { // 문자가 숫자 또는 point 일때
while (i < len && (isdigit(expression[i]) || expression[i] == '.')) {
convert_str[i] = expression[i];
i++;
}
pushDouble(stack, strtod(&convert_str[j], &endptr)); // 숫자 push
}
if (i < len && !isdigit(expression[i])){ // 문자가 연산자일때
if (rator_top != -1) {
while (rator_top != -1 && operator_Priority(operator_Stack[rator_top]) >= operator_Priority(expression[i])) { // 연산자 우선순위 비교
pushChar(stack, operator_Stack[rator_top--]); // 연산자 push
}
operator_Stack[++rator_top] = expression[i];
i++;
} else {
operator_Stack[++rator_top] = expression[i];
i++;
}
}
}
if (rator_top != -1) {
for (int k = rator_top; k > -1; k--) {
pushChar(stack, operator_Stack[k]);
}
}
}
double calc(Stack *stack) {
double op1, op2;
double result[stack->top+1];
int result_top = -1;
char operator;
for (int i = 0; i <= stack->top; i++) {
if (stack->elements[i].type == DOUBLE) { // stack[i]가 피연산자 일때
result[++result_top] = stack->elements[i].data.doubleValue;
} else if (stack->elements[i].type == CHAR) { // stack[i]가 연산자 일때 연산 수행
op1 = result[result_top--];
op2 = result[result_top--];
operator = stack->elements[i].data.charValue;
switch (operator) {
case '+':
result[++result_top] = add(op1, op2);
break;
case '-':
result[++result_top] = sub(op1, op2);
break;
case 'x':
result[++result_top] = multiply(op1, op2);
break;
case '/':
result[++result_top] = divide(op1, op2);
break;
case '%':
result[++result_top] = mod(op1, op2);
break;
}
}
}
return result[result_top--]; // 연산 결과 result[0] 반환
}
JNIEXPORT jdouble JNICALL
Java_com_test_calculator_Calculator_calculateExpression(JNIEnv *env, jobject obj, jstring expression) {
const char* nativeExpression = (*env)->GetStringUTFChars(env, expression, 0);
printf("Received expression: %s\n", nativeExpression);
Stack stack;
init_Stack(&stack); // 스택 초기화
inToPost(nativeExpression, &stack); // 문자열 후위표기식으로 변경
double result = calc(&stack); // 연산
(*env)->ReleaseStringUTFChars(env, expression, nativeExpression);
result = round(result * 100000) / 100000;
return result;
}
이제 해당 라이브러리를 불러와보자
패키지명 하위에 Calculator이라는 클래스 파일을 생성해준다.
해당 클래스에서 loadLibrary를 통해 작성된 모듈을 가져올 수 있다.
이 때 라이브러리 파일 이름은 CMakeLists.txt에서 지정한 생성할 라이브러리 파일 이름을 적어준다.
package com.hyunho.calc
class Calculator {
companion object {
init {
System.loadLibrary("calculator-lib") // CMakeLists.txt에 정의된 라이브러리 이름
}
}
external fun calculateExpression(input: String): Double // 모듈에서 정의된 네이티브 함수
}
이제 MainActivity에서 해당 Calculator 클래스 객체 생성 시 init 생성자 과정을 통해 calculator-lib 라이브러리가 로드된다.
val calculator = Calculator()
이제 '=' 버튼을 눌럿을 때 tvResult의 text 값을 네이티브 함수로 보내주는 로직을 추가해준다.
연산 결과를 ViewModel 클래스의 result 함수로 보내준다.
// MainActivity
...
...
binding.result.setOnClickListener {
if (binding.tvResult.text == "0") {
Toast.makeText(this, "연산을 입력하세요!!!", Toast.LENGTH_SHORT).show()
} else {
binding.tvInput.text = binding.tvResult.text
var result_FromNative = calculator.calculateExpression(binding.tvResult.text.toString())
if (result_FromNative.toString().endsWith(".0")) {
calculatorViewModel.result(result_FromNative.toDouble().toInt().toString())
} else {
calculatorViewModel.result(result_FromNative.toString())
}
}
}
LiveData를 통해 tvResult.text 값이 변하게된다.
fun result(result: String) {
_result.value = result
}
테스트 결과 연산 수행 시 네이티브 함수를 통해 계산되는 것을 확인할 수 있다.
