Kotlin/Native로 Apple 프레임워크 만들기 – 튜토리얼

Kotlin/Native로 Apple 프레임워크 만들기 – 튜토리얼

Kotlin/Native는 Swift/Objective-C와 양방향 상호 운용성을 제공해요. Objective-C 프레임워크와 라이브러리를 Kotlin 코드에서 사용할 수도 있고, Kotlin 모듈을 Swift/Objective-C 코드에서 사용할 수도 있죠. 이 튜토리얼에서는 macOS와 iOS에서 동작하는 Swift/Objective-C 애플리케이션에 쓸 수 있도록 직접 프레임워크를 만들고 Kotlin/Native 코드를 사용하는 방법을 배워볼 거예요.

이 튜토리얼에서 다룰 내용은 다음과 같아요:

Note:

Objective-C 라이브러리 가져오기는 Beta 상태예요. cinterop 도구가 Objective-C 라이브러리에서 생성한 모든 Kotlin 선언에는 @ExperimentalForeignApi 애노테이션이 붙어야 해요. Kotlin/Native에 포함된 네이티브 플랫폼 라이브러리(Foundation, UIKit, POSIX 등)는 일부 API에만 옵트인이 필요해요.

출처: Kotlin/Native as an Apple framework – tutorial

본문

명령줄을 사용해 Kotlin 프레임워크를 만들 수도 있어요. 직접 실행하거나 스크립트 파일(.sh.bat 파일 등)을 통해 실행하는 방식이죠. 다만 이 방식은 수백 개의 파일과 라이브러리를 다루는 큰 프로젝트에서는 확장성이 떨어져요. 빌드 시스템을 쓰면 Kotlin/Native 컴파일러 바이너리와 라이브러리를 전이적 의존성까지 포함해 다운로드하고 캐시하고, 컴파일러와 테스트를 실행하는 과정이 훨씬 간단해져요. Kotlin/Native는 Kotlin Multiplatform 플러그인을 통해 Gradle 빌드 시스템을 사용할 수 있어요.

Note:

Mac을 사용하면서 iOS나 다른 Apple 타깃용 애플리케이션을 만들고 실행하려면 먼저 Xcode Command Line Tools를 설치하고 실행한 뒤 라이선스 약관에 동의해야 해요.

Kotlin 라이브러리 만들기

Tip:

새 Kotlin/Native 프로젝트를 만들고 IntelliJ IDEA에서 여는 방법에 대한 자세한 첫 단계와 안내는 Get started with Kotlin/Native 튜토리얼을 참고하세요.

Kotlin/Native 컴파일러는 Kotlin 코드로 macOS와 iOS용 프레임워크를 만들 수 있어요. 만들어진 프레임워크에는 Swift/Objective-C에서 사용하는 데 필요한 모든 선언과 바이너리가 포함돼요.

먼저 Kotlin 라이브러리를 만들어 볼게요:

  1. src/nativeMain/kotlin 디렉터리에 라이브러리 내용을 담은 lib.kt 파일을 만들어요:

    package example
    
    object Object {
        val field = "A"
    }
    
    interface Interface {
        fun iMember() {}
    }
    
    class Clazz : Interface {
        fun member(p: Int): ULong? = 42UL
    }
    
    fun forIntegers(b: Byte, s: UShort, i: Int, l: ULong?) { }
    fun forFloats(f: Float, d: Double?) { }
    
    fun strings(str: String?) : String {
        return "That is '$str' from C"
    }
    
    fun acceptFun(f: (String) -> String?) = f("Kotlin/Native rocks!")
    fun supplyFun() : (String) -> String? = { "$it is cool!" }
    
  2. build.gradle(.kts) Gradle 빌드 파일을 다음과 같이 수정해요:

    import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
    
    plugins {
        kotlin("multiplatform") version "2.4.20"
    }
    
    repositories {
        mavenCentral()
    }
    
    kotlin {
        iosArm64()
        // macosArm64()
        // iosSimulatorArm64()
    
        targets.withType<KotlinNativeTarget>().configureEach {
            binaries {
                framework {
                    baseName = "Demo"
                }
            }
        }
    }
    
    tasks.wrapper {
        gradleVersion = "9.7.0"
        distributionType = Wrapper.DistributionType.ALL
    }
    
    import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
    
    plugins {
        id 'org.jetbrains.kotlin.multiplatform' version '2.4.20'
    }
    
    repositories {
        mavenCentral()
    }
    
    kotlin {
        iosArm64()
        // macosArm64()
        // iosSimulatorArm64()
    
        targets.withType(KotlinNativeTarget).configureEach {
            binaries {
                framework {
                    baseName = "Demo"
                }
            }
        }
    }
    
    wrapper {
        gradleVersion = "9.7.0"
        distributionType = "ALL"
    }
    

    binaries {} 블록은 프로젝트가 동적 또는 공유 라이브러리를 생성하도록 설정해요.

    Kotlin/Native는 iOS용 iosArm64iosSimulatorArm64 타깃, macOS용 macosArm64 타깃을 지원해요. 그러니 타깃 플랫폼에 맞는 Gradle 함수로 iosArm64()를 바꿔서 사용하면 돼요:

    타깃/기기 Gradle 함수
    macOS ARM64 macosArm64()
    iOS ARM64 iosArm64()
    iOS 시뮬레이터 (ARM64) iosSimulatorArm64()

    지원하는 다른 Apple 타깃에 대한 정보는 Kotlin/Native target support에서 확인할 수 있어요.

  3. 프레임워크를 빌드하려면 IDE에서 linkDebugFramework<YourTargetName> Gradle 태스크를 실행하거나 터미널에서 아래처럼 콘솔 명령을 실행해요:

    ./gradlew linkDebugFrameworkIosArm64
    

빌드가 끝나면 프레임워크는 build/bin/<yourTargetName>/debugFramework 디렉터리로 생성돼요.

Tip:

일반 link<YourTargetName> Gradle 태스크를 사용하면 프레임워크의 debugrelease 변형을 모두 생성할 수도 있어요.

생성된 프레임워크 헤더

각 프레임워크 변형에는 헤더 파일이 들어 있어요. 헤더는 타깃 플랫폼에 의존하지 않아요. 헤더 파일에는 여러분의 Kotlin 코드에 대한 정의와 몇 가지 Kotlin 전체에 걸친 선언이 포함돼요. 어떤 내용이 들어 있는지 살펴볼게요.

Kotlin/Native 런타임 선언

build/bin/<yourTargetName>/debugFramework/Demo.framework/Headers 디렉터리에서 Demo.h 헤더 파일을 열어요. Kotlin 런타임 선언을 살펴보면:

NS_ASSUME_NONNULL_BEGIN
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wunknown-warning-option"
#pragma clang diagnostic ignored "-Wincompatible-property-type"
#pragma clang diagnostic ignored "-Wnullability"

#pragma push_macro("_Nullable_result")
#if !__has_feature(nullability_nullable_result)
#undef _Nullable_result
#define _Nullable_result _Nullable
#endif

__attribute__((swift_name("KotlinBase")))
@interface DemoBase : NSObject
- (instancetype)init __attribute__((unavailable));
+ (instancetype)new __attribute__((unavailable));
+ (void)initialize __attribute__((objc_requires_super));
@end

@interface DemoBase (DemoBaseCopying) <NSCopying>
@end

__attribute__((swift_name("KotlinMutableSet")))
@interface DemoMutableSet<ObjectType> : NSMutableSet<ObjectType>
@end

__attribute__((swift_name("KotlinMutableDictionary")))
@interface DemoMutableDictionary<KeyType, ObjectType> : NSMutableDictionary<KeyType, ObjectType>
@end

@interface NSError (NSErrorDemoKotlinException)
@property (readonly) id _Nullable kotlinException;
@end

Kotlin 클래스는 Swift/Objective-C에서 NSObject 클래스를 확장하는 KotlinBase 기본 클래스를 가져요. 컬렉션과 예외를 위한 래퍼도 있어요. 대부분의 컬렉션 타입은 Swift/Objective-C의 비슷한 컬렉션 타입으로 매핑돼요:

Kotlin Swift Objective-C
List Array NSArray
MutableList NSMutableArray NSMutableArray
Set Set NSSet
MutableSet NSMutableSet NSMutableSet
Map Dictionary NSDictionary
MutableMap NSMutableDictionary NSMutableDictionary

Kotlin 숫자 타입과 NSNumber

Demo.h 파일의 다음 부분에는 Kotlin/Native 숫자 타입과 NSNumber 사이의 타입 매핑이 담겨 있어요. 기본 클래스는 Objective-C에서 DemoNumber, Swift에서 KotlinNumber라고 불러요. NSNumber를 확장하죠.

Kotlin 숫자 타입마다 대응하는 미리 정의된 하위 클래스가 있어요:

Kotlin Swift Objective-C 단순 타입
- KotlinNumber <Package>Number -
Byte KotlinByte <Package>Byte char
UByte KotlinUByte <Package>UByte unsigned char
Short KotlinShort <Package>Short short
UShort KotlinUShort <Package>UShort unsigned short
Int KotlinInt <Package>Int int
UInt KotlinUInt <Package>UInt unsigned int
Long KotlinLong <Package>Long long long
ULong KotlinULong <Package>ULong unsigned long long
Float KotlinFloat <Package>Float float
Double KotlinDouble <Package>Double double
Boolean KotlinBoolean <Package>Boolean BOOL/Bool

모든 숫자 타입에는 대응하는 단순 타입에서 새 인스턴스를 만드는 클래스 메서드가 있어요. 또한 단순 값으로 다시 추출하는 인스턴스 메서드도 있죠. 이런 선언들은 개략적으로 모두 다음과 같은 모양이에요:

__attribute__((swift_name("Kotlin__TYPE__")))
@interface Demo__TYPE__ : DemoNumber
- (instancetype)initWith__TYPE__:(__CTYPE__)value;
+ (instancetype)numberWith__TYPE__:(__CTYPE__)value;
@end;

여기서 __TYPE__는 단순 타입 이름 중 하나이고, __CTYPE__는 대응하는 Objective-C 타입이에요. 예를 들면 initWithChar(char)처럼요.

이 타입들은 박스형 Kotlin 숫자 타입을 Swift/Objective-C로 매핑하는 데 사용돼요. Swift에서는 생성자를 호출해 인스턴스를 만들 수 있어요. 예를 들어 KotlinLong(value: 42)처럼요.

Kotlin의 클래스와 object

classobject가 Swift/Objective-C로 어떻게 매핑되는지 살펴볼게요. 생성된 Demo.h 파일에는 Class, Interface, Object의 정확한 정의가 들어 있어요:

__attribute__((swift_name("Interface")))
@protocol DemoInterface
@required
- (void)iMember __attribute__((swift_name("iMember()")));
@end

__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("Clazz")))
@interface DemoClazz : DemoBase <DemoInterface>
- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer));
+ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead")));
- (DemoULong * _Nullable)memberP:(int32_t)p __attribute__((swift_name("member(p:)")));
@end

__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("Object")))
@interface DemoObject : DemoBase
+ (instancetype)alloc __attribute__((unavailable));
+ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable));
+ (instancetype)object __attribute__((swift_name("init()")));
@property (class, readonly, getter=shared) DemoObject *shared __attribute__((swift_name("shared")));
@property (readonly) NSString *field __attribute__((swift_name("field")));
@end

이 코드의 Objective-C 속성들은 프레임워크를 Swift와 Objective-C 양쪽 언어에서 모두 사용할 수 있게 도와줘요. DemoInterface, DemoClazz, DemoObject는 각각 Interface, Clazz, Object를 위해 만들어졌어요.

Interface@protocol로 변환되고, classobject는 모두 @interface로 표현돼요. Demo 접두사는 프레임워크 이름에서 나온 거예요. nullable 반환 타입 ULong?는 Objective-C에서 DemoULong으로 변환돼요.

Kotlin의 전역 선언

Kotlin의 모든 전역 함수는 Objective-C에서 DemoLibKt, Swift에서 LibKt로 변환돼요. 여기서 Demokotlinc-native-output 매개변수로 설정한 프레임워크 이름이에요:

__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("LibKt")))
@interface DemoLibKt : DemoBase
+ (NSString * _Nullable)acceptFunF:(NSString * _Nullable (^)(NSString *))f __attribute__((swift_name("acceptFun(f:)")));
+ (void)forFloatsF:(float)f d:(DemoDouble * _Nullable)d __attribute__((swift_name("forFloats(f:d:)")));
+ (void)forIntegersB:(int8_t)b s:(uint16_t)s i:(int32_t)i l:(DemoULong * _Nullable)l __attribute__((swift_name("forIntegers(b:s:i:l:)")));
+ (NSString *)stringsStr:(NSString * _Nullable)str __attribute__((swift_name("strings(str:)")));
+ (NSString * _Nullable (^)(NSString *))supplyFun __attribute__((swift_name("supplyFun()")));
@end

Kotlin String과 Objective-C NSString*는 투명하게 매핑돼요. 마찬가지로 Kotlin의 Unit 타입은 void로 매핑돼요. 기본 타입은 직접 매핑되고, non-nullable 기본 타입은 투명하게 매핑돼요. nullable 기본 타입은 위 표에서 본 것처럼 Kotlin<TYPE>* 타입으로 매핑돼요. 두 고차 함수 acceptFunFsupplyFun도 모두 포함되며 Objective-C 블록을 받아요.

타입 매핑에 대한 더 자세한 정보는 Interoperability with Swift/Objective-C에서 확인할 수 있어요.

가비지 컬렉션과 참조 카운팅

Swift와 Objective-C는 자동 참조 카운팅(ARC)을 사용해요. Kotlin/Native는 자체 가비지 컬렉터가 있고, 이것은 Swift/Objective-C ARC와도 통합돼요.

사용하지 않는 Kotlin 객체는 자동으로 제거돼요. Swift나 Objective-C에서 Kotlin/Native 인스턴스의 수명을 제어하기 위해 추가로 할 일은 없어요.

Objective-C에서 코드 사용하기

Objective-C에서 프레임워크를 호출해 볼게요. 프레임워크 디렉터리에 다음과 같은 코드로 main.m 파일을 만들어요:

#import <Foundation/Foundation.h>
#import <Demo/Demo.h>

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        [DemoObject.shared field];
        
        DemoClazz* clazz = [[ DemoClazz alloc] init];
        [clazz memberP:42];
        
        [DemoLibKt forIntegersB:1 s:1 i:3 l:[DemoULong numberWithUnsignedLongLong:4]];
        [DemoLibKt forIntegersB:1 s:1 i:3 l:nil];
        
        [DemoLibKt forFloatsF:2.71 d:[DemoDouble numberWithDouble:2.71]];
        [DemoLibKt forFloatsF:2.71 d:nil];
        
        NSString* ret = [DemoLibKt acceptFunF:^NSString * _Nullable(NSString * it) {
            return [it stringByAppendingString:@" Kotlin is fun"];
        }];
        
        NSLog(@"%@", ret);
        return 0;
    }
}

여기서 Objective-C 코드에서 Kotlin 클래스를 직접 호출해요. Kotlin object는 <object name>.shared 클래스 속성을 사용하는데, 이를 통해 object의 유일한 인스턴스를 얻고 그 인스턴스에서 object 메서드를 호출할 수 있어요.

Clazz 클래스의 인스턴스를 만드는 데 널리 쓰이는 패턴이 사용돼요. Objective-C에서 [[ DemoClazz alloc] init]을 호출하는 거예요. 매개변수가 없는 생성자에는 [DemoClazz new]를 쓸 수도 있어요.

Kotlin 소스의 전역 선언은 Objective-C에서 DemoLibKt 클래스 아래로 범위가 정해져요. 모든 Kotlin 함수는 그 클래스의 클래스 메서드로 변환돼요.

strings 함수는 Objective-C에서 DemoLibKt.stringsStr 함수로 변환되기 때문에, NSString을 직접 전달할 수 있어요. 반환 값도 NSString으로 보여요.

Swift에서 코드 사용하기

생성한 프레임워크에는 Swift에서 더 쉽게 사용할 수 있게 해주는 도우미 속성들이 있어요. 앞의 Objective-C 예제를 Swift로 변환해 볼게요.

프레임워크 디렉터리에 다음과 같은 코드로 main.swift 파일을 만들어요:

import Foundation
import Demo

let kotlinObject = Object.shared

let field = Object.shared.field

let clazz = Clazz()
clazz.member(p: 42)

LibKt.forIntegers(b: 1, s: 2, i: 3, l: 4)
LibKt.forFloats(f: 2.71, d: nil)

let ret = LibKt.acceptFun { "\($0) Kotlin is fun" }
if (ret != nil) {
    print(ret!)
}

원래 Kotlin 코드와 Swift 버전 사이에는 몇 가지 작은 차이가 있어요. Kotlin에서는 어떤 object 선언이든 인스턴스가 하나뿐이에요. 이 유일한 인스턴스에 접근할 때 Object.shared 구문을 사용해요.

Kotlin 함수와 프로퍼티 이름은 그대로 변환돼요. Kotlin의 String은 Swift의 String이 돼요. Swift는 NSNumber* 박싱도 숨겨요. Swift 클로저를 Kotlin에 전달하고 Swift에서 Kotlin 람다 함수를 호출할 수도 있어요.

타입 매핑에 대한 더 자세한 정보는 Interoperability with Swift/Objective-C에서 확인할 수 있어요.

프레임워크를 iOS 프로젝트에 연결하기

이제 생성한 프레임워크를 iOS 프로젝트에 의존성으로 연결할 수 있어요. 설정하고 자동화하는 방법은 여러 가지가 있으니, 자신에게 가장 잘 맞는 방법을 선택하면 돼요:

Choose iOS integration method

더 알아보기