08 · Android Basics¶
About this module's verification
Every other module in this course has its code blocks actually
compiled and run (kotlinc, or a real Gradle/Ktor/Exposed project) and
the printed output pasted in verbatim. Android code cannot be
compiled or run that way — it needs the Android Gradle Plugin, the
Android SDK's platform jars, and either a real device or an emulator
to execute an Activity or a Composable. That toolchain isn't
available in this environment beyond the bare SDK components, with no
running emulator. The Android-framework-specific code below (the
Activity, ViewModel extends, and @Composable snippets) has been
written carefully and reviewed line by line against current Android
API signatures, but it has not been compiled or executed — treat
it as a well-reviewed reference, not a verified-working example, and
expect to fix minor issues (a missing import, a Gradle plugin version
mismatch) when you actually build it in Android Studio. The one piece
of plain Kotlin logic in this module (the counter state class) has
been compiled and run normally, as noted where it appears.
Android apps are ordinary JVM (or Kotlin/Native, for parts of Compose
Multiplatform) programs built on top of the Android framework — Kotlin is
Google's recommended language for Android since 2019. This module covers
the shape of an Android app: activities, the view lifecycle, ViewModel
for surviving configuration changes, and a first look at Jetpack Compose,
Android's modern declarative UI toolkit.
Project shape¶
An Android module needs the Android Gradle Plugin and a compileSdk:
// app/build.gradle.kts
plugins {
id("com.android.application")
kotlin("android")
}
android {
namespace = "com.example.counter"
compileSdk = 34
defaultConfig {
applicationId = "com.example.counter"
minSdk = 24
targetSdk = 34
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4")
implementation("androidx.activity:activity-compose:1.9.1")
implementation(platform("androidx.compose:compose-bom:2024.06.00"))
implementation("androidx.compose.material3:material3")
}
Activities and the lifecycle¶
An Activity is a single screen. Android calls fixed lifecycle methods as
the screen is created, shown, hidden, and destroyed — critical for
releasing resources (camera, location, network listeners) at the right
time.
import android.os.Bundle
import androidx.activity.ComponentActivity
import android.util.Log
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d("MainActivity", "onCreate")
}
override fun onStart() {
super.onStart()
Log.d("MainActivity", "onStart -- screen becoming visible")
}
override fun onResume() {
super.onResume()
Log.d("MainActivity", "onResume -- screen interactive")
}
override fun onPause() {
super.onPause()
Log.d("MainActivity", "onPause -- release camera/sensors here")
}
override fun onDestroy() {
super.onDestroy()
Log.d("MainActivity", "onDestroy")
}
}
onCreate runs once per Activity instance — and a screen rotation
destroys and recreates the Activity by default, running onCreate
again from scratch. That's the whole reason ViewModel exists (next
section): plain fields on an Activity don't survive rotation, but a
ViewModel does.
ViewModel: state that survives configuration changes¶
The state-holding logic itself is plain Kotlin and doesn't need the
Android SDK to write or test — only the class X : ViewModel() wrapper is
Android-specific.
// Pure Kotlin logic -- this part IS compiled and run below, no Android SDK needed.
class CounterState {
var count: Int = 0
private set
fun increment() { count++ }
fun reset() { count = 0 }
}
fun main() {
val state = CounterState()
repeat(3) { state.increment() }
println("Count after 3 increments: ${state.count}")
state.reset()
println("Count after reset: ${state.count}")
}
// Android-specific wrapper (reviewed, not executed) -- same logic, but
// surviving Activity re-creation by living in a ViewModel instead.
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class CounterViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count.asStateFlow()
fun increment() { _count.value++ }
fun reset() { _count.value = 0 }
}
ViewModel instances are retained by the ViewModelStore across
configuration changes (rotation, dark-mode toggle) and are only actually
destroyed when the Activity finishes for good — that's the mechanism, and
it works whether the state inside is a plain var or, as shown here, a
StateFlow for observing changes from the UI.
Jetpack Compose: declarative UI¶
Compose replaces XML layouts with functions annotated @Composable that
describe UI as a function of state. Recomposition (Compose re-running a
composable) happens automatically when observed state (like a StateFlow
collected via collectAsState()) changes.
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
@Composable
fun CounterScreen(viewModel: CounterViewModel) {
val count by viewModel.count.collectAsState()
Column {
Text(text = "Count: $count")
Button(onClick = { viewModel.increment() }) {
Text("Increment")
}
Button(onClick = { viewModel.reset() }) {
Text("Reset")
}
}
}
by viewModel.count.collectAsState() is the Compose bridge from a
StateFlow to a Compose-observed State<Int> — reading count inside
Text(text = "Count: $count") registers this composable to recompose
whenever the flow emits, without any manual "refresh the UI" call.
How It Actually Works¶
@Composable is not a marker the JVM understands natively — Compose ships
its own compiler plugin that runs alongside the standard Kotlin compiler and
rewrites every composable function's signature, appending a hidden
Composer parameter (roughly fun CounterScreen(viewModel: ..., $composer:
Composer, $changed: Int)). That injected Composer is what lets Compose
track, at runtime, exactly which piece of state each composable read while
running, by recording reads into a data structure called the slot
table. When count (from collectAsState()) changes, Compose doesn't
re-run your whole UI tree — it looks up, via the slot table, precisely
which composables read that specific state value last time, and
re-invokes only those functions (this is "smart recomposition," and it's
why Compose scales to complex screens without repainting everything on
every state change).
by viewModel.count.collectAsState() combines two mechanisms already
covered elsewhere in this course: collectAsState() is a @Composable
extension function that internally launches a coroutine collecting the
Flow (using the Flow-collection mechanics from the coroutines-flow
module) and stores each emitted value into a Compose MutableState<Int>
object; the by is the same property-delegation mechanism from the Exposed
module, where State<T> implements getValue(), so reading count really
calls the delegate's getValue, which both returns the current value and
registers this composable's Composer as an observer of that specific
state slot — that registration is what triggers targeted recomposition when
the state's setValue is later called from inside the coroutine collecting
the flow.
ViewModel surviving configuration changes is an ordinary object-lifetime
trick, not magic: the Android framework retains the ViewModelStore
instance itself across an Activity being destroyed and recreated (which
genuinely happens — a brand new Activity object is constructed after a
rotation), and hands the same ViewModel instance back to the new
Activity when it asks for one with a matching key — so ViewModel state
survives not because Kotlin makes it durable, but because the platform
deliberately keeps that one object alive across an Activity teardown/rebuild
cycle that destroys everything else.
Kotlin-specific traps¶
- Configuration changes destroy the Activity, not the
ViewModel. State stored directly in Activity fields (not aViewModel) is lost on rotation — a very common "why did my counter reset" bug for people new to Android. @Composablefunctions can be called from any thread Compose chooses, potentially many times. They must be free of side effects aside from emitting UI — network calls or mutable state writes belong in aLaunchedEffector theViewModel, not directly in the composable body.collectAsState()needsimport androidx.compose.runtime.getValuefor thebydelegate syntax to resolve — a surprisingly common missing import error, since the delegate operator function lives in a separate package fromcollectAsStateitself.Log.dcalls do nothing (and don't crash) outside an Android runtime — unlikeprintln, which is why the Android-specific snippets above useLog.dintentionally, to show idiomatic Android logging, but they can't be exercised with plainkotlinc.minSdk/targetSdk/compileSdkare three different numbers with different meanings —compileSdkis which API surface you compile against,targetSdksignals which behavior version you've tested for,minSdkis the oldest OS version the app installs on. Mixing these up is a common Gradle configuration mistake.
Cheat sheet¶
| Concept | API |
|---|---|
| Single screen | class X : ComponentActivity() |
| Lifecycle callbacks | onCreate, onStart, onResume, onPause, onStop, onDestroy |
| Survives rotation | class X : ViewModel() |
| Observable state | MutableStateFlow + StateFlow (or Compose mutableStateOf) |
| Declarative UI | @Composable fun Screen() { ... } |
| Bridge Flow to Compose | val x by flow.collectAsState() |
| Side effects in Compose | LaunchedEffect(key) { ... } |
Exercise¶
Sketch (don't need to run) a TodoViewModel holding a
MutableStateFlow<List<String>> of todo items, with addItem(text:
String) and removeItem(index: Int) methods. Then sketch a @Composable
TodoScreen that collects the list, renders each item as a Text with a
"Remove" Button next to it, and a text field + "Add" button at the
bottom. Focus on getting the state-flow-to-Compose wiring right, since
that's the part this module actually demonstrated working code for.