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:
WeakReferencechecks are fragile and boilerplate-heavycancel(true)interrupts the thread but doesn't cancel the network call- Serial
Executorby 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
ViewModelfor the screen - Define a
sealed classfor UI state (Loading, Success, Error) - Expose state as
StateFlowfrom the ViewModel - Move business logic to a
Repositorywithsuspend funs - Use
viewModelScope.launch+withContext(Dispatchers.IO)for background work - Observe state in the Fragment/Activity with
repeatOnLifecycle(STARTED) - Replace
onProgressUpdatewith intermediate state emissions - Delete the
AsyncTaskclass and all its boilerplate - Write a unit test for the ViewModel using
runTest
What You Gained
| Before (AsyncTask) | After (Coroutines) |
|---|---|
| WeakReference boilerplate | Automatic lifecycle cancellation |
| Silent task abandonment | Structured cancellation via scope |
| Thread interruption (unreliable) | Cooperative cancellation |
| Hard to test | ViewModel tests with runTest |
| Deprecated API | Current, maintained API |
| Serial executor (default) | Flexible dispatcher choice |
| No error propagation | Exception handling with try/catch |