contentintech
Learn/mobile dev/Android with Kotlin
Intermediate~20 min read

Android with Kotlin

Modern Android development with Kotlin and Jetpack Compose — language fundamentals, declarative UI, state management with ViewModel and StateFlow, Material 3, Navigation Compose, coroutines, Flow, and networking with Retrofit.

Jetpack ComposeKotlinCoroutinesState

Why Kotlin + Jetpack Compose

Kotlin is Google's preferred language for Android — concise, null-safe, and fully interoperable with the Java ecosystem. Jetpack Compose is the modern declarative UI toolkit that replaces the old XML layout + findViewById world. You describe what the UI should look like for a given state, and the framework figures out how to update the screen when that state changes.

In 2026 the standard stack is Kotlin 2.x, Compose with the Material 3 library, coroutines and Flow for async work, ViewModel + StateFlow for state, Navigation Compose for screens, and Retrofit for networking.

Kotlin Basics

val, var, and Type Inference

Use val for read-only (immutable) references and var for mutable ones. Prefer val by default. Types are usually inferred.

kotlin
val name = "Ada"          // inferred String, cannot reassign
var count = 0             // inferred Int, mutable
val price: Double = 9.99  // explicit type
count += 1

// String templates
println("Hello $name, count is ${count + 1}")

Null Safety

Types are non-nullable by default. Add ? to allow null, then use the safe-call ?., the Elvis operator ?:, and (rarely) the not-null assertion !!.

kotlin
var nickname: String? = null
val length = nickname?.length ?: 0     // safe call + Elvis fallback

nickname?.let { println("Nick is $it") } // runs only if non-null

fun greet(user: String?) {
    val safe = user ?: return           // early return on null
    println("Welcome, $safe")
}

Functions and Lambdas

kotlin
fun add(a: Int, b: Int): Int = a + b          // expression body
fun greet(name: String, punct: String = "!") = "Hi $name$punct" // default arg
greet(name = "Sam")                            // named argument

// Lambda: type is (Int) -> Int
val square = { n: Int -> n * n }
val nums = listOf(1, 2, 3, 4)
val evens = nums.filter { it % 2 == 0 }        // trailing lambda + implicit `it`
val doubled = nums.map { it * 2 }

Data Classes and Sealed Classes

data class auto-generates equals, hashCode, toString, and copy. Sealed hierarchies model a closed set of states — perfect for exhaustive when.

kotlin
data class User(val id: Int, val name: String, val email: String)

val u1 = User(1, "Ada", "ada@x.io")
val u2 = u1.copy(name = "Ada L.")   // copy with one field changed

sealed interface UiState {
    data object Loading : UiState
    data class Success(val users: List<User>) : UiState
    data class Error(val message: String) : UiState
}

fun render(state: UiState) = when (state) {   // exhaustive, no `else` needed
    UiState.Loading      -> "Loading…"
    is UiState.Success   -> "${state.users.size} users"
    is UiState.Error     -> state.message
}

Declarative UI with @Composable

A composable is a function annotated with @Composable that emits UI. Composables are the building blocks; they can call other composables. The framework calls them to build the tree and re-invokes them when their inputs change.

kotlin
@Composable
fun Greeting(name: String) {
    Text(text = "Hello, $name!")
}

// Entry point wired from an Activity
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AppTheme { Greeting(name = "Android") }
        }
    }
}

Layout Composables

Column stacks children vertically, Row horizontally, and Box overlays them. For long, scrollable lists use LazyColumn — it only composes the items on screen.

kotlin
@Composable
fun UserList(users: List<User>) {
    LazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(users, key = { it.id }) { user ->
            Row(verticalAlignment = Alignment.CenterVertically) {
                Box(
                    Modifier.size(40.dp).clip(CircleShape)
                        .background(MaterialTheme.colorScheme.primary)
                )
                Spacer(Modifier.width(12.dp))
                Column {
                    Text(user.name, style = MaterialTheme.typography.titleMedium)
                    Text(user.email, style = MaterialTheme.typography.bodySmall)
                }
            }
        }
    }
}

State and Recomposition

When state a composable reads changes, Compose recomposes — it re-runs that function (and only what needs updating) to reflect the new value. State that Compose can observe is created with mutableStateOf, kept across recompositions with remember.

kotlin
@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }   // `by` delegate reads the value directly
    Button(onClick = { count++ }) {
        Text("Clicked $count times")
    }
}

What triggers recomposition

Compose only recomposes when a tracked state value that a composable actually reads changes. Reading a plain var won't trigger anything — you must read from a State<T>. Keep composables free of side effects; they may run any number of times, in any order, and in parallel.

State Hoisting

State hoisting means lifting state out of a composable so the caller owns it. The child becomes stateless: it receives the value and an event callback. This makes composables reusable, testable, and easy to preview.

kotlin
// Stateless: value in, event out
@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
    TextField(value = query, onValueChange = onQueryChange)
}

// Stateful caller owns the state
@Composable
fun SearchScreen() {
    var query by remember { mutableStateOf("") }
    SearchBar(query = query, onQueryChange = { query = it })
}

remember vs rememberSaveable

remember survives recomposition but is lost on configuration changes (like rotation) or process death. rememberSaveable additionally persists the value to a saved-instance bundle, so it survives rotation. Use it for UI state the user shouldn't lose, like text field contents or a selected tab.

ViewModel + StateFlow

For screen-level state that must survive configuration changes and outlive individual composables, use a ViewModel exposing an immutable StateFlow. The UI collects it lifecycle-aware.

kotlin
class UserViewModel(private val repo: UserRepository) : ViewModel() {
    private val _state = MutableStateFlow<UiState>(UiState.Loading)
    val state: StateFlow<UiState> = _state.asStateFlow()

    init { load() }

    fun load() = viewModelScope.launch {
        _state.value = UiState.Loading
        _state.value = try {
            UiState.Success(repo.fetchUsers())
        } catch (e: Exception) {
            UiState.Error(e.message ?: "Unknown error")
        }
    }
}

@Composable
fun UserScreen(vm: UserViewModel = viewModel()) {
    val state by vm.state.collectAsStateWithLifecycle()
    when (val s = state) {
        UiState.Loading    -> CircularProgressIndicator()
        is UiState.Success -> UserList(s.users)
        is UiState.Error   -> Text(s.message)
    }
}

Modifiers

Modifier is how you decorate and configure a composable — size, padding, background, click handling, and more. Order matters: modifiers are applied left to right, so padding before background differs from padding after it.

kotlin
Text(
    text = "Tap me",
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)                 // outer padding
        .background(Color(0xFF0F766E))  // background fills padded area
        .clip(RoundedCornerShape(12.dp))
        .clickable { /* handle tap */ }
        .padding(12.dp)                 // inner padding on the content
)

Material 3 Theming

Material 3 (androidx.compose.material3) provides ready-made components and a theming system with dynamic color. Wrap your app in a MaterialTheme and read from colorScheme, typography, and shapes.

kotlin
@Composable
fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
    val scheme = if (darkTheme) darkColorScheme() else lightColorScheme()
    MaterialTheme(colorScheme = scheme, typography = Typography, content = content)
}

@Composable
fun HomeScaffold() {
    Scaffold(
        topBar = { TopAppBar(title = { Text("Home") }) },
        floatingActionButton = { FloatingActionButton(onClick = {}) { Icon(Icons.Default.Add, null) } }
    ) { inner ->
        Column(Modifier.padding(inner)) { /* screen content */ }
    }
}

Navigation Compose manages moving between screens with a NavHost and a NavController. Modern 2026 apps use type-safe routes defined as serializable classes rather than string paths.

kotlin
@Serializable object Home
@Serializable data class Details(val userId: Int)

@Composable
fun AppNav() {
    val nav = rememberNavController()
    NavHost(navController = nav, startDestination = Home) {
        composable<Home> {
            HomeScreen(onOpen = { id -> nav.navigate(Details(id)) })
        }
        composable<Details> { entry ->
            val args = entry.toRoute<Details>()
            DetailsScreen(userId = args.userId, onBack = { nav.popBackStack() })
        }
    }
}

Coroutines and suspend Functions

Coroutines make asynchronous code read like sequential code. A suspend function can pause without blocking a thread. Launch them in a scope (like viewModelScope) so they're cancelled automatically when no longer needed. Use Dispatchers.IO for blocking I/O.

kotlin
suspend fun loadProfile(id: Int): Profile = withContext(Dispatchers.IO) {
    val user = api.getUser(id)          // network, suspends
    val posts = api.getPosts(id)        // runs after user returns
    Profile(user, posts)
}

// Run two calls concurrently with async/await
suspend fun loadDashboard(): Dashboard = coroutineScope {
    val users = async { api.getUsers() }
    val stats = async { api.getStats() }
    Dashboard(users.await(), stats.await())
}

Flow

A Flow is a cold, asynchronous stream of values — ideal for observing a database, sensor, or repeated events. Operators like map, filter, and debounce transform it.

kotlin
fun searchResults(queries: Flow<String>): Flow<List<User>> =
    queries
        .debounce(300)                  // wait for typing to settle
        .filter { it.length >= 2 }
        .distinctUntilChanged()
        .mapLatest { q -> repo.search(q) } // cancels previous search

// Collect inside a scope
viewModelScope.launch {
    searchResults(queryFlow).collect { results -> _state.value = UiState.Success(results) }
}

Networking with Retrofit

Retrofit turns a Kotlin interface into a type-safe HTTP client. Declare endpoints as suspend functions so they integrate directly with coroutines, and pair it with kotlinx.serialization for JSON.

kotlin
interface UserApi {
    @GET("users")
    suspend fun getUsers(): List<User>

    @GET("users/{id}")
    suspend fun getUser(@Path("id") id: Int): User
}

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
    .build()

val api: UserApi = retrofit.create(UserApi::class.java)

Lifecycle

Compose has its own side-effect APIs tied to the composition lifecycle. Use LaunchedEffect to run suspend work when a key changes, and DisposableEffect for setup/teardown. Collect flows with collectAsStateWithLifecycle so collection stops when the screen is in the background.

kotlin
@Composable
fun DetailsScreen(userId: Int, vm: DetailsViewModel = viewModel()) {
    LaunchedEffect(userId) {        // re-runs only when userId changes
        vm.load(userId)
    }
    DisposableEffect(Unit) {
        val job = analytics.startTracking()
        onDispose { job.stop() }   // cleanup when leaving composition
    }
    // ...
}

Compose vs XML Views

AspectJetpack ComposeXML Views
ParadigmDeclarative — describe UI for stateImperative — mutate views manually
UI definitionKotlin functionsXML layout files
State updatesAutomatic recompositionManual findViewById + setters
ListsLazyColumnRecyclerView + adapter
ReusabilityComposable functionsCustom views / includes
Previews@Preview in the IDELayout editor

Practice Exercises

  1. Write a data class Task(id, title, done) and a sealed interface for the screen's UI state (Loading / Success / Empty / Error). Render each case with an exhaustive when.
  2. Build a Counter composable using state hoisting: a stateless child that takes the count and an onIncrement callback, and a stateful parent that owns the value with rememberSaveable so it survives rotation.
  3. Create a LazyColumn of tasks with a stable key, a checkbox to toggle done, and a swipe-to-delete gesture.
  4. Add a ViewModel that exposes a StateFlow<UiState>, loads tasks in viewModelScope, and is collected with collectAsStateWithLifecycle.
  5. Set up Navigation Compose with type-safe routes: a list screen and a details screen that receives a task id, plus a back action via popBackStack.
  6. Wire a Retrofit suspend endpoint into the ViewModel, add a search Flow with debounce + mapLatest, and handle loading and error states in the UI.

Section navigation