← Back to Logs

Why iOS Apps Cannot Run On Android, Even Though Both Phones Are ARM64

Try the interactive lab for this articleTake the quiz (6 questions · ~5 min)

Take two phones from the same shelf in a Berlin electronics shop: an iPhone 15 and a Pixel 8. Open both, and the chips inside are from the same ARM64 family. Both implement the ARMv8.4 or ARMv8.6 instruction set. Both have 64-bit general-purpose registers, the same calling convention for passing arguments on the stack and in registers, the same SIMD unit (NEON with 128-bit vectors), the same weak memory ordering model, the same set of atomic operations, the same page table format. At the silicon level the two CPUs are close enough that a block of ARM64 machine code compiled for one will decode and execute correctly on the other. The CPU does not care which operating system is above it.

Now download an .ipa file for an iPhone app, push it to a Pixel 8 over USB, and try to run it. Nothing happens. Android does not recognise the file. Even if you unwrap the .ipa to expose the .app bundle and its embedded executable, Android's package manager will not install it. Even if you bypass the package manager and try to exec the binary directly from a shell, the Linux kernel will refuse, because it does not recognise the format. And even if you wrote a loader that forced the kernel to accept the binary, nothing inside it would work, because every library call the app makes refers to code that does not exist on Android, on an ABI that Android does not implement, in an environment that Android does not provide.

The gap between "same ARM64 chip" and "runs the same software" is enormous, and the ARM64 part is the smallest piece of it. Almost everything that makes an operating system an operating system lives above the ISA: the binary format, the loader, the kernel interface, the standard library, the graphics stack, the UI framework, the code signing chain, the sandbox, the inter-process communication primitives, and the filesystem layout. iOS and Android share almost none of that. The ARM64 chip is a coincidence, not a compatibility story.

This article walks through every layer where the two systems diverge, why each divergence is a hard wall rather than a soft difference, and why attempts to build a Wine-for-iOS translation layer (projects like Cycada, Darling, and more recent efforts) have never produced a usable product for iPhone apps on Android. By the end it should be clear that "both are ARM64" is the least relevant fact about the comparison, and that the real question is not "can the bits execute" but "does anything the bits call exist on the other side".

The Kernel: XNU Versus Linux

The first and largest gap is the kernel. iOS runs on XNU, Apple's hybrid kernel descended from NeXTSTEP. Android runs on Linux. These are two completely different operating system kernels, with different designs, different interfaces, different scheduling policies, different memory managers, different filesystems, different security models, and different syscall tables. The only thing they share is that they are both Unix-like in the superficial sense: both have files, processes, threads, pipes, and the general shape of a Unix userland above them.

XNU is a fusion of the Mach microkernel and a BSD personality layer. The Mach side provides low-level primitives: tasks, threads, virtual memory, ports, IPC. The BSD side provides the POSIX surface on top: processes (which are really Mach tasks plus BSD state), signals, sockets, file descriptors, and most of the syscalls an application expects. The two sides are tightly welded together inside a single kernel address space, which is why people call XNU a hybrid kernel rather than a pure microkernel. When an iOS app calls open, the call enters the kernel, lands in the BSD syscall dispatcher, walks the BSD file system layer (which calls through VFS into APFS), and returns. When it calls a Mach port operation, it lands in the Mach side instead.

Linux is a monolithic kernel with a very different heritage. It was designed in 1991 as a reimplementation of Unix syscalls, and its architecture reflects that origin: a single shared address space for all kernel code, a single syscall dispatcher, a single VFS, a single set of schedulers, and so on. Linux has no concept of Mach ports, no concept of tasks as distinct from processes, no concept of XNU's particular BSD-plus-Mach split. Its filesystems are different (ext4, f2fs, btrfs on Android, nothing like APFS), its networking stack is different (netfilter, nftables, the Linux-specific socket options), its scheduling classes are different (CFS on Linux, Apple's deadline-scheduling-plus-QoS on iOS).

The kernel syscall ABI is the clearest divergence. On ARM64 Linux, the syscall convention is: system call number in x8, arguments in x0 through x5, return value in x0, execute the svc #0 instruction. The syscall numbers are defined by the Linux ABI: for example, openat is 56, read is 63, write is 64, clone is 220, and so on. These numbers are frozen forever; Linus Torvalds' famous "we do not break userspace" rule applies directly to them.

On ARM64 iOS, the convention is similar at the instruction level (arguments in registers, svc #0x80 to enter the kernel) but the numbering and semantics are completely different. iOS inherits BSD system call numbers, which are an entirely separate space: open is 5, read is 3, write is 4, fork is 2. It also has Mach trap numbers, which are negative and dispatched through the Mach side of XNU: mach_reply_port is -26, mach_msg is -31. A call that passes x16 = 0x2000005 with x0 pointing at a path would be open on iOS but complete nonsense on Linux, which would look up syscall 5 (setpriority on some Linux builds and something else on others) and happily return the wrong answer.

The upshot is that even if an iOS binary could be loaded into memory on Android, the first time it issued a system call, the Linux kernel would either reject it outright or execute a different operation. Fixing this means interposing a layer that intercepts every svc instruction, inspects the context, decides what the caller meant on iOS, and translates it into the equivalent Linux call, or into a sequence of Linux calls if no direct equivalent exists. That is the same kind of work Wine does for Windows on Linux, and for Windows it is a multi-decade engineering effort that still has rough edges. For iOS it has barely been attempted seriously.

Mach-O Versus ELF

The next gap is the binary format. Linux executables use ELF, the Executable and Linkable Format, which is the native format of System V Unix and most Unix derivatives. iOS executables use Mach-O, the format inherited from NeXTSTEP. The two formats share the same general ideas (a header, a list of segments, a list of symbols, a relocation table) but their bytes are incompatible in every detail.

An ELF file starts with the magic bytes \x7fELF, followed by a header that describes the architecture, the bit width, the endianness, the type of file (executable, shared object, core), the entry point, and the offsets of the program headers and section headers. The program headers describe loadable segments, dynamic linking tables, thread-local storage, and a few other runtime concerns. The Linux kernel loader (fs/binfmt_elf.c) reads the ELF header, walks the program headers, mmaps each loadable segment at the right virtual address, resolves the interpreter (ld-linux-aarch64.so.1), and jumps to the entry point.

A Mach-O file starts with the magic bytes \xFE\xED\xFA\xCE for 32-bit or \xFE\xED\xFA\xCF for 64-bit, followed by a header that describes the CPU type, the number of load commands, and the file type. Instead of ELF's program headers and section headers, Mach-O uses load commands: LC_SEGMENT_64 describes a segment to map, LC_LOAD_DYLIB asks the dynamic linker to load a dependent library, LC_LOAD_DYLINKER points at dyld, LC_MAIN specifies the entry point, LC_CODE_SIGNATURE points at the signing blob, LC_ENCRYPTION_INFO_64 marks the __TEXT segment as encrypted with Apple's FairPlay DRM. The XNU kernel loader reads the Mach header, walks the load commands, and sets up the process.

The Linux kernel has no code to parse Mach-O. A raw execve on a Mach-O binary fails at the check_header step because the magic bytes do not match any binfmt_* handler. You can register a new binfmt that recognises Mach-O and defers to user space (the same mechanism Linux uses to run Wine binaries, binfmt_misc), but that only hands the parsing problem to a user-mode loader. It does not actually solve anything, because the loader you write has to understand every Mach-O feature the binary uses: segment protections, relocations, encrypted text segments, code signing, compressed segments, dyld shared cache references, Objective-C metadata sections, Swift metadata sections, and the many other Apple-specific encodings that no public documentation fully covers.

More awkwardly, iOS binaries are not standalone. They make heavy use of the dyld shared cache, a gigantic pre-linked file at /System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64 that contains the text and data of nearly every system library rolled into one address-space-efficient blob. When an iOS app references libSystem.B.dylib or Foundation or UIKit, the dyld loader does not actually load those dylibs from disk; it walks the shared cache, finds the right offset inside the pre-linked blob, and maps that range into the process. This is hugely efficient on iOS, where every app starts with the same set of frameworks already mostly resident in physical memory. It is also a hard dependency on the shared cache being there, which it never is on Android.

To run an iOS binary on Android, you would need to either replicate the entire dyld shared cache by extracting the libraries from an iPhone and shipping them with the translation layer (which is legally problematic because Apple's system libraries are not redistributable) or reimplement every function those libraries expose in native Android code. Neither path is small. The shared cache on a recent iOS is more than 2 GiB of compiled code, and it contains many tens of thousands of symbols from dozens of frameworks.

libSystem And Bionic: Two C Libraries That Barely Share An Ancestor

On the Linux side, Android uses Bionic as its C library. Bionic is Google's from-scratch reimplementation of libc, pthreads, and the dynamic linker, deliberately stripped down compared to glibc to make Android small and fast. It omits features that mainstream Linux distributions rely on (locale support was minimal for years, wide-character handling is limited, many glibc extensions are missing) and adds Android-specific features in their place. Bionic's ABI is stable but distinct from glibc's, which is why normal Linux binaries do not run on Android without recompiling against Bionic.

On the iOS side, the closest equivalent is libSystem.B.dylib, which is itself a thin umbrella dylib that re-exports the real C library, the math library, pthreads, the dispatch library, the malloc family, and a dozen other core services. Underneath libSystem is a BSD-flavoured libc that descends from 4.4BSD via the Darwin open-source project, with decades of Apple modifications on top. It has BSD extensions (kqueue, fts, paths.h layout), Darwin-only APIs (pthread_setname_np_darwin, Mach-specific primitives), and macOS/iOS-specific APIs (dispatch, asl logging, XPC helpers) layered in.

A side-by-side comparison of symbols is instructive. Bionic exports around 1,400 symbols at the libc level. libSystem exports roughly 4,500. The intersection is the expected POSIX core (open, read, write, memcpy, strlen, printf), and the non-overlap is the bulk of the surface. An iOS app written in Objective-C or Swift almost never calls plain libc; it calls into Foundation, CoreFoundation, dispatch, and UIKit, all of which call into libSystem, which then calls into XNU. If you wanted to let the app run on Android, you would need to provide working implementations of all those layers that produced the same observable behaviour.

One concrete example: Grand Central Dispatch, Apple's famous concurrency library, is part of libSystem and is the primary way iOS apps manage threads. Every dispatch_async(dispatch_get_main_queue(), ^{ ... }) call an app makes goes through GCD. GCD has been open-sourced as libdispatch and ported to Linux, which means you can get most of it working on Android, but it expects certain kernel features (kqueue, which exists on BSD and macOS and iOS, but not on Linux) that Bionic does not provide. Porting libdispatch to Linux required rewriting its event loop on top of epoll, which changed timing characteristics in subtle ways that real iOS apps detect and break on. Porting it once is possible. Porting it in a way that preserves the exact timing semantics iOS apps depend on is much harder.

The Objective-C And Swift Runtimes

iOS apps are not written in C. They are written in Objective-C or, increasingly, Swift, both of which rely on substantial runtime libraries that come with the OS rather than being bundled with the app.

The Objective-C runtime is a C library (libobjc.A.dylib) that implements dynamic dispatch, method lookup, class registration, selectors, and the metadata format for classes and protocols. Every method call in Objective-C is a call to objc_msgSend, which looks up the method implementation at runtime by selector and dispatches through a cache. The runtime also handles categories (adding methods to existing classes), the isa pointer manipulation (tagged pointers, non-pointer isa), reference counting (retain, release, autorelease), and the bridge to CoreFoundation (the toll-free bridging between NSString and CFString).

The Swift runtime is even larger. Swift compiles to native machine code, but the code is full of calls into libswiftCore.dylib, which implements generics, protocol witness tables, metadata allocation, value witness tables, reflection, the copy-on-write semantics of Array and Dictionary, string handling, and the bridge between Swift types and Objective-C. Swift also has its own ABI (the Swift calling convention, error handling through a specific register, self passing, protocol existentials) that does not match the C ABI Android expects.

Both runtimes are built into the dyld shared cache on iOS. No iOS app ships its own copy of the Objective-C or Swift runtime. When you run otool -L on an iOS binary, you see references like /usr/lib/libobjc.A.dylib and /usr/lib/swift/libswiftCore.dylib that point at system-provided libraries the app assumes will be present. On Android, neither library exists. To make the app work, you would need to either port the runtimes to Android (Apple has open-sourced parts of them, but not the complete up-to-date versions that match the binary you are trying to run) or write your own compatible implementations. The latter is the approach the GNUstep project has been taking for Objective-C on Linux for twenty years, and GNUstep still lags Apple's runtime in dozens of visible ways.

Android's own application runtime, ART (the Android Runtime, formerly Dalvik), is fundamentally different. ART runs bytecode (DEX) compiled from Java or Kotlin, ahead-of-time compiled to native machine code at install time, with just-in-time hotspot recompilation after that. It has its own garbage collector, its own class loader, its own method dispatch mechanism, its own JNI bridge for native code. None of it maps onto the Objective-C or Swift runtime. An Android app lives inside ART; an iOS app lives inside libSystem plus Foundation plus UIKit. The two runtimes are not compatible, and there is no thin shim that makes one look like the other.

UIKit, SwiftUI, And The Framework Wall

Even if you solved the kernel problem, the loader problem, the libc problem, and the runtime problem, you would still be stuck at the framework problem, which is the largest single piece of engineering in the list.

An iOS app draws its UI using UIKit or SwiftUI (or, for games, UIKit plus Metal). Neither framework exists outside iOS. UIKit is built on top of Core Animation, which is built on top of Core Graphics and Metal, which are built on top of the iOS windowing system (SpringBoard and the IOSurface framework), which is built on top of Mach, IOKit, and the Apple GPU drivers. Every UIKit call the app makes eventually crosses several of these layers. A UITableView with ten thousand rows is producing Core Animation layers, which are being composed by the window server, which is handing them to the GPU driver via Metal, which is issuing commands to the Apple GPU through a proprietary command stream.

Android has none of this. The Android UI framework is a completely different design: View objects, a Canvas abstraction, SurfaceFlinger for compositing, Skia or HWUI for rasterisation, OpenGL ES or Vulkan for the GPU path. An Android GPU driver speaks a totally different command format, there is no Metal support anywhere in the Android stack, and the compositor expects Surface buffers rather than Core Animation layers.

Porting UIKit to Android means reimplementing every class, every method, every piece of layout behaviour, every animation timing curve, every touch handling rule, in terms of primitives that Android provides. The open-source project that has come closest is UIKit-for-Linux (and related forks), which started from GNUstep's Cocoa implementation and added iOS-specific behaviour on top. It is incomplete in obvious ways and incompatible in subtle ones; apps that work look broken, apps that look right behave wrong. The problem is not a bug; it is that UIKit is tens of thousands of behaviours intertwined with the rest of iOS, and the only way to be fully compatible with it is to be iOS.

SwiftUI is even worse, because it is layered on top of UIKit internally (on iOS) and depends on the Combine framework for its reactive state model, which is implemented as part of the system. The open-source Swift toolchain does not ship SwiftUI at all. If you wanted to run a SwiftUI app on Android, you would have to reimplement SwiftUI from scratch against an Android-native rendering backend. The OpenSwiftUI community project has tried; it has also taken years and still does not run real apps.

Graphics: Metal Versus Vulkan And OpenGL ES

A specific piece of the framework wall that deserves its own section is the GPU API. iOS exposes graphics through Metal, Apple's low-level GPU API that replaced OpenGL ES around 2015. Metal is the only modern way to push pixels on iOS: UIKit uses it under the covers, games use it directly, and most of the visual polish of modern iOS apps comes from the fact that Metal is tightly integrated with the Apple GPU and with the Core Animation compositor.

Android uses OpenGL ES and, on newer devices, Vulkan. There is no Metal driver on Android. There is no Metal compiler for Android's GPUs. There is no Metal header set in the Android NDK. Translating Metal calls to Vulkan is theoretically possible, and projects like MoltenVK do the opposite direction (Vulkan on top of Metal on Apple hardware), but nobody has built a production-quality Metal-on-Vulkan translator for Android because the incentives do not exist. Every iOS game that uses Metal would need to go through that translator, and the translator would have to handle the differences in shader languages (Metal Shading Language versus SPIR-V), resource binding models, render pass abstractions, and compute pipeline semantics.

Even if you had a perfect Metal-on-Vulkan translator, you would still be at the mercy of the Android GPU drivers, which have their own bugs, missing features, and performance quirks. Graphics is one of the areas where "the same API on a different driver" produces visible differences, and iOS apps are written assuming the behaviour of Apple's specific GPU drivers, which are nothing like Qualcomm's Adreno drivers or ARM's Mali drivers.

Code Signing And Entitlements

Before an iOS app can even begin to run, it has to pass code signing. Every executable segment in a Mach-O binary that is going to be loaded on iOS carries a code signature blob, produced by a chain of certificates that ultimately chains to an Apple root. The XNU kernel's AppleMobileFileIntegrity subsystem (AMFI) walks the chain at load time, verifies every signed page, and refuses to execute anything that fails the check. This is enforced by hardware on modern iPhones through the Secure Enclave Processor: there is a code directory hash that has to match, and if it does not, the CPU never executes the first instruction of the binary.

Beyond the signature, iOS apps declare entitlements: a plist embedded in the signed binary that lists which privileged APIs the app is allowed to call. Entitlements are checked at runtime by the system frameworks and the kernel. An app without com.apple.developer.healthkit cannot call HealthKit, even if it knows the function symbols. An app without keychain-access-groups cannot read the keychain. Entitlements are a second layer of access control on top of the sandbox, and they are tied to the signing identity.

On Android, code signing works completely differently. An APK is signed with the developer's own certificate (self-signed, not chained to a Google root), and the signature is checked at install time by the package manager. After install, the code runs without any further signing checks. There is no equivalent of AMFI; the Linux kernel does not verify pages at load time. There is no equivalent of entitlements; the equivalent concept is Android's permission system, which is controlled by the package manager and is a completely different format, attached to the APK manifest rather than to the binary.

A translation layer that wanted to run iOS apps on Android would have to either ignore code signing entirely (which means any modification to the binary passes trivially, but some apps cross-check their own signature at runtime and refuse to run if it was tampered with) or reimplement a full Apple code signing verifier using the actual Apple root certificate (which is not legally distributable as part of a third-party product). There is no clean path.

The Sandbox

iOS runs every app in a tight sandbox enforced by the XNU kernel through the sandbox subsystem, which uses a language called SBPL (Sandbox Profile Language, a TinyScheme dialect) to describe what the app can and cannot do. A default iOS app profile denies most file system access outside the app's own container, denies most network access without a specific entitlement, denies Mach port access to most system services, and denies any attempt to attach a debugger.

Android also sandboxes apps, but through a completely different mechanism. Each Android app runs under its own Linux UID, isolated from other apps by standard Unix file permissions. Newer Android versions layer SELinux on top, with per-app policies that constrain syscall access. The sandbox is enforced at the Linux kernel level rather than through an extra user-mode daemon.

The two sandbox systems are not compatible. iOS apps call Apple-specific APIs (sandbox_init, sandbox_check) that do not exist on Linux, and they assume SBPL-shaped restrictions (no direct file access outside the container, but read access to specific shared frameworks through specific paths) that SELinux cannot easily replicate. If a translation layer wanted the app's sandbox behaviour to match, it would have to reimplement SBPL parsing on top of SELinux policy generation, which is a substantial effort with uncertain correctness guarantees.

Mach Ports, XPC, And Binder

iOS apps communicate with system services through Mach ports and XPC, layered on top. Every iOS process has a set of ports it can send messages to: the window server, the media server, the location daemon, the network daemon, and so on. XPC is a higher-level IPC mechanism built on Mach ports that handles serialisation, versioning, and lifecycle. When an iOS app calls CLLocationManager to get the user's location, the call goes through a CoreLocation framework that sends an XPC message to locationd, which is a separate process.

Android apps communicate with system services through Binder, a Linux kernel driver that implements a synchronous RPC mechanism optimised for on-device IPC. Binder is nothing like Mach ports at the ABI level. It has a different message format, different lifecycle rules, different threading model, and different security checks. Android framework services (ActivityManagerService, WindowManagerService, LocationManagerService, and dozens of others) are all exposed through Binder proxies, and apps call into them via Java interfaces generated from AIDL (Android Interface Definition Language).

To make an iOS app work on Android, you would need every iOS system service it talks to, over every IPC boundary, to have a working stand-in. CoreLocation would need a fake locationd that accepts XPC messages and forwards them to Android's LocationManager. CoreMedia would need a fake media server. CFNetwork would need a fake networking daemon. Each of these is a nontrivial engineering project, and each of them has to handle every quirk of the real service, including timing, error codes, and undocumented behaviours that real apps rely on.

Darling, Cycada, And Why These Projects Never Finish

There have been serious attempts to build Mac and iOS compatibility layers for Linux. Darling is the best known. It is an open-source project that aims to run macOS binaries on Linux, similar to how Wine runs Windows binaries. Darling has been under development since 2012 and has made real progress: it can run many command-line macOS tools, some GUI apps, and a subset of Cocoa. It cannot run real modern Mac apps reliably, and it has never seriously targeted iOS.

The reasons Darling struggles are exactly the reasons discussed above. The Mach-O loader is largely done. The libc compatibility layer is mostly done. The Objective-C runtime is reimplemented (or bridged to GNUstep). But the higher layers, Foundation, CoreFoundation, AppKit, CoreGraphics, Metal, and the entire plist and XPC infrastructure, are only partially working, and the parts that do work lag the current macOS release by years. Every macOS update adds new APIs that Darling has to chase, and the chase is always behind.

Cycada was a 2014 academic project by researchers at Columbia University that explored running iOS apps on Android by implementing iOS frameworks on top of Android. The researchers succeeded in booting small iOS apps and getting basic UIKit interactions working, and published a paper demonstrating the approach. The project never turned into a product, for reasons the paper itself acknowledged: the framework surface of iOS is so large, and changes so frequently, that keeping up with Apple is a full-time job for a large engineering team, and no academic project can sustain that pace. Cycada showed the approach was technically possible in a limited sense. It also showed that the cost of making it useful is enormous.

More recent projects (Touch HLE for iOS games, libcocotron for Objective-C on Linux, various Swift-on-Linux efforts) have each picked a smaller slice of the problem and made real progress on it. None of them aims at "run an arbitrary modern iOS app on Android", because that goal is too expensive. The closest anyone has come to iOS-on-non-Apple-hardware is the iOS Simulator on macOS, which cheats by running on macOS and sharing most of the framework code with iOS directly. The Simulator still cannot run real .ipa files signed for the device; it runs simulator-specific builds that were compiled differently.

The Filesystem Layout

Something as mundane as the file layout is different enough to break apps on its own. On iOS, a third-party application lives inside a container under /var/mobile/Containers/Data/Application/<uuid>/, with subdirectories Documents, Library, tmp, and SystemData. The main bundle lives under /var/containers/Bundle/Application/<uuid>/MyApp.app/. App Group shared containers live elsewhere under /var/mobile/Containers/Shared/AppGroup/<uuid>/. Access to anything outside those specific paths is blocked by the sandbox. Apps that want to read the user's photos go through the PhotoKit framework, which talks to photolibraryd, not to /var/mobile/Media/DCIM/ directly. An iOS app that hard-codes /var/mobile/Media is unusual and would be broken even on real iOS because the sandbox would deny access.

On Android, a third-party app lives in /data/data/<package-name>/ with subdirectories files, cache, databases, and shared_prefs. The APK itself is stored separately in /data/app/<package-name>/. External storage (the user-visible part) is mounted through a FUSE overlay at /storage/emulated/0/ with per-app scoped access rules that depend on the Android version. The paths, the permissions, the meaning of "the user's documents folder", and the way apps expect to discover files are all completely different.

A translation layer has to intercept every file system call an iOS app makes and remap it. open("/var/mobile/Containers/Data/Application/<uuid>/Documents/foo.plist") has to become an open call somewhere under /data/data/com.example.translator/iosdata/<appid>/Documents/foo.plist. Every readdir has to return paths that look like iOS paths, even though the underlying filesystem has Android paths. The filesystem abstraction layer has to be fully faithful to the iOS layout, or the app's own file path manipulation will produce nonsense. This is doable but it is yet another piece of engineering nobody has done completely.

APFS, the filesystem that iOS actually uses on device, has features Android's file systems do not: copy-on-write clones, snapshots, per-file encryption keys tied to the passcode and the Secure Enclave, extended attributes with specific semantics, resource forks through ._ files, and a specific case-sensitivity mode. Apps that use any of these rely on behaviour the translation layer would have to emulate on top of ext4 or f2fs. Most of those features can be faked for most apps, but "most" is not "all", and "all" is what compatibility demands.

The Background Execution Model

The way apps run in the background is architected completely differently on the two platforms, and iOS apps depend on the iOS model in ways that do not translate.

On iOS, an app is suspended within seconds of moving to the background. The OS kills its CPU time, keeps its memory resident as long as pressure allows, and only wakes it up for specific reasons: a background fetch quota, a silent push notification, a location update if the app has the location entitlement, an audio stream continuing, a BLE event, or one of a short list of allowed background modes. The kernel and SpringBoard cooperate to enforce this aggressively. An iOS app cannot run a long-lived background thread the way a desktop process can.

On Android, the model is different. Background execution is negotiated through Services, WorkManager, and JobScheduler, each of which has its own lifecycle and constraints. Android has historically been more permissive about background CPU (leading to battery life complaints), and recent versions (Android 12, 13, 14) have added restrictions that make it closer to iOS but through different APIs. An Android app that wants to run in the background declares a Service with a foreground notification, or schedules a periodic job through WorkManager.

The iOS background modes an app declares in its Info.plist (audio, location, voip, fetch, remote-notification, bluetooth-central, processing) have no direct Android equivalent. A translation layer that wanted to make an iOS app run correctly in the background would have to map each mode onto the closest Android mechanism, handle the timing differences (iOS background fetch runs opportunistically maybe once every few hours; Android WorkManager runs on a schedule), and paper over the differences in push notification models. Apple's push notifications go through APNs, which is not reachable from Android, so the translation layer would also need to translate APNs messages into Firebase Cloud Messaging pushes, which is yet another bridge that has to run somewhere.

The net effect is that even if the foreground experience of an iOS app worked perfectly on Android, any behaviour that depended on background execution (which is most meaningful apps: messaging, location tracking, music streaming, news fetching) would break in ways users would notice within hours.

A Concrete Example: One Button Tap

To make all of this concrete, consider what happens on iOS when a user taps a button in a simple app. Follow the path and note every layer that would need a replacement on Android.

The user's finger touches the capacitive sensor. The touch controller sends an event over SPI to the main SoC. The iOS kernel's HID driver reads the event, packages it as a Mach message, and sends it to backboardd, the user-mode daemon that handles input. backboardd sends the event via XPC to SpringBoard, which decides which app should receive it based on which app is frontmost and which window is under the touch. SpringBoard forwards the event via another XPC channel to the app's process. Inside the app, the system frameworks receive the event on the main run loop, convert it into a UIEvent, deliver it through the responder chain to the UIButton, which calls its target-action, which invokes the developer's callback.

The callback runs some Objective-C or Swift code that calls into UIKit to update the UI: setTitle(_:) on the button, or animate(withDuration:) on a container. UIKit processes the call, marks the corresponding layer as needing display, and on the next CADisplayLink tick Core Animation walks the layer tree, computes the updated transforms, and sends a render pass to the compositor via IOSurface. The compositor, which lives in backboardd, merges the app's surface with the rest of the screen and hands it to the GPU. The GPU, driven by Metal, composites the final frame into the display scanout buffer. The user sees the button change.

Every layer of that trace is iOS-specific. The HID driver is Apple's. backboardd is Apple's. SpringBoard is Apple's. XPC is Apple's. UIKit is Apple's. Core Animation is Apple's. IOSurface is Apple's. The compositor is Apple's. Metal is Apple's. The display driver is Apple's. A translation layer that wanted to make the same button tap work on Android would need an equivalent or a fake for every one of these layers, plumbed together, with matching semantics. The Android equivalents exist (InputDispatcher, WindowManagerService, View, Canvas, Skia, SurfaceFlinger, Vulkan, the Qualcomm display driver), but they are not compatible.

When people say "iOS apps just need a translation layer", this is the trace they are underestimating. It is not one abstraction to bridge; it is a dozen, each depending on the next, each with subtly different assumptions, each carrying decades of accumulated behaviour that apps depend on without realising it.

What Apple's Hardware Dependence Adds On Top

So far we have talked only about software. Apple's vertical integration adds another layer: iOS apps depend on specific hardware features that Android phones do not expose.

The Secure Enclave Processor is the clearest example. Every modern iPhone has a separate ARM processor that runs its own tiny OS, manages cryptographic keys, handles biometric authentication, and enforces device integrity. iOS apps that use Face ID, Touch ID, or secure key storage ultimately talk to the SEP through specific kernel interfaces. Android phones have their own secure enclaves (Qualcomm's TrustZone, Google's Titan M), but the interfaces and capabilities are completely different. An iOS app that expects to store a key in the SEP cannot do so on Android because there is no SEP to store it in, and the equivalent Android Keystore has different APIs and different guarantees.

The Apple Neural Engine is another. iOS apps that use Core ML for on-device machine learning often target the ANE for hardware acceleration. Android has its own neural processing hardware on some devices, exposed through NNAPI, but the models are compiled differently and the runtime is not compatible.

Apple's GPU is another, already discussed. The Secure Element for Apple Pay is another. The H-chip in AirPods that cooperates with iOS for low-latency audio is another. Every one of these is a piece of Apple hardware with an Apple-specific interface that an iOS app may depend on, and none of them has an equivalent on Android hardware. A translation layer could stub out the calls, but stubbed-out hardware is not a working experience for apps that actually needed the capability.

The Calling Convention Looks Similar, But The Details Diverge

One more subtle point worth nailing down. People often assume that because both iOS and Android use the standard ARM64 C calling convention (AAPCS64), a C function from one should be directly callable on the other. In the narrow case of a small self-contained function that takes integers and returns an integer, that is true. In real code it almost never is, because the calling convention only covers how arguments and return values are laid out in registers. It does not cover anything about what those arguments mean, or which libraries the function calls, or how errors are reported, or how exceptions propagate.

Apple maintains its own variant of the ARM64 calling convention for Darwin, with small but meaningful differences from the Linux AAPCS64. Variadic functions are passed differently (Darwin passes variadic arguments on the stack, Linux passes them in registers up to a point). char signedness differs by default in some historic compilers. Structure packing rules have edge cases at sub-word sizes that are handled differently. The red zone (the 128 bytes below the stack pointer that functions can use without adjustment) exists in both ABIs but can be disabled on Darwin under specific circumstances that Linux does not match.

On top of the ABI, Swift has its own calling convention (swiftcall), error handling through a specific register, self passing for method calls, and protocol witness tables that expect very specific runtime functions to exist. Objective-C has objc_msgSend, which is not a function call at the C level but a tail-called dispatcher that expects a specific runtime in memory. None of these translate transparently to Android even if the basic AAPCS64 layer matches.

The practical upshot is that "the calling convention is the same" is a fact that holds for the bottom 5 per cent of what an iOS app actually does, and fails for the other 95 per cent because those 95 per cent are dispatched through runtimes that do not exist on Android.

Why Android-On-iOS Is Also Impossible

For completeness, the reverse direction is just as broken, and for the same reasons in reverse. An Android APK cannot run on iOS because: the kernel is different (XNU has no Binder driver, no Linux ioctls, no /dev/binder file, no Android-specific syscalls), the runtime is different (iOS has no ART, no DEX loader, no JNI environment that matches Android's), the framework is different (iOS has no Android framework APIs, no Activity, no ContentProvider, no Intent system), and the code signing is different (Android signatures are not Apple-valid and AMFI would refuse to load them). The symmetrical statement is true: "both are ARM64" is the least relevant fact about the compatibility question.

There is a small asterisk worth noting. Apple does let iOS run iPad apps on Mac and lets ARM64 macOS run iOS apps directly through a thin compatibility layer, because macOS and iOS share most of their framework stack on Apple Silicon. That works because Apple controls both sides and makes them compatible on purpose. The mechanism is not a translation layer; it is a shared userland with the same frameworks on both operating systems.

IPA Versus APK: Even The Package Format

Before any of the above even comes into play, the packaging format is different, and the differences are substantive. An .ipa file is a zip archive containing a Payload/ directory with an .app bundle inside. The .app bundle is itself a directory with the executable, the Info.plist, the embedded provisioning profile, the code signature, the asset catalogue (Assets.car), storyboards (*.storyboardc), localised strings (*.lproj), and any embedded frameworks. The signing is Apple-chain, the provisioning profile is Apple-specific, and the asset catalogue is a proprietary format that only Apple's tooling can read or write.

An Android APK is also a zip archive, but the contents are structured completely differently. It has classes.dex (the compiled bytecode), AndroidManifest.xml (in a binary XML format, not plain XML), resources.arsc (a binary resource table), res/ (resources in a specific layout), lib/ (native libraries organised by ABI directory), and META-INF/ (the signing metadata). APK signing uses the developer's own certificate, and the signature scheme has gone through four versions (v1 through v4) each with different guarantees.

The zip envelope is the only common ancestor. Beyond that, every single file inside an .ipa is structured for Apple's tooling, and every single file inside an .apk is structured for Google's. A translation layer cannot even reuse the asset catalogue or the storyboard files; it would need a runtime that reads Apple's .car format and renders it into an Android-compatible drawable. storyboardc files are another proprietary serialisation of a scene graph that only UIKit knows how to load. Localised strings in .lproj folders use a specific naming and lookup convention that Android's resource system does not match.

Even if all the runtime layers were in place, you would need a packaging shim that unpacked the .ipa, extracted the bundle, parsed the Info.plist, handled the asset catalogue, and registered a fake provisioning profile with the translation layer's sandbox. This is not hard in isolation; it is hard because it is one more layer that has to exist, work correctly, and stay current with Apple's format changes over time.

What A Real Port Actually Requires

If you wanted to run iOS apps on Android as a serious engineering project, the required components would be, at minimum: a Mach-O loader for Linux, a BSD-syscall-to-Linux-syscall translation layer, a working Darwin libc reimplementation, a Mach port emulation layer, an XPC reimplementation, a libdispatch port (this already exists), a working Objective-C runtime (GNUstep provides one but not fully current), a working Swift runtime (the open-source Swift toolchain can provide this on Linux), a working CoreFoundation, a working Foundation, a working UIKit (this is the largest single piece by an order of magnitude), a working Metal-on-Vulkan translator, reimplementations of dozens of system services (locationd, mediaserverd, networkd, springboard, and more), a sandbox shim that maps SBPL to SELinux, a code signing bypass or a legitimate Apple root cert chain, and a way to obtain and redistribute Apple's frameworks legally.

Each of these is a multi-year project on its own. The combined effort is beyond anything the open-source world has ever attempted for a single platform. Even if the money existed, the legal exposure would be immediate: Apple would sue, and the discovery process would expose any use of non-redistributable Apple code. This is why no such project exists, and why "run iOS apps on Android" is not a product you can buy in any store in Europe, despite enormous latent demand from users who want to mix their phones.

The lesson worth taking away is that "an operating system" is not the kernel, the CPU, or the instruction set. An operating system is the full stack of loaders, libraries, frameworks, runtimes, services, IPC mechanisms, hardware interfaces, and policies that programs call into to get work done. Two systems that share a CPU are still different operating systems if anything above the CPU differs, and the amount that differs between iOS and Android is very nearly everything. The ARM64 chip is what makes them look similar from a hardware catalogue. The rest of the stack is what makes them separate worlds.

What This Means For The Lab And The Quiz

The lab paired with this article visualises the layered stack of both operating systems side by side and lets you see at each layer where iOS and Android diverge. The beginner mode explains what each layer does in plain language. The advanced mode names specific APIs and kernel facilities. Stepping through the stack top to bottom makes the argument visceral: it is not one wall, it is ten walls stacked, and any credible translation layer has to breach every one.

The companion quiz checks the three facts that tend to surprise people who come at this question cold: that iOS and Android have entirely different kernels (XNU versus Linux), that iOS binaries depend on the dyld shared cache and a set of Apple frameworks that do not exist on Android, and that running an .ipa on Android would require a full reimplementation of UIKit, not just a Mach-O loader. If you finish the lab and come away thinking of "iOS" and "Android" as two completely different operating systems that happen to use similar CPUs, the article has done its job.