13 KiB
Legado Compose Migration Project Patterns
Existing Shape
- App module:
app. - Main source root:
app/src/main/java/io/legado/app. - UI package:
ui/..., usually grouped by feature. - Data package:
data/dao,data/entities,data/repository. - Domain package:
domain/gateway,domain/model,domain/repository,domain/usecase. - DI:
di/appModule.kt, using KoinsingleOf,viewModelOf, and parameterizedviewModel { ... }. - Main navigation:
ui/main/MainActivity.kt, using Navigation3NavKey,rememberNavBackStack,entryProvider, andNavDisplay, plus legacy route extras such asEXTRA_START_ROUTE. - Base Compose host:
base/BaseComposeActivity.kt, which wrapsContent()inAppTheme, configures system bars, locale/font, background image, and LiveBus recreation events. - Compose dependencies are already enabled in
app/build.gradle.kts; do not add new UI libraries unless the target screen truly requires one. kotlinx.collections.immutableis available for Compose-facing screen state. PreferImmutableList,ImmutableSet, andImmutableMapatUiStateboundaries when collection state is passed to composables and changes often.
Files to Inspect Before Migrating
Inspect these as local examples, not as APIs to copy blindly:
ui/book/info/BookInfoActivity.ktui/book/info/BookInfoContract.ktui/book/info/BookInfoViewModel.ktui/book/info/BookInfoScreen.ktui/book/search/SearchContract.ktui/book/search/SearchViewModel.ktui/book/search/SearchScreen.ktui/main/MainActivity.ktui/main/MainScreen.ktui/widget/components/AppScaffold.ktui/widget/components/list/ListScaffold.ktui/theme/LegadoTheme.ktui/theme/AppTheme.kt
Recommended Feature Layout
For a migrated feature, prefer colocated files:
ui/<area>/<feature>/
FeatureActivity.kt # only for legacy Intent compatibility or full-screen migration host
FeatureContract.kt
FeatureViewModel.kt
FeatureScreen.kt
FeatureSheets.kt # only if sheets are substantial
FeatureDialogs.kt # only if dialogs are substantial
Use the smallest set of files. Do not split files just to match this shape.
MVI and UDF Rules
Use this shape for behavior-heavy screens:
data class FeatureUiState(
val isLoading: Boolean = false,
val items: List<Item> = emptyList(),
val dialog: FeatureDialog? = null,
val sheet: FeatureSheet = FeatureSheet.None,
)
sealed interface FeatureIntent {
data object BackPressed : FeatureIntent
data object Refresh : FeatureIntent
data class ItemClick(val id: Long) : FeatureIntent
}
sealed interface FeatureEffect {
data object Finish : FeatureEffect
data class OpenDetail(val id: Long) : FeatureEffect
}
ViewModel rules:
- Keep
_uiState = MutableStateFlow(FeatureUiState())private and exposeuiState = _uiState.asStateFlow(). - Keep
_effects = MutableSharedFlow<FeatureEffect>(extraBufferCapacity = 8)private and exposeeffects = _effects.asSharedFlow(). - Prefer
fun onIntent(intent: FeatureIntent)as the UI entry point. - Treat
FeatureIntentas an MVI/user-action type. When AndroidIntentlaunch/extras are also involved, name variables and explanations clearly enough that the two concepts are not confused. - Use
_uiState.update { it.copy(...) }for state changes. - Emit one-shot work through effects, not booleans in state.
- Keep cached current entities private in the ViewModel when existing project behavior needs mutation or incremental sync, but publish immutable render state.
- For collection-heavy
UiState, convert DAO/repositoryList/Set/Mapvalues tokotlinx.collections.immutableat the ViewModel/UI-state boundary withtoImmutableList(),toImmutableSet(), ortoImmutableMap(). Do not force repository, DAO, or domain APIs to use persistent collections unless the domain contract truly benefits. - Keep internal flow pipelines free to use normal Kotlin collections for sorting, grouping, and persistence work; the immutable collection rule is primarily for values exposed to Compose.
- Use existing
BaseViewModel.execute { ... }.onSuccess { ... }.onError { ... }when the surrounding ViewModel already uses that pattern.
Activity rules:
- Prefer
MainActivityas the owner of Compose-first navigation. Add a Navigation3 route/key andentryProviderentry there for new destinations. - Extend
BaseComposeActivityfor migrated Activity screens only when a standalone Activity remains necessary. - Keep a legacy Activity for a migrated screen only when unreworked View code still calls it through
Intent; in that case, make the Activity a thin compatibility host that translates extras/results and delegates to Compose state/effects. - Keep Activity Result launchers, file openers, clipboard, permission APIs, Android DialogFragments, and framework navigation in the Activity.
- In
Content(), collect state withcollectAsStateWithLifecycle().value. - Collect effects in
LaunchedEffect(Unit) { viewModel.effects.collectLatest { ... } }. - Pass only
stateand callbacks into the screen.
Navigation rules:
- For new Compose-first destinations, add the route to
MainActivityinstead of adding a new Activity. - Follow
MainActivity's current route style: serializableNavKeyroutes for in-process navigation and launcherIntenthelper methods only when external or legacy callers need a stable entry point. - Keep legacy extra names stable when replacing an old Activity entry point.
- When a compatibility Activity is retained, let it translate old
Intentinputs/results; do not make it the source of truth for navigation or business behavior.
Screen rules:
- Make
FeatureScreen(state, onIntent, ...)stateless for business state. - Use
rememberandrememberSaveableonly for local UI affordances such as menu expansion, scroll state, transient animation state, and text field drafts when committing through an intent. - Use
rememberSaveablefor user-driven transient targets that should survive recreation, such as a delete-confirmation item ID or URL. Store only the stable ID in saved state and resolve the current entity fromUiState. - Use
BackHandlerto route back actions throughFeatureIntent.BackPressedwhen ViewModel decisions matter. - Keep expensive derived values in
remember(key)or ViewModel state when they depend on repository data. - Prefer
LazyColumn,LazyRow, stable keys, and existing fast-scroller/list components for adapter migrations. For heterogeneous lazy lists or grids, providecontentTypein addition tokey. - In long-lived
LaunchedEffectcollectors, wrap changing callbacks,Context-dependent operations, or lambdas from parents withrememberUpdatedStatewhen the effect should not restart. - Hoist state only to the lowest common owner that reads and writes it. Move branch-only ViewModel lookup and Flow collection into the branch or a small child composable instead of collecting at a high-level route.
- Avoid UI-state feedback loops such as
LaunchedEffect(uiState.items) { viewModel.pruneSelection(...) }. Prefer deriving consistency inside the ViewModel withcombine(...), or reduce the state as part of the flow that produces the data.
Current Compose Performance Notes
- Kotlin 2.x enables strong skipping by default, but ordinary Kotlin
List,Set, andMapare still unstable to Compose. This means high-frequency screen state with normal collections can still widen recomposition work. - Use immutable collections for render state that crosses into Compose. Do not mechanically replace every temporary collection, Room query result, or internal mutable accumulator.
- If data entities come from modules where the Compose compiler does not run, consider a stable UI model wrapper when the entity is passed deeply through composables and causes measurable recomposition cost.
- Treat
SnapshotStateList/SnapshotStateMapas UI-owned mutable state, not as a default ViewModelUiStatetransport type.
Clean Architecture Boundaries
This project is mid-migration, so use Clean Architecture pragmatically. For newly created screens, default to standard modern Android architecture and avoid mixed legacy patterns unless they are needed to interoperate with View screens that have not been rewritten.
- For new screens, keep UI, presentation, domain, and data boundaries explicit: Compose renders state, ViewModel owns state and intent handling, usecases hold reusable business actions, repositories mediate data access.
- For new screens, do not place business rules in Activity/Fragment or composables.
- For new screens, do not read DAOs directly from UI/presentation unless the project already has no reasonable repository/usecase boundary and adding one would be disproportionate.
- For migrations, use existing DAOs/repositories directly if the old ViewModel already does and the migration is UI-only.
- Prefer existing
domain/usecaseclasses for reusable business actions such as cache, delete, group update, startup maintenance, reading progress, and WebDAV flows. - Add a usecase when a new screen has meaningful business rules, when logic would otherwise be duplicated across features, or when a UI migration needs to extract meaningful business rules from an Activity/adapter.
- Keep UI-only formatting and Compose layout decisions out of domain.
- Keep entity-to-display mapping near the UI or ViewModel unless it is reused business language.
Compose Components and Theme
Prefer project components before raw Material widgets:
- Scaffold/top bars:
AppScaffold,GlassTopAppBar...,DynamicTopAppBar,TopBarActionButton,TopBarNavigationButton. - Lists:
ListScaffold,TopFloatingStickyItem,VerticalFastScroller,SelectionBottomBar. - Dialog/sheet:
AppAlertDialog,AppModalBottomSheet,OptionSheet, text list input components. - Settings:
ClickableSettingItem,SwitchSettingItem,SliderSettingItem,ListSettingItem,SettingCard. - Buttons/chips:
AppIconButton,SmallIconButton,SmallTextButton,ToggleChip,AlertButton. - Covers/images:
CoilBookCover,BookshelfCover,buildCoverImageRequest; use Coil for Compose image loading. - Theme:
LegadoTheme.colorScheme,LegadoTheme.typography,ThemeResolver,ProvideThemeOverridewhere existing feature behavior needs theme override.
Avoid introducing a second visual system. If a raw Material 3 component is used, style it from LegadoTheme.
Migration Checklist
Before editing:
- Identify old XML layouts, ViewBinding fields, adapters, decorations, menu XML, dialogs, and result contracts.
- Capture behavior: empty/loading/error states, refresh, search, sort, selection, long click, swipe, menu actions, back behavior, result codes, events, and persisted preferences.
- Decide whether the task is UI-only or needs domain extraction.
- For new screens, explicitly decide the presentation/domain/data boundary before coding; do not default to legacy mixed architecture.
During editing:
- Create/update
Contract, thenViewModel, thenScreen, thenMainActivityroute or compatibility host. - For new Compose destinations, wire navigation through
MainActivityunless a legacyIntentcaller must remain supported. - When retaining an Activity for compatibility, keep it small: parse legacy extras, collect effects, bridge result codes, and avoid placing feature business logic there.
- Reuse existing string resources and add new resources for new user-facing text.
- Register the ViewModel in
di/appModule.kt. - Remove only resources made obsolete by this migration.
- Keep existing public intent extra names and result codes unless explicitly changing an API.
Verification:
- Run
.\gradlew.bat :app:compileAppDebugKotlinfor Kotlin-only changes. - Run
.\gradlew.bat :app:assembleAppDebugwhen resources, manifests, XML deletion, or generated bindings are affected. - If a screen uses Room queries or migration-sensitive repositories, run the relevant existing Android/unit tests if practical.
- For manual verification, open the migrated screen and compare core flows against the old behavior list.
Common Pitfalls
- Do not put navigation or Activity launchers inside composables.
- Do not store one-time navigation as nullable fields in
UiStateunless the existing local pattern already does; preferSharedFloweffects. - Do not keep adapter selection state split between old adapter and new Compose state.
- Do not delete XML/menu resources until all references and generated binding imports are gone.
- Do not add speculative domain layers for a single migrated screen.
- Do not use mixed architecture for new screens unless compatibility with unreworked View UI requires it.
- Do not make a migrated screen's retained Activity the primary navigation model when
MainActivitycan own the Compose destination. - Do not bypass
BaseComposeActivityunless the screen must remain inside a Fragment or legacy host. - Do not replace existing event bus or service interactions as part of a UI migration unless required for correctness.