Lesson We Skipped in Jetpack Compose ☄

Narayan Panthi
Kt. Academy
Published in
9 min readMar 11, 2024

--

Concepts of Composables and its helping hands — Episode III

We have used Composable in the last two episodes of the Jetpack Compose Series. Now, let’s see what happens under the hood of Composables.

When we annotate @Composable we tell the Compose compiler that this function is meant to convert data into UI elements. This rendering process happens in three key phases:

  1. Composition: Determines what UI elements to display. Compose execute composable functions to create a blueprint of UI.
  2. Layout: Determines where to position UI elements. This phase includes measurement and placement steps, where elements are sized and positioned within a 2D coordinate system.
  3. Drawing: Renders the UI elements onto a Canvas/ Device Screen.
Source: Official Doc

Composition

Compose constructs a Composition by executing a Composable function during runtime. The Composition forms a tree structure.

Composable emits Composition

Recomposition

It is the process of re-executing composable functions when their inputs vary, so they can emit updated information and update the tree.

Source: Official Doc

To optimize this recomposition process, Compose runtime marks each function and type with one of several tags.

1. Stable

It indicates a type whose properties can change after construction. If and when those properties change during runtime, compose becomes aware of those changes.

@Stable
data class HomeState(
val productList: List<Product>,
val isLoading: Boolean = false,
)

Compose optimizes performance by skipping the recomposition process for these stable parameters. For example; Int, String, Float, ImmutableList types are stables parameters.

2. @Immutable

This type holds immutable data, meaning the data never changes. Compose can treat this as stable data.

@Immutable
data class HomeState(
val productList: List<Product>,
val isLoading: Boolean = false,
)

By default, DataClasses are marked as Stable, and Lists that are not marked as stable are by default unstable.

If your composable isn’t being skipped, it might be due to variables like var parameters causing instability.

What is a State?

It is a container that holds our data. Whenever there are any changes to this data, it automatically updates all the UI elements that are connected to it.

// Suppose, we need to change the value of Text while typing in TextField.
@Composable
fun EditProfileScreen() {
var fullName by remember { mutableStateOf("") }
Column(
Modifier
.background(Color.White)
.padding(30.dp)
.fillMaxSize(),
verticalArrangement = Arrangement.SpaceEvenly,
horizontalAlignment = Alignment.CenterHorizontally
) {

OutlinedTextField(
value = fullName,
onValueChange = { fullName = it },
label = { Text("Full Name") }
)
Text(text = fullName)
}
}

Components in Jetpack Compose

Stateful — Smart 🤓

Stateful composables manage their internal state, making them capable of handling complex logic independently. It can also reduce reusability and increase testing complexity.

@Composable
fun EditProfileScreen() {
var fullName by remember { mutableStateOf("") }

// dumb component
EditProfileContent(fullName, onFullNameChange = { fullName = it })
}

Stateless — Dumb 🤡

Stateless composables don’t manage their internal state. Instead, they receive their state from external parameters and emit events to inform the parent composable of any changes.

This approach enhances reusability and maintains a clear separation of concerns, as these composables primarily handle presentation and interaction logic.

When creating reusable composables, it’s common to offer both stateful and stateless versions.

An easy way to achieve stateless is by using state hoisting.

State Hoisting

It is the way of making a component stateless by relocating its state to a higher level in the component hierarchy.

Let’s remake the above EditProfileScreen.kt

@Composable
fun EditProfileScreen() {
var fullName by remember { mutableStateOf("") }

// state hoisting
EditProfileContent(fullName, onFullNameChange = { fullName = it })
}

@Composable
fun EditProfileContent(fullName: String, onFullNameChange: (String) -> Unit) {

Column(
Modifier
.background(Color.White)
.padding(30.dp)
.fillMaxSize(),
verticalArrangement = Arrangement.SpaceEvenly,
horizontalAlignment = Alignment.CenterHorizontally
) {

OutlinedTextField(
value = fullName,
onValueChange = onFullNameChange,
label = { Text("Full Name") }
)
Spacer(modifier = Modifier.padding(20.dp))
Text(text = fullName)
}
}

The general pattern for state hoisting is to replace the state variable with two parameters:

  • value: T: the current value to display
  • onValueChange: (T) -> Unit: an event that requests the value to change, where T is the proposed new value
State & Event of EditProfileScreen

This pattern where the state goes down, and events go up is called a unidirectional data flow.

A hoisted state offers several key advantages:

- SingleSourceOfTruth : Centralizing the state reduces errors, providing a single, reliable source.
- Encapsulated: State changes are restricted to specific composables, ensuring internal management.
- Shareable: The hoisted state can be accessed by multiple composables, facilitating seamless data sharing.
- Interceptable: Callers can intercept or modify events before they affect the state, allowing for customized handling.
- Decoupled: Stateless composables can reside anywhere, promoting separation of concerns.

Just Keep In Mind

  • Composable shouldn’t break the unidirectional data flow.
  • Composable functions can be executed in any order or in parallel.
  • Recomposition is optimistic and optimizes by skipping unnecessary functions.
  • Composables can run frequently, even in every animation frame.

Okay Then… ⚠️

What if we need a service that needs to be initialized or created on the composables screen? Will it recompose multiple times?

Yes, there are scenarios where we must interact with the external world or perform certain actions unrelated to UI rendering.

“We have a problem, We have introduced another problem to fix it” — Jetpack Compose Engineers👺

To prevent these unexpected issues in composable functions when recomposition occurs, we use effect-handlers

Effect-Handlers 🔧

Effect- handlers in Jetpack Compose are mechanisms used to manage side effects in the UI. They facilitate the execution of tasks that are not directly related to rendering the user interface, such as network requests, database operations, or animations.

Effect handlers enhance performance, maintainability, and debugging by separating non-UI tasks from UI rendering logic.

There are two types of effect handlers:

  1. Suspended Effect Handler: for suspending functions.
    — LaunchEffect
    — rememberCoroutineScope
  2. Non-suspended Effect Handler: for non-suspending functions
    — DisposableEffect
    — SideEffect

LaunchEffect

It is the commonly used side effect in Jetpack Compose. It triggers when a composition first starts and can execute suspend functions.

@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
// This key can be changed as necessary like using counter
LaunchedEffect(Unit) {
// Here Unit will Trigger Only 1 Time
viewModel.sentMessageSeenStatus()
}
//....
}

It accepts two parameters: a key and a coroutineScope block.

  • We can use any state in the key parameter.
  • Inside the coroutineScope block, we can use suspended or non-suspended functions.
  • LaunchEffect runs only once in the composable function.
  • To run the LaunchEffect block again, you can provide a state that changes over time in the key parameter.

rememberCoroutineScope()

rememberCoroutineScope() creates a coroutine scope tied to the current composable, ensuring proper lifecycle management for launched coroutines.

@Composable
fun ResetPasswordScreen(viewModel: AuthViewModel = hiltViewModel()) {

// Remember the coroutine scope tied to this composable's lifecycle
val coroutineScope = rememberCoroutineScope()

LaunchedEffect(Unit) {
coroutineScope.launch {
viewModel.startReSendTokenTimer()
}
}

//....
}

This ensures that any coroutines launched within this scope are automatically canceled when the composable is removed from the composition.

Disposable-effect

DisposableEffect() operates similarly to LaunchedEffect(). Both trigger their side effects upon entering the composition and rerun them when the specified keys change.

The difference is that DisposableEffect allows us to specify cleanup logic that will be executed when the composable is removed from the composition.

@Composable
fun TrackingScreen() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var data by remember { mutableStateOf<SensorMetaData?>(null) }

DisposableEffect(Unit) {
val sensorMetaManager = SensorMetaManager(context)
sensorMetaManager.init()

val job = scope.launch {
sensorMetaManager.data
.receiveAsFlow()
.onEach {
data = it
}
.collect()
}

onDispose {
sensorMetaManager.unRegisterSensors()
job.cancel()
}
}
}

This is useful for scenarios where you need to perform cleanup or cancel ongoing operations when the composable is no longer needed.

Side-effects

It is used to perform side effects that don’t depend on the composable’s inputs. It’s useful for executing actions like logging, analytics, or interactions with external systems.

The SideEffect function allows you to perform side effects during composition. It is executed each time the composable is recomposed. For example:

@Composable
fun LoginScreen() {

SideEffect {
Log.d("LoginScreen", "Starting login screen...")
}

}

Note: Avoid executing non-composable code within a composable function; Always use side effects for such tasks.

State Management with Kotlin Flow

Flows in Kotlin allow for the sequential retrieval of multiple values from coroutine-based asynchronous tasks. They are particularly useful for scenarios like receiving a stream of data over a network connection.

val devicesFlow: Flow<String> = flowOf("Android", "IOS", "Web")
Figure: devices String Flow in components
  • Producers: Responsible for providing the data that makes up the flow.
  • Intermediaries: Operators are applied to manipulate the flow stream between the producer and consumer.
  • Consumers: Collect and process the values emitted by the producer.

Types of Flows

  • Cold Flows: Producer code executes only when a consumer begins collecting values. It’s like a Water Tap that only releases water when someone turns it on.
  • Hot Flows: Immediately emit values upon creation, regardless of consumer status.

Ways of Creating Flow

  1. Using the flow builder function

You can create a flow using the flow builder function. Inside the flow block, you can emit values using the emit function.

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

fun simpleFlow(): Flow<Int> = flow {
for (i in 1..5) {
emit(i)
}
}

2. Converting collections to Flow

You can convert collections (such as lists or arrays) to a Flow using the asFlow() extension function.

import kotlinx.coroutines.flow.asFlow

fun listToFlow(): Flow<Int> {
val list = listOf(1, 2, 3, 4, 5)
return list.asFlow()
}

3. Using flowOf function

You can use the flowOf function to create a Flow with predefined values.

import kotlinx.coroutines.flow.flowOf

fun predefinedFlow(): Flow<Int> = flowOf(1, 2, 3, 4, 5)

StateFlow and SharedFlow:

StateFlow and SharedFlow are both implementations of hot flows that can be used for managing state and sharing data between different parts of the application.

StateFlow represents a flow of state values that emits the latest value immediately and then only emits subsequent values upon change.

SharedFlow allows multiple collectors and can buffer and replay values for new subscribers.

Converting a flow from Cold to Hot

A cold flow can be made hot by calling the shareIn() function on the flow. This call requires a coroutine scope in which to execute the flow, a replay value, and a start policy setting indicating the conditions under which the flow is to start and stop. The available start policy options are as follows:

  • SharingStarted.WhileSubscribed() — The flow is kept alive as long as it has active subscribers.
  • SharingStarted.Eagerly() — The flow begins immediately and remains active even in the absence of active subscribers.
  • SharingStarted.Lazily() — The flow begins only after the first consumer subscribes and remains active even in the absence of active subscribers.
fun getColdDevicesFlow(): Flow<String> = flow {
val devices = listOf("Android", "iOS", "Web", "Smart TV")
for (device in devices) {
emit(device)
delay(2000) // Emit every 2 second
}
}.shareIn(viewModelScope, SharingStarted
.WhileSubscribed(replayExpirationMillis = 0))

MutableStateFlow

It is a type of state flow in Kotlin coroutines, often used in Jetpack Compose for managing mutable states. Unlike regular state flows, MutableStateFlow allows you to change its value programmatically using its value property. This makes it ideal for representing mutable states in your app. It emits values sequentially over time and observers can track changes to its value.

class ProfileViewModel : ViewModel() {
private val _deviceFlow = MutableStateFlow("Android")
val deviceFlow: StateFlow<String> = _deviceFlow.asStateFlow()

fun updateText(newText: String) {
_deviceFlow.value = newText
}
}

@Composable
fun ProfileScreen(viewModel: ProfileViewModel = viewModel()) {
val deviceFlow = viewModel.deviceFlow.collectAsState()
// Use deviceFlow.value in your UI
}

Flows (Unlike LiveData) don’t naturally understand Android’s lifecycle because they’re from Kotlin, not Android.

However, we can handle this by collecting flow values responsibly within lifecycle scopes using coroutines or other methods. It helps applications by facilitating asynchronous programming, state management, and composition of UI components reactively and efficiently.

We will look into the usages of Flow in the upcoming series. From requesting API to storing in the room & rendering in LazyColumn.

Hope you will see it in FYP! Until then, Keep Learning, Keep Composing…

Jetpack Compose Tutorial Series

4 stories
Kt. Academy Open Workshops

--

--