Flutter Platform Channels: When Dart Isn't Enough
Alarm scheduling on iOS and Android requires native code. Here's how I bridged Flutter to the platform layer without losing my mind.

Flutter's promise is cross-platform development from a single codebase. That promise holds for 90% of features. The other 10% — the features that need to talk to the operating system directly — require platform channels. For Flashcards Alarm, alarm scheduling was firmly in that 10%.
The decision to use platform channels should never be taken lightly. Each channel you create is a contract between three codebases — Dart, Swift, and Kotlin — that must be maintained in sync. Before reaching for platform channels, I exhaust every Dart-only option: community plugins, FFI bindings, and isolate-based workarounds. For Flashcards Alarm, alarm scheduling was genuinely impossible without native code. No Dart plugin could guarantee the exact-time, wake-from-sleep, survives-reboot alarm behavior we needed on both platforms. That clarity of 'this truly can't be done in Dart' is the bar I set before writing any platform channel code.
“Alarm scheduling on iOS and Android requires native code. Here's how I bridged Flutter to the platform layer without losing my mind.”
The problem: iOS and Android handle alarms completely differently. iOS uses UNNotificationRequest for scheduling, with strict limitations on how many alarms can be active. Android uses AlarmManager or the newer ExactAlarmPermission API, with its own quirks around Doze mode, battery optimization, and manufacturer-specific restrictions (looking at you, Xiaomi).
The channel architecture for Flashcards Alarm uses a single MethodChannel named 'com.flashcardalarm/alarms' with a clearly defined message protocol. Every method call follows a request-response pattern with JSON-serializable arguments. The Dart side defines a typed interface class — AlarmPlatformInterface — that abstracts the channel calls behind clean method signatures. This abstraction serves two purposes: it makes the business logic layer platform-agnostic, and it provides a natural seam for testing where we can inject a mock implementation that doesn't require a running emulator.
Platform channels are Flutter's bridge to native code. The Dart side sends a message ('scheduleAlarm', {time: ..., deckId: ...}), and native code (Swift on iOS, Kotlin on Android) receives it, performs the platform-specific work, and optionally sends a result back. The interface is asynchronous and type-safe.
Type safety across the platform boundary is a persistent challenge. Dart's type system doesn't extend across the channel — a Map<String, dynamic> on the Dart side could contain anything by the time it reaches Swift or Kotlin. We enforce type safety through a shared schema definition: a JSON Schema file that describes every method's arguments and return types. A code generation step produces typed wrappers on all three platforms from this single schema. When the schema changes, the generated code changes, and type mismatches surface as compile-time errors rather than runtime crashes.
The iOS implementation was relatively straightforward. UNNotificationRequest handles scheduling, and the notification content includes the flashcard deck ID so the app knows which cards to present when the alarm fires. The main challenge was handling the 64-notification limit — we schedule alarms in rolling batches, refreshing the next batch when the app comes to foreground.
Background execution on iOS was the subtlest challenge. iOS aggressively terminates background processes to preserve battery life, and an alarm app's entire value depends on running code when the app isn't in the foreground. We use three complementary mechanisms: UNNotificationRequest for scheduling the alarm trigger, a Notification Service Extension for last-moment customization of the alert, and Background App Refresh to periodically sync card state and reschedule upcoming alarms. The interplay between these mechanisms is fragile — a single misconfigured entitlement or missing background mode capability silently breaks the entire alarm chain.
Android was a different story. AlarmManager's exact alarm permission was deprecated, then re-introduced with new restrictions. Samsung, Xiaomi, and Huawei each have their own battery optimization that can kill background alarms. The solution was a foreground service that runs continuously, a persistent notification that prevents the OS from killing the process, and manufacturer-specific intent handling for the worst offenders.
The manufacturer-specific battery optimization workarounds on Android deserve their own horror story. Xiaomi's MIUI has an 'AutoStart' permission that must be granted manually — without it, the app's foreground service is killed within minutes. Samsung's OneUI has a 'Sleeping Apps' list that aggressively restricts background activity. Huawei's EMUI requires the app to be added to a 'Protected Apps' list. We built a manufacturer detection system that identifies the device brand on first launch and guides users through the specific settings they need to change, complete with screenshots for each manufacturer's settings UI. This onboarding flow is ugly and platform-specific — exactly the kind of thing Flutter is supposed to eliminate — but it's essential for reliable alarm delivery on 80% of Android devices.
The testing matrix was enormous: 2 platforms × 5+ Android manufacturers × 3 OS versions each. We couldn't test every combination manually, so we built a test harness that automated alarm scheduling and verification across a device farm. Even with automation, platform channel work remains the most time-consuming part of Flutter development.
The biggest lesson from building platform channels is to minimize the surface area ruthlessly. Every method you expose across the bridge is a maintenance liability multiplied by three platforms. Flashcards Alarm started with twelve platform channel methods; after refactoring, we consolidated down to five: scheduleAlarm, cancelAlarm, getScheduledAlarms, requestPermissions, and getDeviceInfo. The consolidation pushed complexity to the native side — scheduleAlarm handles create-or-update logic internally rather than exposing separate create and update methods — but the simpler interface dramatically reduced cross-platform bugs and made the test matrix manageable.
Flutter's promise is cross-platform development from a single codebase. That promise holds for 90% of features. The other 10% — the features that need to talk to the operating system directly — require platform channels. For Flashcards Alarm, alarm scheduling was firmly in that 10%.
The decision to use platform channels should never be taken lightly. Each channel you create is a contract between three codebases — Dart, Swift, and Kotlin — that must be maintained in sync. Before reaching for platform channels, I exhaust every Dart-only option: community plugins, FFI bindings, and isolate-based workarounds. For Flashcards Alarm, alarm scheduling was genuinely impossible without native code. No Dart plugin could guarantee the exact-time, wake-from-sleep, survives-reboot alarm behavior we needed on both platforms. That clarity of 'this truly can't be done in Dart' is the bar I set before writing any platform channel code.
The problem: iOS and Android handle alarms completely differently. iOS uses UNNotificationRequest for scheduling, with strict limitations on how many alarms can be active. Android uses AlarmManager or the newer ExactAlarmPermission API, with
...
Tags: Flutter, iOS, Android, Native
See Also:
→ The Five-Word Quiz That Fills an Empty Deck on Day One→ AI Agents Are Replacing the Traditional Software Development Lifecycle→ Building a Multi-Tenant Marketplace from Scratch→ PostgreSQL vs Firestore: A Practical Decision Framework→ How GenAI Reduced Our Operational Overhead by 90%Browse all articles →Key Facts
- • Category: Dev
- • Reading time: 14 min read
- • Technology: Flutter
- • Technology: iOS
- • Technology: Android