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
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:
- Creating a native iOS module in Swift
- Implementing bi-directional communication using platform channels
- Following clean architecture principles
- 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
- 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:
- BLoC Pattern: Separates business logic from UI, making the code more testable and maintainable
- Singleton Pattern: Used for method and event channels to ensure a single point of access
- Observer Pattern: Implemented via event channels and stream subscriptions for reactive updates
- 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:
If you found this article helpful, follow me for more Flutter and native integration tips. Happy coding!