The Launcher That Outlives Your UI: Field Notes on rememberLauncherForActivityResult

2026-11-12来源 dev.toEnglish
The Launcher That Outlives Your UI: Field Notes on rememberLauncherForActivityResult

Compose makes calling system UI feel like calling a function. The catch is that the callee can outlive the caller, and your process can die in the middle of the call.

The Launcher That Outlives Your UI: Field Notes on rememberLauncherForActivityResult

Why this API deserves a senior-level look

Most Compose code you write is a pure function of state. rememberLauncherForActivityResult is one of the few places where a composable negotiates with something outside your process: the camera app, the system photo picker, a permission dialog, a billing sheet. The call looks synchronous in your code, but the system is free to destroy your activity, or your whole process, before the answer comes back.

The API handles that surprisingly well, and it still has sharp edges that only show up on real devices, in the hands of real users, under memory pressure. This post covers:

  • what the function actually does under the hood
  • idiomatic Kotlin wrappers that keep launchers out of your screens
  • the failure modes that have caused real production bugs
  • how to test and preview code that depends on a registry

Examples assume a recent androidx.activity:activity-compose. Package imports are trimmed for readability.

1. What happens when you call it

The activity-compose artifact exposes this function:

@Composable fun <I, O> rememberLauncherForActivityResult( contract: ActivityResultContract<I, O>, onResult: (O) -> Unit ): ManagedActivityResultLauncher<I, O> 

Conceptually, the implementation does four things:

  1. Wraps contract and onResult with rememberUpdatedState, so a recomposition that changes either one is picked up without re-registering the whole launcher.
  2. Creates a key with rememberSaveable, a random UUID string that is written to saved instance state and restored after recreation.
  3. Reads the ActivityResultRegistry from LocalActivityResultRegistryOwner.
  4. Registers the callback inside a DisposableEffect, and unregisters it in onDispose.

The infographic below shows the whole journey of a single request.

launcher-lifecycle

The important consequence is step 5. The registry persists pending results against the key. If your process dies while the camera is open, Android recreates your activity, your composition re-enters, rememberSaveable restores the same key, the DisposableEffect registers again, and the registry hands over the result it was holding. That mechanism is elegant, and it is also the source of the most common bug, which we reach in section 4.

2. Three idiomatic baselines

2.1 Photo picker

The simplest case: the contract returns a nullable Uri, and null means the user backed out.

@Composable fun AvatarPicker(onPicked: (Uri) -> Unit, modifier: Modifier = Modifier) { val picker = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia() ) { uri: Uri? -> uri?.let(onPicked) } Button( modifier = modifier, onClick = { picker.launch( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) ) } ) { Text("Choose a photo") } } 

The launch happens in onClick, an event handler. Keep that in mind; it becomes failure mode 1.

2.2 Hide the launcher behind a small state holder

Screens should say what they want, not how the platform delivers it. A remembered wrapper gives you a clean seam:

@Stable fun interface ImagePicker { fun pick() } @Composable fun rememberImagePicker(onResult: (Uri?) -> Unit): ImagePicker { val launcher = rememberLauncherForActivityResult( ActivityResultContracts.PickVisualMedia(), onResult ) return remember(launcher) { ImagePicker { launcher.launch( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) ) } } } 

Call sites shrink to val picker = rememberImagePicker(viewModel::onImage) and picker.pick(). The @Stable annotation keeps the holder from invalidating callers for no reason, and fun interface keeps the lambda ergonomics.

2.3 Camera capture that survives process death

TakePicture writes the photo to a Uri you supply and returns only a Boolean. Whatever you remember about which Uri you supplied has to live as long as the key does:

@Stable fun interface CameraCapture { fun launch() } @Composable fun rememberCameraCapture(onCaptured: (Uri) -> Unit): CameraCapture { val context = LocalContext.current var pendingUri by rememberSaveable { mutableStateOf<Uri?>(null) } val launcher = rememberLauncherForActivityResult( ActivityResultContracts.TakePicture() ) { success -> val uri = pendingUri pendingUri = null if (success && uri != null) onCaptured(uri) } return remember(launcher, context) { CameraCapture { val target = createCaptureUri(context) pendingUri = target launcher.launch(target) } } } private fun createCaptureUri(context: Context): Uri { val dir = File(context.cacheDir, "captures").apply { mkdirs() } val file = File.createTempFile("capture_", ".jpg", dir) return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) } 

pendingUri uses rememberSaveable on purpose. Swap that for plain remember and you have built a bug that passes every test you run by hand.

One more note on createCaptureUri: it creates a directory and a temp file inside a click handler, which is disk I/O on the main thread. For one small file in the cache directory this is usually tolerable, but StrictMode can flag it. If it matters in your app, create the file on Dispatchers.IO from a rememberCoroutineScope(), set pendingUri, and call launcher.launch(target) back on the main thread once the file exists.

3. Contracts at a glance

The default contracts cover most needs. Each one has a type signature, and each has a trap that is easy to miss.

contract-cheatsheet

For anything not covered, write a custom ActivityResultContract<I, O>. Keep createIntent free of side effects and make parseResult total, so that a cancelled or malformed result maps to a sensible default instead of throwing.

4. Failure modes from the field

Failure mode 1: launching during composition

// Looks harmless. It is not. @Composable fun Broken(showPicker: Boolean) { val launcher = rememberLauncherForActivityResult(PickVisualMedia()) { /* ... */ } if (showPicker) { launcher.launch(PickVisualMediaRequest()) // side effect in the body } } 

Composable bodies may run many times, in any order, or be discarded. A launch placed there can fire on every recomposition, and the user sees the picker reopen in a loop. Launch from event handlers. When the trigger is state, a one-shot effect is acceptable, provided the “already launched” flag is itself saveable:

var launched by rememberSaveable { mutableStateOf(false) } LaunchedEffect(showPicker) { if (showPicker && !launched) { launched = true launcher.launch(PickVisualMediaRequest()) } } 

Without the saveable flag, a rotation or process recreation will launch the system UI a second time.

Failure mode 2: the Uri that evaporated

This one is the classic. A developer stores the camera target in remember or in a plain ViewModel field, the user switches to a memory-hungry camera app, Android kills your process, and on return the registry faithfully delivers success = true. Your callback runs with pendingUri == null, and the photo is silently dropped.

The rule: anything the callback needs to interpret the result must survive at least as long as the launcher key. The infographic shows where common storage options stand.

state-survival

Use rememberSaveable, or a SavedStateHandle in the ViewModel. If the state is too large for a bundle, save a small identifier and rebuild the rest.

Failure mode 3: the launcher lives in a disposable subtree

Put the launcher inside a lazy list item, a tab that is only composed when selected, or a conditional branch, and its registration follows that subtree in and out of composition. While it is gone, nobody is listening. The registry holds the result, but your UI may not return to that exact spot, so the answer is delivered late or never.

Hoist the launcher to a stable ancestor, typically the screen-level composable, and pass a plain callback down:

@Composable fun GalleryScreen(vm: GalleryViewModel = hiltViewModel()) { val picker = rememberImagePicker(vm::onImagePicked) // stable, screen-level LazyColumn { items(vm.items, key = { it.id }) { item -> GalleryRow(item, onReplace = picker::pick) // rows never own a launcher } } } 

Failure mode 4: the permission dialog that never appears

After two denials, Android stops showing the runtime dialog for that permission, and launch completes immediately with false. Users read this as a dead button.

The tricky part is shouldShowRequestPermissionRationale. Before the first request it returns false, and after a permanent denial it also returns false, so it cannot be used as a pre-check. Read it inside the result callback, where false genuinely means "blocked":

enum class PermissionOutcome { Granted, CanRetry, Blocked } @Composable fun rememberPermissionRequester( permission: String, onOutcome: (PermissionOutcome) -> Unit ): () -> Unit { val context = LocalContext.current val launcher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> val activity = context.findActivity() onOutcome( when { granted -> PermissionOutcome.Granted activity != null && ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) -> PermissionOutcome.CanRetry else -> PermissionOutcome.Blocked } ) } return remember(launcher, permission) { { launcher.launch(permission) } } } tailrec fun Context.findActivity(): Activity? = when (this) { is Activity -> this is ContextWrapper -> baseContext.findActivity() else -> null } 

permission-outcomes

On Blocked, offer a path to app settings with Settings.ACTION_APPLICATION_DETAILS_SETTINGS and a package: Uri. Do not keep relaunching.

Treat Blocked as "the system is not going to show this dialog again, as far as we can tell", not as proof of an explicit "Don't ask again" tap. Dismissing the dialog by tapping outside it or using the back gesture is treated as a denial on recent Android versions, a few OEM builds report the rationale flag differently, and a device policy can block a permission outright. So make the settings screen an offer ("Open settings") that the user can decline, rather than an automatic redirect, and keep a non-permission fallback for the feature where you can.

Failure mode 5: the camera permission you declared yourself

If your manifest declares android.permission.CAMERA, then TakePicture requires that permission to be granted at runtime. If it is not, the camera intent can fail with a SecurityException. Many teams add the permission "just in case" for a feature they later drop. Either remove the declaration or request it first.

Failure mode 6: no activity to handle the intent

On devices without a camera, with a disabled default app, or inside restricted profiles, launching can throw ActivityNotFoundException. Treat that as an expected outcome:

fun <I> ManagedActivityResultLauncher<I, *>.launchSafely( input: I, onUnavailable: () -> Unit ) { try { launch(input) } catch (e: ActivityNotFoundException) { onUnavailable() } } 

(The exception is named rather than written as _: discard syntax in catch is not available on every Kotlin version, and a named, unused variable compiles everywhere.)

For image picking, note that PickVisualMedia already falls back to ACTION_OPEN_DOCUMENT when the system photo picker is missing. You only need ActivityResultContracts.PickVisualMedia.isPhotoPickerAvailable(context) when you want a different fallback, such as GetContent. Use the overload that takes a Context: the no-argument version is the deprecated one, because it cannot see the backported picker delivered through Google Play services.

Failure mode 7: result arrives at an awkward moment

The registry can deliver a pending result as soon as your launcher registers, which may be before the UI is resumed. Keep onResult thin: translate the output and hand it to a ViewModel. Avoid navigating, showing dialogs, or touching views directly from the callback.

Failure mode 8: launching from a ViewModel

A ViewModel has no registry and must not hold a reference to a launcher, an activity or a context that points at one. Model the request as state or as an event the UI consumes:

sealed interface UiRequest { data object PickImage : UiRequest } // In the screen val request by vm.pendingRequest.collectAsStateWithLifecycle() LaunchedEffect(request) { when (request) { UiRequest.PickImage -> { picker.pick() vm.onRequestHandled() } null -> Unit } } 

Prefer state with an explicit “handled” acknowledgement over a raw Channel, so a request is not lost when the collector is briefly absent.

Failure mode 9: previews crash

rememberLauncherForActivityResult insists on a registry owner. Inside @Preview, there is none, and the call throws. Provide a stub:

@Composable fun PreviewRegistryHost(content: @Composable () -> Unit) { val owner = remember { object : ActivityResultRegistryOwner { override val activityResultRegistry = object : ActivityResultRegistry() { override fun <I, O> onLaunch( requestCode: Int, contract: ActivityResultContract<I, O>, input: I, options: ActivityOptionsCompat? ) = Unit } } } CompositionLocalProvider(LocalActivityResultRegistryOwner provides owner, content = content) } 

5. Testing with a fake registry

Because the registry arrives through a CompositionLocal, tests can replace it and dispatch any result on demand, with no real camera involved:

@get:Rule val compose = createComposeRule() @Test fun pickedImageReachesCaller() { val expected = Uri.parse("content://test/photo.jpg") var received: Uri? = null val registry = object : ActivityResultRegistry() { override fun <I, O> onLaunch( requestCode: Int, contract: ActivityResultContract<I, O>, input: I, options: ActivityOptionsCompat? ) { dispatchResult(requestCode, expected) } } val owner = object : ActivityResultRegistryOwner { override val activityResultRegistry = registry } compose.setContent { CompositionLocalProvider(LocalActivityResultRegistryOwner provides owner) { AvatarPicker(onPicked = { received = it }) } } compose.onNodeWithText("Choose a photo").performClick() compose.runOnIdle { assertEquals(expected, received) } } 

To rehearse process death, use StateRestorationTester for the rememberSaveable half, and verify that a result dispatched after restoration still resolves the saved pendingUri.

6. A short review checklist

  • Every launch call sits in an event handler, or in an effect guarded by saveable state.
  • Any value the callback needs to interpret the result is saveable, not just remembered.
  • Launchers live at screen level or higher, never inside lazy items or conditional branches.
  • Permission handling distinguishes Granted, CanRetry and Blocked, and reads rationale only after a result.
  • ActivityNotFoundException and missing providers have a user-visible fallback.
  • Callbacks are thin and delegate to a ViewModel.
  • Previews and tests inject a registry through LocalActivityResultRegistryOwner.

🔚 Closing thought

rememberLauncherForActivityResult earns its place by moving a fiddly lifecycle contract, "register early, unregister late, match results by key", into the composition model. What it cannot do is protect state you forgot to save. Treat the launcher key as a promise that an answer may come back to a very different process than the one that asked, and design the surrounding state to keep that promise.

🧵 Stop Treating Coroutines Like Magic Keywords

If “just add launch and hope” describes how your team handles concurrency, I’d recommend “Kotlin Coroutines Under the Hood” — a 25-chapter deep dive that goes past the syntax and down to the bytecode: the Continuation-Passing Style transform, the compiler-generated state machine, and exactly why a suspended coroutine costs a few dozen bytes while a blocked thread costs a megabyte.

It walks through structured concurrency and Job hierarchies, dispatcher internals (Default vs IO vs Unconfined), cooperative cancellation and exception aggregation, Flow’s cold-stream semantics and the full operator vocabulary, StateFlow/SharedFlow’s hot-broadcast model, Channels and fan-in/fan-out patterns, Mutex/Semaphore/actors for shared state, and a complete Android case study retrofitting a legacy callback-driven app onto coroutines and Flow — including a 100,000-coroutines-vs-100,000-threads benchmark lab you can run yourself.

原文:https://www.a8q9.cn/en/article/the-launcher-that-outlives-your-ui-field-notes-on-rememberlauncherforactivityres-839497.html

相关文章