Feeling stuck with Segment? Say 👋 to RudderStack.

SVG
Log in

Blogs

How to Extend a Flutter Plugin to Support Web

Written by
Debanjan Chatterjee

Debanjan Chatterjee

Senior Software Engineer
Blog Banner

When we think of Flutter, most of us tend to think of mobile app development. What some may not know is that Flutter also offers web support to make browser-based delivery of existing Flutter mobile apps and plugins easy.

In this article, we’ll take a look at how to extend a Flutter Plugin to support web. We recently walked readers through this with the RudderStack Flutter SDK, which allows you to easily collect event data from your Flutter applications and route it to every destination in your data stack, so we’ll use this as an example. Just a heads-up – you’ll want to have some familiarity with Flutter and Dart before continuing.

Some helpful reference material

Version 1 of our Flutter SDK supported Android and iOS applications. You can read about how we built this Flutter plugin in our previous post, Developing a Custom Plugin using Flutter. Version 2 features an enhanced architecture that supports web. We were able to leverage RudderStack’s existing Javascript-based web SDK to power up v1 with web support.

Overview of the internals

It’s not necessary to have a strong understanding of the internals of Dart MethodChannel to extend your Flutter plugin with web support, but I find it helpful to review how things work at a low level before starting any project. Feel free to skip ahead to How We Added Web Support to Our Flutter Plugin if you’d prefer to get right down to business. MethodChannel enables two-way communication between your Flutter app and various platform channels. It asynchronously passes the method name and arguments from Dart to the platform and returns results back from the platform.

A Flutter plugin internally uses channels to communicate over serialized data, i.e bytes with different platforms.

If we take a quick look at platform_channels.dart, we see three classes there:

  • BasicMessageChannel
  • MethodChannel
  • EventChannel

Let’s detail these individually…

BasicMessageChannel

Of the three channels, BasicMessageChannel is the most straightforward. It’s just a simple layer of abstraction that delegates to the binary messaging layer for all communication:

DART
// String messages
// Dart side
const channel = BasicMessageChannel<String>('rudder_msg', StringCodec());
// Send message to platform and receive reply.
final String reply = await channel.send('Hello, Rudder lovers');
print(reply);
// Receive messages from platform and send replies.
channel.setMessageHandler((String message) async {
print('Received: $message');
return 'Hi from Dart';
});
KOTLIN
// Android side
val channel = BasicMessageChannel<String>(
flutterView, "rudder_msg", StringCodec.INSTANCE)// Send message to Dart and receive reply.
channel.send("Hello, Rudder") { reply ->
Log.i("reply", reply)
}// Receive messages from Dart and send replies.
channel.setMessageHandler { message, reply ->
Log.i("MSG", "Received: $message")
reply.reply("Hi from Android")
}
SWIFT
// iOS side
let channel = FlutterBasicMessageChannel(
name: "rudder_msg",
binaryMessenger: controller,
codec: FlutterStringCodec.sharedInstance())
// Send message to Dart and receive reply.
channel.sendMessage("Hello, Rudder") {(reply: Any?) -> Void in
os_log("%@", type: .info, reply as! String)
}
// Receive messages from Dart and send replies.
channel.setMessageHandler {
(message: Any?, reply: FlutterReply) -> Void in
os_log("Received: %@", type: .info, message as! String)
reply("Hi from iOS")
}

It should be noted that BasicMessageChannels are lightweight and stateless, which means that two basic message channels with the same name and codec are equivalent and can interfere with each other’s communication. (I’ll explain codec a bit further down the page.)

MethodChannel

MethodChannel is used for invoking named platform methods, along with their parameters.

Basically, it transmits the data in a particular format from one side to the other. Because it’s a bi-directional channel, the code must account for both sending data and looking for callback from the other side.

To send data from the application side to the platform side (or vice-versa), you need to invoke a method on the channel.

You also need to set a method call handler to listen to any method being called from the other side.

When you invoke a method on the platform side you should expect the following results on the Dart side:

For answering a method call from the platform side, you need to set up an async method call handler. Let’s look at an example of invocation of platform methods, to help put it into perspective:

DART
// Dart side.
const channel = MethodChannel('rudder_msg');
final String greeting = await channel.invokeMethod('rudder', 'world');
print(greeting);
KOTLIN
// Android side.
val channel = MethodChannel(flutterView, "rudder_msg")
channel.setMethodCallHandler { call, result ->
when (call.method) {
"rudder" -> result.success("Hello, ${call.arguments}")
else -> result.notImplemented()
}
}
SWIFT
// iOS side.
let channel = FlutterMethodChannel(
name: "rudder_msg", binaryMessenger: flutterView)
channel.setMethodCallHandler {
(call: FlutterMethodCall, result: FlutterResult) -> Void in
switch (call.method) {
case "rudder": result("Hello, \\(call.arguments as! String)")
default: result(FlutterMethodNotImplemented)
}
}

On the platform side, the steps are the same, but the parameters and return types change.

  • On Android, invocation is handled by a method taking a callback argument. The callback interface defines three methods, of which one is called, depending on the outcome. Client code implements the callback interface to define what should happen on success, on error, and on not implemented.
  • On iOS, invocation is also handled by a method taking a callback argument, but here the callback is a single-argument function that is given either a

    FlutterError

    instance, the

    FlutterMethodNotImplemented

    constant, or in case of success, the result of the invocation. Client code provides a block with conditional logic to handle the different cases, as needed.

EventChannel

You can use event streams to send data any time a certain event occurs. As the name suggests, EventChannel streams a series of events from the platform to the Dart side. Currently, bi-directional stream is not supported through EventChannel.

To set up EventChannel on the Dart side, you create an EventChannel that listens to a stream.  On the platform side, you must implement the StreamHandler and set this object to channel.setStreamHandler() method. The StreamHandler consists of onListen and onCancel methods which get called when a listener starts listening to the event channel on the Dart side and when it’s removed, respectively:

DART
// Consuming events on the Dart side.
const channel = EventChannel('rudder_msg');channel.receiveBroadcastStream().listen((dynamic event) {
print('Received event: $event');
}, onError: (dynamic error) {
print('Received error: ${error.message}');
});
KOTLIN
// Producing sensor events on Android.
// SensorEventListener/EventChannel adapter.
class SensorListener(private val sensorManager: SensorManager) :
EventChannel.StreamHandler, SensorEventListener {
private var eventSink: EventChannel.EventSink? = null
// EventChannel.StreamHandler methods
override fun onListen(
arguments: Any?, eventSink: EventChannel.EventSink?) {
this.eventSink = eventSink
registerIfActive()
}
override fun onCancel(arguments: Any?) {
unregisterIfActive()
eventSink = null
}
// SensorEventListener methods.
override fun onSensorChanged(event: SensorEvent) {
eventSink?.success(event.values)
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
if (accuracy == SensorManager.SENSOR_STATUS_ACCURACY_LOW)
eventSink?.error("SENSOR", "Low accuracy detected", null)
}
// Lifecycle methods.
fun registerIfActive() {
if (eventSink == null) return
sensorManager.registerListener(
this,
sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE),
SensorManager.SENSOR_DELAY_NORMAL)
}
fun unregisterIfActive() {
if (eventSink == null) return
sensorManager.unregisterListener(this)
}
}
// Use of the above class in an Activity.
class MainActivity: FlutterActivity() {
var sensorListener: SensorListener? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GeneratedPluginRegistrant.registerWith(this)
sensorListener = SensorListener(
getSystemService(Context.SENSOR_SERVICE) as SensorManager)
val channel = EventChannel(flutterView, "rudder_msg")
channel.setStreamHandler(sensorListener)
}
override fun onPause() {
sensorListener?.unregisterIfActive()
super.onPause()
}
override fun onResume() {
sensorListener?.registerIfActive()
super.onResume()
}
}

Codec

We’ve mentioned crossing over from the Dart side to the platform side (and vice versa) a number of times so far. This is where codec comes in. In short, Codec is the visa or contract that must be satisfied on both sides to successfully send and receive data over a channel. From an implementation perspective, a codec is responsible for encoding and decoding the messages across the Dart side and platform side. The encoding and decoding logic should complement each other.

The source code for codec used in BasicMessageChannel is the following:

DART
abstract class MessageCodec<T> {
/// Encodes the specified [message] in binary.
///
/// Returns null if the message is null.
ByteData? encodeMessage(T message);
/// Decodes the specified [message] from binary.
///
/// Returns null if the message is null.
T? decodeMessage(ByteData? message);
}

Similarly, both MethodChannels and EventStreamChannels use MethodCodec, which envelopes the data and decodes the enveloped data:

DART
/// A codec for method calls and enveloped results.
///
/// All operations throw an exception, if conversion fails.
///
/// See also:
///
/// * [MethodChannel], which use [MethodCodec]s for communication
/// between Flutter and platform plugins.
/// * [EventChannel], which use [MethodCodec]s for communication
/// between Flutter and platform plugins.
abstract class MethodCodec {
/// Encodes the specified [methodCall] into binary.
ByteData encodeMethodCall(MethodCall methodCall);
/// Decodes the specified [methodCall] from binary.
MethodCall decodeMethodCall(ByteData? methodCall);
/// Decodes the specified result [envelope] from binary.
///
/// Throws [PlatformException], if [envelope] represents an error, otherwise
/// returns the enveloped result.
///
/// The type returned from [decodeEnvelope] is `dynamic` (not `Object?`),
/// which means *no type checking is performed on its return value*. It is
/// strongly recommended that the return value be immediately cast to a known
/// type to prevent runtime errors due to typos that the type checker could
/// otherwise catch.
dynamic decodeEnvelope(ByteData envelope);
/// Encodes a successful [result] into a binary envelope.
ByteData encodeSuccessEnvelope(Object? result);
/// Encodes an error result into a binary envelope.
///
/// The specified error [code], human-readable error [message] and error
/// [details] correspond to the fields of [PlatformException].
ByteData encodeErrorEnvelope({ required String code, String? message, Object? details});
}

Standard codecs are already available by default, so you can rely on the default codecs.

The diagram above, from Flutter, illustrates how message channels and codecs work as a bridge between Flutter and native implementations.

How we added web support to our Flutter plugin

Now that we’ve gotten a handle on the internals, let’s get to the task at hand. When we built v1 of our Flutter SDK, we used a

MethodChannel

to cross over to different platforms from Dart code. However, when we considered extending our SDK to support web, we found a couple of drawbacks to this approach, as noted by Google’s Harry Terkelsen in his article on writing a Flutter Web Plugin.

First, it involves the unnecessary overhead of sending plugin method calls over a

MethodChannel

:

“On the web, your entire app is compiled into one JavaScript bundle, so the plugin code is needlessly serializing the method call into a byte array, which is then instantly deserialized by the web plugin.“

Second, the SDK might get bloated:

“... it makes it difficult for the compiler to remove (by tree-shaking) unused plugin code. The web plugin calls the appropriate method based on the name of the method call passed by the MethodChannel, so the compiler has to assume that all of the methods in the plugin are live, and none of them can be tree-shaken out.”

After a bit of exploration, we decided to follow the industry approach for developing a Flutter plugin that caters to both mobile and web. This meant we needed to change the structure of the older SDK. While this resulted in a bit more work for our team, splitting the implementations into distinct packages delivers some key advantages, as covered by Terkelsen:

  • Plugin authors don’t need to be experts for every platform (Android, iOS, Web, etc.)
  • Support for new platforms can be added without requiring the original plugin author to review or pull in code
  • You can maintain and test each package separately

So, how can we implement web support without using method channels? Well, according to recommended practices, we need to have an exposed module (in our case it’s

rudder_plugin

), that will interact with a middle layer (

rudder_plugin_interface

) which, in turn, will be implemented by the platform-specific implementations:

  • rudder_plugin_android
  • rudder_plugin_ios
  • rudder_plugin_web

However, unlike its Android and iOS counterparts,

rudder_plugin_web

isn’t going to use

MethodChannel

. To interact with the RudderStack JS SDK, it’s going to use the Dart js Package.

Getting Started

As covered in our earlier post on creating a flutter plugin, we’ll first need to set up the project boilerplate. To do this we’ll use the command

flutter create --org com.example --template**=**plugin --platforms**=**android,ios -a kotlin packageName

.This creates a project folder with Android, iOS, and lib folders inside it. We’ll refactor these folders a bit as and when necessary.

Step 1: Create the platform interface

To get started, we write a common interface for all platforms to abstract the behavior we expect from a method call without implying how a platform implements this behavior.

The platform interface lists the methods to be implemented by each platform.

To create the platform interface we make a package named “rudder_plugin_interface” inside the project folder.

You’ll want to create a folder for your platform interface. You can do this from terminal using the following command while you’re inside your project folder:

SH
mkdir rudder_plugin_interface

To be defined as a Flutter package, a folder must contain a pubspec.yaml file.

This YAML file contains the name, version, and dependencies for the particular module package.

Our plugin_interface pubspec.yaml looks like this:

YAML
name: rudder_sdk_flutter_platform_interface
version: 2.0.0
description: A platform interface for mobile and web sdk
homepage: <https://github.com/rudderlabs/rudder-sdk-flutter>
repository: <https://github.com/rudderlabs/rudder-sdk-flutter>
issue_tracker: <https://github.com/rudderlabs/rudder-sdk-flutter/issues>
documentation: <https://docs.rudderstack.com/rudderstack-sdk-integration-guides/rudderstack-flutter-sdk>
environment:
sdk: '>=2.12.0 <3.0.0'
flutter: ">=2.0.0"
dependencies:
flutter:
sdk: flutter
logger: ^1.0.0
intl: ^0.17.0
plugin_platform_interface: ^2.0.0
dev_dependencies:
flutter_lints:
flutter_test:
sdk: flutter
test: ^1.19.5

In the above file, we declare the details of the plugin (name, description, etc.), and we create the “plugin_platform_interface” dependency which provides us with the

PlatformInterface

class, which I’ll cover later.

With this complete, we create a folder titled lib inside rudder_plugin_interface for the source code.

This allows us to accumulate all the necessary methods in an abstract class named

RudderSdkPlatform

inside our “lib” folder:

DART
abstract class RudderSdkPlatform extends PlatformInterface {
RudderSdkPlatform() : super(token:_token);
static final Object_token= Object();
static RudderSdkPlatform_instance= MethodChannelRudderSdk();
/// Platform-specific plugins should set this with their own platform-specific
/// class that extends [UrlLauncherPlatform] when they register themselves.
static setinstance(RudderSdkPlatform instance) {
PlatformInterface.verify(instance,_token);
_instance= instance;
}
/// The default instance of [RudderSdkPlatform] to use.
///
/// Defaults to [MethodChannelRudderSdk].
static RudderSdkPlatform getinstance=>_instance;
void initialize(String writeKey,
{RudderConfig? config, RudderOption? options}) {
throw UnimplementedError(
'initialize(String writeKey,{RudderConfig? config, '
'RudderOption? options}) has not been implemented.');
}
void identify(String userId, {RudderTraits? traits, RudderOption? options}) {
throw UnimplementedError('identify(String userId, {RudderTraits? traits, '
'RudderOption? options}) has not been implemented.');
}

As you can see,

RudderSdkPlatform

extends

PlatformInterface

, and we pass a final Object instance as a token to the parent constructor. This is because Dart has no provision for “interfaces”, but you can “implement” a non-final class. Abstract and non-final classes can both act as interfaces in Dart, the only difference is that properties aren’t inherited as in the case of an extension.

PlatformInterface

ensures that platform-specific implementations of RudderSdkPlatform are implemented using “extends” and not “implements”. This is because RudderSdkPlatform does not consider newly added methods to be breaking changes. Extending this class using

extends

ensures that the subclass will get the default implementation. If we used

implements

the interface would be broken by newly-added RudderSdkPlatform methods.

You can have a few other classes in this package based on your requirements. I suggest using a library Dart file that helps all dependencies, so Users of the library can easily import the dependencies transitively, just by importing this single file. The file, which we name platform.dart, looks like this:

DART
library rudder_sdk_flutter_platform_interface;
export 'src/constants.dart';
export 'src/rudder_logger.dart';
export 'src/models/rudder_config.dart';
export 'src/models/rudder_integration.dart';
export 'src/models/rudder_option.dart';
export 'src/models/rudder_property.dart';
export 'src/models/rudder_traits.dart';

The User can just import platform.dart, and all exported files will be implicitly imported.

import'package:rudder_sdk_flutter_platform_interface/platform.dart';

Step 2: Create platform specific packages

After setting up the platform interface, we create platform specific packages built on the common plugin interface.

At this point, I should introduce the term “app-facing package.” This refers to the package that our Users will add as a dependency to their app. The platform packages are “unlisted” packages that Users may not be aware of, and they are downloaded transitively. The “app-facing package” amalgamates these platform packages. More on this later.

Android Plugin

To get started here, as with the platform interface, we create a folder, this one called

SH
mkdir rudder_plugin_android

Then we add a pubspec.yaml file to declare the details and dependencies of the package:

YAML
name: rudder_plugin_android
version: 2.0.0
description: The RudderStack Flutter SDK allows you to track event data from your app. It can be easily integrated into your Flutter application. After integrating this SDK, you will also send the event data to your preferred analytics destination/s, such as Google Analytics, Amplitude, and more.
homepage: <https://github.com/rudderlabs/rudder-sdk-flutter>
repository: <https://github.com/rudderlabs/rudder-sdk-flutter>
issue_tracker: <https://github.com/rudderlabs/rudder-sdk-flutter/issues>
documentation: <https://docs.rudderstack.com/rudderstack-sdk-integration-guides/rudderstack-flutter-sdk>
environment:
sdk: '>=2.12.0 <3.0.0'
flutter: ">=2.0.0"
dependencies:
flutter:
sdk: flutter
rudder_sdk_flutter_platform_interface: //when platform_interface is uploaded//,use ^version_number, prior to that use path
path: "../rudder_plugin_interface"
dev_dependencies:
flutter_lints:
flutter_test:
sdk: flutter
test: ^1.19.5
plugin_platform_interface: ^2.0.0
flutter:
plugin:
implements: rudder_sdk_flutter
platforms:
android:
package: com.rudderstack.sdk.flutter
pluginClass: RudderSdkFlutterPlugin
dartPluginClass: RudderSdkFlutterAndroid

There are a few new items here, so let’s discuss the pieces we haven’t seen in the earlier pubspec files.

  • flutter: Denotes the Flutter map
    • plugin: denotes the plugin map
      • implements: denotes the app-facing package that this platform package depends on
    • platforms: Denotes the supported platforms map
      • android: denotes the Android-specific configuration map
        • package: the package name where the pluginClass resides
        • pluginClass: The Android (Kotlin/Java) class that implements the method channel
        • dartPluginClass: The Dart class that extends RudderSdkPlatform (the platform interface)

With the pubspec.yaml file created, next, we created a lib folder:

DART
mkdir lib

Inside the lib folder,  we created a file named “rudder_plugin_android.dart”

This file marks our Android-specific implementation of the formerly created file RudderSdkPlatform:

DART
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:rudder_sdk_flutter_platform_interface/platform.dart';
import 'package:rudder_sdk_flutter_platform_interface/rudder_sdk_platform.dart';
const MethodChannel _platformChannel = MethodChannel('rudder_sdk_flutter');
/// An implementation for RudderSdk for android platform
class RudderSdkFlutterAndroid extends RudderSdkPlatform {
/// Register this class
static void registerWith() {
RudderSdkPlatform.instance = RudderSdkFlutterAndroid();
}
@override
void initialize(String writeKey,
{RudderConfig? config, RudderOption? options}) {
config ??= RudderConfigBuilder().build();
Map<String, dynamic> params = {};
params['writeKey'] = writeKey;
params['config'] = config.toMapMobile();
if (options != null) {
params['options'] = options.toMap();
}
_platformChannel.invokeMethod("initializeSDK", params);
}
...
}

You’ll find the full implementation here.

Let’s dissect this code a bit.

First, it extends

RudderSdkPlatform

(remember why we’re using extends instead of implements).

Next, it creates a final

MethodChannel

instance for communicating with the Android platform side. The platform code (which I’ll cover later in this segment) will define a MethodChannel with the same name, for proper communication.

The Flutter SDK implicitly calls the static method “registerWith,” and this code registers our platform implementation instance inside it.

Next, it conveys a message to the platform code about which method is invoked along with the parameters, Then the platform code can take care of the rest.

Finally, we need an Android native library, that adheres to the above platform code written in Dart.

Remember, we already generated different platform-specific code templates using the “flutter create” command line instruction. We just move the generated Android folder to the “flutter_plugin_android” folder.

The “flutter_plugin_android” folder structure now looks like this:

With this set, we’re ready to modify the Android source code for the plugin.

Depending on your choice of language while creating the Flutter plugin, you will have either a .java or a .kotlin file inside your source.

In our case, we re-used our java file RudderSdkFlutterPlugin.java. You can follow the link for complete implementation. I’ll cover a few methods here:

DART
public class RudderSdkFlutterPlugin
implements FlutterPlugin, MethodCallHandler {
/// The MethodChannel that will the communication between Flutter and native Android
///
/// This local reference serves to register the plugin with the Flutter Engine and unregister it
/// when the Flutter Engine is detached from the Activity
private MethodChannel channel;
private Context context;
@Override
public void onAttachedToEngine(
@NonNull FlutterPluginBinding flutterPluginBinding
) {
channel =
new MethodChannel(
flutterPluginBinding.getBinaryMessenger(),
"rudder_sdk_flutter"
);
channel.setMethodCallHandler(this);
context = flutterPluginBinding.getApplicationContext();
ActivityLifeCycleHandler.registerActivityLifeCycleCallBacks(context);
}
public RudderClient initializeSDK(MethodCall call) {
Map<String, Object> argumentsMap = (Map<String, Object>) call.arguments;
String writeKey = (String) argumentsMap.get("writeKey");
...
return rudderClient;
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result){
if (call.method.equals("initializeSDK")) {
rudderClient = initializeSDK(call);
for (Runnable runnableTask : ActivityLifeCycleHandler.runnableTasks) {
runnableTask.run();
}
initialized = true;
return;
}
}
}

Here we’re overriding two methods:

  • onAttachedToEngine
  • onMethodCall

onAttachedToEngine

gets called when your plugin gets attached to a Flutter experience. It’s important to initialize your plugin’s behavior in

onAttachedToEngine()

, and then clean up your plugin’s references in

onDetachedFromEngine()

onMethodCalled

gets called when an incoming message arrives through the method channel. It contains the method name as well as the respective parameters.

iOS Plugin

Making an iOS plugin follows the same steps as that of an Android plugin with a few small differences. To get started, we create our folder,

SH
mkdir rudder_plugin_ios

Then we add a pubspec.yaml file to declare the details and dependencies of the package.

YAML
name: rudder_plugin_ios
version: 2.0.1
description: The RudderStack Flutter SDK allows you to track event data from your app. It can be easily integrated into your Flutter application. After integrating this SDK, you will also send the event data to your preferred analytics destination/s, such as Google Analytics, Amplitude, and more.
homepage: <https://github.com/rudderlabs/rudder-sdk-flutter>
repository: <https://github.com/rudderlabs/rudder-sdk-flutter>
issue_tracker: <https://github.com/rudderlabs/rudder-sdk-flutter/issues>
documentation: <https://docs.rudderstack.com/rudderstack-sdk-integration-guides/rudderstack-flutter-sdk>
environment:
sdk: '>=2.12.0 <3.0.0'
flutter: ">=2.0.0"
dependencies:
flutter:
sdk: flutter
rudder_sdk_flutter_platform_interface: //before publishing we will be using //path
path: "../rudder_plugin_interface"
dev_dependencies:
flutter_lints:
flutter_test:
sdk: flutter
test: ^1.19.5
plugin_platform_interface: ^2.0.0
flutter:
plugin:
implements: rudder_sdk_flutter
platforms:
ios:
pluginClass: RudderSdkFlutterPlugin
dartPluginClass: RudderSdkFlutterIos

The iOS package contains a couple of new elements:

  • ios
    • pluginClass- The iOS (swift/obj-C) class that implements the method channel

The rest are the same as the Android package.

Next, we create a “lib” folder for our source code:

YAML
mkdir lib

Like the Android package, here we need two classes, mainly. One Dart plugin class to convey the Dart commands to native code. And one native plugin class to receive the messages from Dart.

To create these classes, we made a file inside lib named rudder_plugin_ios.dart:

DART
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:rudder_sdk_flutter_platform_interface/platform.dart';
import 'package:rudder_sdk_flutter_platform_interface/rudder_sdk_platform.dart';
const MethodChannel _platformChannel = MethodChannel('rudder_sdk_flutter');
/// An implementation for RudderSdk for ios platform
class RudderSdkFlutterIos extends RudderSdkPlatform {
/// Register this class
static void registerWith() {
RudderSdkPlatform.instance= RudderSdkFlutterIos();
}
@override
void initialize(String writeKey,
{RudderConfig? config, RudderOption? options}) {
config ??= RudderConfigBuilder().build();
Map<String, dynamic> params = {};
params['writeKey'] = writeKey;
params['config'] = config.toMapMobile();
if (options != null) {
params['options'] = options.toMap();
}
_platformChannel.invokeMethod("initializeSDK", params);
}
}

You’ll find the full implementation here.

This code is similar to the Android implementation code. First, it creates a method channel to read the Dart method calls. This method channel gets a string as a parameter that defines which underlying channel to read.

It then creates a class named

RudderSdkFlutterIos

remember we used the same name in YAML under the name

dartPluginClass

– this class extends the RudderSdkPlatform which provides the methods to override.

registerWith

does the same thing here as it does for Android.  It registers this class to RudderSdkPlatform to handle the calls made to it. This method is implicitly called upfront and serves as a way to initialize the platform packages.

As with the Android code, this code overrides the initialize method to read the params and forward them to iOS specific platform code.

With this complete, we’re ready to modify the source code for iOS platform.

We already have an iOS folder created, so we just move the package inside rudder_plugin_ios.

You can see a folder named Classes inside iOS. This folder contains a header file and a class with the same names.

Depending on your choice of language, you can either use swift or objective C. We reused our previously written plugin class named

RudderSdkFlutterPlugin.

You can see the full implementation here.

You can add your methods of choice to the header file and implement the same in your class. I’m not an objective C expert, so pardon me for any discrepancies 😅.

Let’s take a look at the important parts of the iOS source code:

OBJECTIVEC
#import "RudderSdkFlutterPlugin.h"
#import "RSMessageType.h"
#import <Rudder/Rudder.h>
static NSNotification* _notification;
@implementation RudderSdkFlutterPlugin
NSMutableArray* integrationList;
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
FlutterMethodChannel* channel = [FlutterMethodChannel
methodChannelWithName:@"rudder_sdk_flutter"
binaryMessenger:[registrar messenger]];
RudderSdkFlutterPlugin* instance = [[RudderSdkFlutterPlugin alloc] init];
[registrar addMethodCallDelegate:instance channel:channel];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(listenAppLaunchNotification:) name:UIApplicationDidFinishLaunchingNotification object:UIApplication.sharedApplication];
}
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
if ([call.method isEqualToString:@"initializeSDK"]) {
[RSClient getInstance:[call.arguments objectForKey:@"writeKey"] config:[self getRudderConfigObject:[call.arguments objectForKey:@"config"]] options:[self getRudderOptionsObject:[call.arguments objectForKey:@"options"]]];
if(_notification!= nil)
{
[[RSClient sharedInstance] trackLifecycleEvents:_notification.userInfo];
}
return;
}
}
@end

So, what’s going on in the code above?

registerWithRegistrar

registers this plugin using the context information and callback registration methods exposed by the given registrar.

The registrar is obtained from a FlutterPluginRegistry which keeps track of the identity of registered plugins and provides basic support for cross-plugin coordination.

The caller of this method, a plugin registrant, is usually autogenerated by Flutter tooling based on declared plugin dependencies. The generated registrant asks the registry for a registrar for each plugin and calls this method to allow the plugin to initialize itself and register callbacks with application objects available through the registrar protocol.

This method creates and registers our Flutter channel.

handleMethodCall

helps us listen to the flutter method calls.

In our implementation, we check for the called method name and provide our implementation accordingly.

That’s it for our iOS specific platform package 😃

Web Plugin

While the Android and iOS plugin packages are similar, as you might guess, the web plugin package is quite different.  On the web, your entire app is compiled into one JavaScript bundle, so the plugin code is needlessly serializing the method call into a byte array which is then instantly deserialized by the web plugin. Because of this, we access the Web plugin directly to improve performance.

Before we dive into this one, it’s important to note one specific to our project. In general, Flutter web interacts with Flutter or HTML components. For our purpose here, we're interacting with our existing JavaScript SDK available on GitHub. So, this article focuses on how to interact with JavaScript libraries through flutter plugins.

Now, let’s get back to the code…

We created a folder named rudder_plugin_web inside the root directory:

SH
mkdir rudder_plugin_web

At this point, it should be clear that for a folder to act like a package, it must have a lib folder and a pubspec.yaml file.

So, we create a lib folder inside rudder_plugin_web folder:

SH
mkdir lib

And then a pubspec.yaml file inside rudder_plugin_web folder:

YAML
name: rudder_plugin_web
version: 2.0.0
description: The RudderStack Flutter SDK allows you to track event data from your app. It can be easily integrated into your Flutter application. After integrating this SDK, you will also send the event data to your preferred analytics destination/s, such as Google Analytics, Amplitude, and more.
homepage: <https://github.com/rudderlabs/rudder-sdk-flutter>
repository: <https://github.com/rudderlabs/rudder-sdk-flutter>
issue_tracker: <https://github.com/rudderlabs/rudder-sdk-flutter/issues>
documentation: <https://docs.rudderstack.com/rudderstack-sdk-integration-guides/rudderstack-flutter-sdk>
environment:
sdk: '>=2.12.0 <3.0.0'
flutter: ">=2.0.0"
dependencies:
flutter:
sdk: flutter
flutter_web_plugins:
sdk: flutter
logger: ^1.0.0
intl: ^0.17.0
js: ^0.6.3
rudder_sdk_flutter_platform_interface: //using path as above
path: "../rudder_plugin_interface"
dev_dependencies:
flutter_lints:
flutter_test:
sdk: flutter
test: ^1.19.5
flutter:
plugin:
implements: rudder_sdk_flutter
platforms:
web:
pluginClass: RudderSdkFlutterWeb
fileName: rudder_plugin_web.dart

This file is the same as our Android/iOS platform packages except for the dependencies and platforms section. I’ll cover the platforms section first and come back to dependencies later.

platforms:

  • web Indicates the web configuration of a plugin
    • pluginClass is the dart class that serves as the entry point to the plugin, the class that extends RudderSdkPlatform
    • fileName is the name of the file that the above class resides in

With that covered, let’s concentrate on the code for web plugin.

To forward our method calls to our existing JS SDK, we need to interpolate JavaScript code with Dart. To do this, there’s an awesome annotation-based flutter plugin named js available pub.dev. This is what we’re referencing with

js: ^0.6.3

in the dependencies section of pubspec.yaml file above.

To incorporate our Rudderstack JS SDK in our web package, we create another file named web_js.dart that uses annotations from the js plugin to enable access to the JavaScript code from Dart.

You can see the full implementation here.

For aesthetic purposes, we kept the file inside a folder named internal. Let’s dissect a slice of this file:

DART
@JS()
library rudder_analytics.js;
import 'package:js/js.dart';
@JS("rudderanalytics.load")
external load(
String writeKey, String dataPlaneUrl, dynamic options);

First, we use the library keyword to define the library name. Next, we import the library that does all the interpolation for using

import 'package:js/js.dart';

With the library imported, we write a function annotated by @JS(). The parameter passed through this function represents the method to be called in Javascript. It can be named anything, but because it will be used by other Dart code to call the underlying JS function, it’s best to name the annotated function and the underlying JS function the same to keep parity.

The annotated function should contain exactly all the parameters that need to be supplied to the underlying JS function.

Since JS is an interpreted language, sending dynamic should suffice.

The external keyword is to indicate that the function body is defined elsewhere.

Now that we've enabled access to the JS code from Dart, we're ready to create our implementation of Platform Interface for web.

As defined in

pubspec.yaml

, we create a file named

rudder_plugin_web.dart

inside lib. This contains the class named

RudderSdkFlutterWeb

that extends

RudderSdkPlatform

. The complete code can be found here.

Let’s take a closer look at the important parts of this code:

DART
import 'dart:async';
// In order to *not* need this ignore, consider extracting the "web" version
// of your plugin as a separate package, instead of inlining it in the same
// package as the core of your plugin.
// ignore: avoid_web_libraries_in_flutter
// import 'dart:html' as html show window;
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'package:rudder_sdk_flutter_platform_interface/platform.dart';
import 'package:rudder_sdk_flutter_platform_interface/rudder_sdk_platform.dart';
import 'package:js/js_util.dart' as js;
import 'internal/web_js.dart' as webJs;
/// A web implementation of the RudderSdkFlutter plugin.
class RudderSdkFlutterWeb extends RudderSdkPlatform {
///we don't intend to use method channel for web, as this adds an overhead
static void registerWith(Registrar registrar) {
///setting this instance
RudderSdkPlatform.instance= RudderSdkFlutterWeb();
}
void initialize(String writeKey,
{RudderConfig? config, RudderOption? options}) {
final rudderConfig = config ?? RudderConfigBuilder().build();
final integrationMap = options?.integrations
?.map((key, value) => MapEntry(key, value is bool ? value : false));
final configMap = rudderConfig.toMapWeb();
configMap["integrations"] = integrationMap;
return webJs.load(writeKey, rudderConfig.dataPlaneUrl, _jsify(configMap));
}
//bonus, how to convert collections in dart to JS objects and arrays
dynamic _jsify(Object? object){
if(object != null) {
// final encode = json.encode(object);
// final encode = JsObject.jsify(object);
if(object is Map) {
final encode = mapToJSObj(object);
return encode;
}
}
return null;
}
static dynamic mapToJSObj(Map<dynamic,dynamic> map){
var object = js.newObject();
map.forEach((k, v) {
var key = k;
var value = v is Map? mapToJSObj(v):
v is Iterable ? _iterableToJSArray(v) :v;
js.setProperty(object, key, value);
});
return object;
}
static dynamic _iterableToJSArray(Iterable<dynamic> array){
var preparedArray = array.map((element) => element is Map? mapToJSObj(element) :
element is Iterable? _iterableToJSArray(element) : element);
return [...preparedArray];
}
}

The last part here is critical because if you send Map to JS functions, they will be passed as Symbolic, and the [] operator won’t work. You have to convert them to javascript objects as shown in the code.

As with the other platform packages,

registerWith

is called implicitly and used to register this implementation to RudderSdkPlatform. The difference with web platform lies in the implementation of the overridden methods.

We do not need to forward the method calls through a channel because we can execute the needed calls in this file.

For example, you can import ‘dart:html’ in this file and work with

import 'dart:html' as html

; Then you can use this to access the window as shown here:

DART
Future<bool> someMethod(String url) {
return Future<bool>.value(html.window.open(url, '') != null);
}

Coming back to our implementation code, you can see we extracted the parameters needed for making a call to the JS SDK, followed by

return webJs.load(writeKey, rudderConfig.dataPlaneUrl, configMap);

. This allows use to call the Javascript functions using the formerly created

web_js.dart

At this point, our platform interface and platform packages are prepped. Now we’re ready to develop the app-facing package that accumulates all of the platform packages. In our case this is rudder_plugin.

Step 3: Create the app-facing package

rudder_plugin is our main plugin that will be exposed to Users. This module will act as a bridge between the platform interface and the third-party app using the library. Here’s how we created it.

As with earlier modules, we go to the root folder and create a sub folder named

KOTLIN
mkdir rudder_plugin

Once inside that folder, we create another folder named lib:

KOTLIN
mkdir lib

Next we create a pubspec.yaml file inside the rudder_plugin folder. This is where we provide all module dependencies:

YAML
name: rudder_sdk_flutter
version: 2.1.1
description: The RudderStack Flutter SDK allows you to track event data from your app. It can be easily integrated into your Flutter application. After integrating this SDK, you will also send the event data to your preferred analytics destination/s, such as Google Analytics, Amplitude, and more.
homepage: <https://github.com/rudderlabs/rudder-sdk-flutter>
repository: <https://github.com/rudderlabs/rudder-sdk-flutter>
issue_tracker: <https://github.com/rudderlabs/rudder-sdk-flutter/issues>
documentation: <https://docs.rudderstack.com/rudderstack-sdk-integration-guides/rudderstack-flutter-sdk>
environment:
sdk: '>=2.12.0 <3.0.0'
flutter: ">=2.0.0"
dependencies:
flutter:
sdk: flutter
#module dependencies
rudder_sdk_flutter_platform_interface:
path: "../rudder_plugin_interface"
rudder_plugin_android:
path: "../rudder_plugin_android"
rudder_plugin_ios:
path: "../rudder_plugin_ios"
rudder_plugin_web:
path: "../rudder_plugin_web"
logger: ^1.0.0
intl: ^0.17.0
dev_dependencies:
flutter_lints:
flutter_test:
sdk: flutter
test: ^1.19.5
flutter:
plugin:
platforms:
android:
default_package: rudder_plugin_android
ios:
default_package: rudder_plugin_ios
web:
default_package: rudder_plugin_web

Next we create a class called

RudderController

to expose all public APIs:

DART
import 'package:rudder_sdk_flutter_platform_interface/platform.dart';
import 'package:rudder_sdk_flutter_platform_interface/rudder_sdk_platform.dart';
class RudderController {
RudderController._();
static final RudderController _instance = RudderController._();
static RudderController get instance => _instance;
// final _platformChannel = const MethodChannel('rudder_sdk_flutter');
void initialize(String writeKey,
{RudderConfig? config, RudderOption? options}) {
RudderSdkPlatform.instance
.initialize(writeKey, config: config, options: options);
}
void identify(String userId, {RudderTraits? traits, RudderOption? options}) {
RudderSdkPlatform.instance
.identify(userId, traits: traits, options: options);
}
}

You can take a look at the full implementation here.

Note how we conveniently use

RudderSdkPlatform.instance

since we know the instance will be based on the platform currently in use.

That’s it. At last, our plugin is complete!

For testing we have an example folder right inside rudder_plugin. To create your flutter application inside this folder, you can run:

SH
flutter create .

To test the library before it’s uploaded to pub.dev, you can use the local path in pubspec.yaml:

YAML
rudder_sdk_flutter:
path: ../

Now you can reference the library in lib/main.dart. For brevity, I won’t include the code here, but you can check it out on GitHub.

Conclusion

We started with an overview of MethodChannel to provide some context for adding web support to your Flutter mobile applications. Then we walked through how to add web support to your flutter mobile plugin using the RudderStack Flutter SDK as an example (sign up for RudderStack if you’d like to check it out.). At this point, you’re ready to tackle your own project. Feel free to join our Slack community and reach out to me if you have any questions or just want to talk Flutter.

Test drive our Flutter SDK today

Sign up for RudderStack today to check out our Flutter SDK. Start tracking event data from your Flutter applications (including web!) and sending it to every destination in your stack.

November 9, 2022