Flutter Platform Channel: 네이티브 iOS/Android 기능 연동

모바일 개발

FlutterPlatform ChanneliOSAndroid네이티브 연동

이 글은 누구를 위한 것인가

  • Flutter 앱에서 네이티브 SDK(NFC, 블루투스, 카메라)를 연동해야 하는 팀
  • Platform Channel의 MethodChannel과 EventChannel 차이를 모르는 개발자
  • Pigeon으로 타입 안전한 채널 코드를 생성하고 싶은 엔지니어

들어가며

Flutter 플러그인이 없는 네이티브 기능은 Platform Channel로 직접 구현해야 한다. 한 번 패턴을 이해하면 iOS Swift + Android Kotlin + Flutter Dart를 연결하는 것이 어렵지 않다.

이 글은 bluefoxdev.kr의 Flutter 네이티브 연동 가이드 를 참고하여 작성했습니다.


1. Platform Channel 유형

[채널 유형 비교]

MethodChannel:
  단방향 호출: Flutter → Native → Flutter
  use case: 일회성 요청 (파일 읽기, 권한 확인)
  패턴: invokeMethod("methodName", args)

EventChannel:
  스트리밍: Native → Flutter (지속)
  use case: 센서 데이터, 위치, BLE 이벤트
  패턴: receiveBroadcastStream().listen()

BasicMessageChannel:
  양방향 메시지: Flutter ↔ Native
  use case: 커스텀 직렬화, 대용량 데이터
  패턴: send() + setMessageHandler()

[Pigeon (권장)]
  타입 안전한 코드 자동 생성
  컴파일 타임 오류 감지
  Dart + Swift + Kotlin 코드 동시 생성
  null 안전성 보장

[채널 이름 규칙]
  "com.company.app/feature"
  예: "com.myapp.bluetooth"
  충돌 방지를 위해 도메인 역순

2. Platform Channel 구현

// Flutter 측 (Dart)
import 'package:flutter/services.dart';

class NfcChannel {
  static const _channel = MethodChannel('com.myapp/nfc');
  static const _eventChannel = EventChannel('com.myapp/nfc_events');

  // NFC 읽기 요청
  Future<String?> readNfcTag() async {
    try {
      final result = await _channel.invokeMethod<String>('readTag');
      return result;
    } on PlatformException catch (e) {
      throw NfcException(e.code, e.message);
    }
  }

  // NFC 쓰기
  Future<void> writeNfcTag({required String data}) async {
    await _channel.invokeMethod('writeTag', {'data': data});
  }

  // NFC 이벤트 스트림 (EventChannel)
  Stream<NfcEvent> get nfcEventStream {
    return _eventChannel
        .receiveBroadcastStream()
        .map((event) => NfcEvent.fromMap(event as Map));
  }
}

class NfcEvent {
  final String type;
  final String? tagId;
  final String? data;

  NfcEvent({required this.type, this.tagId, this.data});

  factory NfcEvent.fromMap(Map map) => NfcEvent(
        type: map['type'] as String,
        tagId: map['tagId'] as String?,
        data: map['data'] as String?,
      );
}

class NfcException implements Exception {
  final String code;
  final String? message;
  NfcException(this.code, this.message);
}
// iOS 측 (Swift) - AppDelegate.swift 또는 별도 플러그인
import Flutter
import CoreNFC

class NfcPlugin: NSObject, FlutterPlugin, NFCNDEFReaderSessionDelegate {
    private var channel: FlutterMethodChannel!
    private var eventSink: FlutterEventSink?
    private var readerSession: NFCNDEFReaderSession?
    private var pendingResult: FlutterResult?

    static func register(with registrar: FlutterPluginRegistrar) {
        let instance = NfcPlugin()
        
        let methodChannel = FlutterMethodChannel(
            name: "com.myapp/nfc",
            binaryMessenger: registrar.messenger(),
        )
        registrar.addMethodCallDelegate(instance, channel: methodChannel)
        instance.channel = methodChannel
        
        let eventChannel = FlutterEventChannel(
            name: "com.myapp/nfc_events",
            binaryMessenger: registrar.messenger(),
        )
        eventChannel.setStreamHandler(instance)
    }

    func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
        switch call.method {
        case "readTag":
            pendingResult = result
            startNFCSession()
        case "writeTag":
            guard let args = call.arguments as? [String: Any],
                  let data = args["data"] as? String else {
                result(FlutterError(code: "INVALID_ARGS", message: "데이터 누락", details: nil))
                return
            }
            writeNFCTag(data: data, result: result)
        default:
            result(FlutterMethodNotImplemented)
        }
    }

    private func startNFCSession() {
        readerSession = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: true)
        readerSession?.begin()
    }

    func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
        guard let record = messages.first?.records.first,
              let text = String(data: record.payload, encoding: .utf8) else {
            pendingResult?(FlutterError(code: "READ_FAILED", message: "태그 읽기 실패", details: nil))
            return
        }
        
        pendingResult?(text)
        pendingResult = nil
        
        // 이벤트 스트림으로도 알림
        eventSink?(["type": "tagRead", "data": text])
    }

    func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
        pendingResult?(FlutterError(code: "NFC_ERROR", message: error.localizedDescription, details: nil))
        pendingResult = nil
    }

    private func writeNFCTag(data: String, result: @escaping FlutterResult) {
        // 쓰기 구현...
        result(nil)
    }
}

extension NfcPlugin: FlutterStreamHandler {
    func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
        eventSink = events
        return nil
    }

    func onCancel(withArguments arguments: Any?) -> FlutterError? {
        eventSink = nil
        return nil
    }
}
// Android 측 (Kotlin)
class NfcPlugin(private val activity: Activity) : MethodCallHandler, EventChannel.StreamHandler {
    private var eventSink: EventChannel.EventSink? = null
    
    companion object {
        fun registerWith(registrar: PluginRegistry.Registrar) {
            val instance = NfcPlugin(registrar.activity())
            
            MethodChannel(registrar.messenger(), "com.myapp/nfc")
                .setMethodCallHandler(instance)
            
            EventChannel(registrar.messenger(), "com.myapp/nfc_events")
                .setStreamHandler(instance)
        }
    }
    
    override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
        when (call.method) {
            "readTag" -> readNfcTag(result)
            "writeTag" -> {
                val data = call.argument<String>("data")
                    ?: return result.error("INVALID_ARGS", "데이터 누락", null)
                writeNfcTag(data, result)
            }
            else -> result.notImplemented()
        }
    }
    
    private fun readNfcTag(result: MethodChannel.Result) {
        // NFC 읽기 구현...
        result.success("tag_data")
    }
    
    private fun writeNfcTag(data: String, result: MethodChannel.Result) {
        // NFC 쓰기 구현...
        result.success(null)
    }
    
    override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
        eventSink = events
    }
    
    override fun onCancel(arguments: Any?) {
        eventSink = null
    }
}

마무리

Platform Channel은 Flutter가 네이티브의 모든 기능에 접근하는 탈출구다. MethodChannel은 일회성 호출, EventChannel은 지속 스트림에 사용한다. 팀 규모가 크거나 채널이 많아지면 Pigeon으로 타입 안전한 코드를 자동 생성하면 런타임 에러를 컴파일 타임에 잡을 수 있다.