Topic: 11 Understanding Coroutines in Android

This is our eleventh topic from learn android from basic to advance series

Topic: 11 Understanding Coroutines in Android

Hello devs, In today's blog, let's explore coroutines. As you're aware, our task requires running asynchronously on either the main thread or in the background. Coroutines provide the means to handle this seamlessly.

Coroutines

Coroutines is a framework that provides a way to write asynchronous, non-blocking code that is more readable and maintainable than traditional callback-based or thread-based approaches. Coroutines are widely used for handling background tasks, network requests, and other asynchronous operations.

In asynchronous programs, multiple tasks execute in parallel on separate threads without waiting for the other tasks to complete. Threads are an expensive resource and too many threads lead to a performance overhead due to high memory consumption and CPU usage.

Coroutines are an alternate way of writing asynchronous programs but are much more lightweight compared to threads. They are computations that run on top of threads.

Before going through the creation and usage let’s get familiar with some important concepts related to coroutines

  1. suspend

  2. Job

  3. Dispatchers

  4. CoroutineScope

  5. CoroutineContext

Understanding Suspend Functions in Coroutines

A suspend function is nothing but a function that can be paused and resumed at some later point in time. We can execute long-running operations and wait for them to complete without blocking. The syntax of a suspending function is the same as the regular function but has an addition of the suspend keyword. The suspend function should be called only from the coroutine or another suspend function

Let's say you're building a weather app and you need to fetch weather data from a remote server. You don't want to block the main thread while waiting for the network request to complete, as it would freeze the UI and lead to a poor user experience.

import kotlinx.coroutines.*

suspend fun fetchWeatherData(city: String): Weather {
    return withContext(Dispatchers.IO) {
        // Simulate network delay
        delay(2000)

        // Perform network request (e.g., using Retrofit)
        // val response = weatherApi.getWeather(city)

        // Parse response and return weather data
        // response.body()
        Weather("Sunny", 25) // Sample weather data
    }
}

In this example:

  • The fetchWeatherData function is marked with the suspend modifier, indicating that it can be paused and resumed asynchronously.

  • Inside the function, we use withContext(Dispatchers.IO) to switch to the IO dispatcher, which is optimized for performing I/O-bound operations like network requests.

  • We simulate a network delay using delay(2000) to mimic the time it takes to fetch data from the server.

  • Finally, we return the weather data (in this case, a Weather object).

Advantages of Suspend Functions

  1. Concise Asynchronous Code: Suspend functions allow you to write asynchronous code in a sequential, linear manner, making it easier to understand and maintain.

  2. UI Responsiveness: By offloading long-running tasks to background threads, suspend functions help keep the UI responsive and smooth.

  3. Structured Concurrency: Suspend functions integrate seamlessly with coroutines, providing a structured approach to concurrency management and resource cleanup.

  4. Error Handling: Suspend functions support exception handling similar to regular functions, making it straightforward to handle errors in asynchronous code.

Understanding Jobs in Coroutines

Job represents a unit of work that can be launched asynchronously. When you launch a coroutine, it returns a reference to its Job, which you can use to monitor and control the execution of that coroutine. Job is a cancellable thing with a life cycle that ends on its completion or cancellation or failure.

Let's say you're developing a chat application where users can send messages to each other. When a user sends a message, you want to perform some background processing, such as sending the message to the server. You use coroutines to handle this asynchronous task.

val job = CoroutineScope(Dispatchers.IO).launch {
    // Perform background processing (e.g., send message to server)
    sendMessageToServer(message)
}

// Later, you can cancel the job if needed
job.cancel()

In this example:

  • We use CoroutineScope(Dispatchers.IO).launch to launch a coroutine on the IO dispatcher, which is suitable for performing I/O-bound operations like network requests.

  • Inside the coroutine, we perform background processing, such as sending a message to the server.

  • The launch function returns a reference to the Job associated with the coroutine.

  • We store this Job in a variable (job) so that we can monitor and control the execution of the coroutine.

  • We can later call job.cancel() to cancel the coroutine if needed.

Advantages of Using Jobs

  1. Cancellation: Jobs provide a convenient way to cancel coroutines, helping to manage resources and prevent memory leaks. This is particularly useful in scenarios where you need to stop ongoing tasks, such as when a user navigates away from a screen.

  2. Composition: You can compose complex asynchronous operations by combining multiple coroutines and organizing them using structured concurrency. Jobs help you manage the lifecycle of these composite operations.

  3. Error Handling: Jobs allow you to handle errors and exceptions that occur within coroutines. You can attach error handlers to Jobs to gracefully handle failures and prevent crashes in your application.

  4. Monitoring: Jobs provide status information about the execution of coroutines, such as whether they are active, completed, or cancelled. This information can be useful for logging, debugging, and analytics purposes.

Understanding Dispatchers in Coroutines

Dispatchers in Kotlin coroutines are responsible for determining which thread or threads a coroutine will run on. They provide a way to control the execution context of coroutines, allowing you to specify whether a coroutine should run on the main thread, a background thread, or a custom thread pool. Basically, dispatchers specify on which thread the operation should be performed.

Let's say you're developing an Android application that fetches data from a remote server and updates the UI with the fetched data. You want to perform the network request on a background thread to avoid blocking the main thread and update the UI on the main thread.

CoroutineScope(Dispatchers.Main).launch {
    // Perform network request on background thread
    val data = withContext(Dispatchers.IO) {
        fetchDataFromServer()
    }

    // Update UI on main thread
    updateUiWithData(data)
}

In this example:

  • We use CoroutineScope(Dispatchers.Main).launch to launch a coroutine on the main thread.

  • Inside the coroutine, we use withContext(Dispatchers.IO) to switch to the IO dispatcher, which is optimized for performing I/O-bound operations like network requests.

  • We call fetchDataFromServer() to fetch data from the server asynchronously.

  • Once the data is fetched, we switch back to the main dispatcher using withContext(Dispatchers.Main) and call updateUiWithData(data) to update the UI with the fetched data.

Common Dispatchers in Android

  1. Dispatchers.Main: This dispatcher is used for performing UI-related tasks, such as updating UI elements. All coroutines launched with this dispatcher will execute on the main thread.

  2. Dispatchers.IO: This dispatcher is optimized for performing I/O-bound tasks, such as network requests, disk I/O operations, or database queries. Coroutines launched with this dispatcher will execute on a shared pool of background threads.

  3. Dispatchers.Default: This dispatcher is suitable for performing CPU-bound tasks that don't involve blocking the main thread or performing I/O operations. Coroutines launched with this dispatcher will execute on a shared pool of background threads.

Advantages of Using Dispatchers

  1. UI Responsiveness: By using Dispatchers. Main for UI-related tasks and Dispatchers.IO for background tasks, you can ensure that long-running operations don't block the main thread, keeping the UI responsive and smooth.

  2. Concurrency Control: Dispatchers allow you to control the concurrency behavior of coroutines, making it easier to write concurrent and asynchronous code without worrying about thread management and synchronization.

  3. Thread Safety: Dispatchers provide a safe and efficient way to switch between different execution contexts, ensuring that coroutines are executed in a thread-safe manner.

UnderstandingwithContext in Coroutines

withContext is a coroutine builder in Kotlin that allows you to switch the execution context of a coroutine to a different dispatcher temporarily. It enables you to perform a specific block of code in a different thread or dispatching context while ensuring that the rest of the coroutine continues to execute in its original context.

Let's say you're developing an Android application that fetches data from a remote server using Retrofit. Since network operations can be time-consuming, you want to perform them on a background thread to avoid blocking the main thread.

CoroutineScope(Dispatchers.Main).launch {
    // Perform network request on background thread
    val data = withContext(Dispatchers.IO) {
        fetchDataFromServer()
    }

    // Update UI with fetched data
    updateUiWithData(data)
}

In this example:

  • We launch a coroutine on the main thread using CoroutineScope(Dispatchers.Main).launch.

  • Inside the coroutine, we use withContext(Dispatchers.IO) to temporarily switch the execution context to the IO dispatcher, which is optimized for I/O-bound operations like network requests.

  • Within the block of code passed to withContext, we call fetchDataFromServer() to perform the network request asynchronously.

  • Once the data is fetched, the execution context is automatically switched back to the main dispatcher, and updateUiWithData(data) is called to update the UI with the fetched data.

Advantages of Using withContext

  1. Convenience: withContext provides a convenient way to switch between different execution contexts within a coroutine without explicitly managing threads or dispatchers.

  2. Clarity: By using withContext, you can clearly delineate different parts of a coroutine that require different execution contexts, making the code easier to understand and maintain.

  3. Safety: withContext ensures that the code executed within its block is thread-safe, as it automatically handles the context switching and synchronization for you.

  4. Performance: By performing I/O-bound operations on background threads using withContext(Dispatchers.IO), you can improve the responsiveness and performance of your application by avoiding blocking the main thread.

Alright devs, Now we talk about coroutine scopes. In Kotlin, coroutine scopes are used to define the lifecycle of coroutines and manage their execution. Here are the different types of coroutine scopes:

  1. GlobalScope

  2. CoroutineScope

  3. viewModelScope

  4. lifecycleScope

GlobalScope

GlobalScope is a predefined coroutine scope in Kotlin coroutines that is not tied to any specific lifecycle. Coroutines launched within GlobalScope are not bound to the lifecycle of any particular component and continue to run until they are explicitly cancelled or until the application process is terminated. The GlobalScope is a predefined global scope that lives as long as your application is running.

While GlobalScope can be used to launch coroutines that are not tied to the lifecycle of any specific component, it is generally discouraged in Android development due to potential issues with managing the lifecycle of coroutines. Coroutines launched in GlobalScope may continue to run even after the associated component (e.g., activity or fragment) is destroyed, leading to memory leaks and unpredictable behavior.

Creating Coroutines in GlobalScope

You can launch coroutines within GlobalScope using the launch coroutine builder:

GlobalScope.launch {
    // Coroutine code goes here
}

Here's an example demonstrating the usage of GlobalScope in Android Kotlin:

import kotlinx.coroutines.*

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // Launching a coroutine in GlobalScope
        GlobalScope.launch {
            // Simulate a long-running task
            delay(3000) // Simulate delay of 3 seconds

            // Update UI on the main thread
            withContext(Dispatchers.Main) {
                textView.text = "Coroutine finished"
            }
        }
    }
}

In this example:

  • We launch a coroutine in GlobalScope using GlobalScope.launch.

  • Inside the coroutine, we simulate a long-running task using delay(3000) to introduce a delay of 3 seconds.

  • After the delay, we switch the context to Dispatchers.Main using withContext(Dispatchers.Main) to update the UI with the result.

CoroutineScope

CoroutineScope is a construct in Kotlin coroutines that provides a structured way to manage the lifecycle of coroutines. It defines a scope within which coroutines are launched and managed, allowing you to control their lifecycle and ensure proper cleanup of resources.

In Android development, CoroutineScope is commonly used to tie the lifecycle of coroutines to the lifecycle of components such as activities, fragments, or ViewModels. This ensures that coroutines are cancelled when the associated component is destroyed, preventing memory leaks and resource wastage.

Creating CoroutineScope

You can create a CoroutineScope instance using one of the following methods:

  1. Using CoroutineScope() constructor:

     val coroutineScope = CoroutineScope(Dispatchers.Main)
    

    This creates a CoroutineScope tied to the main dispatcher, making it suitable for performing UI-related tasks.

  2. Using lifecycleScope extension property (available in AndroidX):

     val coroutineScope = lifecycleScope
    

    This creates a CoroutineScope tied to the lifecycle of the associated component (e.g., activity or fragment), making it automatically cancelled when the component is destroyed.

  3. Using ViewModelScope (available in AndroidX ViewModel):

     val coroutineScope = viewModelScope
    

    This creates a CoroutineScope tied to the lifecycle of a ViewModel, ensuring that coroutines are cancelled when the ViewModel is cleared.

Launching Coroutines in CoroutineScope

Once you have a CoroutineScope instance, you can launch coroutines within that scope using the launch coroutine builder:

coroutineScope.launch {
    // Coroutine code goes here
}

Cancelling Coroutines

Coroutines launched within a CoroutineScope are automatically cancelled when the scope is cancelled. You can cancel the scope manually when it's no longer needed, typically in the onDestroy() method of activities or fragments, or in the onCleared() method of ViewModels:

override fun onDestroy() {
    super.onDestroy()
    coroutineScope.cancel()
}

Benefits of CoroutineScope

  1. Lifecycle Awareness: CoroutineScope ties the lifecycle of coroutines to the lifecycle of components, ensuring proper cleanup of resources when the component is destroyed.

  2. Structured Concurrency: CoroutineScope provides a structured way to launch and manage coroutines, making it easier to reason about concurrency and avoid common pitfalls like memory leaks and race conditions.

  3. Simplified Error Handling: Coroutines launched within a CoroutineScope benefit from structured error handling, allowing you to handle exceptions in a centralized manner.

  4. Improved Readability: By explicitly defining the scope of coroutines, CoroutineScope enhances the readability of asynchronous code, making it clear where coroutines are launched and managed.

viewModelScope

ViewModelScope is a coroutine scope provided by AndroidX ViewModel library in Android development. It is designed to tie the lifecycle of coroutines to the lifecycle of a ViewModel, ensuring that coroutines are automatically cancelled when the ViewModel is cleared or destroyed, thus preventing memory leaks and resource wastage.

In Android development, viewModelScope is commonly used within ViewModels to launch coroutines that are scoped to the ViewModel's lifecycle. It allows you to perform asynchronous operations such as network requests, database operations, or background tasks in a structured and lifecycle-aware manner.

Creating Coroutines in viewModelScope

You can create coroutines within viewModelScope using the launch coroutine builder:

class MyViewModel : ViewModel() {
    fun fetchData() {
        viewModelScope.launch {
            // Coroutine code goes here
        }
    }
}

In this example, the coroutine launched within viewModelScope is tied to the lifecycle of MyViewModel. It will be automatically cancelled when the ViewModel is cleared, such as when the associated activity or fragment is destroyed.

Benefits of viewModelScope

  1. Lifecycle Awareness: Coroutines launched within viewModelScope are automatically cancelled when the associated ViewModel is cleared or destroyed, ensuring the proper cleanup of resources and preventing memory leaks.

  2. Structured Concurrency: viewModelScope provides a structured way to launch and manage coroutines within ViewModels, making it easier to reason about concurrency and avoid common pitfalls like memory leaks and race conditions.

  3. Simplified Error Handling: Coroutines launched within viewModelScope benefit from structured error handling, allowing you to handle exceptions in a centralized manner within the ViewModel.

  4. Integration with Architecture Components: viewModelScope integrates seamlessly with AndroidX ViewModel library, providing a convenient way to perform asynchronous operations within ViewModel classes.

Here's an example demonstrating the usage of viewModelScope in a ViewModel:

class MyViewModel : ViewModel() {
    fun fetchData() {
        viewModelScope.launch {
            // Simulate fetching data from a remote server
            val data = fetchDataFromServer()
            // Update UI with fetched data
            updateUiWithData(data)
        }
    }
}

In this example, fetchData() function launches a coroutine within viewModelScope to fetch data from a remote server asynchronously. Once the data is fetched, the coroutine updates the UI with the fetched data.

LifecycleScope

lifecycleScope is an extension property available in AndroidX libraries that provides a coroutine scope tied to the lifecycle of an Android component, such as an Activity or Fragment. It allows you to launch coroutines that are automatically cancelled when the associated component is destroyed, helping to manage the lifecycle of coroutines in Android applications.

In Android development, lifecycleScope is commonly used to launch coroutines within the lifecycle of an activity or fragment. It ensures that coroutines are cancelled when the associated component is destroyed, preventing memory leaks and resource wastage.

Creating Coroutines in lifecycleScope

You can launch coroutines within lifecycleScope using the launch coroutine builder:

lifecycleScope.launch {
    // Coroutine code goes here
}

Cancelling Coroutines

Coroutines launched within lifecycleScope are automatically cancelled when the associated activity or fragment is destroyed. You don't need to manually cancel these coroutines; the cancellation is handled automatically by the Android lifecycle framework.

Here's an example of using lifecycleScope in an Android activity:

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        lifecycleScope.launch {
            // Simulate a long-running task
            delay(3000) // Simulate delay of 3 seconds

            // Update UI on the main thread
            textView.text = "Coroutine finished"
        }
    }
}

In this example:

  • We launch a coroutine within the lifecycleScope of the activity using lifecycleScope.launch.

  • Inside the coroutine, we simulate a long-running task using delay(3000) to introduce a delay of 3 seconds.

  • After the delay, we update the UI with the result by setting the text of a TextView.

Benefits of lifecycleScope

  1. Lifecycle Awareness: Coroutines launched within lifecycleScope are automatically cancelled when the associated activity or fragment is destroyed, ensuring the proper cleanup of resources and preventing memory leaks.

  2. Simplified Error Handling: Coroutines launched within lifecycleScope benefit from structured error handling, allowing you to handle exceptions in a centralized manner.

  3. Integration with Architecture Components: lifecycleScope integrates seamlessly with Android Architecture Components, such as LiveData and ViewModel, making it easy to launch coroutines within the lifecycle of these components.

Alright devs, Let's move on to the SharedFlow and StateFlow. In Kotlin, StateFlow and SharedFlow are both part of the Kotlin coroutines library and are used to handle flow-based data streams.

Before understanding these two flows we need to understand Flow.

So What is Flow?

Flow is a key component of the Kotlin Coroutines library, which provides a stream of data that can be asynchronously collected. Think of Flow as a sequence of values that can be emitted one by one, making it an excellent choice for handling asynchronous data. similar to sequences or collections, but with support for asynchronous and potentially infinite data streams.

Key Concepts

  1. Asynchronous Data Streams: Flow represents a sequence of values that are emitted asynchronously over time. These values can be emitted one by one or in batches, depending on the data source.

  2. Cold Streams: Flow is cold by default, meaning it does not start emitting values until it is collected by a downstream collector. This lazy behavior allows for efficient resource usage, as data is only processed when there's a demand from the collector.

  3. Cancellation Propagation: Like coroutines, Flow supports cancellation, allowing you to cancel the processing of data streams when they are no longer needed. Cancellation is propagated downstream, ensuring that resources are cleaned up properly.

  4. Flow Operators: Flow provides a set of operators similar to those found in functional programming libraries, such as map, filter, transform, zip, and many others. These operators allow you to transform, filter, combine, and manipulate data streams efficiently.

Here's a simple example demonstrating the usage of Flow to emit a sequence of integers asynchronously:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun main() = runBlocking {
    // Create a flow that emits integers from 1 to 5
    val flow = flow {
        for (i in 1..5) {
            delay(1000) // Simulate delay
            emit(i)     // Emit value
        }
    }

    // Collect the flow and print each emitted value
    flow.collect { value ->
        println(value)
    }
}

In this example:

  • We create a Flow using the flow builder function, which emits integers from 1 to 5 with a delay of 1 second between each emission.

  • We collect the flow using the collect terminal operator, which starts the flow and collects each emitted value. Inside the collector, we print each emitted value to the console.

OK, devs Now we talk about SharedFlow.

SharedFlow

SharedFlow is an extension of Flow that allows multiple collectors to receive the same data stream. This makes it an ideal choice when you want to share data across multiple parts of your application. This flow is a hot type flow. It is suitable for scenarios where multiple subscribers need access to the same stream of data.

Characteristics of SharedFlow:

  1. Hot Flow: Unlike cold flows, which start emitting items when a terminal operator is applied and a new collector subscribes, hot flows emit items even if there are no collectors. This means that the emission of values is decoupled from the presence of collectors.

  2. Concurrent Collectors: SharedFlow allows multiple collectors to receive emitted values concurrently. Each collector receives all the emitted values, similar to broadcasting.

  3. State Preservation: SharedFlow preserves the most recent emitted value (or values) for new collectors. When a new collector subscribes, it receives the most recent value emitted by the SharedFlow.

  4. Back Pressure Handling: SharedFlow supports back pressure handling. If a collector is too slow to process emitted values, it can request to suspend emissions until it's ready to receive more data.

Usage of SharedFlow:

  1. Creating a SharedFlow: You can create a SharedFlow using the sharedFlow builder function:

     val sharedFlow = MutableSharedFlow<Int>()
    
  2. Emitting Values: You can emit values to the SharedFlow using the emit function:

     sharedFlow.emit(42)
    
  3. Collecting Values: You can collect values from the SharedFlow using the collect function:

     sharedFlow.collect { value ->
         println("Received: $value")
     }
    
  4. Sharing Values: Multiple collectors can simultaneously collect values from the SharedFlow:

     sharedFlow.collect { value ->
         println("Collector 1 received: $value")
     }
    
     sharedFlow.collect { value ->
         println("Collector 2 received: $value")
     }
    
  5. State Preservation: New collectors receive the most recent emitted value (or values) when they subscribe:

     sharedFlow.emit(42)
     sharedFlow.emit(43)
    
     sharedFlow.collect { value ->
         println("Collector 3 received: $value") // Output: Collector 3 received: 43
     }
    
  6. Back Pressure Handling: You can use the buffer parameter to specify the size of the buffer for handling back pressure:

     val sharedFlow = MutableSharedFlow<Int>(replay = 1, extraBufferCapacity = 10)
    

Use Cases of SharedFlow:

  1. Event Bus: SharedFlow can be used as an event bus to broadcast events to multiple subscribers concurrently.

  2. Caching: SharedFlow can be used to cache and share data between different parts of an application.

  3. Real-time Updates: SharedFlow can provide real-time updates to multiple UI components based on a common data source.

StateFlow

StateFlow is a specialized version of SharedFlow, designed to represent a single state value that can be observed and updated. It’s handy for tracking the current state of your Android application, such as user authentication status, theme preferences, or any other global state. StateFlow is particularly useful for representing and observing state changes in reactive programming scenarios.

Characteristics of StateFlow:

  1. Hot Flow: Like SharedFlow, StateFlow is a hot flow, meaning it emits items regardless of whether there are active collectors. This allows for the decoupling of emissions from collection.

  2. State Preservation: StateFlow preserves the most recent emitted value as its state. When a new collector subscribes, it immediately receives the current state.

  3. Immutability: StateFlow's state is immutable. You cannot modify the state directly; instead, you emit a new state, which replaces the current state.

  4. Concurrent Collectors: Multiple collectors can observe the state emitted by StateFlow concurrently.

Usage of StateFlow:

  1. Creating a StateFlow: You can create a StateFlow using the stateFlow builder function or by using MutableStateFlow:

     val stateFlow = MutableStateFlow(initialValue)
    
  2. Accessing the State: You can access the current state of the StateFlow using the value property:

     val currentState = stateFlow.value
    
  3. Updating the State: You can update the state of the StateFlow by emitting a new value:

     stateFlow.value = newValue
    
  4. Collecting State Changes: You can collect state changes from the StateFlow using the collect function:

     stateFlow.collect { newState ->
         println("State changed: $newState")
     }
    
  5. Sharing State with Multiple Collectors: Multiple collectors can simultaneously observe state changes emitted by StateFlow:

     Flow.collect { newState ->
         println("Collector 1 received: $newState")
     }
    
     stateFlow.collect { newState ->
         println("Collector 2 received: $newState")
     }
    

Use Cases of StateFlow:

  1. UI State Management: StateFlow can represent the state of UI components and propagate state changes to multiple UI elements.

  2. Model-View-ViewModel (MVVM) Architecture: StateFlow can be used in ViewModels to represent the state of the UI and communicate state changes to the UI layer.

  3. Caching and Data Sharing: StateFlow can cache and share data between different parts of an application, ensuring that all observers are notified of changes to the shared state.

SharedFlow and StateFlow both are the same but they have some key differences:

FeatureStateFlowSharedFlow
TypeHot flowHot flow
PurposeEmits the most recent value to new collectorsAllows multiple collectors to receive the same emitted values
Initial ValueNew collectors immediately receive the last emitted valueCollectors do not receive previously emitted values upon subscription
Ideal Use CasesMaintaining and updating a single shared state across multiple componentsBroadcasting events or data updates to multiple consumers
ObserversMultiple collectorsMultiple collectors
Value PropagationNew collectors receive the most recent emitted valueAll collectors receive values emitted after they start observing
FlexibilitySuited for scenarios where maintaining a single shared state is criticalOffers more flexibility in handling multiple observers and different update scenarios

I know devs that this blog is pretty long but finally, it's time to wrap up this blog. I hope this blog helps you to understand Coroutines in detail. See you on our next topic ReactiveX it is also used for handling async tasks.


Connect with Me:

Hey there! If you enjoyed reading this blog and found it informative, why not connect with me on LinkedIn? 😊 You can also follow my Instagram page for more mobile development-related content. 📲👨‍💻 Let’s stay connected, share knowledge and have some fun in the exciting world of app development! 🌟

Check out my Instagram page

Check out my LinkedIn