Skip to content

Commit 4c86e07

Browse files
Refactor: Apply Jetpack Compose best practices
This commit implements several Jetpack Compose best practices to improve performance, stability, and maintainability of the Android UI. Key changes include: 1. **Composable Stability:** * Ensured lambdas passed as parameters to composables are stable by wrapping them with `remember` where necessary (e.g., in `MainActivity`, `CommandView`, `CommandListScreen`). 2. **Optimized Derived State Calculation:** * Replaced `remember` with `derivedStateOf` for `isSearchVisible` in `MainActivity` for more efficient state updates. 3. **Enhanced ViewModel and UI State Management:** * Refactored `CommandDetailUiState` to use `ImmutableList` and `ImmutableMap` for its properties. * Introduced `BasicGroupsUiState` and `SearchUiState` data classes to encapsulate screen state with immutable collections. * Modified `BasicGroupsViewModel` and `SearchViewModel` to expose their respective `UiState` objects via `StateFlow`. This centralizes state management and ensures immutability. 4. **Documentation:** * Generated a `COMPOSITION_BEST_PRACTICES.md` document outlining the applied changes and providing recommendations for future Compose development. These changes contribute to a more robust, performant, and easier-to-maintain Compose codebase, adhering to recommended patterns for state management and composable design.
1 parent e2c59c8 commit 4c86e07

13 files changed

Lines changed: 491 additions & 60 deletions

File tree

COMPOSITION_BEST_PRACTICES.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Jetpack Compose Best Practices & Recommendations
2+
3+
This document summarizes the Jetpack Compose best practices applied during recent refactoring efforts and provides recommendations for ongoing development to ensure a performant, stable, and maintainable application.
4+
5+
## 1. Summary of Improvements Implemented
6+
7+
Several key areas of the application's Compose implementation were enhanced:
8+
9+
* **Composable Stability:**
10+
* Unstable lambdas passed as parameters to composables (e.g., `onNavigate` functions, `onClick` handlers) were stabilized using `remember` with appropriate keys. This prevents unnecessary recompositions of child composables when their lambda parameters are recreated by a parent, even if the lambda's underlying logic hasn't changed.
11+
12+
* **Efficient Derived State Calculation:**
13+
* The calculation of `isSearchVisible` in `MainActivity.kt`, which depends on other state variables (`searchTextValue` and `navBackStackEntry`), was optimized by replacing `remember { ... }` with `remember { derivedStateOf { ... } }`. `derivedStateOf` ensures that the derived state is only recalculated when its specific dependencies change, leading to more efficient state updates.
14+
15+
* **Robust UI State Management:**
16+
* ViewModels (`BasicGroupsViewModel`, `SearchViewModel`, `CommandDetailViewModel`) were refactored to expose UI state via a single `StateFlow<UiState>`.
17+
* Dedicated `UiState` data classes (e.g., `BasicGroupsUiState`, `SearchUiState`, `CommandDetailUiState`) were introduced or updated to hold all relevant UI data for a given screen or component.
18+
* These `UiState` objects now consistently use immutable collections (`kotlinx.collections.immutable.ImmutableList`, `kotlinx.collections.immutable.ImmutableMap`) for properties like lists of items or maps of collapsed states. This ensures that the state itself is immutable, making it easier to reason about, track changes, and prevent accidental modification outside of the ViewModel's defined update mechanisms.
19+
20+
## 2. Key Patterns Implemented
21+
22+
The refactoring efforts centered around establishing a clear and unidirectional data flow pattern:
23+
24+
* **`ViewModel -> StateFlow<UiState> -> Composable` Pattern:**
25+
* **ViewModel:** Owns and manages the mutable state. It performs business logic, data fetching, and updates the state.
26+
* **`StateFlow<UiState>`:** The ViewModel exposes a single `StateFlow` (e.g., `val uiState = _uiState.asStateFlow()`) that emits instances of a dedicated `UiState` data class. This `StateFlow` acts as the single source of truth for the UI.
27+
* **`UiState` Data Class:** This immutable data class encapsulates all information the Composable needs to render itself. It typically contains:
28+
* Data to be displayed (e.g., `ImmutableList<Item>`).
29+
* UI-specific state (e.g., `isLoading: Boolean`, `ImmutableMap<Long, Boolean>` for expanded/collapsed items).
30+
* It is defined with `val` properties and uses immutable collections (e.g., `kotlinx.collections.immutable.ImmutableList`, `kotlinx.collections.immutable.ImmutableMap`) with default empty immutable collections (e.g., `persistentListOf()`).
31+
* **Composable:** Observes the `StateFlow` using `collectAsState()` (e.g., `val state by viewModel.uiState.collectAsState()`). When the `UiState` changes, the Composable automatically recomposes with the new state, ensuring the UI reflects the latest data. Events from the UI (e.g., button clicks, search input) are sent to the ViewModel via public methods, which then updates the state, triggering the flow again.
32+
33+
This pattern promotes:
34+
* **Testability:** ViewModels are easily unit-testable as they don't have direct Android framework dependencies for state management.
35+
* **Clarity:** A clear separation of concerns between UI presentation (Composables) and UI logic/state (ViewModels).
36+
* **Maintainability:** State changes are centralized and predictable.
37+
* **Lifecycle Awareness:** `StateFlow` and `collectAsState` handle lifecycle complexities automatically.
38+
39+
## 3. Recommendations for Ongoing Development
40+
41+
To maintain and further improve the quality of the Compose codebase, the following practices are recommended:
42+
43+
* **Prioritize Immutability:**
44+
* Continue to use `kotlinx.collections.immutable` (`ImmutableList`, `ImmutableMap`, etc.) for any collections within `UiState` objects or when passing collections as parameters to composables. This is crucial for Compose's skippability and performance.
45+
46+
* **Write Small & Skippable Composables:**
47+
* Break down complex UI into smaller, focused composables.
48+
* Ensure parameters passed to these composables are stable (primitive types, immutable classes, stable lambdas, immutable collections). This allows Compose to skip recomposition if the inputs haven't changed.
49+
50+
* **Use `key` in Lazy Lists:**
51+
* Always provide a unique and stable `key` for items in `LazyColumn`, `LazyRow`, etc. (e.g., `items(items = data, key = { it.id }) { ... }`). This helps Compose efficiently track items, manage their state across recompositions (e.g., scroll position, item-specific states), and handle data changes correctly.
52+
53+
* **Profile Regularly:**
54+
* Utilize Android Studio's Layout Inspector and its Recomposition Tracker to identify unnecessary recompositions or performance bottlenecks in your UI.
55+
56+
* **Leverage `derivedStateOf`:**
57+
* When a piece of state is calculated based on one or more other state objects, use `derivedStateOf`. This ensures the derived value is only recomputed when its direct dependencies change.
58+
59+
* **Stable Lambdas:**
60+
* When passing lambdas as parameters to composables, ensure they are stable. Either:
61+
* Wrap the lambda instance in `remember` at the call site: `onClick = remember(key1, key2) { { /* lambda body */ } }`.
62+
* Pass a reference to a top-level function or a lambda held in a variable that is itself remembered and stable.
63+
64+
* **Check Compose Compiler Metrics:**
65+
* Enable and periodically review the Compose compiler metrics. These reports provide insights into the stability of your composables and parameters, helping you identify and fix sources of unnecessary recompositions.
66+
67+
* **ViewModel Best Practices:**
68+
* Keep ViewModels lean and focused on UI logic for a specific screen or feature. Avoid turning them into massive god-objects.
69+
* Ensure ViewModels are correctly scoped to the lifecycle of the UI they support (e.g., using Koin's `viewModel()` or `koinViewModel()` for Activity/Fragment or navigation graph scoping).
70+
71+
* **Testing:**
72+
* **Unit Tests:** Continue writing comprehensive unit tests for ViewModels to cover all UI logic, state transformations, and interactions with data layers (using mocks/fakes for dependencies).
73+
* **UI Tests (Composable Tests):** For critical UI components or user flows, consider writing UI tests using `androidx.compose.ui.test`. This allows you to verify that composables render correctly and respond to user interactions as expected.
74+
75+
By adhering to these practices, the team can build a robust, performant, and enjoyable-to-maintain Jetpack Compose application.

android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/MainActivity.kt

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import androidx.compose.foundation.layout.systemBarsPadding
2020
import androidx.compose.material.MaterialTheme
2121
import androidx.compose.material.Scaffold
2222
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.derivedStateOf
24+
import androidx.compose.runtime.getValue
2325
import androidx.compose.runtime.mutableStateOf
2426
import androidx.compose.runtime.remember
2527
import androidx.compose.ui.Modifier
@@ -108,10 +110,8 @@ fun LinuxApp() {
108110
TextFieldValue(text = "", selection = TextRange(0)),
109111
)
110112
}
111-
val showSearch = remember { mutableStateOf(false) }
112-
val onNavigate: (String) -> Unit = {
113-
navController.navigate(it)
114-
}
113+
val showSearch = remember { mutableStateof(false) }
114+
val onNavigate: (String) -> Unit = remember(navController) { { route -> navController.navigate(route) } }
115115

116116
Scaffold(
117117
topBar = {
@@ -220,12 +220,11 @@ fun LinuxApp() {
220220
}
221221
}
222222

223-
val isSearchVisible = remember(
224-
searchTextValue.value.text,
225-
navBackStackEntry.value?.destination?.route,
226-
) {
227-
searchTextValue.value.text.isNotEmpty() &&
228-
navBackStackEntry.value?.destination?.route?.startsWith("command?") == false
223+
val isSearchVisible by remember(navBackStackEntry, searchTextValue) {
224+
derivedStateOf {
225+
searchTextValue.value.text.isNotEmpty() &&
226+
navBackStackEntry.value?.destination?.route?.startsWith("command?") == false
227+
}
229228
}
230229
AnimatedVisibility(
231230
visible = isSearchVisible,
@@ -234,9 +233,7 @@ fun LinuxApp() {
234233
) {
235234
SearchScreen(
236235
searchText = searchTextValue.value.text,
237-
onNavigate = {
238-
navController.navigate(it)
239-
},
236+
onNavigate = remember(navController) { { route -> navController.navigate(route) } },
240237
)
241238
}
242239
}

android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/CommandView.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,10 @@ fun CommandView(
107107
)
108108

109109
val context = LocalContext.current
110+
val shareAction = remember(context, command) { { shareCommand(context, command) } }
110111
IconButton(
111112
modifier = Modifier.align(Alignment.CenterVertically),
112-
onClick = {
113-
shareCommand(context, command)
114-
},
113+
onClick = shareAction,
115114
) {
116115
Icon(
117116
imageVector = Icons.Filled.Share,

android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basicgroups/BasicGroupsScreen.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import androidx.compose.material.ExperimentalMaterialApi
1111
import androidx.compose.material.Icon
1212
import androidx.compose.material.ListItem
1313
import androidx.compose.runtime.Composable
14+
import androidx.compose.runtime.collectAsState
15+
import androidx.compose.runtime.getValue
1416
import androidx.compose.runtime.remember
1517
import androidx.compose.ui.Modifier
1618
import androidx.compose.ui.res.painterResource
@@ -48,15 +50,17 @@ fun BasicGroupsScreen(
4850
),
4951
onNavigate: (String) -> Unit = {},
5052
) {
53+
val uiState by viewModel.uiState.collectAsState()
54+
5155
LazyColumn(Modifier.fillMaxSize()) {
5256
items(
53-
items = viewModel.basicGroups,
57+
items = uiState.basicGroups,
5458
key = { it.id },
5559
contentType = { "basic_group_item" },
5660
) { basicGroup ->
5761
BasicGroupColumn(
5862
basicGroup = basicGroup,
59-
isExpanded = !viewModel.isGroupCollapsed(basicGroup.id),
63+
isExpanded = !uiState.collapsedMap.getOrDefault(basicGroup.id, false),
6064
onToggleCollapse = { viewModel.toggleCollapse(basicGroup.id) },
6165
onNavigate = onNavigate,
6266
)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.basicgroups
2+
3+
import databases.BasicGroup
4+
import kotlinx.collections.immutable.ImmutableList
5+
import kotlinx.collections.immutable.ImmutableMap
6+
import kotlinx.collections.immutable.persistentListOf
7+
import kotlinx.collections.immutable.persistentMapOf
8+
9+
data class BasicGroupsUiState(
10+
val basicGroups: ImmutableList<BasicGroup> = persistentListOf(),
11+
val collapsedMap: ImmutableMap<Long, Boolean> = persistentMapOf()
12+
)

android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basicgroups/BasicGroupsViewModel.kt

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.basicgroups
22

3-
import androidx.compose.runtime.mutableStateMapOf
43
import androidx.lifecycle.ViewModel
54
import com.linuxcommandlibrary.shared.databaseHelper
6-
import databases.BasicGroup
7-
import kotlinx.collections.immutable.ImmutableList
85
import kotlinx.collections.immutable.toImmutableList
6+
import kotlinx.collections.immutable.toPersistentMap
7+
import kotlinx.coroutines.flow.MutableStateFlow
8+
import kotlinx.coroutines.flow.asStateFlow
9+
import kotlinx.coroutines.flow.update
910

1011
/* Copyright 2022 Simon Schubert
1112
*
@@ -24,14 +25,22 @@ import kotlinx.collections.immutable.toImmutableList
2425

2526
class BasicGroupsViewModel(categoryId: Long) : ViewModel() {
2627

27-
private val collapsedMap = mutableStateMapOf<Long, Boolean>()
28+
private val _uiState = MutableStateFlow(BasicGroupsUiState())
29+
val uiState = _uiState.asStateFlow()
2830

29-
var basicGroups: ImmutableList<BasicGroup> =
30-
databaseHelper.getBasicGroupsByQuery(categoryId).toImmutableList()
31+
init {
32+
val groups = databaseHelper.getBasicGroupsByQuery(categoryId).toImmutableList()
33+
_uiState.value = BasicGroupsUiState(basicGroups = groups)
34+
}
3135

32-
fun isGroupCollapsed(id: Long): Boolean = collapsedMap[id] == true
36+
fun isGroupCollapsed(id: Long): Boolean =
37+
_uiState.value.collapsedMap.getOrDefault(id, false)
3338

3439
fun toggleCollapse(id: Long) {
35-
collapsedMap[id] = !collapsedMap.getOrDefault(id, false)
40+
_uiState.update { currentState ->
41+
val newMap = currentState.collapsedMap.toMutableMap()
42+
newMap[id] = !currentState.collapsedMap.getOrDefault(id, false)
43+
currentState.copy(collapsedMap = newMap.toPersistentMap())
44+
}
3645
}
3746
}

android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commanddetail/CommandDetailUiState.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.commanddetail
22

33
import databases.CommandSection
4+
import kotlinx.collections.immutable.ImmutableList
5+
import kotlinx.collections.immutable.ImmutableMap
46

57
data class CommandDetailUiState(
6-
val sections: List<CommandSection>,
7-
val expandedSectionsMap: Map<Long, Boolean>,
8+
val sections: ImmutableList<CommandSection>,
9+
val expandedSectionsMap: ImmutableMap<Long, Boolean>,
810
val isBookmarked: Boolean = false,
911
val showBookmarkDialog: Boolean = false,
1012
) {

android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commandlist/CommandListScreen.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,11 @@ fun CommandListItem(
9090
pattern = searchText,
9191
)
9292
},
93-
modifier = Modifier.clickable {
94-
onNavigate("command?commandId=${command.id}&commandName=${command.name}")
95-
},
93+
modifier = Modifier.clickable(
94+
onClick = remember(command.id, command.name, onNavigate) {
95+
{ onNavigate("command?commandId=${command.id}&commandName=${command.name}") }
96+
}
97+
),
9698
)
9799
}
98100

android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/search/SearchScreen.kt

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,8 @@ fun SearchScreen(
2727
viewModel: SearchViewModel = koinViewModel(),
2828
onNavigate: (String) -> Unit,
2929
) {
30-
if (searchText.isEmpty()) {
31-
return
32-
}
33-
val commands by viewModel.filteredCommands.collectAsState()
34-
val basicGroups by viewModel.filteredBasicGroups.collectAsState()
30+
// Removed the early return for searchText.isEmpty() as ViewModel now handles it by emitting an empty state.
31+
val uiState by viewModel.uiState.collectAsState()
3532

3633
LaunchedEffect(searchText) {
3734
viewModel.search(searchText)
@@ -40,13 +37,14 @@ fun SearchScreen(
4037
val keyboardController = LocalSoftwareKeyboardController.current
4138
val lazyListState = rememberLazyListState()
4239

43-
val showEmptyMessage by remember {
40+
val showEmptyMessage by remember(uiState.filteredCommands, uiState.filteredBasicGroups) {
4441
derivedStateOf {
45-
commands.isEmpty() && basicGroups.isEmpty()
42+
uiState.filteredCommands.isEmpty() && uiState.filteredBasicGroups.isEmpty()
4643
}
4744
}
4845

49-
if (showEmptyMessage) {
46+
// Only show "404" if search text is not empty and results are empty
47+
if (searchText.isNotEmpty() && showEmptyMessage) {
5048
Box(
5149
modifier = Modifier
5250
.fillMaxSize()
@@ -62,21 +60,20 @@ fun SearchScreen(
6260
state = lazyListState,
6361
) {
6462
items(
65-
items = basicGroups,
63+
items = uiState.filteredBasicGroups,
6664
key = { "basicGroup_${it.id}" },
6765
contentType = { "search_basic_group_item" },
6866
) { basicGroup ->
6967
BasicGroupColumn(
7068
basicGroup = basicGroup,
7169
searchText = searchText,
7270
onNavigate = onNavigate,
73-
// Assuming isGroupCollapsed(id) == true means content is hidden
74-
isExpanded = !viewModel.isGroupCollapsed(basicGroup.id),
71+
isExpanded = !uiState.collapsedMap.getOrDefault(basicGroup.id, false),
7572
onToggleCollapse = { viewModel.toggleCollapse(basicGroup.id) },
7673
)
7774
}
7875
items(
79-
items = commands,
76+
items = uiState.filteredCommands,
8077
key = { "command_${it.id}" },
8178
contentType = { "search_command_item" },
8279
) { command ->
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.search
2+
3+
import databases.BasicGroup
4+
import databases.Command
5+
import kotlinx.collections.immutable.ImmutableList
6+
import kotlinx.collections.immutable.ImmutableMap
7+
import kotlinx.collections.immutable.persistentListOf
8+
import kotlinx.collections.immutable.persistentMapOf
9+
10+
data class SearchUiState(
11+
val filteredCommands: ImmutableList<Command> = persistentListOf(),
12+
val filteredBasicGroups: ImmutableList<BasicGroup> = persistentListOf(),
13+
val collapsedMap: ImmutableMap<Long, Boolean> = persistentMapOf()
14+
)

0 commit comments

Comments
 (0)