Updating UI from Background Threads: Best Practices for Android Kotlin Developers

In modern Android development, performing heavy calculations or long-running tasks on the main thread is a bad practice as it can cause the UI to freeze. Instead, these tasks should be offloaded to worker threads. However, updating the UI based on calculations running in a worker thread can be challenging. In this article, we explore multiple approaches—from traditional techniques to modern Compose-native methods—for updating the UI during such scenarios.



1. Using Handler and Thread (Traditional Approach)

This approach involves creating a worker thread and using a Handler to post updates to the main thread.

Code Example

val handler = Handler(Looper.getMainLooper())
Thread {
    for (i in 1..100) {
        Thread.sleep(50) // Simulate work
        val progress = i
        handler.post {
            // Update UI
            progressText = "Progress: $progress%"
        }
    }
}.start()

Pros:

  • Simple and straightforward.
  • No additional libraries are required.

Cons:

  • Verbose and error-prone.
  • Harder to manage lifecycle events.
  • Not well-suited for Compose.

2. Using AsyncTask (Deprecated)

AsyncTask was previously the go-to solution for background work. It provided methods to communicate results to the main thread.

Code Example

@Deprecated("Deprecated in API level 30")
class MyAsyncTask(private val onProgressUpdate: (String) -> Unit) : AsyncTask<Void, Int, Void>() {
    override fun doInBackground(vararg params: Void?): Void? {
        for (i in 1..100) {
            Thread.sleep(50)
            publishProgress(i)
        }
        return null
    }

    override fun onProgressUpdate(vararg values: Int?) {
        val progress = values[0] ?: 0
        onProgressUpdate("Progress: $progress%")
    }
}

Pros:

  • Built-in methods for updating the UI.

Cons:

  • Deprecated since API 30.
  • Poor lifecycle awareness.

3. Using HandlerThread

HandlerThread allows you to create a background thread with a Looper for posting messages.

Code Example

val handlerThread = HandlerThread("MyWorkerThread").apply { start() }
val handler = Handler(handlerThread.looper)

handler.post {
    for (i in 1..100) {
        Thread.sleep(50)
        val progress = i
        Handler(Looper.getMainLooper()).post {
            progressText = "Progress: $progress%"
        }
    }
}

Pros:

  • Better than plain Handler and Thread.

Cons:

  • Requires manual lifecycle management.
  • Verbose.

4. Using LiveData

LiveData is lifecycle-aware and works well with Compose.

Code Example

val progressLiveData = MutableLiveData<String>()

viewModelScope.launch(Dispatchers.IO) {
    for (i in 1..100) {
        delay(50) // Simulate work
        progressLiveData.postValue("Progress: $i%")
    }
}

progressLiveData.observe(lifecycleOwner) { progress ->
    progressText = progress
}

Pros:

  • Lifecycle-aware.
  • Easy to integrate with Compose using observeAsState.

Cons:

  • Requires additional boilerplate in Compose.

5. Using StateFlow and CoroutineScope (Recommended Modern Approach)

StateFlow is a Compose-friendly and lifecycle-aware solution.

Code Example

val progressFlow = MutableStateFlow("Progress: 0%")

viewModelScope.launch(Dispatchers.IO) {
    for (i in 1..100) {
        delay(50) // Simulate work
        progressFlow.value = "Progress: $i%"
    }
}

@Composable
fun ProgressUI(progressFlow: StateFlow<String>) {
    val progress by progressFlow.collectAsState()
    Text(text = progress)
}

Pros:

  • Compose-friendly.
  • Lifecycle-aware.
  • Cleaner integration with UI.

Cons:

  • Requires familiarity with StateFlow and Coroutines.

6. Using Worker and WorkManager

If the task is suitable for background work that requires persistence, you can use WorkManager.

Code Example

class MyWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
    override fun doWork(): Result {
        for (i in 1..100) {
            Thread.sleep(50)
            setProgressAsync(workDataOf("PROGRESS" to i))
        }
        return Result.success()
    }
}

@Composable
fun ProgressUI(workInfo: WorkInfo) {
    val progress = workInfo.progress.getInt("PROGRESS", 0)
    Text(text = "Progress: $progress%")
}

Pros:

  • Great for persistent background tasks.
  • Lifecycle-aware.

Cons:

  • Overhead for simple tasks.
  • Best suited for persistent tasks.

Which Approach is Best?

For modern Android development with Jetpack Compose, StateFlow with Coroutines is the best option. It is lifecycle-aware, Compose-friendly, and ensures clean code with less boilerplate. LiveData is a close second for projects already using it, but it’s less ideal for new Compose projects. Use WorkManager if persistence and task scheduling are required.

Why StateFlow?

  • Compose Integration: Works seamlessly with collectAsState in Compose.
  • Lifecycle Awareness: Automatically handles lifecycle changes.
  • Scalability: Suitable for simple to complex state management.

Choose the approach that aligns best with your project requirements, but for most Compose-based apps, StateFlow is the way to go!

Thank you for reading my latest article! I would greatly appreciate your feedback to improve my future posts. 💬 Was the information clear and valuable? Are there any areas you think could be improved? Please share your thoughts in the comments or reach out directly. Your insights are highly valued. 👇😊.  Happy coding! 💻✨

0 comments:

Post a Comment