Project Larvae - Migrating our native apps to Flutter part 2

Visiba Care
10 min readAug 31, 2023

--

Link to part 1

Integration with existing codebase

The strategy for implementing Flutter would of course not be that we would start over on a blank slate. Instead, we would utilise Flutters support for being able to integrate into existing apps. [1]

You can add a flutter module to your app as any normal third-party dependency. From the app’s perspective everything works exactly as it did before. You can then make calls to code in the Flutter module. The module is a separate project in GitHub and can be added to the iOS and Android project as a Git submodule.

Having it as a submodule removes the complexity of maintaining two separate repositories when developing new features in our mobile apps. Any changes you make in our existing apps for the Flutter module will update the Flutter Module project as well.

The Flutter module behaves as its own isolated application that can be run independently separate from the native apps. This is useful for when you want to make small changes in the Flutter code without having to compile the entire app.

Below you will see a recording how this can work in practice. When you launch the app, everything looks normal but instead of being routed to the home view I rerouted the navigator to instead launch a Flutter view.

https://vimeo.com/858606577

The flutter module as part of the existing Android project, added as a submodule.

This means that from the native app’s perspective Flutter is just like any other view that navigator navigates to instead of the native view.

First implementation

A first usable and real implementation of Flutter in our existing apps would be converting one existing screen from native code to Flutter. We decided to be ambitious and convert our messaging chat view as the first. The messaging chat covers a lot of the bases that we must make in Flutter to be able to use it. We are talking about things like a socket connection, file uploading, API-calls, and complex animations.

Before we could start coding, we first had to decide on which architecture to use. Our choice ended up being heavily based on the Android app’s existing architecture, which is based on the Model, View, View model pattern or MVVM-pattern for short. This pattern has resulted in a nicely layered codebase which is testable, and where each layer has clear responsibilities. In Flutters case this means that the Widgets are only responsible for drawing things on the screen, interactors are responsible for keeping track of states and the repositories responsible for data access.

Image showing our architectural layers with View (Widget), Interactors, Repository, and data source.

The different layers are communicating with each other via interfaces rather than actual implementation of classes. This allows dependencies between layers to be easily mocked, reused, or replaced entirely which gives us a highly testable code base and bugs much easier to track down.

After the architecture was put into place; we had to adopt and implement our internal design system Cellula into Flutter. I will not go into details about Cellula in this post, but our apps are so called white label apps which means colours, texts and themes are loaded at runtime rather than compile time. This means that all our Widgets need to be able to have colours and text injected via their constructor and dynamically applied.

Next step was to build support in our native apps for displaying Flutter views instead of native views. What you do is that you create something called a Flutter Engine object in the native code that hosts and runs your Flutter application in parallel with the native app. This engine can then be displayed as a view in the native app and handle all forms of inputs and network operations by itself.

However, when you start up the engine it will display whatever is the default Widget in the Flutter code. We want it specifically to display our messaging chat view and send it the arguments it needs to fetch the right data for that Widget. For example, you need to use an access token and a case ID, then data can be retrieved for the correct messaging chat. This is where we need to start talking about platform channels.

Platform channel

The Flutter module and the native app can talk to each other via something called a Platform channel. A Platform channel is bi-directional meaning that the native code can talk to the Flutter module and vice versa. Data can be transferred between the two as JSON objects since Dart and the native code can use the same models.

We load all colours and text in the native app and then transfer them over to the Flutter module with JSON via the platform channel. We also use it to tell Flutter to internally navigate to the messaging with arguments we provide with JSON.

The platform channel also handles crashes within the Flutter module and reporting it back to the native code to enable normal crash handling procedures.

Below is a code snippet example of Flutter receiving a method call from the native code:

Future<bool> methodHandler(MethodCall call) async {
final arguments = call.arguments;
switch (call.method) {
case messagingMethodName:
final messagingViewArguments =
MessagingViewArguments.fromJson(jsonDecode(arguments));
onNavigation(MessagingView.routeName, messagingViewArguments);
return true;
}
}

Here is a code snippet with an example of how we call on a method in the Flutter module from the Android app with Kotlin:

@UiThread
private fun invokeMethod(methodName: String, jsonData: String?) {
methodChannel.invokeMethod(methodName, jsonData, object : MethodChannel.Result {
override fun success(result: Any?) {
Log.d(
TAG,
"Successfully called flutter method: $methodName result: ${result?.toString()}"
)
}

override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
Log.d(
TAG,
"Failed to call Flutter method $methodName ERROR DETAILS: $errorMessage $errorDetails $errorCode"
?: ""
)
}

override fun notImplemented() {
Log.d(
TAG,
"This could be the result of the FlutterMethodChannel never being started in the Flutter Module check for that! It could also be that he Function was not implemented in the Flutter Module. Double check the name? $methodName"
)
}
})
}

override fun navigateToAppointmentDetails(callTicket: String) {
runOnUIThread {
invokeMethod(
METHOD_NAME_APPOINTMENT_DETAILS,
JsonUtil().toJson(FlutterAppointmentDetailsNavArgs(callTicket))
)
}
}

override fun navigateToMessaging(caseId: Long, accessToken: String?) {
runOnUIThread {
invokeMethod(
METHOD_NAME_MESSAGING,
JsonUtil().toJson(
FlutterMessagingNavArgs(
caseId,
accessToken,
true
)
)
)
}
}

Third party libraries

We were incredibly careful when selecting all the third-party library dependencies we use in Flutter. We only want dependencies that are secure and deliver us more value than we could simply do ourselves.

One critical aspect to look at is if the library is actively maintained and sufficiently popular, to ensure continued support for a long time in the future. The more critical role the library fulfils and the more complex it is these points become more important. Often if a non-critical library is abandoned you can just extract the code snippets you need directly into your own codebase.

Ensuring the library has a licence that allows for full commercial usage and making your own modifications is also something you must look up for all dependencies you add. In the Flutter ecosystem this is very rarely a problem, fortunately.

Finally, documentation and ease of use so that you can implement these libraries in a stable way just as the library author intended. Sometimes it can be easy to misunderstand how the library is meant to be used therefore good documentation is important so you can read and understand what you are doing.

Here is a non-exhaustive list of some of the third-party libraries we use:

https://pub.dev/packages/provider — Helps our state management so that Widgets only redraw themselves when something in the state they listen to changes.

https://pub.dev/packages/flutter_svg — Flutter doesn’t support the .svg file type by default but all our icon assets use this file format.

https://pub.dev/packages/go_router — For internal navigation within the Flutter module.

https://pub.dev/packages/json_serializable — Automatically generate JSON serialisation/deserialization for our API-models.

Automated tests

We found writing tests in Flutter easy. The testing framework is very robust and feels in general well thought out.

From the very start writing in code in a way that allowed for easy test writing was important. We achieved this but having dependency injection, mock library, and default implementations of certain interfaces from the very start.

We are pleased to now have both unit and Widget tests that covers all parts of the architectural layers in the codebase.

Below is a code snippet showing one of our Widget tests. This one ensures that our text components handle clickable links as expected by asserting that the link is highlighted in a different colour and is indeed clickable.

void main() {
testWidgets('ClickableText shows text and links',
(WidgetTester tester) async {
const textPart1 = 'Best ';
const textPart2 = 'https://visibapark.vcare.pl';
const textPart3 = ' website';
const testText = textPart1 + textPart2 + textPart3;
const testLinkColor = Colors.blue;
const testTextColor = Colors.red;

await tester.pumpWidget(
createWidgetInMaterialApp(
CellulaText(
text: testText,
linkColor: testLinkColor,
fontVariant: CellulaFontDisplay.small.fontVariant,
color: testTextColor,
isSelectable: true,
),
),
);

await tester.pump();

final selectableTextFinder = find.byType(SelectableText).first;
final selectableTextWidget =
tester.widget<SelectableText>(selectableTextFinder);
final textSpans = selectableTextWidget.textSpan!.children;

expect(textSpans!.length, 3);
expect(textSpans[0].toPlainText(), textPart1);
expect(textSpans[0].style?.color, null);
expect(textSpans[1].toPlainText(), textPart2);
expect(textSpans[1].style?.color, testLinkColor);
expect(textSpans[2].toPlainText(), textPart3);
expect(textSpans[2].style?.color, null);

expect(
(textSpans[1] as TextSpan).recognizer,
isA<TapGestureRecognizer>(),
);
});
}

Widget tests do not even need a device to run on and can thus easily be run regularly together with the unit tests in our continuous integration with Azure Pipelines.

Challenges

By far the most common hurdle we kept running into was ensuring that the integration with our native apps worked properly. Since the Flutter views need to be rendered alongside our existing native views some creative workarounds became necessary. For example, we need support for displaying a Flutter view with its own navigation stack together with our bottom navigation view in the native app.

In the Android app the official code examples for displaying a Flutter view within a Jetpack Compose view were meagre at best. Therefore, a lot of time and energy had to be spent to make this work with things like the back stack and OS-events. For the iOS app, which still primarily relies on storyboards, it was a bit smoother since that is extensively covered in the documentation.

Since only a small part of the app is partially migrated at the time, we had to make sure we spent our resources wisely. For example, if there was a bug in a view that shortly would be remade with Flutter it would not be the wisest to spend a significant time fixing that bug. Views that are much further down the pipeline for conversion is a much better thing to put resource on when working in the native codebase.

Result and summary

One of the goals with this project was to increase the productivity of the app team. We can happily report that has absolutely been the case. Previously all redesigns or major architectural changes in the app required full commitment of two app developers to be completed in a timely manner. With the conversions of view to Flutter we quickly noticed that only one developer needed to commit fully to the redesign and the rest of the developers could primarily focus on other things.

Areas were we still needed at least two developers’ full attention was during code reviews and when adapting the native code to support the new Flutter views in the flow. Of course, for the sake of knowledge sharing and utilizing everyone strengths; all app developers should participate in coding Flutter along the way to the goal of having the entire codebase be Flutter.

Overall, we are happy with how our implementation of Flutter turned out. We have not seen any performance issues and both Android and iOS give the user an identical experience. We’re happy to see that the appearance and user experience of our new mobile web and apps are now harmonized. This means no matter which platform our users use they can recognize themselves and are guaranteed the same features.

Personally, I would recommend any company that has not started yet to seriously evaluate different multiplatform frameworks and how they could be used in their applications. The progress on this front only during the last three years or so have been incredibly impressive.

Finally, to summarize what we have done in this project:

1. Realizing that the current codebases for our native apps are really outdated and have accumulated a lot of technical debt over the years.

2. Discussed and noted down things we wanted to solve and different ways to solve them.

3. Evaluated and benchmarked different multiplatform frameworks to determine if any of them was mature enough to live up to our requirements.

4. Created a proof of concept for the most promising candidates to get a feeling of how they are to work with.

5. Reflected on the findings of our research and the proof of concepts, where it became clear that Flutter was the best choice for us.

6. Started the migration process with the focus of laying a solid foundation from day one.

7. Created a view in Flutter with our new design system that has full support for accessibility and test coverage.

8. Added support for displaying the new Flutter view in our native apps alongside our existing views.

9. Released the app to production with feature flags.

10. Made a short post launch analysis (where the results looked great).

Screenshot from our mobile app showing the messaging view made with Flutter
Screenshot from our mobile app displaying the messaging view made in Flutter with some files uploaded.

Bonus — Design story book as a web page

If you are not sure what a Storybook is: https://whitespace.se/blogg/what-is-storybook/

A huge advantage of Flutter is that since it is a multiplatform framework and uses its own rendering engine you can guarantee that your UI-components will look the same no matter what platform you run. Therefore, an idea of creating a storybook where we can showcase all the components was born.

Since it is the easiest platform for sharing and accessing an application, we decided that the storybook would utilize Flutter web. We already had a storybook on the web for the React components meaning it became natural to have Flutter storybook hosted in the same way.

Every time code is pushed to our master branch in the Flutter module the storybook website is automatically updated so we always showcase the latest version of all the components.

Screenshot from our Flutter storybook webpage showcasing the Notification toast component.

Sources

[1] https://docs.flutter.dev/development/add-to-app

Portrait of the blogpost author

Philip Sandegren
App Developer at Visiba Care

--

--