跳到正文
DEV Community· seongsu kim·· 2 小時前AI 評分42

使用 Compose Multiplatform 和 Swift Export 建構相機應用程式

Building a camera app with Compose Multiplatform and Swift Export

AI 導讀

Keepture 以 Compose Multiplatform 和 Swift Export 建構相機應用程式,讓 Android 與 iOS 共用介面和應用程式規則,同時使用各平台原生相機 API。

正文

I built Keepture because my camera roll kept filling up with photos I only needed briefly: a parking spot, a receipt, or a notice to check later. The app keeps those records separately, with an optional note and a retention period.

The more interesting engineering question was how much of a camera app I could share between Android and iOS while still using each platform's camera APIs. I also wanted to use Swift Export in an app I could actually ship.

This article covers the boundary I ended up with: Compose for the shared screens, Kotlin for the app rules, native camera adapters, and a small SwiftUI host. The examples below come from the implementation; the camera interface is shortened for readability.

The three boundaries in the app

Android host                         SwiftUI host
     |                                    |
     |                           Swift Export module
     |                                    |
     +---------- Shared Compose UI -------+
                         |
             Common state and app rules
                         |
               PlatformCamera contract
                   /             \
       androidMain                 iosMain
       CameraX                     AVFoundation
       PreviewView                 AVCaptureVideoPreviewLayer

The shared module contains the UI, records, groups, retention rules, zoom state, and settings logic. Platform code owns camera sessions, permissions, photo pickers, and file-system connections.

One detail that can get lost in a diagram: the iOS camera adapter is written in Kotlin in iosMain, calling AVFoundation through Kotlin/Native interop. Swift Export connects Kotlin declarations to the Swift host. It does not generate a camera implementation or translate CameraX into AVFoundation.

That distinction helped me keep the exported Swift surface small. Most interaction between a Compose screen and its camera adapter stays inside Kotlin.

Connecting the SwiftUI host

The shared module configures the Apple targets and the exported module name:

kotlin {
    iosArm64()
    iosSimulatorArm64()

    swiftExport {
        moduleName = "Shared"
        flattenPackage = "com.uni.keepture"
    }
}

The Xcode build phase invokes:

./gradlew :shared:embedSwiftExportForXcode

On the Kotlin side, the entry point returns a Compose view controller:

import androidx.compose.ui.window.ComposeUIViewController
import platform.UIKit.UIViewController

fun MainViewController(): UIViewController =
    ComposeUIViewController { App() }

The SwiftUI wrapper imports the generated module:

import UIKit
import SwiftUI
import Shared

struct ComposeView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UIViewController {
        Shared.MainViewController()
    }

    func updateUIViewController(
        _ uiViewController: UIViewController,
        context: Context
    ) {}
}

These snippets describe this project's direct Xcode integration. The repository snapshot used for this article has Kotlin 2.4.20 and Compose Multiplatform 1.11.1. They are context for the examples, not a requirement to upgrade an existing project.

The official Swift Export documentation currently labels it Alpha and documents its supported mappings and limitations. Shipping this particular app gave me confidence in the integration paths it uses; I would still check those limitations before designing a different Swift-facing API.

A shared camera contract

The common screen talks to an interface like this shortened extract:

interface PlatformCamera {
    val flashMode: FlashMode
    val supportsFlash: Boolean
    val canSwitchLens: Boolean
    val zoom: CameraZoom

    fun setZoom(ratio: Float)
    fun switchLens()
    fun cycleFlash()

    @Composable
    fun Preview(
        recipe: EditRecipe,
        modifier: Modifier,
        onHardwareShutter: () -> Unit,
    )
}

@Composable
expect fun rememberPlatformCameraState(
    requestPermission: Boolean
): PlatformCameraState

The full contract also handles capture results. The state model distinguishes permission denial, initialization, an unavailable camera, and a ready adapter. This gives the shared UI explicit states to render.

Android uses CameraX with a PreviewView. iOS uses an AVCaptureSession and AVCaptureVideoPreviewLayer, hosted through Compose's UIKit interop. Each adapter discovers its capabilities and publishes them to the same controls.

For zoom, the common CameraZoom model validates finite values and clamps requested ratios to the supported range. The adapters supply device-specific bounds and perform the actual camera update. A shared button can request a ratio; the meaning and availability of that ratio still depend on the camera device.

A missing button taught me about capability state

One Android bug was a front/back switch button that did not reliably appear after startup.

CameraX connects asynchronously. Initially, the provider is unavailable. I was deriving lens-switch support from ordinary fields, so the UI could observe false during its first composition and receive no observable update when binding completed.

The fix was to publish the capability through Compose state:

override var supportsFlash by mutableStateOf(false)
    private set

override var canSwitchLens by mutableStateOf(false)
    private set

The adapter clears these values when rebinding begins and updates them after the camera is connected. The common screen also disables switching during capture or import work.

The useful lesson was that capabilities have a lifecycle. Permission approval, asynchronous initialization, and lens changes can all change what the UI should offer. A getter over a native object is only enough if something observable also causes the UI to read it again.

I added a shared UI test with a controlled camera adapter: start with switching unavailable, publish availability, verify the button appears and invokes the callback, then remove availability and verify it disappears. That checks the shared state-to-UI behavior on both targets. Physical camera switching and image quality still need separate device checks.

Session ownership belongs in the adapter

Sharing a camera screen also means deciding who stops the underlying session when that screen disappears.

The Android adapter uses a DisposableEffect to register listeners and bind CameraX, then unregisters listeners and closes the adapter on disposal. It checks whether the effect was disposed before using an asynchronous provider result.

The iOS implementation uses a serial camera-session queue shared by camera instances. This orders session work when one screen closes and another opens. Permission state is refreshed when the app resumes so the UI can reflect changes made in Settings.

Those responsibilities stay close to the APIs that require them. The common layer decides which screen and action are active; the adapter manages its operating-system resources.

Saving a file and saving a record are separate steps

Camera work made another boundary clear: a successful JPEG write does not mean the record has been committed.

The shared repository contract reports whether a record write succeeded:

// True only after the record write succeeds.
// Callers must retain files on failure.
fun upsert(photo: PhotoAsset): Boolean

The app uses a pending-file journal for new capture, import, and drawing output. It prepares the journal before creating the file and clears it after the record write succeeds. On a later process start, recovery preserves files referenced by stored records and only cleans up new files it can establish are unreferenced. Unknown or unreadable data is retained conservatively.

This recovery policy lives in common code, while platform implementations perform the actual file operations. Settings use AndroidX DataStore Preferences through the shared module, with platform-specific paths.

Keeping these rules together matters during updates: Android and iOS should agree on what constitutes a saved record, what can be cleaned up, and what must be preserved after a failed write.

Image memory needs a separate budget

Photo apps can hold several representations of one image at once: encoded bytes, a decoded bitmap, a preview, and an editing result. Sharing Kotlin code does not remove those allocations.

The app bounds images for specific tasks. Imported images are copied into app storage as JPEGs with a maximum dimension of 4096 pixels, leaving the photo-library original unchanged. OCR uses a separate image limited to 3072 pixels on its longest edge. Common coroutine coordination serializes OCR work, and platform code handles decoder and recognition resources.

I keep image-workload tests separate from common policy and screen tests. A controlled adapter can verify a screen flow, but it does not measure a real camera preview's GPU use or prove the absence of leaks on every device.

What I would keep in a future KMP app

The approach I would reuse is a small Swift-facing entry point, shared Compose screens and app rules, and explicit adapters for operating-system resources.

Swift Export is useful in that arrangement because the Swift host can call the exported Kotlin entry point directly. Kotlin/Native interop remains useful inside iosMain for APIs such as AVFoundation. They serve different connections in the same app.

The shared camera contract also made testing more practical: the same UI can run against a controlled adapter, while native permission prompts, camera behavior, and hardware limits receive their own checks.

If you have shipped a Compose Multiplatform app with Swift Export, how large is your Swift-facing API? I would be interested in how you chose that boundary.

來源:DEV Community · dev.to