In my previous post, I introduced Flutter Native Bridge — a plugin that eliminates MethodChannel boilerplate for calling native code.
I'm excited to announce version 1.1.0 with a major new feature: EventChannel support for real-time streams.
What's new? Stream continuous data from native code to Flutter using Stream<T> instead of Future<T>.
Upgrade to v1.1.0
Update your pubspec.yaml:
dependencies:
flutter_native_bridge: ^1.1.0
Then regenerate your Dart bindings:
dart run flutter_native_bridge:generate
Backwards Compatible: All your existing MethodChannel code continues to work. Streams are purely additive.
MethodChannel vs EventChannel
Before diving in, let's understand when to use each:
| Aspect | MethodChannel | EventChannel |
|---|---|---|
| Pattern | Request → Response | Subscribe → Continuous Events |
| Dart Type | Future<T> |
Stream<T> |
| Use Case | Get device model, fetch data | Sensor updates, live counters |
| Lifecycle | One-time call | Ongoing subscription |
When to Use Streams
Sensor Data
Accelerometer, gyroscope, proximity sensors that emit continuous readings.
Location Updates
GPS coordinates that change as the user moves.
Bluetooth/BLE
Device discovery, connection state, and characteristic notifications.
System Events
Battery level changes, network connectivity, app lifecycle.
The Traditional Way (Pain)
Setting up EventChannel manually requires significant boilerplate:
class MainActivity : FlutterActivity() {
private var eventSink: EventChannel.EventSink? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
EventChannel(flutterEngine.dartExecutor, "counter_channel")
.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(args: Any?, sink: EventChannel.EventSink?) {
eventSink = sink
startCounter()
}
override fun onCancel(args: Any?) {
stopCounter()
eventSink = null
}
})
}
// Plus all the counter logic...
}
And the same amount of code for iOS. Then wire it up in Dart. Exhausting!
The Flutter Native Bridge Way
With v1.1.0, streaming is as simple as adding one annotation:
Android (Kotlin)
import io.nativebridge.*
@NativeBridge
class CounterService {
private val handler = Handler(Looper.getMainLooper())
private var counter = 0
private var runnable: Runnable? = null
@NativeStream // That's it! This method now streams to Flutter
fun counterUpdates(sink: StreamSink) {
counter = 0
runnable = object : Runnable {
override fun run() {
sink.success(mapOf(
"count" to counter,
"timestamp" to System.currentTimeMillis()
))
counter++
handler.postDelayed(this, 1000)
}
}
handler.post(runnable!!)
}
fun stopCounter() {
runnable?.let { handler.removeCallbacks(it) }
runnable = null
counter = 0
}
}
iOS (Swift)
import flutter_native_bridge
class CounterService: NSObject {
private var timer: Timer?
private var counter = 0
private var activeSink: StreamSink?
// Method name must end with "WithSink:" for streams
@objc func counterUpdatesWithSink(_ sink: StreamSink) {
activeSink = sink
counter = 0
DispatchQueue.main.async { [weak self] in
self?.timer = Timer.scheduledTimer(
withTimeInterval: 1.0,
repeats: true
) { [weak self] _ in
guard let self = self else { return }
self.activeSink?.success([
"count": self.counter,
"timestamp": Date().timeIntervalSince1970 * 1000
])
self.counter += 1
}
}
}
@objc func stopCounter() {
timer?.invalidate()
timer = nil
activeSink = nil
}
}
Dart (Flutter)
import 'native_bridge.g.dart';
// Subscribe to the stream
StreamSubscription? subscription;
void startListening() {
subscription = CounterService.counterUpdates().listen((data) {
if (data is Map) {
print('Count: ${data['count']}');
print('Time: ${data['timestamp']}');
}
});
}
void stopListening() {
subscription?.cancel();
CounterService.stopCounter();
}
That's it! The @NativeStream annotation + StreamSink parameter is all you need. No manual EventChannel setup.
Key Concepts
The StreamSink Object
StreamSink is the bridge that sends data to Flutter. It has three methods:
| Method | Description |
|---|---|
success(data) |
Send data to the Flutter stream |
error(code, message, details) |
Send an error to the stream |
endOfStream() |
Close the stream (optional) |
Platform-Specific Requirements
| Platform | Requirement |
|---|---|
| Android | Add @NativeStream annotation + StreamSink parameter |
| iOS | Method selector must end with WithSink: + StreamSink parameter |
Real-World Example: Accelerometer
Here's a practical example streaming accelerometer data:
Android (Kotlin)
@NativeBridge
class SensorService(private val context: Context) : SensorEventListener {
private var sensorManager: SensorManager? = null
private var accelerometer: Sensor? = null
private var sink: StreamSink? = null
@NativeStream
fun accelerometerUpdates(sink: StreamSink) {
this.sink = sink
sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
accelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
sensorManager?.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_UI)
}
override fun onSensorChanged(event: SensorEvent) {
sink?.success(mapOf(
"x" to event.values[0],
"y" to event.values[1],
"z" to event.values[2]
))
}
fun stopAccelerometer() {
sensorManager?.unregisterListener(this)
sink = null
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
}
Dart Usage
import 'native_bridge.g.dart';
class AccelerometerWidget extends StatefulWidget {
@override
State<AccelerometerWidget> createState() => _AccelerometerWidgetState();
}
class _AccelerometerWidgetState extends State<AccelerometerWidget> {
StreamSubscription? _subscription;
double x = 0, y = 0, z = 0;
@override
void initState() {
super.initState();
_subscription = SensorService.accelerometerUpdates().listen((data) {
if (data is Map) {
setState(() {
x = (data['x'] as num).toDouble();
y = (data['y'] as num).toDouble();
z = (data['z'] as num).toDouble();
});
}
});
}
@override
void dispose() {
_subscription?.cancel();
SensorService.stopAccelerometer();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text('X: ${x.toStringAsFixed(2)}, Y: ${y.toStringAsFixed(2)}, Z: ${z.toStringAsFixed(2)}');
}
}
Runtime API for Streams
Don't want code generation? Use the runtime API:
import 'package:flutter_native_bridge/flutter_native_bridge.dart';
// Stream without code generation
FlutterNativeBridge.stream<Map>('CounterService', 'counterUpdates').listen((data) {
print('Count: ${data['count']}');
});
// Discover available streams
final streams = await FlutterNativeBridge.discoverStreams();
// {'CounterService': ['counterUpdates'], 'SensorService': ['accelerometerUpdates']}
Quick Reference
- @NativeStream — Android annotation for stream methods
- StreamSink — Object to emit events to Flutter
- WithSink: — iOS method naming convention
- Stream<T> — Dart return type for subscriptions
- discoverStreams() — Runtime API to list available streams
- Cancel + Cleanup — Always cancel subscriptions and stop native sources
Resources
Get Started
Conclusion
Flutter Native Bridge v1.1.0 brings the same zero-boilerplate philosophy to real-time data streams. Whether you're building a fitness app with sensor data, a location-aware service, or any feature requiring continuous native events — you can now do it with minimal setup.
Annotate. Register. Stream.
Try it out and let me know what you build!
If you found this useful, give the package a like on pub.dev and star the repo on GitHub!