androidengineers.Book a session

Threading & Concurrency

Exercise: Replace AsyncTask with Coroutines

exercise50 minMedium

AsyncTask was deprecated in API 30 and removed in API 33. If you're maintaining legacy code, you'll encounter it. This exercise walks through a real migration — from a brittle, leak-prone AsyncTask to clean, lifecycle-aware coroutine code.

The Starting Point

Here's a typical AsyncTask usage you might find in legacy code:

// ❌ Legacy: Don't do this
class UserProfileActivity : AppCompatActivity() {

    private var loadTask: LoadUserTask? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_profile)
        loadTask = LoadUserTask(this).execute(intent.getStringExtra("userId"))
    }

    override fun onDestroy() {
        super.onDestroy()
        loadTask?.cancel(true) // often doesn't actually stop the background work
    }

    // Static class to avoid implicit Activity reference
    private class LoadUserTask(
        activity: UserProfileActivity
    ) : AsyncTask<String, Int, User?>() {

        private val activityRef = WeakReference(activity)

        override fun doInBackground(vararg params: String?): User? {
            val userId = params[0] ?: return null
            return try {
                UserApi.getUser(userId) // blocking network call
            } catch (e: Exception) {
                null
            }
        }

        override fun onProgressUpdate(vararg values: Int?) {
            activityRef.get()?.progressBar?.progress = values[0] ?: 0
        }

        override fun onPostExecute(result: User?) {
            val activity = activityRef.get() ?: return // Activity may be gone
            if (activity.isDestroyed) return
            if (result != null) {
                activity.nameText.text = result.name
                activity.emailText.text = result.email
            } else {
                activity.showError("Failed to load user")
            }
        }
    }
}

Problems with this code:

  • WeakReference checks are fragile and boilerplate-heavy
  • cancel(true) interrupts the thread but doesn't cancel the network call
  • Serial Executor by default — concurrent tasks queue up
  • No structured error handling
  • Hard to test
  • Deprecated and removed

Step 1: Add the ViewModel

First, move business logic out of the Activity and into a ViewModel. The ViewModel survives configuration changes and has a lifecycle-aware scope.

class UserProfileViewModel(
    private val userRepository: UserRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<UserProfileState>(UserProfileState.Loading)
    val uiState: StateFlow<UserProfileState> = _uiState.asStateFlow()

    fun loadUser(userId: String) {
        viewModelScope.launch {
            _uiState.value = UserProfileState.Loading
            _uiState.value = try {
                val user = withContext(Dispatchers.IO) {
                    userRepository.getUser(userId)
                }
                UserProfileState.Success(user)
            } catch (e: IOException) {
                UserProfileState.Error("Network error. Please retry.")
            } catch (e: Exception) {
                UserProfileState.Error("Something went wrong.")
            }
        }
    }
}

sealed class UserProfileState {
    object Loading : UserProfileState()
    data class Success(val user: User) : UserProfileState()
    data class Error(val message: String) : UserProfileState()
}

Step 2: Rewrite the Repository

Replace the static UserApi.getUser() blocking call with a proper suspending repository:

class UserRepository(
    private val api: UserApi,
    private val dao: UserDao
) {
    // suspend function — the caller decides which dispatcher to use
    suspend fun getUser(userId: String): User {
        return try {
            val remote = api.getUser(userId) // Retrofit with suspend fun — non-blocking
            dao.upsert(remote.toEntity())
            remote
        } catch (e: IOException) {
            // Fall back to cache on network failure
            dao.getUser(userId)?.toDomain()
                ?: throw e // re-throw if no cache available
        }
    }
}

If UserApi is a Retrofit interface, make the method a suspend fun and Retrofit handles threading automatically:

interface UserApi {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") userId: String): User
}

Step 3: Rewrite the Activity

The Activity now just observes state — it has no concurrency logic of its own:

// ✅ Modern: Clean, safe, testable
class UserProfileActivity : AppCompatActivity() {

    private val viewModel: UserProfileViewModel by viewModels()
    private lateinit var binding: ActivityProfileBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityProfileBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val userId = intent.getStringExtra("userId") ?: return

        // Collect state — automatically cancelled when Activity is destroyed
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    render(state)
                }
            }
        }

        viewModel.loadUser(userId)
    }

    private fun render(state: UserProfileState) {
        when (state) {
            is UserProfileState.Loading -> {
                binding.progressBar.isVisible = true
                binding.contentGroup.isVisible = false
                binding.errorText.isVisible = false
            }
            is UserProfileState.Success -> {
                binding.progressBar.isVisible = false
                binding.contentGroup.isVisible = true
                binding.nameText.text = state.user.name
                binding.emailText.text = state.user.email
            }
            is UserProfileState.Error -> {
                binding.progressBar.isVisible = false
                binding.errorText.isVisible = true
                binding.errorText.text = state.message
            }
        }
    }
}

Step 4: Handle Progress Updates

The original code reported progress from onProgressUpdate. With coroutines, use setProgress (WorkManager) or emit intermediate state values:

// ViewModel with progress
fun importContacts(contacts: List<Contact>) {
    viewModelScope.launch {
        _uiState.value = ImportState.Running(progress = 0)
        withContext(Dispatchers.IO) {
            contacts.forEachIndexed { index, contact ->
                dao.insert(contact)
                val progress = ((index + 1).toFloat() / contacts.size * 100).toInt()
                withContext(Dispatchers.Main) {
                    _uiState.value = ImportState.Running(progress)
                }
            }
        }
        _uiState.value = ImportState.Complete
    }
}

Step 5: Write a Test

The key advantage of this architecture: the ViewModel is testable without an Activity or Android framework:

@OptIn(ExperimentalCoroutinesApi::class)
class UserProfileViewModelTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule() // replaces Main dispatcher with TestDispatcher

    private val fakeRepository = FakeUserRepository()
    private lateinit var viewModel: UserProfileViewModel

    @Before
    fun setup() {
        viewModel = UserProfileViewModel(fakeRepository)
    }

    @Test
    fun `loadUser emits Success state on valid userId`() = runTest {
        val user = User("1", "Ada Lovelace", "ada@example.com")
        fakeRepository.setUser("1", user)

        viewModel.loadUser("1")

        assertIs<UserProfileState.Success>(viewModel.uiState.value)
        assertEquals(user, (viewModel.uiState.value as UserProfileState.Success).user)
    }

    @Test
    fun `loadUser emits Error state on network failure`() = runTest {
        fakeRepository.setShouldThrow(true)

        viewModel.loadUser("1")

        assertIs<UserProfileState.Error>(viewModel.uiState.value)
    }
}

Migration Checklist

Use this when tackling AsyncTask in a codebase:

  • Create or identify a ViewModel for the screen
  • Define a sealed class for UI state (Loading, Success, Error)
  • Expose state as StateFlow from the ViewModel
  • Move business logic to a Repository with suspend funs
  • Use viewModelScope.launch + withContext(Dispatchers.IO) for background work
  • Observe state in the Fragment/Activity with repeatOnLifecycle(STARTED)
  • Replace onProgressUpdate with intermediate state emissions
  • Delete the AsyncTask class and all its boilerplate
  • Write a unit test for the ViewModel using runTest

What You Gained

Before (AsyncTask)After (Coroutines)
WeakReference boilerplateAutomatic lifecycle cancellation
Silent task abandonmentStructured cancellation via scope
Thread interruption (unreliable)Cooperative cancellation
Hard to testViewModel tests with runTest
Deprecated APICurrent, maintained API
Serial executor (default)Flexible dispatcher choice
No error propagationException handling with try/catch

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Exercise: Replace AsyncTask with Coroutines | Android System Design | Android Engineers