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 코드를 사용하는 방법을 배워볼 거예요.
이 튜토리얼에서 다룰 내용은 다음과 같아요:
- Kotlin 라이브러리를 만들어 프레임워크로 컴파일하기
- 생성된 Swift/Objective-C API 코드 살펴보기
- Objective-C에서 프레임워크 사용하기
- Swift에서 프레임워크 사용하기
Note:
Objective-C 라이브러리 가져오기는 Beta 상태예요. cinterop 도구가 Objective-C 라이브러리에서 생성한 모든 Kotlin 선언에는
@ExperimentalForeignApi애노테이션이 붙어야 해요. Kotlin/Native에 포함된 네이티브 플랫폼 라이브러리(Foundation, UIKit, POSIX 등)는 일부 API에만 옵트인이 필요해요.
본문
명령줄을 사용해 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 라이브러리를 만들어 볼게요:
-
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!" } -
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용
iosArm64와iosSimulatorArm64타깃, macOS용macosArm64타깃을 지원해요. 그러니 타깃 플랫폼에 맞는 Gradle 함수로iosArm64()를 바꿔서 사용하면 돼요:타깃/기기 Gradle 함수 macOS ARM64 macosArm64()iOS ARM64 iosArm64()iOS 시뮬레이터 (ARM64) iosSimulatorArm64()지원하는 다른 Apple 타깃에 대한 정보는 Kotlin/Native target support에서 확인할 수 있어요.
-
프레임워크를 빌드하려면 IDE에서
linkDebugFramework<YourTargetName>Gradle 태스크를 실행하거나 터미널에서 아래처럼 콘솔 명령을 실행해요:./gradlew linkDebugFrameworkIosArm64
빌드가 끝나면 프레임워크는 build/bin/<yourTargetName>/debugFramework 디렉터리로 생성돼요.
Tip:
일반
link<YourTargetName>Gradle 태스크를 사용하면 프레임워크의debug와release변형을 모두 생성할 수도 있어요.
생성된 프레임워크 헤더
각 프레임워크 변형에는 헤더 파일이 들어 있어요. 헤더는 타깃 플랫폼에 의존하지 않아요. 헤더 파일에는 여러분의 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
class와 object가 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로 변환되고, class와 object는 모두 @interface로 표현돼요. Demo 접두사는 프레임워크 이름에서 나온 거예요. nullable 반환 타입 ULong?는 Objective-C에서 DemoULong으로 변환돼요.
Kotlin의 전역 선언
Kotlin의 모든 전역 함수는 Objective-C에서 DemoLibKt, Swift에서 LibKt로 변환돼요. 여기서 Demo는 kotlinc-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>* 타입으로 매핑돼요. 두 고차 함수 acceptFunF와 supplyFun도 모두 포함되며 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 프로젝트에 의존성으로 연결할 수 있어요. 설정하고 자동화하는 방법은 여러 가지가 있으니, 자신에게 가장 잘 맞는 방법을 선택하면 돼요: