Migrating existing native Android/iOS applications to Flutter

Migrating existing native Android/iOS applications to Flutter

Flutter is one of the most interesting technologies, which allows building mobile applications for iOS and Android platforms, having one codebase (in an ideal world, of course). Before, we have developed two native applications, one for iOS and one for Android. Both of them contained almost the same business logic and user interface with small but not critical differences.

A long time ago, it was a small application with a couple of commonly used features that could be described from the technical point of view, like “please, make some network CRUD operations and display lists.” But since those times, many things have evolved, grown, and changed.

For now, our mobile applications contain a wide range of features and integrations. They work with several services and include:

  • Integration with AWS;
  • QR-codes & barcodes scanner;
  • Integration with specialized client’s hardware;
  • RESTful API;
  • Data synchronization;
  • Local storage, data caching, partial offline mode;
  • Web-sockets;
  • and so on.

Usually, we have implemented features in parallel, both for iOS and Android. That is why our dev team implemented every feature twice: for each mobile platform. There are both advantages and disadvantages of such an approach.

As we have developed our mobile applications using native technologies (Java/Kotlin for Android and Objective C/Swift for iOS), it has been easy to design and implement such things as custom scanners, working with hardware, native local storage, etc. Also, there have been multiple open-source native libraries both for Android and iOS that can do almost everything, from specific material components to integration with other services - for example, with PlayStore/AppStore.

On the other hand, developing two separate applications which do almost the same things may lead to some problems:

  • Business logic and UI are implemented twice;
  • Sometimes, minor differences and implementation details appear during the continuous development of the application;
  • If requirements are changed, all changes need to be implemented twice too;
  • Sometimes, we have implemented features only for one platform, and after that, we have needed to re-implement it again for another platform, including all feature changes that happened before.

The alternative approach is to apply cross-platform tools: ReactNative, Ionic, and Flutter. That allows having only one code base for business logic and almost one code base for UI and system-dependent features. Of course, you cannot implement all things in JS (ReactNative) and Dart (Flutter). That is why cross-platform tools usually provide a way of extending their functionality via Native Extensions. So actually, you should take into account that using a cross-platform tool instead of native ones never gives you x2 to the development process velocity.

In the real world, it may even slow down the development process, especially at the initial setup and if your application hardly relies on Android/iOS-specific features. But if your application contains a lot of business logic, and if the UI for Android and iOS are almost the same, then using a cross-platform tool like Flutter may help you. Flutter can reduce the amount of time spent on developing new features and probably cut down the number of bugs making your iOS and Android application more stable.

We decided to try Flutter and start rewriting our mobile application but not from scratch. Often, if a company wants to create a mobile application, it may choose a cross-platform tool for that purpose (likely ReactNative) because of having web developers in its staff.

Or another case: there is already an existing and well-working website, and now an idea to extend it with a mobile application (which can be published to AppStore/PlayStore) arises. As the website was likely developed by web developers, there is a high probability that the same web developers would work on a mobile application writing it in JS (ReactNative/Ionic).

Such an approach may lead to problems, and often it does. The reason is that web developers do not have enough experience with mobile platforms and their specific corner cases. That is why you may face some rumors that cross-platform tools are not good enough, buggy, and not usable, etc.

We have another situation because we have a mobile team of native Android/iOS developers. So even if we face some problems with cross-platform mobile tools, we always have a chance to finish our work by deep-diving into cross-platform mechanisms or extend the functionality by writing our native modules for cross-platform frameworks.

So here we go.

After some research and playing with Flutter, we decided to continue developing our native iOS and Android mobile applications but include Flutter in parallel. We are developing new features in Flutter; changes to old features remain native. Meanwhile, some of the old features are rewritten in Flutter when we have time to do it. And when all old features are rewritten by using Flutter, we can fully migrate from native to Flutter.

We divided the migration process into several steps. Before integrating Flutter, the mobile application has looked as following:

BEFORE:

Two separate projects (one native iOS and one native Android) were developed separately (Java/Kotlin & AndroidStudio for Android, Swift & XCode for iOS).

The first goal was to make one Flutter project but without adding any Flutter code. Just one project for building both Android and iOS applications. All features, business logic, UI remained the same at this step:

AFTER, STEP #1:

In order to make one Flutter project from two native Android/iOS projects, we modified the build configuration for both iOS & Android native projects. Also, one of the critical problems we faced at this step was the problem of android build types. Application has DEV, QA, STAGING, and PRODUCTION stages, and on the Android side, those stages were configured using Build Types. But as Flutter has hardcoded its own Build Types and supports only Android Flavors out of the box, we had to rewrite build configuration from build types to build flavors supported by Flutter.

Let’s see how it looks before and after:

Flutter uses the following build types: Debug, Profile, and Release. Debug for active development, Profile for profiling, and Release for making the final build. That is why flavors should be used for configuring the app environment instead of building types. After that, we can specify the required flavor to build the desired version of the app. For example:

$ flutter build app bundle --flavor qa --debug

Next, we implemented a communication channel between native and Flutter code. Flutter provides a way of such communication. Here is the list of features supported by Flutter out of the box:

  • Flutter method channels: Native app can call Flutter methods and retrieve results;
  • Native method channels: Flutter app can call Native methods and retrieve results;
  • Native broadcast event streams: the Flutter app can listen for events broadcasted by native code.

Restrictions:

  • Event streams are hot and broadcast. You need to write additional logic to have a possibility to create some local event stream, listen to it, and then close;
  • There is no straight way to listen to events in the opposite direction (from Flutter to native).

But as we wanted to integrate Flutter smoothly (e.g., rewrite feature #1 in Flutter leaving other features native), we had to overcome the communication restriction listed above. In the native app, we used Reactive Streams (reactivex.io) to make work asynchronously and listen for model changes. Almost all operations were wrapped to Rx streams, e.g., Completable, Single<R>, Observable<R>, and so on.

Also, some of the app business logic blocks used other business logic blocks. For example, we had LocationsService, which provided a set of methods of managing locations, ensuring their consistency. Some other services used LocationsService as a dependency. LocationService, in its turn, might use other services and so on. These blocks can be depicted as the following:

Some services may use other services; even some services may use each other.

Referring to the above, we decided to implement a communication channel that can map native operations for Flutter and vise versa; example for RxJava:

  • Rx Completable ↔  Flutter Future<void>
  • Rx Single<R> ↔ Flutter Future<R>
  • Rx Observable<R> ↔ Flutter Stream<R>

After implementing such conversions, we can easily share data and operations in both directions: from native to Flutter and from Flutter to native. So even if we decide to move RFIDs Service to Flutter, it can still communicate with other native services and repositories if needed:

AFTER, STEP #2:

Now we can take, for example, the whole Feature1 or at least some of the business logic services and move them to the Flutter’s side. Even more! If the app has some complicated, heavily dependent native logic (e.g., we had already implemented and profiled worker for MQTT-connections in our app both for Android and iOS), then it is possible not to rewrite it in Flutter but just implement a mediator: MqttManager Flutter interface and then add IosMqttManager and AndroidMqttManager implementations.

The implementations are simple; they call native logic, for example:

abstract class MqttManager {
 Stream<Event> listenEvents();
 Future<void> sendEvent(Event event);
}

class AndroidMqttManager implements MqttManager {
 final CommunicationChannel channel;
 final String topic;
 AndroidMqttManager(this.channel, this.topic);

 @override
 Stream<Event> listenEvents() {
   return channel.invokeNativeStream("listenEvents", {"topic": topic})
     .map((response) => toFlutterMqttEvent(event));
 }

 @override
 Future<void> sendEvent(Event event) {
   return channel.invokeNativeFuture(
     "sendEvent",
     {"topic": topic, "event": event.serialize()}
   );
 }
}

And then, after some time, when we rewrite native logic for MQTT protocol, we can easily replace the AndroidMqttManager above (and IosMqttManager too) with a new FlutterMqttManager. And if we also manage to rewrite all native features that use MQTT manager to Flutter, we can remove native logic, which works with MQTT protocol, and then fully migrate to Flutter.

Such an approach with mediators may be applied for all features in the application. In general, we can describe the process as following:

1) Let’s have three native features

Here and below, we mark native and flutter code as following:

2) Next, let’s rewrite Feature 1 in Flutter. As it depends on Feature 2, we also add a mediator that can communicate with native code. The mediator does not duplicate any native business logic; it just provides methods for calling native methods of Feature #2 from Flutter :

3) Then, after some time, let’s rewrite Feature 2 in Flutter too:

4) In this case, we do not need the Flutter mediator for Feature 1 anymore because both Feature 1 and Feature 2 are implemented in Flutter:

5) But as you see, native Feature 3 also depends on Feature 2, and as it remains native, it expects Feature 2 to work as before. No problems, just adding a Native Mediator in the same way as we did it for Flutter:

6) And when Feature 3 is rewritten in Flutter, we can remove all mediators and communication channels:

So at some point, the situation was as following:

AFTER, STEP #3:

There is a need to pay attention to the navigation system. While the app has both native and Flutter screens, they are managed by a native navigation system. Native screens can launch other screens with some arguments and receive results. In order to incorporate Flutter smoothly, screens should be decoupled as much as possible, like features/services discussed above.

In our case, we have a separate base class named Screen and multiple subclasses with different arguments. They contain information about the page that should be launched. Also, we have an interface Router, which provides a set of methods for launching/closing screens and delivering screen results. Obviously, navigation implementation details are different on iOS and Android.

So on the Flutter’s side, the following steps are required:

  • Define navigation interface (Router) with a set of methods that provide basic navigation operations;
  • Create 2 implementations, e.g. FlutterIosRouterFlutterAndroidRouter;
  • All navigation logic should call Router’s methods for starting new screens, closing screens, etc.;
  • FlutterIosRouter and FlutterAndroidRouter, in its turn, decide - 1) how to launch the screens; 2) what screen should be launched: native or flutter one.

A very similar logic is mirrored by native applications too. Because we need to allow launching:

  • Native screens from Native screens;
  • Native screens from Flutter screens;
  • Flutter screens from Native screens;
  • Flutter screens from Flutter screens.

But when all screens are rewritten in Flutter, it is safe to remove a native navigation system (both from Android and iOS applications) and replace it with some Flutter Router. In this case, the concrete implementations (FlutterAndroidRouter/FlutterIosRouter) are removed and replaced by one Flutter router; all other Flutter code remains the same.

It is even possible to use a declarative approach of Flutter Navigation v2.0. But it will look like non-declarative Navigation v1.0 because declarative logic is written only inside the router.

If the application is not just about getting data from the backend and displaying lists, native Android/iOS code likely cannot be completely removed. And it is okay because such things as Bluetooth connections, QR-code scanning, or some special Android/iOS features are easier to be implemented and supported on the native side. Also, some native libraries are more stable than their Flutter analogs. For example, in 2021, Amplify SDK for Android/iOS works fine, but Amplify SDK for Flutter is a bit tricky for usage.

So if you have some complex or heavily native-dependent logic written both in Android and iOS, you do not need to rewrite it in Flutter completely. But you can rewrite the other parts of the native application in Flutter and call native parts when needed by using Flutter Native modules.

Let’s make some conclusions in the form of FAQs:

1. Can a native mobile app be rewritten in Flutter?

Yes, but likely some native code will remain in the project.

2. Can a native mobile app be rewritten in Flutter smoothly (e.g., continue supporting native features, and add new features in Flutter)?

Yes, if your native mobile apps have +- adequate architecture (decoupled navigation logic, view, and model layers). But combining navigation and flutter screens may not work as you expect, so be ready to spend time to solve issues related to the in-app navigation.

3. When do you recommend migrating native mobile apps to Flutter?

I think the prerequisites are the following:

  1. you have both Android and iOS applications with almost the same logic;
  2. you have at least one native iOS developer and native Android developer in your team (likely you have indeed, because who wrote native applications if you do not have native developers?).

4. Is it okay to start a new mobile app project in Flutter?

Yes, but for complex applications only if you have at least one native iOS developer and native Android developer in your team, just in case.

5. What about other cross-platform tools - is Flutter better than other ones?

Each cross-platform framework has its own advantages and disadvantages. And Flutter has some issues and bugs too. But it is in active development now, and from our point of view, it is much more stable and faster than other cross-platform frameworks we have faced (ReactNative, Xamarin, Ionic).

Anyway, Flutter is an efficient tool for creating simple mobile applications and prototyping in almost all cases. But it can be used for applications with complex logic too. And as we have discovered in this article, it is even possible to migrate native apps to Flutter smoothly if your dev team has appropriate experience in all related technologies: Android/iOS + Flutter.