Cross-Platform Development Patterns

Build applications that run on iOS and Android from a single codebase. Covers React Native, Flutter, KMP architecture decisions, platform-specific code handling, shared business logic, and the patterns that maximize code reuse without sacrificing native quality.

Cross-platform development enables teams to build mobile applications that run on multiple platforms—iOS, Android, and sometimes web—using a shared codebase. This approach matters when teams need to deliver feature parity across platforms with limited resources, especially when time-to-market and developer velocity are critical. It is most effective when the core app logic is consistent across platforms, and when the cost of maintaining separate native codebases outweighs the benefits of platform-specific customisation.

Building a Shared UI Layer

The most immediate goal in cross-platform development is to create a UI that behaves consistently across platforms. The shared UI layer typically renders platform-specific components from a common set of primitives. This layer is built using declarative UI frameworks that compile or interpret a single codebase into native views.

The foundational step is defining a UI tree using a shared language. For example, a layout in a declarative UI might use a JSON or DSL-like structure:

{
  "type": "View",
  "children": [
    {
      "type": "Text",
      "text": "Welcome to our app",
      "style": { "fontSize": 18, "color": "#333" }
    },
    {
      "type": "Button",
      "label": "Start",
      "onPress": "navigateTo('/onboarding')"
    }
  ],
  "style": { "padding": 20, "backgroundColor": "#f5f5f5" }
}

When the app launches, the runtime reads this definition and instantiates native components. The mapping from abstract UI elements to platform-specific views happens at runtime. On Android, Text becomes a TextView, Button becomes a MaterialButton, and on iOS, Text becomes a UILabel, Button becomes a UIButton.

A common failure mode occurs when the UI fails to adapt to platform-specific idioms. A Button with onPress may trigger on tap but not on long press on iOS, where the touch model expects UIControlEvent.TouchUpInside. The developer must explicitly define handlers:

{
  "type": "Button",
  "label": "Submit",
  "onPress": "submitForm",
  "onLongPress": "showMenu"
}

Without this explicit setup, users perceive the app as “not native.” The root cause is a mismatch between the abstraction layer and the platform’s event model. This mismatch is silent: the button appears and responds, but users feel the app is “off.”

Another subtle failure arises from inconsistent styling. A Text element with fontSize: 18 may render at 18 points on iOS but 18 density-independent pixels on Android. The difference becomes apparent in high-DPI screens. The solution is to define a scaleFactor in the runtime configuration:

{
  "platform": "android",
  "scaleFactor": 2.0,
  "font": {
    "defaultFamily": "Roboto",
    "fallbackFamilies": ["sans-serif", "system"]
  }
}

This ensures that 18pt text appears consistently across devices. Failing to set scaleFactor results in text that is too small on high-DPI Android devices, a failure that is hard to diagnose because it appears only on certain devices.

Sharing Business Logic Across Platforms

Once the UI layer is established, the next goal is to share business logic—validation, state management, API clients, and domain models—across platforms. This is achieved by compiling shared code into a common runtime or by executing it in a shared virtual machine.

Shared logic is typically written in a language that compiles to bytecode or native code. For example, a state manager written in TypeScript compiles to JavaScript and runs on a JavaScript engine embedded in both iOS and Android.

A typical setup includes a state store defined as a class:

class AppState {
  user: User | null = null;
  isLoading: boolean = false;
  errors: string[] = [];

  async login(username: string, password: string): Promise<void> {
    this.isLoading = true;
    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify({ username, password }),
        headers: { 'Content-Type': 'application/json' }
      });
      if (!response.ok) throw new Error('Login failed');
      this.user = await response.json();
    } catch (err) {
      this.errors.push(err.message);
    } finally {
      this.isLoading = false;
    }
  }
}

This AppState class is compiled into a shared library, typically as a .js file or a .wasm binary. The app entry point on both platforms imports and instantiates this library:

const appState = new AppState();
await appState.login('[email protected]', 'secret');

The shared logic layer communicates with native platform APIs through a bridge. The bridge enables calls from JavaScript to native code and vice versa. For example, when the login method calls fetch, the runtime dispatches the request to the native HTTP stack.

A key failure mode is memory leakage across the bridge. Each call from JavaScript to native code creates a new stack frame, and if objects are not properly retained, they become garbage collectible but are still referenced in native code. The result is that a User object passed from JavaScript to native code may be freed while the native code is still using it.

This issue manifests as null or corrupted data in native functions. A common error is:

TypeError: Cannot read property 'name' of null
    at nativeFunction (index.js:123)
    at appState.login (appState.js:45)

The cause is not a JavaScript bug but a mismatch in object lifetime between the JavaScript and native heaps. The solution is to explicitly define object lifetimes in the bridge configuration:

{
  "bridge": {
    "retainObjects": true,
    "objectLifetime": "shared",
    "callbackThreshold": 1000
  }
}

With retainObjects: true, the runtime ensures that JavaScript objects passed to native code are kept alive until explicitly released. The objectLifetime field can be set to shared, transient, or owned, each with different trade-offs in memory usage and performance.

Integrating Platform-Specific Features

Even with a shared UI and logic layer, apps need to access platform-specific features such as camera, location, push notifications, and native animations. These features are exposed via platform-specific APIs and must be integrated into the shared codebase.

The integration pattern involves defining a set of platform bindings—functions that map from the shared code to native APIs. These bindings are typically declared in a manifest or configuration file.

For example, to use the device’s camera, the shared code calls:

const image = await Camera.capture({
  quality: 'high',
  enableAutoFocus: true,
  flash: 'on'
});

This function is implemented in the native code and registered in the runtime. On Android, the implementation might be a CameraModule.java:

public class CameraModule {
  public static void capture(final Callback callback, Map<String, Object> options) {
    final Activity activity = getCurrentActivity();
    final Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);

    if (options.containsKey("quality") && options.get("quality").equals("high")) {
      intent.putExtra("quality", 90);
    }

    if (options.containsKey("enableAutoFocus") && (Boolean) options.get("enableAutoFocus")) {
      intent.putExtra("autoFocus", true);
    }

    activity.startActivityForResult(intent, 101);
    activity.registerForActivityResult(new ActivityResultLauncher() {
      @Override
      public void onActivityResult(int resultCode, Intent data) {
        if (resultCode == Activity.RESULT_OK) {
          final Bitmap bitmap = (Bitmap) data.getExtras().get("data");
          final byte[] bytes = bitmapToBytes(bitmap);
          callback.invoke(bytes);
        } else {
          callback.invoke(null);
        }
      }
    });
  }
}

The bridge automatically maps the Camera.capture call to this native implementation. However, a subtle failure occurs when the Callback object is not properly serialised across the bridge.

The error appears as a NullPointerException in the native code, but the root cause is a misconfigured callback interface. The bridge expects a Callback object with a method invoke(Object result), but the native code forgets to implement it. The solution is to define the callback interface in the bridge config:

{
  "bindings": {
    "Camera.capture": {
      "returnType": "binary",
      "parameters": [
        { "name": "quality", "type": "string", "default": "medium" },
        { "name": "enableAutoFocus", "type": "boolean", "default": false },
        { "name": "flash", "type": "string", "enum": ["off", "on", "auto"] }
      ],
      "callback": {
        "interface": "Callback",
        "method": "invoke",
        "parameterType": "object"
      }
    }
  }
}

Without this configuration, the invoke method is called with a null argument, and the callback fails silently.

Managing Platform-Specific Resources

Shared code often needs to access platform-specific resources such as images, fonts, and configuration files. These resources are stored in platform-specific directories and must be bundled into the final app package.

The resource management pattern involves a build step that collects and copies platform-specific assets into a shared asset bundle. For example, on Android, assets are stored in res/drawable, res/layout, and assets, while on iOS, they are in Assets.xcassets and Contents.json.

The build process uses a manifest to define how resources are mapped:

{
  "assets": [
    {
      "source": "assets/images/logo.png",
      "platforms": ["android", "ios"],
      "scale": "1x"
    },
    {
      "source": "assets/images/icon-40.png",
      "platforms": ["ios"],
      "scale": "1x"
    },
    {
      "source": "assets/fonts/Roboto-Regular.ttf",
      "platforms": ["android"],
      "type": "font"
    }
  ]
}

During the build, the tool reads this manifest and copies files to the appropriate directories. A common failure is missing or mismatched image scales. On iOS, a icon-40.png is expected to be used for the 40×40 point icon, but if the build step does not scale the image to 80×80 for @2x devices, the icon appears blurry.

The error is not in the code but in the asset pipeline. The build log shows:

Warning: Missing @2x image for icon-40.png
Expected: [email protected]
Found: icon-40.png (80x80)

The solution is to add a scale step in the build configuration:

{
  "build": {
    "steps": [
      {
        "type": "scale-images",
        "input": "assets/images",
        "output": "build/images",
        "scales": [ "1x", "2x", "3x" ],
        "format": "png"
      },
      {
        "type": "copy-assets",
        "source": "build/images",
        "destination": "platforms/android/res",
        "platforms": [ "android" ]
      }
    ]
  }
}

Without this step, the app renders poorly on high-DPI devices, and users report the app as “low quality,” even though the code is correct.

Handling Platform-Specific Navigation and Lifecycle

Finally, the app must handle platform-specific navigation and lifecycle events. On iOS, the app may be suspended and resumed, and navigation follows a stack-based model with pushViewController and popViewController. On Android, navigation uses a FragmentManager, and the app can be paused and resumed via onPause, onResume.

The shared code must define navigation routes and lifecycle handlers. A navigation router might be configured as:

{
  "routes": [
    {
      "path": "/",
      "component": "HomeScreen",
      "title": "Home"
    },
    {
      "path": "/profile",
      "component": "ProfileScreen",
      "title": "Profile",
      "platforms": ["ios", "android"]
    },
    {
      "path": "/settings",
      "component": "SettingsScreen",
      "title": "Settings",
      "platform": "ios"
    }
  ]
}

When the app starts, the router initializes the navigation stack. On iOS, it pushes the initial screen onto a UINavigationController. On Android, it creates a FragmentTransaction and adds the screen to the FragmentManager.

A failure occurs when the app is backgrounded and resumed, but the navigation state is not preserved. The user taps a button, navigates to a screen, then returns to the home screen, and the app is suspended. When the user reopens the app, the navigation stack is lost, and the app starts at the root.

The cause is a missing lifecycle handler. The shared code must register handlers for onResume, onPause, and onCreate:

class AppLifecycle {
  async onResume() {
    const state = await AsyncStorage.getItem('navigationState');
    await Navigation.restore(state);
  }

  async onPause() {
    const state = await Navigation.save();
    await AsyncStorage.setItem('navigationState', state);
  }

  async onCreate() {
    await Navigation.initialize();
  }
}

The AsyncStorage is a shared storage layer that persists state across platform boundaries. When the app is resumed, the onResume handler restores the navigation stack from the stored state. Without this handler, users lose their place in the app, and the app feels incomplete.

This pattern is consistent across platforms. The shared code defines the navigation state, and the platform-specific runtime restores it using native APIs. The integration is seamless, but the failure mode—lost state—is silent and hard to debug.

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.