Pigeon을 사용하여 Type-safety한 네이티브 코드 작성하기

2024. 6. 21. 18:15Programming/Flutter

반응형

  가끔씩 Flutter를 사용해서 앱을 작성할 때, 성능이 필요한 부분은 네이티브 코드로 작성해야 할 일이 있습니다. 이 내용에 대해서는 Flutter 공식 문서의 Writing custom platform-specific code에서 찾아볼 수 있는데요. 예전에는 MethodChannel을 사용해서 네이티브서 호출할 메서드를 문자열 형태로 넘겨주고, 인자값이 필요할 때는 dynamic 타입으로 정의되어있는 arguments에 필요한 값을 넘겨줬습니다. 플랫폼에서 작성한 네이티브 코드 역시 문자열을 통해 실행할 함수를 결정하고, Any타입(안드로이드 기준)으로 전달된 값들을 형변환하여 작성해야했죠. 이 방법에는 몇가지 문제가 있습니다. 채널명을 정하고 MethodChannel을 설정해야하며, MethodCallHandler를 등록해서 실행할 함수들을 작성해줘야하고, Any타입으로 전달된 인자값에 문제가 없는지 확인해줘야합니다. 혹시라도 코드를 작성하다가 오타가 발생하면 플랫폼에 작성해놓은 코드를 찾지 못해 예외가 발생하고, 형변환 시 데이터가 잘못되면 예외가 발생하게되죠.

 

 

pigeon | Dart package

Code generator tool to make communication between Flutter and the host platform type-safe and easier.

pub.dev

  이러한 문제를 해결하기 위해 Pigeon 패키지가 등장했습니다. Dart로 코드를 작성한 뒤 실행하면, 설정에 필요한 네이티브 인터페이스와 호출에 필요한 Dart 코드를 만들어주는 패키지죠. Pigeon은 Dart 및 네이티브 코드를 생성해주는 패키지이므로 사용방법을 main.dart로 기재할 수는 없기에, pub.dev의 example 탭에는 아무것도 없습니다. 다만 Readme에 내용이 대충 기재되어있고, 예제 링크가 나와있으니 살펴면 쉽게 사용할 수 있습니다. 이 글에서는 안드로이드를 기준으로 Pigeon을 사용하는 방법에 대해 설명합니다. 아마 iOS도 크게 다르지 않을 것.

pub.dev에 개제되는 example탭은 직접 실행이 가능한 main.dart이지만, pigeon은 코드를 생성해주는 도구이므로 문서에서는 예제를 찾을 수 없다.

 

 

우선 예제에서는 다음과 같이 설정 파일이 나와있습니다.

@ConfigurePigeon(PigeonOptions(
  dartOut: 'lib/src/messages.g.dart',
  dartOptions: DartOptions(),
  cppOptions: CppOptions(namespace: 'pigeon_example'),
  cppHeaderOut: 'windows/runner/messages.g.h',
  cppSourceOut: 'windows/runner/messages.g.cpp',
  kotlinOut:
      'android/app/src/main/kotlin/dev/flutter/pigeon_example_app/Messages.g.kt',
  kotlinOptions: KotlinOptions(),
  javaOut: 'android/app/src/main/java/io/flutter/plugins/Messages.java',
  javaOptions: JavaOptions(),
  swiftOut: 'ios/Runner/Messages.g.swift',
  swiftOptions: SwiftOptions(),
  objcHeaderOut: 'macos/Runner/messages.g.h',
  objcSourceOut: 'macos/Runner/messages.g.m',
  // Set this to a unique prefix for your plugin or application, per Objective-C naming conventions.
  objcOptions: ObjcOptions(prefix: 'PGN'),
  copyrightHeader: 'pigeons/copyright.txt',
  dartPackageName: 'pigeon_example_package',
))

대충 보면 알겠지만, 생성된 코드를 저장할 위치, 패키지 명을 지정해줍니다. 뒤에 클래스 혹은 함수가 정의되지 않으면 Expected a method, getter, setter or operator declaration 라는 에러를 볼 수 있는데, ConfigurePigeon 자체는 어노테이션이니까 당연하다면 당연한 내용입니다. ' ㅇ') 우선 프로젝트 최상단에 pigeons/example.dart를 생성한 뒤 필요한 설정만 남겨놓고, 에러가 발생하지 않도록 나머지 예제 코드를 붙여넣습니다.

@ConfigurePigeon(PigeonOptions(
  dartOut: 'lib/pigeon/example.dart',
  dartOptions: DartOptions(),
  kotlinOut:
      'android/app/src/main/kotlin/com/example/pigeons/Example.kt',
  kotlinOptions: KotlinOptions(),
  swiftOut: 'ios/Runner/pigeon/Example.swift',
  swiftOptions: SwiftOptions(),
))

enum Code { one, two }

class MessageData {
  MessageData({required this.code, required this.data});
  String? name;
  String? description;
  Code code;
  Map<String?, String?> data;
}

@HostApi()
abstract class ExampleHostApi {
  String getHostLanguage();

  // These annotations create more idiomatic naming of methods in Objc and Swift.
  @ObjCSelector('addNumber:toNumber:')
  @SwiftFunction('add(_:to:)')
  int add(int a, int b);

  @async
  bool sendMessage(MessageData message);
}

  위 코드는 각각 Kotlin, Swift, Dart로 코드를 생성하기 위한 내용입니다. 또한 데이터를 전송하기 위해 MessageData 클래스를 정의하고 있고, 각 플랫폼에 구현할 코드는 ExampleHostApi 클래스에 정의되어있는데요. 이 함수에는 문자열을 반환하는 getHostLanguage(), 두 개의 정수를 합산한 결과값을 반환하는 int add(), 앞서 작성한 MessageData 클래스를 전송한 뒤 bool값을 반환하는 sendMessage() 세 개의 함수가 포함되어있습니다. 이제 Pigeon을 실행하게되면 위에서 작성한대로, 요청에 필요한 데이터 구조를 전달받아서 응답하는 인터페이스를 작성하게 됩니다. 그럼 터미널에서 다음의 명령을 실행해서 코드를 생성해봅시다.

dart run pigeon --input ./pigeons/example.dart

 

  잠시 시간이 지나면 @ConfigurePigeon에 설정한 경로에, 지정한 이름으로 파일이 생성되는 것을 확인할 수 있습니다. 생성된 Kotlin 코드의 ExampleHostApi()를 살펴보면 내부 구현이 빠져있는 interface임을 알 수 있는데요. 이제 ExampleHostApi를 구현한 뒤 설정해주기만하면 됩니다. 예제의 Kotlin 코드를 살펴보죠.

private class PigeonApiImplementation : ExampleHostApi {
  override fun getHostLanguage(): String {
    return "Kotlin"
  }

  override fun add(a: Long, b: Long): Long {
    if (a < 0L || b < 0L) {
      throw FlutterError("code", "message", "details")
    }
    return a + b
  }

  override fun sendMessage(message: MessageData, callback: (Result<Boolean>) -> Unit) {
    if (message.code == Code.ONE) {
      callback(Result.failure(FlutterError("code", "message", "details")))
      return
    }
    callback(Result.success(true))
  }
}

  여기서는 ExampleHostApi의 구현체인 PigeonApiImplementation을 작성하고, 각 함수에서 동작할 코드를 작성해줍니다. 재밌는 점이라면 @async를 통해서 비동기 함수로 선언하지 않은 getHostLanguage(), add() 함수는 Result.failure(), Result.success()를 호출하지 않고 return 구문을 통해서 값을 반환하기만 하면 된다는 점입니다.

  실제 구현이 끝났으면, PigeonApiImplementation을 사용해서 MainActivity에 연결해줄 차례입니다.

class MainActivity : FlutterActivity() {
  override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)

    ExampleHostApi.setUp(flutterEngine.dartExecutor.binaryMessenger, PigeonApiImplementation())
  }
}

private class PigeonApiImplementation: ExampleHostApi {
  override fun getHostLanguage(): String {
    return "Kotlin"
  }

  override fun add(a: Long, b: Long): Long {
    Log.d("host", "$a + $b")
    if (a < 0L || b < 0L) {
      throw FlutterError("code", "message", "details")
    }
    return a + b
  }

  override fun sendMessage(message: MessageData, callback: (Result<Boolean>) -> Unit) {
    if (message.code == Code.ONE) {
      callback(Result.failure(FlutterError("code", "message", "details")))
      return
    }
    callback(Result.success(true))
  }
}

  MainActivity.kt에서 configureFlutterEngine()을 override한 뒤, ExampleHostApi.setUp() 함수를 호출해서 BinaryMessenger와 PigeonApiImplementation을 넘겨주기만 하면 끝입니다. ExampleHostApi 인터페이스의 내용을 살펴보면 setUp()함수는 컴페니언 오브젝트로 만들어져있고, 채널명과 메시지 핸들러를 등록해주는 코드가 구현되어있는 것을 살펴볼 수 있습니다.

final ExampleHostApi _api = ExampleHostApi();

/// Calls host method `add` with provided arguments.
Future<int> add(int a, int b) async {
  try {
    return await _api.add(a, b);
  } catch (e) {
    // handle error.
    return 0;
  }
}

/// Sends message through host api using `MessageData` class
/// and api `sendMessage` method.
Future<bool> sendMessage(String messageText) {
  final MessageData message = MessageData(
    code: Code.one,
    data: <String?, String?>{'header': 'this is a header'},
    description: 'uri text',
  );
  try {
    return _api.sendMessage(message);
  } catch (e) {
    // handle error.
    return Future<bool>(() => true);
  }
}

  다음은 Dart로 작성된 ExampleHostApi 객체를 만들어서 호출해봅시다. 생성된 Dart 코드는 인터페이스이지만 실제 코드는 각 플랫폼에 작성되어있으므로, 객체를 생성한 뒤 함수를 호출하기만 하면 됩니다.

  기존에는 채널명을 설정하고 네이티브 함수에서 메소드 채널을 등록해주는 등, 여러모로 귀찮은 작업들을 Pigeon을 사용해서 대체할 수 있었는데요. 이를 통해서 조금은 네이티브 연동을 쉽게 할 수 있을 것 같네요. :)

반응형