Development/Android

안드로이드 네이티브 계산기 앱 개발 - 2

sh711 2025. 3. 3. 15:38

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
    }

 
테스트 결과 연산 수행 시 네이티브 함수를 통해 계산되는 것을 확인할 수 있다.