Skip to content

Commit 80745ec

Browse files
authored
Refactor: Migrate from Fragments to pure Compose Navigation (#399)
* Refactor: Migrate from Fragments to pure Compose Navigation This commit removes the Fragment-based navigation and replaces it with a pure Compose navigation solution using `navigation3`. Key changes: * Deleted `MovieDetailsFragment`, `MoviesFragment`, and their associated XML layouts. * Updated `MainActivity` to use `NavDisplay` from `navigation3` for screen management. * Introduced `MoviesNavScreen` and `MovieDetailsNavScreen` composables to handle navigation logic and state. * Replaced AndroidX Navigation dependencies with `navigation3`. * Removed `collectAsStateWithLifecycle` in favor of passing state directly to screen composables. * Refactor: Remove `ModuleOwner` interface The `ModuleOwner` interface, which was used to decouple fragments from the `Application` class, has been removed. The `App` class now directly exposes the `appModule`. The `appModule()` extension function has been updated to cast the `applicationContext` to `App` instead of `ModuleOwner`. * Refactor: Extract MoviesApplication composable This commit extracts the main navigation logic from `MainActivity` into a new `MoviesApplication` composable. Key changes: - A new `MoviesApplication.kt` file is created to house the `NavDisplay` and its configuration. - `MainActivity` is simplified to just call `MoviesApplication`. - The `observeIn` extension function is removed in favor of `flowWithLifecycle`. - Modules (`TheMovieDbModule`, `MovieDetailsModule`) now own the `actions` `Channel`, removing redundant `remember` calls in the UI. - Dependencies like `AppModule` and `BackStack` are now passed explicitly to screen composables (`MoviesNavScreen`, `MovieDetailsNavScreen`). - Introduced a `BackStack` typealias for `SnapshotStateList<Screen>`. * Migrate to NavKey The `Screen` sealed class now implements the `NavKey` interface from the navigation library and is marked as `@Serializable`. This change removes the manual `remember { mutableStateListOf<Screen>(PopularMovies) }` in favor of a new `rememberNavBackStack` composable function, which utilizes `rememberSerializable` with a `NavBackStackSerializer` for better state restoration. * Test for process death in e2e tests * Revert "Test for process death in e2e tests" This reverts commit c76ceb8. * Update README.md
1 parent 7a2aa68 commit 80745ec

File tree

23 files changed

+245
-271
lines changed

23 files changed

+245
-271
lines changed

README.md

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Check out the [TMDB developer documentation][tmdb] for more info.
2424

2525
## App Architecture
2626

27-
The app uses a reactive architecture built atop Flow. The app follows a layered architecture with data, domain and presentation layer. The package structure is an attempt to package by feature, however both screens share the data and domain layers. The app uses a single activity + fragments and the Jetpack Navigation Component.
27+
The app uses a reactive architecture built atop Flow. The app follows a layered architecture with data, domain and presentation layer. The package structure is an attempt to package by feature, however both screens share the data and domain layers. The app uses a single activity approach. Each screen is represented by a `@Composable` function and navigation is handled by Jatpack Navigation 3.
2828

2929
### Data Layer
3030

@@ -36,23 +36,23 @@ The main class here is `Movie`. It has a static `create` function that converts
3636

3737
### Presentation Layer
3838

39-
Each screen is represented by a `Fragment` which plays the role of glue code. It's responsible for DI, forwarding actions from the view (Compose) to the `ViewModel` and forwarding state from the `ViewModel` to the view. It also handles any side effect emitted by the `ViewModel` e.g. navigation events.
39+
Each screen is represented by a `@Composable` function which plays the role of glue code. It's responsible for DI, forwarding actions from the view (Compose) to the `ViewModel` and forwarding state from the `ViewModel` to the view. It also handles any side effect emitted by the `ViewModel` e.g. navigation events.
4040

41-
Each fragment has a Jetpack ViewModel that:
41+
Each screen has a Jetpack ViewModel that:
4242

4343
- exposes a single `Flow<ViewState>` backed by a `MutableStateFlow` (caching the last item) describing the state of the view at a given time
4444
- exposes a single `Flow<Effect>` backed by a `Channel` for side effects like navigation, Snackbar or similar. Event that happen when no-one is subscribed are cached. All events are delivered when subscribed
4545
- exposes a `CoroutineScope` with operations tied to it's lifecycle
4646

47-
The Fragment observes the `Flow<ViewState>` between `onStart` and `onStop` and updates the `Sceen`. The Fragment observes `Flow<Effect>` between `onStart` and `onStop` making sure fragment transactions are executed only when the view is active.
47+
The screen observes the `Flow<ViewState>` between `onStart` and `onStop` and updates the `Screen`. The screen observes `Flow<Effect>` between `onStart` and `onStop` making sure navigation happens only when the view is active.
4848

49-
The Fragment observes the `Flow<Action>` from `onStart` until `onStop`. However any network calls that result from those interactions are de-coupled from this lifecycle. The operations triggered by the view actions are coupled to the `ViewModel` lifecycle and are only disposed in the `ViewMode.onDispose()` function. Check the [fork() function][fork] for more details.
49+
The screen observes the `Flow<Action>` from `onStart` until `onStop`. However any network calls that result from those interactions are de-coupled from this lifecycle. The operations triggered by the view actions are coupled to the `ViewModel` lifecycle and are only disposed in the `ViewModel.onDispose()` function. Check the [fork() function][fork] for more details.
5050

5151
The logic is written as extension functions on top of a module (collection of dependencies).
5252

5353
### Dependency Injection
5454

55-
The sample uses the DI approach from [Simple Kotlin DI][simple-di]. The dependencies with Singleton scope live in the app as `AppModule`. Each fragment uses the `AppModule` dependencies and can add it's own (e.g. the `ViewModel`) that are un-scoped or use Jetpack for scoping (e.g. `ViewModel`).
55+
The sample uses the DI approach from [Simple Kotlin DI][simple-di]. The dependencies with Singleton scope live in the app as `AppModule`. Each screen uses the `AppModule` dependencies and can add it's own (e.g. the `ViewModel`) that are un-scoped or use Jetpack for scoping (e.g. `ViewModel`).
5656

5757
The logic is written as extension functions on top of a module (collection of dependencies).
5858

@@ -62,7 +62,7 @@ This sample uses JUnit as a testing library and [kotest assertions][kotest] for
6262

6363
The view is tested in isolation using Paparazzi, by setting a ViewState and verifying the current image matches the golden images from the repo.
6464

65-
There is also one E2E (black box) test using Maestro that tests both fragments + activity together.
65+
There is also one E2E (black box) test using Maestro that tests everything glued together. The test uses a test server using Wiremock.
6666

6767
## Acknowledgments
6868

@@ -74,6 +74,5 @@ Approaches is this sample are heavily inspired by open source code I have read.
7474
[tmdb]: https://developer.themoviedb.org/docs/getting-started
7575
[fun-stream]: https://github.com/47degrees/FunctionalStreamsSpringSample
7676
[simple-di]: https://gist.github.com/raulraja/97e2d5bf60e9d96680cf1fddcc90ee67
77-
[view-binding]: https://developer.android.com/topic/libraries/view-binding
78-
[fork]: app/src/main/java/io/github/lordraydenmk/themoviedbapp/common/observable.kt
77+
[fork]: app/src/main/java/io/github/lordraydenmk/themoviedbapp/common/flow.kt
7978
[kotest]: https://github.com/kotest/kotest

app/build.gradle

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,11 @@ dependencies {
8585
def lifecycle = "2.10.0"
8686
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle"
8787
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle"
88-
implementation 'androidx.fragment:fragment-ktx:1.8.9'
89-
def nav_version = "2.9.6"
90-
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
91-
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
88+
89+
def nav3Core = "1.0.0"
90+
implementation "androidx.navigation3:navigation3-runtime:$nav3Core"
91+
implementation "androidx.navigation3:navigation3-ui:$nav3Core"
92+
implementation "androidx.lifecycle:lifecycle-viewmodel-navigation3:2.10.0"
9293

9394
implementation 'com.jakewharton.timber:timber:5.0.1'
9495

app/src/main/java/io/github/lordraydenmk/themoviedbapp/App.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import coil3.SingletonImageLoader
88
import coil3.request.crossfade
99
import timber.log.Timber
1010

11-
class App : Application(), ModuleOwner, SingletonImageLoader.Factory {
11+
class App : Application(), SingletonImageLoader.Factory {
1212

13-
override val appModule by lazy { AppModule.create() }
13+
val appModule by lazy { AppModule.create() }
1414

1515
override fun onCreate() {
1616
super.onCreate()
@@ -24,4 +24,4 @@ class App : Application(), ModuleOwner, SingletonImageLoader.Factory {
2424
.build()
2525
}
2626

27-
fun Context.appModule(): AppModule = (applicationContext as ModuleOwner).appModule
27+
fun Context.appModule(): AppModule = (applicationContext as App).appModule
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
package io.github.lordraydenmk.themoviedbapp
22

33
import android.os.Bundle
4+
import androidx.activity.compose.setContent
45
import androidx.activity.enableEdgeToEdge
56
import androidx.appcompat.app.AppCompatActivity
67

7-
class MainActivity : AppCompatActivity(R.layout.activity_main) {
8+
class MainActivity : AppCompatActivity() {
89

910
override fun onCreate(savedInstanceState: Bundle?) {
1011
enableEdgeToEdge()
1112
super.onCreate(savedInstanceState)
13+
setContent {
14+
MoviesApplication(appModule())
15+
}
1216
}
1317
}

app/src/main/java/io/github/lordraydenmk/themoviedbapp/ModuleOwner.kt

Lines changed: 0 additions & 11 deletions
This file was deleted.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package io.github.lordraydenmk.themoviedbapp
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.saveable.rememberSerializable
5+
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
6+
import androidx.navigation3.runtime.NavBackStack
7+
import androidx.navigation3.runtime.NavEntry
8+
import androidx.navigation3.runtime.NavKey
9+
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
10+
import androidx.navigation3.runtime.serialization.NavBackStackSerializer
11+
import androidx.navigation3.runtime.serialization.NavKeySerializer
12+
import androidx.navigation3.ui.NavDisplay
13+
import io.github.lordraydenmk.themoviedbapp.movies.Screen
14+
import io.github.lordraydenmk.themoviedbapp.movies.Screen.MovieDetails
15+
import io.github.lordraydenmk.themoviedbapp.movies.Screen.PopularMovies
16+
import io.github.lordraydenmk.themoviedbapp.movies.moviedetails.MovieDetailsNavScreen
17+
import io.github.lordraydenmk.themoviedbapp.movies.popularmovies.MoviesNavScreen
18+
19+
typealias BackStack = NavBackStack<Screen>
20+
21+
@Composable
22+
fun <T : NavKey> rememberNavBackStack(vararg elements: NavKey): NavBackStack<T> {
23+
return rememberSerializable(
24+
serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer())
25+
) {
26+
@Suppress("UNCHECKED_CAST")
27+
NavBackStack(*elements) as NavBackStack<T>
28+
}
29+
}
30+
31+
@Composable
32+
fun MoviesApplication(appModule: AppModule) {
33+
val backStack = rememberNavBackStack<Screen>(PopularMovies)
34+
NavDisplay(
35+
entryDecorators = listOf(
36+
rememberSaveableStateHolderNavEntryDecorator(),
37+
rememberViewModelStoreNavEntryDecorator()
38+
),
39+
backStack = backStack,
40+
onBack = { backStack.removeLastOrNull() },
41+
entryProvider = { key ->
42+
when (key) {
43+
PopularMovies -> NavEntry(key) {
44+
MoviesNavScreen(appModule, backStack)
45+
}
46+
47+
is MovieDetails -> NavEntry(key) {
48+
MovieDetailsNavScreen(appModule, key.movieId, backStack)
49+
}
50+
}
51+
}
52+
)
53+
}

app/src/main/java/io/github/lordraydenmk/themoviedbapp/common/flow.kt

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
package io.github.lordraydenmk.themoviedbapp.common
22

3-
import androidx.lifecycle.Lifecycle
4-
import androidx.lifecycle.LifecycleOwner
5-
import androidx.lifecycle.lifecycleScope
6-
import androidx.lifecycle.repeatOnLifecycle
73
import kotlinx.coroutines.CoroutineDispatcher
84
import kotlinx.coroutines.CoroutineScope
95
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -21,7 +17,6 @@ import kotlinx.coroutines.flow.flowOf
2117
import kotlinx.coroutines.flow.flowOn
2218
import kotlinx.coroutines.flow.launchIn
2319
import kotlinx.coroutines.flow.onEach
24-
import kotlinx.coroutines.launch
2520
import kotlin.coroutines.CoroutineContext
2621
import kotlin.coroutines.EmptyCoroutineContext
2722

@@ -65,13 +60,3 @@ suspend inline fun <A, B, C> parZip(
6560
@Suppress("UNCHECKED_CAST")
6661
f(a as A, b as B)
6762
}
68-
69-
fun <A> Flow<A>.observeIn(
70-
lifecycleOwner: LifecycleOwner,
71-
state: Lifecycle.State = Lifecycle.State.STARTED
72-
): Job =
73-
lifecycleOwner.lifecycleScope.launch {
74-
lifecycleOwner.lifecycle.repeatOnLifecycle(state) {
75-
collect()
76-
}
77-
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package io.github.lordraydenmk.themoviedbapp.movies
2+
3+
import androidx.navigation3.runtime.NavKey
4+
import io.github.lordraydenmk.themoviedbapp.movies.domain.MovieId
5+
import kotlinx.serialization.Serializable
6+
7+
@Serializable
8+
sealed class Screen : NavKey {
9+
@Serializable
10+
data object PopularMovies : Screen()
11+
12+
@Serializable
13+
data class MovieDetails(val movieId: MovieId) : Screen()
14+
}

app/src/main/java/io/github/lordraydenmk/themoviedbapp/movies/moviedetails/MovieDetailsFragment.kt

Lines changed: 0 additions & 82 deletions
This file was deleted.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package io.github.lordraydenmk.themoviedbapp.movies.moviedetails
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.LaunchedEffect
5+
import androidx.compose.runtime.collectAsState
6+
import androidx.compose.runtime.getValue
7+
import androidx.compose.runtime.remember
8+
import androidx.lifecycle.Lifecycle
9+
import androidx.lifecycle.compose.LocalLifecycleOwner
10+
import androidx.lifecycle.flowWithLifecycle
11+
import androidx.lifecycle.repeatOnLifecycle
12+
import androidx.lifecycle.viewmodel.compose.viewModel
13+
import io.github.lordraydenmk.themoviedbapp.AppModule
14+
import io.github.lordraydenmk.themoviedbapp.BackStack
15+
import io.github.lordraydenmk.themoviedbapp.common.presentation.ViewModelAlgebra
16+
import io.github.lordraydenmk.themoviedbapp.movies.domain.MovieId
17+
import kotlinx.coroutines.channels.Channel
18+
import kotlinx.coroutines.flow.collect
19+
import kotlinx.coroutines.flow.map
20+
import kotlinx.coroutines.flow.receiveAsFlow
21+
22+
@Composable
23+
fun MovieDetailsNavScreen(
24+
appModule: AppModule,
25+
movieId: MovieId,
26+
backStack: BackStack,
27+
viewModel: MovieDetailsViewModel = viewModel()
28+
) {
29+
val module = remember {
30+
object : MovieDetailsModule,
31+
AppModule by appModule,
32+
ViewModelAlgebra<MovieDetailsViewState, MovieDetailsEffect> by viewModel {
33+
override val actions: Channel<MovieDetailsAction> = Channel(Channel.UNLIMITED)
34+
}
35+
}
36+
37+
38+
with(module) {
39+
val lifecycleOwner = LocalLifecycleOwner.current
40+
LaunchedEffect(lifecycleOwner) {
41+
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
42+
program(movieId, actions.receiveAsFlow())
43+
}
44+
}
45+
LaunchedEffect(lifecycleOwner) {
46+
viewModel.effects
47+
.flowWithLifecycle(lifecycleOwner.lifecycle)
48+
.map { effect ->
49+
when (effect) {
50+
is NavigateUp -> backStack.removeLastOrNull()
51+
}
52+
}.collect()
53+
}
54+
}
55+
val state by viewModel.viewState.collectAsState(Loading)
56+
MovieDetailsScreen(state, movieId, module.actions)
57+
}

0 commit comments

Comments
 (0)