It is a truth universally acknowledged, that a business in possession of a good mobile app, must be in want of an iOS and Android version.
Business logic, implemented once, wrapped in a quick, native feeling user interface is what most businesses want.
But how difficult is it?
Until last year, my company only had a native iOS version of our main app, Easy Diet Diary. A general-purpose Australian diet tracker with special versions for research and for people unlucky enough to have renal disease. The app has:
- 75,000 lines of Objective C and Swift code as per cloc
- an Amazon AWS backend: DynamoDB, Postgres and S3
- 22,000 daily users and 1.25 million downloads
Then Flutter came along (Beta 2 April 2018)
It ticked enough boxes (cross-platform, good performance, quick to implement, native feel, open-source) for us to try and build a single Flutter version for iOS and Android.
After six months I released a Google Open Beta without resorting to native code. I wrote about the experience here.
This article is about getting from Open Beta to a:
- released Android version in Google’s Play Store.
- drop-in replacement for the original native iOS app in the App Store.
This is what I found:
Lines of Code & Development Speed
When I started the port it was a revelation to me how much more productive I was with declarative programming and how I was able to reuse interface code instead of having it be inaccessibly bound up in XML based storyboards. Well, with the introduction of Jetpack Compose and SwiftUI it seems there is no longer anything to see here 😃.
I ended up with 35,000 lines of Dart code. Additionally, there are 3000 lines of Objective-C/Swift code to handle iOS specific stuff like HealthKit and upgrading legacy users and 500 lines of Java image processing code.
After the port, the Flutter app had half as many lines of code as the original iOS app.
Google Open Beta
I’ve spent a lot of time maneuvering apps through Apple’s TestFlight process and getting an evolving app into the hands of the public is difficult. And I don’t expect it to change any time soon because Apple touts its review process as a way of ensuring that apps meet certain standards and are not malicious. For a competent developer with good intentions, it can be frustrating.
In contrast, using Google’s Open Beta process, the public can search for beta apps in the Play Store, just like any other app, and fairly seamlessly join the beta program to use the app and give (limited) feedback. When you’re happy with an Open Beta version you can promote it to general release. If an app is reasonably usable, users are understanding and provide constructive feedback. At least that’s been my experience. It’s a great way to develop.
Easy Diet Diary accumulated 10,000 beta users as I added functionality and fixed bugs. I made a 1.0 Android release in March.
When I started last year I was new to declarative UI programming and the kind of state management that goes with it. For me, Redux and BLoC, with their dependence on asynchronous streams, had a steep learning curve and I ended up using InheritedWidgets to synchronize state across the widget tree. I did my best to separate the business logic from the presentation logic but didn’t use a state management framework to enforce the separation.
Since then, approaches and heated discussions about state management have multiplied. It’s interesting to contrast the open-source evolution of state management in Flutter with the development of SwiftUI’s reactive programming framework — a large team working in secret. Very Steve Jobs 😃. Matt Gallagher has some interesting analysis of Apple’s Combine framework.
At Google I/O 2019 the Flutter team, partially I think to make state management less daunting for new developers and reduce the proliferation of InheritedWidget wrappers, promoted Remi Rousselet’s provider widget described here. I replaced my modest efforts with Provider. I’m still not using a more formal state management approach like BLoC or MobX (both can be used alongside Provider) as that would require substantial code changes.
Back-end Services (Amazon AWS)
Apart from Crashlytics and ML Kit, all Easy Diet Diary’s cloud services are on Amazon AWS.
Unfortunately, so far, there are no official Flutter SDKs for AWS and very few AWS related plugins.
For me, it wasn’t a deal-breaker because we have our own server sitting between our mobile app and all our AWS services …except for S3 (cloud file storage). Our server takes care of authentication and synchronizing user diaries stored in DynamoDB.
Bypassing our server, the native iOS app used the AWS S3 SDK to directly upload and download photos. To move to Flutter I had to switch to pre-signed S3 URLs (provided by AWS via our server). It works nicely.
While I didn’t suffer too much pain porting to Flutter I think Flutter would be much more popular with AWS SDK plugins (they don’t have to be official). If cloud revenue is a rough proxy for mobile app cloud usage then AWS is the market leader by far.
I would love to be able to use Dart to experiment with the mushrooming number of AWS services. I’m sure the same could be said for Azure, although I haven’t used it.
The kinds of things that test Flutter’s performance in Easy Diet Diary are things like animating a camera viewport halfway up a screen (as the camera loads) or displaying charts after reading hundreds of 20KB JSON files.
Comparing the native iOS and Flutter versions of the app, our testers (actually it’s mostly one tester) did not encounter noticeable performance degradation on our test phones except on an iPhone 6 (the app worked well on a 6S). The 6 was released in 2014 but there are still a few out there. My teenage son has one. Our other test phones at the slower end included a Samsung Galaxy J5 Pro and a Motorola G5S Plus (my phone while porting).
When I started, the iPhone 6 displayed some stuttering and choppiness, particularly when animating the camera viewport but that lessened (but did not completely disappear) in the ensuing months as new Flutter versions were released. The app worked well on the other slower test phones.
On an unrelated note, today my personal phone is a Pixel 3a, a mid-range phone running Android 10. It runs Easy Diet Diary beautifully (of course) and has the openness and flexibility of Android while having a more iPhone like feel than other Android phones I’ve used. Fits nicely with my evolving cross-platform state of mind. All it needs is bouncy lists 😃.
I tried to avoid it.
I fell short when it came to image processing, HealthKit integration, and upgrading legacy users.
For HealthKit and upgrading legacy users I simply used Swift and Objective C code from the native iOS version.
For image processing, I wrote my first Java code. App users take photos in various situations. Multi megapixel photos taken by modern phones take up a lot of space so I do some cropping, scaling and compressing before displaying or uploading. This processing requires native iOS and Android code. I couldn’t find a plugin that worked for me (retaining EXIF metadata correctly was a particular problem) so I cobbled together my own solution from open-source plugins close to what I was looking for, StackOverflow answers and my old Objective-C code.
Plugin packages are straight forward to write if you follow the instructions carefully. Once you’re up and running you can configure your app to launch from your native code and set breakpoints etc. Of course, there’s no hot reload 😢.
I had hoped to offload the processing to an isolate which could then take its time doing the image processing and uploading. Unfortunately, you can’t call plugin code from an isolate. Instead, I spun off a thread in the native plugin.
Flutter lets you control how native you want to make your app. Because I was writing a drop-in replacement for a native iOS app with existing users I wanted the Flutter app to be very similar to the original.
I got there by building a Material Design app first. To immerse myself in the process I switched my personal phone from an iPhone to a mid-range Android device (a Motorola G5S Plus). I didn’t want something too high end.
Then, after stewarding the app through the Open Beta process, I gave it an iOS makeover. I wouldn’t choose this route today though. When I started there were a limited number of iOS style widgets. Since then the list has grown to the point where you can build a Material Design and iOS-style app simultaneously and switch widgets in your widget tree as appropriate.
I won’t go into the details of the cross-platform changes I made. That’s a story in itself. I will say that:
- some of the big differences were quite easy to implement. For example, because the UI is all widget tree code, replacing a sliding drawer menu on Android for a More button on the bottom tab bar in iOS is pleasantly simple.
- some of the small differences were quite fiddly. For example, using the same code to display a native looking dialog on both platforms ended up being quite nuanced. Maybe that’s not a small difference?
After I finished I found this document: Platform specific behaviors and adaptations. I wish I had it when I started!
Flavors and Schemes
Flutter is designed for building multi-platform apps from the same code base but what about building multiple apps from the same code base on a single platform?
For me, it was more time consuming than I’d hoped but in the end, I learned a better way of doing things.
It seems straight forward. Flutter has a command-line switch (also settable via the IDE) that allows you to specify a build flavor that maps to either a Gradle product flavor or an Xcode scheme.
flutter build --flavor research
I was hoping to replicate what had been set up in the native Xcode project. In Xcode, you differentiate app versions using scheme names. Schemes are simple. When you create a scheme you simply tell Xcode what configuration and target you want to use:
- A configuration maps to an ordinary text file with a .xcconfig extension. In it, you specify the environment variables unique to the version of the app you are building. A bundle identifier suffix for example.
- A target is all your build settings (there are hundreds of them) along with a list of what classes, resources, and custom scripts to include in the build. A new Xcode project has one target. In Flutter it’s called ‘Runner’.
In the native iOS app, there were different targets for different versions of the app. That’s how most people do it. Getting Flutter to work with different targets, however, proved frustratingly elusive (i.e. it almost worked).
In the end, I came to the same conclusion as Salvatore Giordano and used a single target and relied entirely on configurations. Then I read this great article by Mattt Thompson of NSHipster and Alamofire fame and I relaxed 😌. According to Mattt, configuration files with a single target are the way to go with the benefit that:
They can be managed without Xcode. They’re plain text, which means they’re much friendlier to source control systems and can be modified with any editor.
You can use them to set your bundle identifier, app icon set, which Google service info file to use (Vasily Bodnarchuk explains), etc.
Additionally, not being able to use different targets in iOS forced me to understand exactly what made versions of the iOS app different and that made configuring Android product flavors straight forward.
The way Flutter is put together:
- its underlying platform that’s a bit like a down to the metal gaming engine
- its widget tree structure with ‘hot reload’ that allows you to construct user interfaces quickly and flexibly
- its comprehensive box of native style widgets for Android and iOS that can be easily switched within the widget tree depending on the platform
- its open-source code base that allows you to debug problems and tweak or enhance widgets as necessary or just see how stuff works
is really good.
Where Flutter could be improved (from my experience):
- Better support for non-Google cloud services. In my case, Amazon AWS.
- It feels like a reasonable proportion of the burgeoning number of Flutter GitHub issues could be better handled on Stack Overflow. Developers (me included) have worked out that that they’ll get a better answer (and action if necessary) if they create an issue. Not sure how to improve this situation. Maybe it’s not an issue?
- Smoother deployment to iOS and Android with simpler, more explicit instructions. Creating user interfaces is the fun part. Fighting with Cocoapods and Gradle to deploy to a particular platform, not so much. Developers tend to have more expertise in one platform (for me it’s iOS) so deploying to the other platform can be difficult. The simpler the better.
- More efficient image display. The native iOS app used the SDWebImage library to great effect. Flutter’s cached_network_image library is not as good.
Some random thoughts:
- After years of TestFlight use, I really like the way Play Store Open Betas let the public test an evolving app.
- Learning Flutter is fun. Here’s Flutter’s Emily Fortuna explaining keys.
- The Flutter team is approachable and responsive and they’re fostering a friendly and diverse open source community.
- Last week, Ray Wenderlich, a site that was indispensable to my iOS education, introduced a Flutter section! Flutter must be going places 😃.
- Concerning Flutter for web, while I appreciate Flutter and Dart’s unique advantages in this area, I hope the Flutter team doesn’t get spread too thinly and can keep its focus on improving Flutter’s mobile cross-platform capabilities.
No matter how seductive SwiftUI or Jetpack Compose appear to be, a good cross-platform mobile solution is what most companies want. At least, that’s my experience. And, after building a drop-in replacement for a largish native iOS app, my experience is that Flutter is it.
Feedback is most welcome.
I’m @gazza500 on Twitter.