package:ffigen을 사용한 Objective-C와 Swift interop
package:ffigen을 사용한 Objective-C와 Swift interop
Dart 프로그램에서 Objective-C와 Swift 코드를 사용하려면 package:ffigen을 사용하세요.
출처: 원문
본문
Dart Native 플랫폼(macOS 또는 iOS)에서 실행되는 Dart 앱은 dart:ffi와 package:ffigen을 사용해 Objective-C와 Swift API를 호출할 수 있어요.
dart:ffi는 Dart 코드가 네이티브 C API와 상호작용할 수 있게 해 줘요. Objective-C는 C에 기반하고 C와 호환되므로 dart:ffi만으로도 Objective-C API와 상호작용하는 게 가능해요. 하지만 그러려면 상당한 보일러플레이트가 필요하기 때문에, 주어진 Objective-C API에 대한 Dart FFI 바인딩을 자동으로 생성하려면 package:ffigen을 사용할 수 있어요. FFI와 C 코드 직접 상호작용에 대해 더 자세히 알고 싶다면 C interop 가이드를 참고하세요.
Swift API에 대한 Objective-C 헤더를 생성하면 dart:ffi와 package:ffigen이 Swift와 상호작용할 수 있게 돼요.
FFIgen 사용에 대한 더 자세한 내용은 FFIgen README와 추가 문서를 참고하세요.
Objective-C 예시
이 가이드는 package:ffigen으로 AVAudioPlayer 바인딩을 생성하는 예시를 안내해요. 이 API는 최소 macOS SDK 10.7이 필요하므로 버전을 확인하고 필요하면 Xcode를 갱신하세요:
$ xcodebuild -showsdks
Objective-C API를 감싸는 바인딩을 생성하는 건 C API를 감싸는 것과 비슷해요. API를 설명하는 헤더 파일에 package:ffigen을 지정하고, dart:ffi로 라이브러리를 로드하면 돼요.
package:ffigen은 LLVM을 사용해 Objective-C 헤더 파일을 파싱하므로 먼저 LLVM을 설치해야 해요. 자세한 내용은 FFIgen README의 Installing LLVM을 참고하세요.
Objective-C용 FFIgen 구성하기
먼저 package:ffigen을 dev 의존성으로, 헬퍼인 package:objective_c와 package:ffi를 일반 의존성으로 추가하세요:
$ dart pub add dev:ffigen objective_c ffi
그다음 FFIgen이 API를 담은 Objective-C 헤더에 대한 바인딩을 생성하도록 구성해요. FFIgen을 YAML 또는 Dart 코드로 구성하세요. 새 프로젝트에는 Dart를 권장해요. YAML 구성은 향후 FFIgen 버전에서 deprecated될 예정이에요. 패키지 어딘가에 generate_code.dart 스크립트를 만들어 시작해요. 이 파일은 my_package/tool에 두는 걸 권장해요.
generate_code.dart 스크립트는 모든 구성 옵션을 담을 FfiGenerator 객체를 만든 다음, 그 .generate() 메서드를 호출해야 해요.
import 'package:ffigen/ffigen.dart';
final config = FfiGenerator(
);
void main() => config.generate();
먼저 FFIgen에 바인딩을 생성하려는 API를 어디서 찾을지 알려줘요. 이를 위해 headers.entryPoints 옵션을 설정해요.
이 예시에서는 AVAudioPlayer.h를 로드할 거예요. 이것은 Xcode 설치에 있는 AVFAudio 프레임워크의 일부예요. FFIgen은 macSdkPath 같은 이 유형의 API를 찾는 데 도움이 되는 헬퍼 함수를 포함해요. 이런 헬퍼 함수를 사용하면 SDK 설치 위치가 다를 수 있는 서로 다른 머신에서 코드 생성 스크립트가 더 안정적으로 동작해요.
macSdkPath 유틸리티는 xcrun --show-sdk-path --sdk macosx를 실행해 macOS SDK를 찾아요. 터미널에서 이 명령을 실행해 macOS SDK를 찾거나, --sdk iphoneos와 함께 실행해 iOS SDK를 찾을 수 있어요. Apple API에 대한 바인딩을 생성할 때 이 디렉토리들을 탐색하면 FFIgen에 전달할 올바른 헤더를 찾는 좋은 방법이 돼요.
import 'package:ffigen/ffigen.dart';
final config = FfiGenerator(
headers: Headers(
entryPoints: [
Uri.file(
'$macSdkPath/System/Library/Frameworks/AVFAudio.framework/Headers/AVAudioPlayer.h',
),
],
),
);
void main() => config.generate();
다음으로 출력 파일을 정의해요. FFIgen의 주요 출력은 주어진 입력에 대한 바인딩을 담은 단일 Dart 파일이에요. 이 파일의 위치는 output.dartFile 옵션으로 정의돼요.
FFIgen은 때때로 API와의 interop에 필요한 Objective-C 코드를 담은 .m 파일을 생성해요. FFIgen은 API가 요구할 때만(예: block이나 protocol을 사용할 때) 이 파일을 생성해요. 기본적으로 이 파일은 Dart 바인딩과 같은 이름을 갖되 파일 이름 끝에 .m이 붙어요. output.objectiveCFile 옵션으로 위치를 바꿀 수 있어요. FFIgen이 이 파일을 만들면 반드시 패키지에 컴파일해야 해요. 그렇지 않으면 누락된 심볼과 관련된 런타임 예외가 발생할 수 있어요. 이 예시에서는 FFIgen이 .m 파일을 생성하지 않아요.
import 'package:ffigen/ffigen.dart';
final config = FfiGenerator(
headers: Headers(
entryPoints: [
Uri.file(
'$macSdkPath/System/Library/Frameworks/AVFAudio.framework/Headers/AVAudioPlayer.h',
),
],
),
output: Output(
dartFile: Uri.file('avf_audio_bindings.dart'),
),
);
void main() => config.generate();
마지막으로 FFIgen에 입력 API의 어느 부분에 바인딩을 생성할지 알려줘요. 기본적으로 FFIgen은 모든 바인딩을 걸러내요. 이 경우 Objective-C 인터페이스인 AVAudioPlayer에 대한 바인딩을 생성하려면 objectiveC.interfaces 필드를 설정해야 해요.
objectiveC 필드를 설정하면 FFIgen에 Objective-C 언어에 대한 바인딩을 생성하라고 알려줘요. 기본적으로 FFIgen은 C 바인딩을 생성해요.
import 'package:ffigen/ffigen.dart';
final config = FfiGenerator(
headers: Headers(
entryPoints: [
Uri.file(
'$macSdkPath/System/Library/Frameworks/AVFAudio.framework/Headers/AVAudioPlayer.h',
),
],
),
objectiveC: ObjectiveC(
interfaces: Interfaces.includeSet({'AVAudioPlayer'}),
),
output: Output(
dartFile: Uri.file('lib/avf_audio_bindings.dart'),
),
);
void main() => config.generate();
includeMember로 클래스에서 특정 메서드를 걸러낼 수 있고, rename 또는 renameMember로 포함된 클래스나 메서드의 이름을 바꿀 수 있어요. protocol과 category에도 비슷한 옵션이 있어요.
구성 옵션의 전체 목록은 FFIgen API 문서를 확인하세요.
Objective-C 바인딩 생성하기
바인딩을 생성하려면 example 디렉토리로 이동해 스크립트를 실행하세요:
$ dart run tool/generate_code.dart
이렇게 하면 이 파일과 비슷한 큰 avf_audio_bindings.dart 파일이 생성돼요. 관심의 핵심 클래스는 AVAudioPlayer예요.
파일 안에서 stub(스텁)이라는 주석이 달린 다른 클래스도 볼 수 있을 거예요. FFIgen은 직접 포함된 API의 모든 전이 의존성에 대해 stub 바인딩을 생성해요. 이 stub들에 대한 전체 바인딩을 생성하려면 config의 includes에 추가하세요. 이 stubbing 동작은 includeTransitive 옵션으로 바꿀 수 있어요.
Objective-C 바인딩 사용하기
이제 생성된 라이브러리를 로드하고 상호작용할 준비가 됐어요. 예시 앱인 play_audio.dart는 커맨드라인 인자로 전달된 오디오 파일을 로드하고 재생해요. 첫 번째 단계는 dylib을 로드하고 네이티브 AVFAudio 라이브러리를 인스턴스화하는 거예요:
import 'dart:ffi';
import 'package:objective_c/objective_c.dart';
import 'avf_audio_bindings.dart';
const _dylibPath =
'/System/Library/Frameworks/AVFAudio.framework/Versions/Current/AVFAudio';
void main(List<String> args) async {
DynamicLibrary.open(_dylibPath);
}
이 예시는 시스템 라이브러리를 로드하므로 dylib 경로는 프레임워크의 내부 dylib를 가리켜요. 자신의 .dylib 파일을 로드할 수도 있고, 라이브러리가 앱에 정적으로 링크되어 있다면(iOS에서 흔한 경우) 아무것도 로드할 필요가 없어요.
이 예시는 커맨드라인 인자로 지정된 각 오디오 파일을 하나씩 재생해요. 각 인자에 대해 먼저 Dart String을 Objective-C NSString로 변환해야 해요. 생성된 NSString 래퍼는 이 변환을 처리하는 편리한 생성자와, 다시 Dart String으로 변환하는 toDartString() 메서드가 있어요.
void main(List<String> args) async {
DynamicLibrary.open(_dylibPath);
for (final file in args) {
final fileStr = NSString(file);
print('Loading $file');
}
}
오디오 플레이어는 NSURL을 기대하므로, 다음으로 fileURLWithPath: 메서드를 사용해 NSString을 NSURL로 변환해요.
void main(List<String> args) async {
DynamicLibrary.open(_dylibPath);
for (final file in args) {
final fileStr = NSString(file);
print('Loading $file');
final fileUrl = NSURL.fileURLWithPath(fileStr);
}
}
이제 AVAudioPlayer를 구성할 수 있어요. Objective-C 객체를 구성하는 데는 두 단계가 있어요. alloc은 객체의 메모리를 할당하지만 초기화하지는 않아요. init*로 시작하는 이름의 메서드가 초기화를 해요. 일부 인터페이스는 이 두 단계를 모두 수행하는 new* 메서드도 제공해요.
AVAudioPlayer를 초기화하려면 initWithContentsOfURL:error: 메서드를 사용해요:
void main(List<String> args) async {
DynamicLibrary.open(_dylibPath);
for (final file in args) {
final fileStr = NSString(file);
print('Loading $file');
final fileUrl = NSURL.fileURLWithPath(fileStr);
final player = AVAudioPlayer.alloc().initWithContentsOfURL(fileUrl);
if (player == null) {
print('Failed to load audio.');
continue;
}
}
}
이 Dart AVAudioPlayer 객체는 기본 Objective-C AVAudioPlayer* 객체 포인터를 감싸는 래퍼예요.
Objective-C는 메모리 관리를 위해 참조 카운팅을 사용하지만(retain, release 및 기타 함수를 통해), Dart 쪽에서는 메모리 관리가 자동으로 처리돼요. Dart 래퍼 객체가 Objective-C 객체에 대한 참조를 유지하고, Dart 객체가 가비지 컬렉션되면 참조가 자동으로 해제돼요.
다음으로 나중에 오디오가 끝나기를 기다리는 데 필요할 오디오 파일의 길이를 조회해요. duration은 @property(readonly)예요. Objective-C 프로퍼티는 생성된 Dart 래퍼 객체의 getter와 setter로 변환돼요. duration이 readonly이므로 getter만 생성돼요.
NSTimeInterval은 double의 타입 별칭이므로, 다음 초로 올림하는 Dart .ceil() 메서드를 바로 사용할 수 있어요:
void main(List<String> args) async {
DynamicLibrary.open(_dylibPath);
for (final file in args) {
final fileStr = NSString(file);
print('Loading $file');
final fileUrl = NSURL.fileURLWithPath(fileStr);
final player = AVAudioPlayer.alloc().initWithContentsOfURL(fileUrl);
if (player == null) {
print('Failed to load audio.');
continue;
}
final durationSeconds = player.duration.ceil();
print('$durationSeconds sec');
}
}
마지막으로 play 메서드로 오디오를 재생한 다음 상태를 확인하고, 오디오 파일의 길이만큼 기다릴 수 있어요:
void main(List<String> args) async {
DynamicLibrary.open(_dylibPath);
for (final file in args) {
final fileStr = NSString(file);
print('Loading $file');
final fileUrl = NSURL.fileURLWithPath(fileStr);
final player = AVAudioPlayer.alloc().initWithContentsOfURL(fileUrl);
if (player == null) {
print('Failed to load audio.');
continue;
}
final durationSeconds = player.duration.ceil();
print('$durationSeconds sec');
final status = player.play();
if (status) {
print('Playing...');
await Future<void>.delayed(Duration(seconds: durationSeconds));
} else {
print('Failed to play audio.');
}
}
}
콜백과 멀티스레딩 제약
멀티스레딩은 Objective-C와 Dart 사이의 interop에 복잡성을 도입해요. 이는 Dart isolate와 OS 스레드의 차이, 그리고 Apple API의 동시성 처리 방식에서 비롯돼요:
- Dart isolate는 스레드와 같은 것이 아니에요. Isolate는 스레드에서 실행되지만 특정 스레드에서 실행된다는 보장은 없고, VM은 isolate가 실행 중인 스레드를 경고 없이 바꿀 수 있어요. isolate를 특정 스레드에 고정할 수 있게 하는 공개 기능 요청이 있어요.
- FFIgen이 Dart 함수를 Objective-C block으로 변환하는 것을 지원하지만, 대부분의 Apple API는 콜백이 어떤 스레드에서 실행될지에 대해 어떤 보장도 하지 않아요.
- UI 상호작용을 포함하는 대부분의 API는 메인 스레드(Flutter에서는 플랫폼 스레드라고도 함)에서만 호출할 수 있어요.
- 많은 Apple API는 스레드 안전하지 않아요.
처음 두 지점은 한 isolate에서 만든 block이 다른 isolate를 실행하는 스레드, 또는 아무 isolate도 없는 스레드에서 호출될 수 있다는 뜻이에요. 사용하는 block의 유형에 따라 앱이 크래시할 수 있어요. Block이 만들어지면, 만들어진 isolate가 그 소유자예요. FooBlock.fromFunction으로 만든 block은 소유자 isolate의 스레드에서 호출돼야 해요. 그렇지 않으면 크래시해요. FooBlock.listener 또는 FooBlock.blocking으로 만든 block은 어떤 스레드에서든 안전하게 호출할 수 있고, 이것들이 감싸는 함수는 (결국) 소유자 isolate 안에서 호출돼요. 다만 이 생성자들은 void를 반환하는 block에서만 지원돼요. 사용자 요구가 있다면 FooBlock.blocking은 향후 void가 아닌 반환 값에 대한 지원을 추가할 수도 있어요.
세 번째 지점은 생성된 Dart 바인딩으로 일부 Apple API를 직접 호출하는 게 스레드 안전하지 않을 수 있다는 뜻이에요. 이로 인해 앱이 크래시하거나 다른 예측할 수 없는 동작이 발생할 수 있어요. 최근 Flutter 버전에서는 메인 isolate가 플랫폼 스레드에서 실행되므로, 메인 isolate에서 이런 스레드 잠금 API를 호출할 때는 문제가 되지 않아요. 다른 isolate에서 이 API를 호출해야 하거나, 이전 Flutter 버전을 지원해야 한다면 runOnPlatformThread 함수를 사용할 수 있어요. 자세한 내용은 Objective-C dispatch 문서를 참고하세요.
마지막 지점에 대해, Dart isolate는 스레드를 바꿀 수 있지만 한 번에 하나의 스레드에서만 실행돼요. 상호작용하는 API가 어떤 스레드에서 호출되는지에 대한 제약이 없다면 스레드 안전할 필요가 없어요.
이런 제약을 염두에 두면 Objective-C 코드와 안전하게 상호작용할 수 있어요.
Swift 예시
이 예시는 Swift 클래스를 Objective-C와 호환되게 만들고, 래퍼 헤더를 생성하고, Dart 코드에서 호출하는 방법을 보여 줘요.
아래 과정은 수동이에요. 이 단계들을 자동화하는 Swiftgen이라는 실험적 프로젝트가 있어요.
Objective-C 래퍼 헤더 생성하기
Swift API는 @objc 어노테이션을 사용해 Objective-C와 호환되게 만들 수 있어요. 사용하려는 클래스나 메서드를 public으로 만들고, 클래스가 NSObject를 상속하도록 하세요.
import Foundation
@objc public class SwiftClass: NSObject {
@objc public func sayHello() -> String {
return "Hello from Swift!";
}
@objc public var someField = 123;
}
수정할 수 없는 서드파티 라이브러리와 상호작용하려면, 사용하려는 메서드를 노출하는 Objective-C 호환 래퍼 클래스를 작성해야 할 수도 있어요.
Objective-C / Swift 상호운용성에 대한 더 자세한 내용은 Swift 문서를 참고하세요.
클래스를 호환되게 만든 뒤 Objective-C 래퍼 헤더를 생성할 수 있어요. Xcode로 하거나 Swift 커맨드라인 컴파일러 swiftc로 할 수 있어요. 이 예시는 커맨드라인을 사용해요:
$ swiftc -c swift_api.swift \
-module-name swift_module \
-emit-objc-header-path swift_api.h \
-emit-library -o libswiftapi.dylib
이 명령은 Swift 파일 swift_api.swift를 컴파일하고 래퍼 헤더 swift_api.h를 생성해요. 또한 나중에 로드할 dylib인 libswiftapi.dylib도 생성해요.
헤더를 열어 인터페이스가 기대한 대로인지 확인해 생성이 올바른지 검증할 수 있어요. 파일 아래쪽 근처에 다음과 같은 내용이 보일 거예요:
SWIFT_CLASS("_TtC12swift_module10SwiftClass")
@interface SwiftClass : NSObject
- (NSString * _Nonnull)sayHello SWIFT_WARN_UNUSED_RESULT;
@property (nonatomic) NSInteger someField;
- (nonnull instancetype)init OBJC_DESIGNATED_INITIALIZER;
@end
인터페이스가 없거나 모든 메서드가 없다면, 모두 @objc와 public으로 어노테이션되어 있는지 확인하세요.
Swift용 FFIgen 구성하기
FFIgen은 Objective-C 래퍼 헤더 swift_api.h만 볼 수 있어요. 그래서 이 config의 대부분은 언어를 objc로 설정하는 것을 포함해 Objective-C 예시와 비슷해요.
ffigen:
name: SwiftLibrary
description: Bindings for swift_api.
language: objc
output: 'swift_api_bindings.dart'
exclude-all-by-default: true
objc-interfaces:
include:
- 'SwiftClass'
module:
'SwiftClass': 'swift_module'
headers:
entry-points:
- 'swift_api.h'
이전과 마찬가지로 언어를 objc로 설정하고, 진입점을 헤더로 설정하고, 기본적으로 모든 것을 제외하며, 바인딩할 인터페이스를 명시적으로 포함해요.
래핑된 Swift API의 핵심 구성 차이는 objc-interfaces -> module 옵션이에요. swiftc가 라이브러리를 컴파일할 때 Objective-C 인터페이스에 모듈 프리픽스를 부여해요. 내부적으로 SwiftClass는 swift_module.SwiftClass로 등록돼요. ffigen에 이 프리픽스를 알려줘서 dylib에서 올바른 클래스를 로드하게 해야 해요.
모든 클래스가 이 프리픽스를 갖는 건 아니에요. 예를 들어 NSString과 NSObject는 내부 클래스이므로 모듈 프리픽스를 갖지 않아요. 그래서 module 옵션이 클래스 이름에서 모듈 프리픽스로 매핑되는 거예요. 정규식을 사용해 여러 클래스 이름을 한 번에 매칭할 수도 있어요.
모듈 프리픽스는 swiftc의 -module-name 플래그에 전달한 값이에요. 이 예시에서는 swift_module이에요. 이 플래그를 명시적으로 설정하지 않으면 기본값은 Swift 파일 이름이 돼요.
모듈 이름이 확실하지 않다면 생성된 Objective-C 헤더를 확인할 수도 있어요. @interface 위에 SWIFT_CLASS 매크로가 있을 거예요:
SWIFT_CLASS("_TtC12swift_module10SwiftClass")
@interface SwiftClass : NSObject
매크로 안의 문자열은 모듈 이름과 클래스 이름을 담고 있어요: "_TtC12``swift_module``10``SwiftClass``".
Swift는 심지어 이 이름을 demangle해 줄 수도 있어요:
$ echo "_TtC12swift_module10SwiftClass" | swift demangle
이것은 swift_module.SwiftClass를 출력해요.
Swift 바인딩 생성하기
이전과 마찬가지로 예시 디렉토리로 이동해 FFIgen을 실행해 바인딩을 생성해요:
$ dart run ffigen
이것은 swift_api_bindings.dart를 생성해요.
Swift 바인딩 사용하기
이 바인딩과 상호작용하는 것은 일반 Objective-C 라이브러리와 완전히 같아요:
import 'dart:ffi';
import 'swift_api_bindings.dart';
void main() {
final lib = SwiftLibrary(DynamicLibrary.open('libswiftapi.dylib'));
final object = SwiftClass.new1(lib);
print(object.sayHello());
print('field = ${object.someField}');
object.someField = 456;
print('field = ${object.someField}');
}
생성된 Dart API에는 모듈 이름이 언급되지 않는다는 점을 기억하세요. 모듈 이름은 클래스를 dylib에서 로드하는 데 내부적으로만 사용돼요.
이제 다음을 사용해 예시를 실행할 수 있어요:
$ dart run example.dart
더 알아보기
- C interop —
dart:ffi로 C API와 상호작용하는 방법을 배워보세요. - package:ffigen — pub.dev의 ffigen 패키지 문서예요.
- Swift 문서: Objective-C 인터페이스 — Objective-C / Swift 상호운용성을 자세히 살펴보세요.