Lesson 4
Retention has its own store and observer lifecycle
Follow RetainedValuesStore ownership, RetainObserver callbacks, RetainedEffect retirement, and DoNotRetain leak protection.
Status: Retention needs an owner beyond the slot table and therefore has a distinct callback vocabulary: Durable . Exact store handoffs, callback order, and lint enforcement are Version-specific .
Outcome
Trace a retained object through successful retention, composition exit, restoration or retirement, and cleanup. Explain why RetainedEffect is not just DisposableEffect with a longer delay.
The store owns the gap after forgetting
RetainedValuesStore defines the handoff around transient removal. Before owned content exits, onContentExitComposition tells the store to prepare. As retained holders are forgotten, saveExitingValue receives their keys and values. On re-entry, retain asks consumeExitedValueOrDefault; after the restored content finishes entering, onContentEnteredComposition lets the store discard unconsumed values (RetainedValuesStore.kt).
LocalRetainedValuesStoreProvider installs this boundary around content. It uses a remembered presence indicator and a frame-end callback so the store is told that content entered only after the content’s successful frame; forgetting the indicator starts exit (LocalRetainedValuesStore.kt). A store belongs to one recomposer and must be disposed when orphaned.
ManagedRetainedValuesStore is the configurable policy: disableRetainingExitedValues() purges held values, while dispose() permanently prevents future retention (ManagedRetainedValuesStore.kt). Retained does not mean forever; it means this owner may keep the object between composition memberships.
RetainObserver names the extra states
A retained object does not receive ordinary RememberObserver callbacks for this lifecycle. RetainObserver has five events (RetainObserver.kt):
| Event | Meaning |
|---|---|
onRetained | A newly created value was successfully installed in the retention system |
onEnteredComposition | Current content now references the value |
onExitedComposition | Content stopped referencing it, but the store may keep it |
onRetired | No future restoration will use it; release resources |
onUnused | A created value was abandoned before successful retention |
Initial success calls onRetained then onEnteredComposition. A transient exit calls onExitedComposition; matching restoration enters again. Without a retaining store, exit retires immediately. onRetired has no universal order and may run on a different thread when an orphaned store is disposed.
flowchart TD
Create[Create retained value] --> Retained[onRetained]
Retained --> Enter[onEnteredComposition]
Enter --> Exit[onExitedComposition]
Exit --> Restore{Store restores?}
Restore -->|Yes| Enter
Restore -->|No| Retire[onRetired]
Create -->|Abandoned| Unused[onUnused]RetainedEffect follows retirement, not forgetting
RetainedEffect(key...) wraps a RetainObserver. Its effect block runs when the retained observer is first retained, and onRetire { ... } runs when that observer is retired (RetainedEffect.kt). During a store-managed transient exit, the observer can remain alive without running its retirement callback; if the content returns with the same keys, it is restored. If the key changes, the old retained effect is eventually retired and a new one is retained. Without a retaining store, it behaves like an immediately retired disposable effect.
The no-key overload is a compile-time deprecation error; keyed declarations are @Composable @NonRestartableComposable with no experimental opt-in (RetainedEffect.kt). Release notes introduce it in 1.10.0-alpha04 as a retention-lifecycle effect (release notes).
Leak protection is part of the contract
The retain docs warn against Context, View, and indirect references. @DoNotRetain is a separately published binary class annotation; runtime-retain lint checks it on direct return types and keys (DoNotRetain.kt, runtime-annotation API). It is a guardrail, not proof of safety. Keys share the value’s retention duration.
Reproduce observer and retirement events
At the pinned checkout, run:
./gradlew :compose:runtime:runtime-retain:desktopTest \
--tests 'androidx.compose.runtime.retain.RetainTests.retain_retaining_reconstruct' \
--tests 'androidx.compose.runtime.retain.RetainTests.retain_callbackOrdering_relativeToRememberObserver' \
--tests 'androidx.compose.runtime.retain.RetainedEffectTest.testRetainedEffect_keysChanged' \
--tests 'androidx.compose.runtime.retain.RetainedEffectTest.testRetainedEffect_retireWhenRetainExitedValuesEnds'
Controls: remove and restore under an enabled store, compare callback logs, then change an effect key and disable the store. Expected: exit emits onExitedComposition, restoration enters without a new object, key change retires the old effect, and disabling the store retires held effects. Limits: desktop does not establish host callback threads or process death. This procedure was not executed for this lesson.
Misconceptions
- “Retention is just a longer
DisposableEffect.” The retained observer can cross a composition exit without retiring. - “
onExitedCompositionmeans release now.” The store may restore the same object; release belongs toonRetired. - “
DoNotRetainmakes retention safe.” It only flags selected classes and direct uses; indirect references and unannotated resources still require review. - “A retained key is ephemeral input.” The current implementation keeps keys while the retained value is kept.
Check yourself
A retained screen leaves composition, its store holds the object, and the screen returns with a different key. Which callback releases the old object, and why is onExitedComposition not sufficient?
Evidence ledger
| Claim | Direct evidence | Status |
|---|---|---|
| A store receives exit, save, consume, and enter handoffs | RetainedValuesStore.kt and LocalRetainedValuesStore.kt | Version-specific |
RetainObserver separates retained, entered, exited, retired, and unused states | RetainObserver.kt and RetainedValueHolder.kt | Durable
Version-specific |
RetainedEffect retires on retention retirement and requires keys | RetainedEffect.kt and RetainedEffectTest.kt | Version-specific |
@DoNotRetain is a binary class annotation used by retention lint | DoNotRetain.kt and 1.10.0-alpha02 release notes | Version-specific |
Freshness
Refresh when store callbacks, RetainObserver thread/order guarantees, RetainedEffect retirement, or the retention lint detector changes. Treat onRetired as the release point, not a guessed frame boundary.
Finished this lesson?
Your progress stays only in this browser.