Building a Bi-directional Communication Layer Between iOS and Flutter

Learn how to create a robust native bridge between Flutter and iOS using platform channels and BLoC pattern

Building a Bi-directional Communication Layer Between iOS and Flutter

Introduction

Have you ever needed to integrate native iOS functionality with your Flutter application? Perhaps you needed to access device-specific features not available through existing Flutter plugins? In this article, I’ll guide you through building a bi-directional communication layer between iOS and Flutter using platform channels.
I recently completed a prototype for Zanis that demonstrates this exact capability. Let’s dive into how it works, the architecture decisions I made, and how you can implement something similar in your own projects.

The Challenge

The task was clear: develop a prototype for an iOS data communication layer that interfaces with a Flutter application. This involved:

  1. Creating a native iOS module in Swift
  2. Implementing bi-directional communication using platform channels
  3. Following clean architecture principles
  4. Handling errors gracefully

Instead of connecting to actual USB hardware, I simulated data reception using a timer in the iOS module that generates random integers. The Flutter app then receives and displays this data in real-time.

Architecture Overview

For this prototype, I implemented a clean architecture approach with the following components:

+----------------------+       +--------------------+       +-------------------+ 
|                      |       |                    |       |                   | 
|    Flutter UI        | <---> |  Platform Channels | <---> |  iOS Native Code  | 
|  (Presentation Layer)|       | (Communication Layer)      |  (Native Layer)   | 
|                      |       |                    |       |                   | 
+----------+-----------+       +--------------------+       +-------------------+ 
           | 
           | 
+----------v-----------+ 
|                      | 
|    BLoC Pattern      | 
|   (Business Logic)   | 
|                      | 
+----------------------+

Key Components

  1. Presentation Layer (Flutter UI)
  • Handles user interactions and displays data
  • Implements animations for better UX

2. Business Logic Layer (BLoC)

  • Manages application state
  • Processes events from UI and iOS
  • Communicates with platform channels

3. Communication Layer (Platform Channels)

  • Method Channel: For Flutter to iOS communication
  • Event Channel: For iOS to Flutter streaming

4. Native Layer (iOS Swift)

  • Receives commands from Flutter
  • Generates random data using a timer
  • Sends data back to Flutter

Design Patterns Used

I implemented several design patterns to ensure the code is maintainable, testable, and follows best practices:

  1. BLoC Pattern: Separates business logic from UI, making the code more testable and maintainable
  2. Singleton Pattern: Used for method and event channels to ensure a single point of access
  3. Observer Pattern: Implemented via event channels and stream subscriptions for reactive updates
  4. Repository Pattern: Abstracts the data source from business logic

Implementation Details

Let’s walk through the key implementation details, starting with setting up the communication channels.

Flutter Side: Setting Up Channel Communication

First, we define our method and event channels in the BLoC:

// In ios_communication_bloc.dart 
static const MethodChannel _methodChannel =  
  MethodChannel('com.zanis.ios_communication/method'); 
static const EventChannel _eventChannel =  
  EventChannel('com.zanis.ios_communication/event');

Next, we implement methods to start and stop data reception:

Future<void> _mapStartRequestedToState( 
    _IosCommunicationStartRequested event,  
    Emitter<IosCommunicationState> emit) async { 
  emit(const IosCommunicationInProgress()); 
 
  try { 
    // Call the iOS method to start generating random numbers 
    await _methodChannel.invokeMethod('startRandomIntPerSecond'); 
 
    // Listen for events from iOS 
    _eventSubscription = _eventChannel.receiveBroadcastStream().listen( 
      (dynamic event) { 
        if (event is Map) { 
          // Convert the received map to model 
          final response = IosCommunicationResponseModel( 
            randomInt: event['randomInt'] as int, 
            timestamp: event['timestamp'] as int, 
          ); 
 
          // Add the received data event 
          add(_IosCommunicationDataReceived(response: response)); 
        } 
      }, 
      onError: (dynamic error) { 
        LoggerHelper.debugLog('Error from event channel: $error'); 
        emit(IosCommunicationFailure( 
          error: CustomBlocException(error.toString()))); 
      }, 
    ); 
 
    emit(const IosCommunicationListening()); 
  } catch (e) { 
    LoggerHelper.debugLog('Failed to start random int generation: $e'); 
    emit(IosCommunicationFailure(error: CustomBlocException(e.toString()))); 
  } 
}

iOS Side: Implementing Native Functionality

On the iOS side, we implement the method channel handler in the AppDelegate:

// In AppDelegate.swift 
override func application( 
  _ application: UIApplication, 
  didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 
) -> Bool { 
  let controller = window?.rootViewController as! FlutterViewController 
   
  // Set up method channel 
  methodChannel = FlutterMethodChannel( 
    name: "com.zanis.ios_communication/method", 
    binaryMessenger: controller.binaryMessenger) 
   
  methodChannel?.setMethodCallHandler { [weak self] (call, result) in 
    guard let self = self else { return } 
     
    switch call.method { 
    case "startRandomIntPerSecond": 
      self.startRandomIntPerSecond() 
      result(nil) 
    case "stopRandomIntPerSecond": 
      self.stopRandomIntPerSecond() 
      result(nil) 
    default: 
      result(FlutterMethodNotImplemented) 
    } 
  } 
   
  // Set up event channel 
  eventChannel = FlutterEventChannel( 
    name: "com.zanis.ios_communication/event", 
    binaryMessenger: controller.binaryMessenger) 
   
  let streamHandler = RandomIntStreamHandler() 
  streamHandler.onListen = { [weak self] sink in 
    self?.eventSink = sink 
  } 
  streamHandler.onCancel = { [weak self] in 
    self?.eventSink = nil 
  } 
   
  eventChannel?.setStreamHandler(streamHandler) 
   
  GeneratedPluginRegistrant.register(with: self) 
  return super.application(application, didFinishLaunchingWithOptions: launchOptions) 
}

And here’s how we simulate data generation using a timer:

private func startRandomIntPerSecond() { 
  // Stop any existing timer 
  stopRandomIntPerSecond() 
   
  // Start a new timer that fires every second 
  timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in 
    guard let self = self, let eventSink = self.eventSink else { return } 
     
    // Generate a random number 
    let randomInt = self.getRandomIntPerSecond() 
    let timestamp = Int(Date().timeIntervalSince1970 * 1000) 
     
    // Send the random number to Flutter 
    eventSink([ 
      "randomInt": randomInt, 
      "timestamp": timestamp 
    ]) 
  } 
   
  // This makes sure the timer is running on the main thread 
  RunLoop.main.add(timer!, forMode: .common) 
}

Data Model and State Management

For clean state management, I created a response model to handle the data:

class IosCommunicationResponseModel { 
  final int randomInt; 
  final int timestamp; 
 
  IosCommunicationResponseModel({ 
    required this.randomInt, 
    required this.timestamp, 
  }); 
 
  factory IosCommunicationResponseModel.fromJson(Map<String, dynamic> json) { 
    return IosCommunicationResponseModel( 
      randomInt: json['randomInt'] as int, 
      timestamp: json['timestamp'] as int, 
    ); 
  } 
 
  Map<String, dynamic> toJson() { 
    return { 
      'randomInt': randomInt, 
      'timestamp': timestamp, 
    }; 
  } 
}

Challenges and Solutions

During development, I encountered several challenges:

1. Ensuring Proper Memory Management

In Swift, it’s important to use weak references to prevent memory leaks, especially when using closures with timers:

timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in 
  guard let self = self, let eventSink = self.eventSink else { return } 
  // Rest of the code 
}

2. Handling Event Channel Lifecycle

When the Flutter view is disposed, we need to properly clean up subscriptions:

@override 
Future<void> close() { 
  _eventSubscription?.cancel(); 
  return super.close(); 
}

3. Error Handling

Robust error handling is crucial for platform channel communication. I implemented try-catch blocks and error states in the BLoC to ensure graceful degradation:

try { 
  // Method invocation 
} catch (e) { 
  LoggerHelper.debugLog('Failed: $e'); 
  emit(IosCommunicationFailure(error: CustomBlocException(e.toString()))); 
}

Multilingual Support

As an added bonus, I implemented multilingual support with English and Japanese languages using Flutter’s built-in localization:

// Using localized strings in the UI 
Text( 
  localizations.randomNumber, 
  style: theme.textTheme.titleLarge, 
),

Testing the Communication

To test the communication, I implemented a simple UI that shows the received random numbers in real-time:

BlocConsumer<IosCommunicationBloc, IosCommunicationState>( 
  bloc: iosCommunicationBloc, 
  listener: (context, state) { 
    // Handle state changes 
  }, 
  builder: (context, state) { 
    if (state is IosCommunicationSuccess) { 
      return Text( 
        '${state.item.randomInt}', 
        style: theme.textTheme.headlineLarge, 
      ); 
    } 
    // Other UI states 
  }, 
)

Conclusion

Building a bi-directional communication layer between iOS and Flutter requires careful consideration of architecture, design patterns, and error handling. By using platform channels and following clean architecture principles, we can create a robust solution that integrates native iOS functionality with Flutter applications.

This prototype demonstrates how to:

  • Set up method and event channels for bi-directional communication
  • Implement the BLoC pattern for state management
  • Handle errors gracefully
  • Structure code for maintainability and testability

While this prototype uses a timer to simulate data generation, the same principles apply when working with real hardware or complex native functionality.

The complete source code for this project is available on GitHub.


Demo:

source code:

GitHub - danial2026/ios_communication_prototype
Contribute to danial2026/ios_communication_prototype development by creating an account on GitHub.

If you found this article helpful, follow me for more Flutter and native integration tips. Happy coding!