As mobile app development continues to evolve, developers are faced with the decision of choosing between Flutter vs Native communication methods and writing custom platform specific code.

Flutter is a cross platform framework which allows users to target multiple platforms with only one code base.

However, on some projects in Flutter, there are limitations caused by platform specific configuration, or the case we had – accessing platform specific preferences. 

Flutter vs Native Pros & Cons

Flutter Pros:

  • Hot reload allows for faster iteration and development
  • Single codebase for both iOS and Android reduces development time (now supports web and desktop too)
  • Flutter’s widget-based architecture makes it easy to build custom UI
  • Strong community support and active development

Flutter Cons:

  • May require additional learning curve for developers since Dart is a new language, but very easy syntax for developers who are familiar with object oriented languages
  • Dart is still young language and compared to the other languages used for mobile app development – such as Kotlin, and definitely still has room for improvement

Native Pros:

  • Robust third-party library support, although Flutter has a lot more libraries and will continue to develop
  • Familiar development environment for developers, although Flutter is more and more represented and the community is growing. Some specific applications that rely on platform specific features are sometimes better to run natively

Native Cons:

  • Separate codebases required for iOS and Android
  • Longer development time and higher costs
  • Steep learning curve for beginners
  • Debugging and testing can be more challenging

Our Custom Platform Specific Code Challenge

We had an application which was developed a few years ago for both mobile platforms – iOS and Android. Since it was no longer optimized for the latest iOS and Android operating system, the app needed an update. 

The question arose whether to update the current apps on both platforms separately or to develop them again with Flutter. 

The logical answer was Flutter because the iOS app was programmed in Objective-C and Android in Java, the UI of the app is very specific and making it easier to maintain in the long term are some main reasons why we decided to use Flutter. 

So we transformed apps to one code basis using Flutter. The app had some saved data which we had to access in order to maintain the normal behavior of the app for users who had already installed the app. That was done with MethodChannel which gives us the opportunity to access previously saved preferences from native app and use them in refreshed app.   

Architectural overview

Platform channels are used to communicate with native code to use the capabilities provided by native in Flutter. Basically, communication is duplex or bi-directional, it allows direct communication from Dart code to Kotlin/Java code in Android module and Swift/Objective-C in iOS module. 

Flutter’s builtin platform-specific API support does not rely on code generation, but rather on a flexible message passing style. Messages are passed between the client (UI) and host (platform) using platform channels as illustrated in this diagram:

On the client side, MethodChannel enables sending messages that correspond to method calls. On the platform side, MethodChannel on Android and FlutterMethodChannel on iOS enable receiving method calls and sending back a result. These classes enable communications between native code and Flutter. This is exactly the same process of how platform plugins work – on that way platform plugins can be developed with very little boilerplate code. If there are no available platform plugins for desired functionality, developers need to manually create platform channels and implement proper communication between Flutter and native modules.

Flutter architecture diagram shows that communication between native code and Dart code occurs between the Framework and Engine. At the core of Flutter is the Flutter engine, which is mostly written in C++ and supports the primitives necessary to support all Flutter applications.

The engine is responsible for rasterizing composited scenes whenever a new frame needs to be painted. It provides the low-level implementation of Flutter’s core API, including graphics (through Skia), text layout, file and network I/O, accessibility support, plugin architecture, and a Dart runtime and compile toolchain.

Flutter Framework allows developers to interact with Flutter. Framework provides a modern, reactive framework written in the Dart language. It includes a rich set of platform, layout, and foundational libraries, composed of a series of layers. There are all foundational classes, rendering layer, widgets layer, Material and Cupertino libraries.

The MethodChannel exchanges data with the Engine in the form of BinaryMessage in framewrap. Data is serialized from a Dart type like Map into a standard format, and then deserialized into an equivalent representation in Kotlin (HashMap) and Swift (Dictionary).

Implementation 

Platform channels use a standard message codec that supports efficient binary serialization of simple JSON-like values, such as booleans, numbers, Strings, Lists and Maps. Data from received or sent messages are serialized and deserialized automatically.

The following table shows how Dart values are received on the platform side and vice versa:

DartJavaKotlinObjective-CSwift
nullnullnullnil (NSNull when nested)nil
boolBooleanBooleanNSNumber numberWithBool:NSNumber(value: Bool)
intjava.lang.IntegerIntNSNumber numberWithInt:NSNumber(value: Int)
doublejava.lang.DoubleDoubleNSNumber numberWithDouble:NSNumber(value: Double)
Stringjava.lang.StringStringNSStringString
Uint8Listbyte[]ByteArrayFlutterStandardTypedData typedDataWithBytes:FlutterStandardTypedData(bytes: Data)
Int32Listint[]IntArrayFlutterStandardTypedData typedDataWithInt32:FlutterStandardTypedData(int32: Data)
Int64Listlong[]LongArrayFlutterStandardTypedData typedDataWithInt64:FlutterStandardTypedData(int64: Data)
Float64Listdouble[]DoubleArrayFlutterStandardTypedData typedDataWithFloat64:FlutterStandardTypedData(float64: Data)
Listjava.util.ArrayListListNSArrayArray
Mapjava.util.HashMapHashMapNSDictionaryDictionary

Firstly, MethodChannel needs to be created and registered with the channel name – usually the name is used as “package name / identity”.

All calls between Dart code and native modules are asynchronous and all of them are initiated through invokeMethod. Method invokeMethod has two parameters – first one (String) is the name of the method which should be called in native module and the second one is the arguments. Arguments have dynamic type, but only the values supported by the codec of the channels can be used. When multiple arguments are involved, they need to be specified in the form of a map.

Below example shows calling the native methods from Dart and receiving the response which returns the result. The result is the user data which was saved on the previous native app and that data is needed to continue normal behaviour of the app for already logged in users (for users that are logged in the native app before the new app update and Flutter created release). User data consist of three attributes – email, device id and id. Return object is the Map which contains key/value pairs of User’s object attributes. Inside of the getUserFromNative() method, if the received data of invokeMethod is not null, received data is transformed to the User type of object in Dart. 

static const platform = const MethodChannel("ch.tsd.appid/prefs");

Future<User> getUserFromNative() async {
 var nativeUserValue = await platform.invokeMethod("getUserDataFromNative");
   if (nativeUserValue != null) {
     return User.fromJson(Map<String, dynamic>.from(nativeUserValue));
   } else {
     return null;
   }
}

On iOS we have a few things – in the AppDelegate file, FlutterMethodChannel needs to be initialized with the same name as the MethodChannel in Dart. After initialization, method call handler needs to be set on the created channel. This is where we come across the reason why every method needs to have a name – if there are more methods, for every one of them a proper routine can be called by checking the method name.

let prefsChannel = FlutterMethodChannel(name: "ch.tsd.appid/prefs", binaryMessenger: controller.binaryMessenger)


 prefsChannel.setMethodCallHandler({
    (call: FlutterMethodCall, result: @escaping FlutterResult)  -> Void in
        if(call.method == "getUserDataFromNative"){
            self.receiveUserData(result: result)
        }     
})

Inside the receiveUserData method, the saved data is retrieved with NSKeyedUnarchiver (an old native app is written in Objective-C). Retrieved data is sent as a result – as a key/value object.

private func receiveUserData(result: FlutterResult) {
   let filePath = self.getDocumentsDirectory().appendingPathComponent("persistence-objects/logged-in-user")
   NSKeyedUnarchiver.setClass(User.self, forClassName: "User")
   if let userFromFile =  NSKeyedUnarchiver.unarchiveObject(withFile: filePath.relativePath) {
      let nativeUser = userFromFile as? User
      result([
         "email": nativeUser?.email,
         "device_id": nativeUser?.deviceId,
         "id":nativeUser?.id])
    } else {
       result(nil)
    }
 }

Same as on the iOS, on Android inside MainActivity is defined MethodChannel with the same name as the one created on Dart side. There is also methodCallHandler which handles the called methods. User data is retrieved from Prefs and passed as a Map object as a result. 

Because the result is returned asynchronously, result can be returned through result.success in MethodCallHandler like in the example below or save the reference of result first, and then call success at a later point in time. However, result.success must be done in the UI thread. 

private val channel = "ch.tsd.appid/prefs"

MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel).setMethodCallHandler { call, result ->

   if (call.method == "getUserDataFromNative") {
     UserData userData = Prefs.readUserData

     if (user != null) {
        result.success(hashMapOf(
           email to user.email,
           deviceId to user.deviceId,
           id to user.id
        ))
     } else {
        result.success(null)
     }
  } 
}

Flutter vs Native Conclusion

To sum up, this was just a short introduction to flutter vs native communication. Communication with native code is the one thing that is needed when some platform specific code needs to be implemented and there is no other option available – plugins or libraries. 

If you have an native application and you want to keep it updated with every new iOS version and every new requirement, you can think about transforming the current iOS and Android native code to Flutter. We already did it on one project where the customer wanted to set up the app with new technologies to make it easier to maintain in the long term, so we chose Flutter.

If you find yourself in this situation and if you have a similar problem or if you need some kind of advice or suggestion what is the best solution for your project, feel free to contact us.