Android SharedPreferences Tutorial and Example

Using Async/Await for Asynchronous Tasks in Android

Android applications must keep the main thread responsive while they load data, query SQLite, decode images, or contact a web service. If a slow operation runs directly inside a click listener, scrolling and screen updates can freeze. Users in Sydney, Melbourne, or Perth expect an app to remain responsive even when a mobile connection becomes unreliable.

Kotlin coroutines provide a readable way to run asynchronous work. Instead of building several callbacks, you can suspend a function, wait for a result, and resume the code when that result is ready. The async builder starts concurrent work, while await() retrieves its result without blocking the user interface.

This approach suits common Android projects such as a local notes database, a shopping list, or a weather screen. A Melbourne commuter might open an app between stations with intermittent reception, so the interface should display cached information while fresh data loads in the background.

Asynchronous code must still respect Android lifecycle rules and Australian privacy requirements. If an app stores contact details, location data, or account information, its design should support appropriate handling under the Privacy Act 1988 and avoid retaining data longer than necessary.

Why Async Work Matters

Android uses a main thread for drawing views and processing many user interactions. Database queries and network requests can take an unpredictable amount of time. Calling them synchronously on that thread can produce dropped frames, an unresponsive screen, or an Application Not Responding error.

A coroutine lets a function pause at a suspension point without blocking the thread. When the database or server responds, the coroutine continues from the same line. This gives asynchronous programming a sequential appearance, which is usually easier to read than nested callbacks.

lifecycleScope.launch {
    statusText.text = "Loading..."

    val notes = withContext(Dispatchers.IO) {
        noteRepository.loadNotes()
    }

    adapter.submitList(notes)
    statusText.text = "${notes.size} notes"
}

The suspend keyword does not automatically move work to a background thread. It means that the function can pause and resume. withContext(Dispatchers.IO) is used here because database and network operations are input/output work.

Running Tasks With Async And Await

Use async when two or more independent operations can run at the same time. It returns a Deferred<T>, which represents a future value. Calling await() suspends the coroutine until that value is available.

viewModelScope.launch {
    val notesTask = async(Dispatchers.IO) {
        noteRepository.loadNotes()
    }

    val categoriesTask = async(Dispatchers.IO) {
        noteRepository.loadCategories()
    }

    val notes = notesTask.await()
    val categories = categoriesTask.await()

    screenState.value = NotesState(notes, categories)
}

The two tasks begin before either result is awaited, reducing total waiting time when both operations are independent. If the categories query needs the notes result, launching it with async provides little benefit. In that case, use ordinary sequential calls with withContext.

async should normally be created inside a structured coroutine scope such as viewModelScope or lifecycleScope. The parent scope owns its child tasks, and cancellation can propagate when a screen is destroyed or a ViewModel is cleared.

Approach Suitable use Main benefit Important caution
withContext One background operation Clear dispatcher change Does not create parallel work
async and await Independent operations Runs tasks concurrently Handle failures and cancellation
launch Work with no returned value Simple fire-and-forget structure Exceptions still need handling
WorkManager Deferrable or guaranteed background work Can survive app closure More setup than a screen coroutine

Choosing Dispatchers Correctly

Dispatchers.Main is the normal context for updating views and observable UI state. Dispatchers.IO is intended for blocking input/output, including SQLite queries, file access, and traditional network clients. Dispatchers.Default is designed for CPU-heavy work such as sorting a large collection, parsing a substantial document, or resizing many images.

A repository can hide the dispatcher choice from the Activity or Fragment:

class NoteRepository(
    private val database: AppDatabase
) {
    suspend fun loadNotes(): List<Note> =
        withContext(Dispatchers.IO) {
            database.noteDao().getAll()
        }
}

This keeps presentation code focused on state and user actions. With Room, suspend DAO methods are commonly dispatched safely by the library, but explicitly defining boundaries remains useful when a repository combines several operations.

Avoid switching to Dispatchers.IO for every line. Updating a TextView, submitting a ListAdapter list, or changing a progress indicator belongs on the main thread. A short background block is easier to reason about than a function that changes contexts repeatedly.

Handling Errors And Cancellation

Network requests can fail because a user loses reception, a server times out, or a service returns an error. Wrap the coroutine body in try and catch, and provide a useful state rather than allowing the screen to remain stuck on a loading message.

viewModelScope.launch {
    screenState.value = ScreenState.Loading

    try {
        val result = withContext(Dispatchers.IO) {
            noteRepository.refreshNotes()
        }
        screenState.value = ScreenState.Ready(result)
    } catch (error: CancellationException) {
        throw error
    } catch (error: IOException) {
        screenState.value = ScreenState.Error("Check your connection")
    } catch (error: Exception) {
        screenState.value = ScreenState.Error("Something went wrong")
    }
}

CancellationException should usually be rethrown. Cancellation is a normal lifecycle event, not an application failure. Suppressing it can leave work running after a user leaves a screen, wasting battery and potentially updating an obsolete interface.

For parallel operations, a failure in one child coroutine normally cancels its siblings. This is useful when the whole screen depends on every result. Use supervisorScope when one optional operation, such as loading recommendations, should fail without discarding the primary content.

Building A Practical Android Flow

A useful pattern separates the interface, ViewModel, and repository. The Fragment sends an event, the ViewModel starts a lifecycle-aware coroutine, and the repository performs database or network work. The UI observes a StateFlow and renders loading, success, and error states.

data class DashboardState(
    val loading: Boolean = false,
    val notes: List<Note> = emptyList(),
    val error: String? = null
)

fun refreshDashboard() {
    viewModelScope.launch {
        _state.value = DashboardState(loading = true)

        try {
            val notes = async(Dispatchers.IO) {
                repository.loadNotes()
            }.await()

            _state.value = DashboardState(notes = notes)
        } catch (error: CancellationException) {
            throw error
        } catch (error: Exception) {
            _state.value = DashboardState(
                error = "Unable to load notes"
            )
        }
    }
}

For work that must continue after the app is closed, use WorkManager instead of a screen-bound coroutine. This is suitable for synchronising records or sending deferred logs, subject to network and battery constraints. An Australian retail app might schedule catalogue updates overnight, while respecting the customer’s preferences and handling Australian Eastern, Central, or Western time zones correctly.

When collecting a Flow in a Fragment, use a lifecycle-aware scope such as repeatOnLifecycle. This prevents collectors from continuing while the screen is not visible. Keep sensitive values out of logs, request only necessary permissions, and document how stored information is used under the Privacy Act 1988. These practices matter when an app is published to the Australian Google Play market, where users expect transparent data handling and reliable performance.