Introduction
Past decisions
Five years ago, we went with Xamarin native and the MVVM approach. It delivered solid performance but meant we had to build the UI separately for iOS and Android.
We tackled challenging tasks like integrating maps and enabling mobile payments via QR codes or NFC with Google Pay, Apple Pay, and our custom Android wallet.
We also had to adapt our app to suit local features, some activated, some not, and some varying by country.
Looking back, this choice was a winner, letting us bring our app to over 3 million users in more than 12 countries in Europe, South America, and Asia, all while reusing as much code as possible.
If you are interested about the human aspects of this migration, you can read more about this in navigating the human aspects.
Generalities
A mobile app’s codebase typically remains viable for around 3 to 4 years.
Over time, it becomes more challenging to maintain, as past decisions may no longer align with current development practices and technologies.
Business requirements also evolve to stay competitive in the market, rendering some features obsolete and requiring new ones that are difficult to implement within the existing application.
Additionally, the human factor plays a role, as developers may seek to switch projects or tech stacks, necessitating the integration of new team members. Dealing with decisions made years ago is generally not enjoyable for most developers.
Challenges
In 2021, we faced the following challenges:
- Developing new features was difficult.
- Code maintenance became challenging.
- Recruiting new Xamarin developers was a struggle.
- Xamarin was on the verge of being replaced by MAUI, which was still in preview with no clear release date.
Options
Betting our future on MAUI seemed unreasonable due to the uncertainties surrounding it.
While rewriting the app natively was a possibility, it didn’t align with our approach to mobile app development.
We aimed to maintain visual consistency across platforms and keep feature releases synchronized. Kotlin Multiplatform mobile was another option, but it closely mirrored our Xamarin approach, making it challenging to see significant delivery speed benefits.
React Native was quickly discarded due to its use of JavaScript and subpar performance on some of our target devices.
However, in May 2021, Google introduced Flutter 2, providing an ideal solution:
- Rapid UI development with an excellent hot reload feature
- Closeness between Dart and C#, facilitating a smooth transition
- Strong community support and growing popularity
- High developer interest in the market
- Outstanding overall performance, even on older devices.
With Flutter as our top choice, I faced with two challenges: learning Flutter and determining our app’s architecture.
Flutter
Learning
The goal of this phase was to ensure a smooth transition of our development teams would be possible.
Dart
Learning Dart was really straightforward. It is really close to C# or Java in its syntax.
We could even copy and paste some of our C# code into Dart with minor adjustments, such as renaming Task
to Future
.
The lack of Linq
wasn’t a showstopper, as we were already using the method syntax.
In Dart, every class can be extended or implemented which proved highly beneficial when writing unit tests, eliminating the need to declare empty interfaces solely for mocking classes.
Flutter
Learning Flutter was a tougher challenge. Transitioning from UIKit or Android XML, which are stateful and follow traditional UI frameworks, to a purely reactive approach was difficult.
I had to rewire my brain, and every task took me some time. Surprisingly, it took less time than accomplishing the same tasks in Xamarin.
Imagine this! As a highly experienced Xamarin Native developer, struggling to replicate some of our screens in Flutter turned out to be faster than doing it in Xamarin.
After about a week of experimenting with Flutter, I was satisfied enough with the results to present it to my management.
State Management
In Xamarin, the decision was straightforward – MVVM or nothing. There weren’t significantly divergent approaches to state management.
It traces back to XAML, initially introduced with WPF, which then extended to Silverlight, Windows Phone, and Xamarin Forms. The entire .NET mobile community just used MVVM.
However, when it comes to Flutter, state management offers a diverse array of options, including GetX, Bloc, Provider, Redux, and more. With so many choices available, it became essential to make a decision that would hold for at least the next four years.
So, I spent days on YouTube, Pluralsight, and reading articles to find a solution that met the following criteria:
- Effective collaboration with distributed teams.
- Avoids use of global variables.
- Simplifies screen rebuilding (fewer stateful widgets).
- Embraces a reactive approach by design.
- Allows for unit testing.
- Provides flexibility for potential replacement without breaking the app.
- Sufficient popularity to ensure an easy onboarding of new developers.
After all that research and exchanges with another mobile architect we brought in the company for another mobile application, we found the Bloc package, with a focus on using Cubits, to be the best choice.
Architecture
In our applications we ended up following the Clean Architecture approach.
Layer | Function | Comment |
---|---|---|
View | Generates the UI. Listens to Cubit events and invokes Cubit methods. | Rely on BlocBuilder and BlocListener. |
Cubit | Invokes one or multiple use cases to generate view states. | Maintains view state and mutates it. |
UseCase | Represent an action or process that provides aggregated data for the view. | Pure, stateless. |
Repository | Handles the data of a particular model. It chooses where it should get its data from but delegates that to DataSources or Services. | Returns aggregates in DDD terms. |
Service | A service is a technical repository. | Retrieves non-business model data or infrastructure data such as authentication tokens or phone languages. |
DataSource | Retrieves a specific model from a specific source. | For exemple calling an API or calling a local database or parsing a local file. |
Development
After doing a real proof of concept, the development of the application started. And even though globally everything went well I want to share you you some problems we had. Most of those issues will get their own dedicated post.
Plugins
Dependency management
In Flutter, it’s easy for developers to add plugins from pub.dev
, so it’s crucial to review and select the right ones to avoid an excessive number of plugins or unmaintained ones that could disrupt Flutter upgrades.
Mobile services
In the Flutter ecosystem, certain mobile service-related plugins, like those for maps, geolocation, or push notifications, often assume the use of Google services or Firebase, even on iOS.
To accommodate Google mobile services, Huawei mobile services and iOS, we had to reimplement some of these plugins to ensure our app functions seamlessly for all our users.
Native plugins
In advanced applications, using native plugins to access the native layer is often necessary. While some providers offer their own plugins, others require custom development.
Creating such a plugin is generally manageable, but certain cases, like one we encountered, may present challenges.
For instance, we had to create a native object cache within the plugin’s native code to maintain object references consistently, ensuring smooth communication between the Flutter and the native code.
Accessing the native platform
Using platform channels for invoking native functions from Flutter and event channels for listening to native events is straightforward. However, invoking Flutter code from Native code directly can be quite challenging to execute correctly. Make sure it’s necessary before attempting it.
Data migration
Migrating an application involves moving both the user and their data to a new version. In the past, we used SQLite and Xamarin.Essentials secure storage to store the user data. In our transition to Flutter, we adopted other tools for application caching and sensitive data storing.
Since encryption methods differed, we had to create custom code to transfer the data from Xamarin to Flutter. This presented challenges, partly because of Flutter’s unique application lifecycle.
Hosting private plugins
Creating private plugin repositories in Flutter can be challenging. There is no official equivalent to pub.dev
. Typically, you’ll depend on git submodules or reference plugins using their git repository uri in your pubspec.yaml
.
In our case, we are using Azure Devops to host our code and build our application.
We faced problems when trying to access repositories external to the organization hosting the mobile app’s code in our build pipeline and solving those was not easy.
Performance
Along with learning a new technology, comes the first mistakes in terms of performance.
Mastering which widget gets rebuilt and when is super important to ensure a great app. Avoid mixing calls to setState
with BlocBuilder
and ensure you place those at the lowest possible level in the widget tree.
Another thing is to understand what is a Future
and how they function. As .NET developers, we had some wrong assumptions about those.
Conclusion
After nine months of rewriting the application, we initially published it to a limited user group and encountered unexpected issues, which we promptly addressed. This process was repeated a few times until we finally released it to all users.
In summary, I have no regrets about the technical choices we made and would choose the same path again. Our development, debugging, and release processes have become faster, and we appreciate that the tool no longer hinders our productivity, as was sometimes the case with Xamarin.
If you have a customer-facing Xamarin application, I recommend making similar choices as we did. However, there are some human-related aspects I would handle differently and that’s the topic of the following post: navigating the human aspects.
Comments