Iden Tumuhirwe
All writing

September 29, 2026 · 12 min read

I've built mobile apps in Kotlin, Swift and Flutter. I still don't have one answer.

Kotlin · Swift · Flutter · Mobile

Most of what I've built in the last couple of years runs on a phone. A few work apps, a couple of side projects, and a lot of porting the same feature from one platform to another. I started with Flutter, moved to native Kotlin for Android, then rebuilt things in Swift for iOS. At some point I was keeping the same feature working in three codebases at once.

People ask me which one they should use. I used to have a quick answer. I don't anymore, so this post is me working out why.

Flutter got me moving fast

Flutter was the first one, and for the first few months it felt like cheating. One codebase, one language, and the app ran on Android, iOS and the web. Dart is a boring language in a good way. Hot reload is as good as everyone says. I had screens up and talking to a real API in days.

The trouble showed up in two places.

The first was anything that touches the phone itself. Camera, location, background work, notifications, files. Flutter talks to all of that through plugins, and a plugin is someone else's Kotlin and Swift code with a Dart wrapper on top. When it works, great. When it doesn't, you end up reading that Kotlin and Swift anyway, except now it's code you didn't write, sitting behind a layer you can't step through easily. I spent more time debugging plugins than I spent on my own code in some weeks.

The second was the web. "One codebase for mobile and web" held up until it didn't. Some features made sense on a phone and not in a browser, and the other way round. The mobile and web builds drifted onto separate branches, and those branches never merged back. After that, every fix meant cherry-picking between them and hoping I remembered which side already had it. That's not a Flutter bug. It's what happens when two products share one repo and pretend to be one app.

I'd still use Flutter for an app that is mostly forms, lists and API calls, especially if it needs a web version. That describes a lot of business apps, and Flutter is very good at them.

Kotlin and Compose felt like home

Going native on Android was slower at the start and faster everywhere after. Compose clicked for me. State goes in, UI comes out, and when something is wrong it's usually my code, which I can fix.

The best part is that nothing sits between me and the platform. When I need the camera or location, I call the Android API directly. When it breaks, the stack trace points at a line I wrote.

Android still had its traps. The one that cost me the most was JSON. I was using Gson with Kotlin data classes, and Kotlin tells you a field can't be null. Gson doesn't care. It builds objects without calling your constructor, so if a key is missing from the response, the field ends up null anyway:

data class Profile(val id: Int, val name: String)

val profile = Gson().fromJson("""{"id": 1}""", Profile::class.java)
profile.name.length // NullPointerException on a String that "can't" be null

The crash doesn't happen where the data came in. It happens three screens later when something finally reads the field. I lost a whole afternoon to that the first time. Now I give each endpoint its own small response class that matches exactly what that endpoint sends, and I prefer kotlinx.serialization, which fails at parse time when a required key is missing. Failing early is the whole point.

The other lesson was that small layout choices break later. This week I added one more tab to a bottom navigation bar. Five tabs looked fine. Six made the labels crush into a column of letters on a normal-sized phone, because every tab had its own padding and none of them had an equal share of the width. The fix was two lines, give each tab Modifier.weight(1f) and drop the padding. The lesson was bigger. Test on a small phone, and test with the system font turned up. Almost every layout bug I've shipped would have shown up there.

Same story with edge-to-edge. Newer Android versions draw your app behind the status bar by default. If your top bar doesn't pad for it, your screen title sits under the clock. It looked fine on my emulator and wrong on a real phone.

Porting to Swift is not translating

Then I ported apps to iOS with SwiftUI. I expected Compose to SwiftUI to be mostly renaming things, since the ideas are close. The code did translate easily. The behaviour didn't.

iOS has its own rules, and they bite in different places. If you use the microphone or camera without the matching usage description in Info.plist, iOS doesn't show a warning. It kills the app the moment you ask for access. On Android a missing permission gives you a denied result you can handle. On iOS it's a crash on a real device, and it's easy to miss if you only tested the screens that don't record anything.

Gestures surprised me too. I turned on text selection on some cards so people could copy text. That quietly ate the long press, and the long-press menu I had on the same cards stopped working. Nothing errored. It just stopped. I only found out because someone tried to use it.

Dark mode was easier than on Android once I did it properly. I defined every colour once with a light and a dark value and banned raw colours in views. After that, dark mode was free. Before that, it was a list of screens where some text was dark grey on dark blue.

The same thing, three ways

Talking about this in general only goes so far, so here are a few things I've had to do in all three.

A screen with some state

A button that counts taps. This is the part everyone compares, and it's the part that matters least.

Compose:

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Tapped $count times")
    }
}

SwiftUI:

struct Counter: View {
    @State private var count = 0

    var body: some View {
        Button("Tapped \(count) times") { count += 1 }
    }
}

Flutter:

class Counter extends StatefulWidget {
  const Counter({super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () => setState(() => count++),
      child: Text('Tapped $count times'),
    );
  }
}

Flutter needs two classes where the others need one, but the idea is the same in all three. State changes, the framework redraws. If you know one of these, you can read the other two on day one. UI is not where the choice gets made.

Asking for location

This is where they start to split. On Android you declare the permission in the manifest and ask at runtime:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
val launcher = rememberLauncherForActivityResult(
    ActivityResultContracts.RequestPermission()
) { granted ->
    if (granted) startLocationUpdates() else showWhyWeNeedIt()
}

Button(onClick = { launcher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }) {
    Text("Use my location")
}

On iOS you add a usage string to Info.plist, then ask through CLLocationManager and wait for a delegate callback:

<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to show nearby places.</string>
final class LocationPermission: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()
    var onChange: ((CLAuthorizationStatus) -> Void)?

    func request() {
        manager.delegate = self
        manager.requestWhenInUseAuthorization()
    }

    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        onChange?(manager.authorizationStatus)
    }
}

In Flutter, with the permission_handler plugin, it's one await:

final status = await Permission.locationWhenInUse.request();
if (status.isGranted) {
  startLocationUpdates();
} else if (status.isPermanentlyDenied) {
  openAppSettings();
}

The Dart looks like the clear winner, and on the Dart side it is. But you still add the manifest line and the Info.plist string yourself, because the plugin can't do that for you. On iOS, permission_handler also wants you to switch each permission on in your Podfile with a preprocessor flag like PERMISSION_LOCATION=1. Forget that and the request returns denied without ever showing the prompt. So Flutter doesn't remove the platform work. It moves it into files you don't open very often, which is worse when something goes wrong.

When there's no plugin

Sooner or later you need something no plugin does, or does the way you need. Then you write the native code yourself and call it over a platform channel:

const channel = MethodChannel('app/battery');
final level = await channel.invokeMethod<int>('getBatteryLevel');

And on the Android side:

MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "app/battery")
    .setMethodCallHandler { call, result ->
        if (call.method == "getBatteryLevel") {
            val battery = getSystemService(BATTERY_SERVICE) as BatteryManager
            result.success(battery.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY))
        } else {
            result.notImplemented()
        }
    }

Then the same again in Swift for iOS. It works, and it's well documented. But look at what holds it together. The channel name and the method name are plain strings, and the result is whatever the native side decided to send. Misspell getBatteryLevel on one side and you find out at runtime, on that platform only. At that point you're writing Kotlin and Swift anyway, plus a stringly typed bridge between them and Dart. That was the moment in most of my Flutter projects where I started asking why I wasn't just writing native.

Work in the background

No code here, because the short version is enough. On Android, WorkManager runs a job later, even after a reboot, and you can mostly trust it to run near when you asked. On iOS, BGTaskScheduler lets you ask, and the system decides if and when it runs, based on things like battery and how often the user opens your app. You don't get a schedule. Flutter plugins for background work sit on top of both, so they inherit both sets of rules, and the plugin's API can't promise more than iOS allows. If background work matters to your app, design for the iOS rules first. The Android side will be the easy half.

Porting copies your bugs too

This is the lesson I think about most.

When you port an app, you copy what it does, and that includes what it does wrong. I had token refresh code on Android that looked right and never worked. The server issued a new refresh token on every refresh, and the app kept using the old one, so the session died on schedule and everyone just logged in again. Nobody reported it as a bug because it looked like normal expiry. Then I ported the same logic to iOS, faithfully, and shipped the same bug on a second platform.

I found it by reading the server code, not the app code. Now when I port a feature, I read how the backend actually behaves first, and I treat the existing app as a hint, not the spec.

The same thing happened with stale data. Both apps fetched a list once on launch and never again, so if something changed on the server, the phone showed old data until someone closed the app. Both apps needed the same fix. Refresh when the app comes back to the foreground, poll every so often while the screen is open, and refetch when a push notification arrives.

Two native apps means every feature twice

The real cost of going native on both platforms isn't the languages. It's keeping them level. Every feature gets built twice and released twice, and the two apps drift. One app gets a fix, the other doesn't, and three weeks later nobody remembers which one is right.

What helped was a plain document in the repo listing what one app has and the other doesn't. It's boring and it works. Before each porting pass I read it, and after, I update it. Without it I was relying on memory, and my memory is not a sync tool.

Releases are their own work too. On Android I have CI that builds, signs and uploads to the Play Store on every merge to main. On iOS, App Store review adds days I don't control. Both pipelines broke at least once for reasons that had nothing to do with the app. An expired access token in a CI secret. A GitHub Actions trigger that only fires on the default branch, so it silently did nothing on the branch I was actually releasing from. None of that shows up in a framework comparison, and all of it costs time.

So which one?

Here's where I've landed, for now.

If the app is mostly screens on top of an API, and especially if it needs a web version, I'd pick Flutter. One team and one codebase is a real advantage, and the plugin problems mostly don't come up.

If the app lives on the hardware, meaning the camera, location, background work, audio, or notifications that have to arrive when the app is closed, I'd go native. I'd rather write Kotlin and Swift myself than debug someone else's through a plugin layer.

If you go native on both, budget for keeping them in sync. That's the real cost, and it's bigger than learning two languages.

And whichever one you pick, test on a small phone, with big fonts, on a real device, before you call a screen done. That habit caught more of my bugs than any framework choice did.