Bryn Flow

🤖 Learn Android (Kotlin)

14 core topics for building native Android apps, from your first Activity to Room databases and Coroutines. Each topic includes a short explanation and a working code sample.

← All Tracks

1 Activities & the Activity Lifecycle

An Activity represents a single screen with a UI. Android manages its lifecycle for you, calling methods like onCreate, onStart, onResume, onPause, onStop, and onDestroy as the user navigates, rotates the device, or the system reclaims memory. Understanding this lifecycle is essential for starting/stopping resources like cameras, sensors, or network listeners at the right time.

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        Log.d("Lifecycle", "onCreate: screen is being built")
    }

    override fun onStart() {
        super.onStart()
        Log.d("Lifecycle", "onStart: activity becoming visible")
    }

    override fun onResume() {
        super.onResume()
        Log.d("Lifecycle", "onResume: activity is in the foreground")
    }

    override fun onPause() {
        super.onPause()
        Log.d("Lifecycle", "onPause: partially obscured, save lightweight state")
    }
}

Never do heavy work in onCreate that blocks the main thread — the UI will freeze and Android may show an "app not responding" dialog.

2 Layouts: XML Views vs Jetpack Compose

Traditionally, Android UIs were built with XML layout files paired with a View hierarchy in code. Jetpack Compose is the modern approach: you describe the UI declaratively in Kotlin functions, and Compose handles rendering and updates automatically when data changes. New apps should generally start with Compose, but XML Views remain common in older, larger codebases.

<!-- res/layout/activity_main.xml (traditional View system) -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">

    <TextView
        android:id="@+id/greeting"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello, View system!" />

</LinearLayout>

// Equivalent in Jetpack Compose (no XML needed)
@Composable
fun GreetingScreen() {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(text = "Hello, Compose!")
    }
}

You can mix both: Compose UIs can be embedded inside XML Views using ComposeView, which is handy during a gradual migration.

3 Jetpack Compose Basics

A @Composable function describes a piece of UI and can be recomposed (re-run) automatically whenever the data it reads changes. remember caches a value across recompositions, and mutableStateOf creates observable state — when it changes, Compose knows exactly which composables to redraw.

import androidx.compose.runtime.*
import androidx.compose.material3.*
import androidx.compose.foundation.layout.*

@Composable
fun Counter() {
    // "count" survives recomposition thanks to remember
    var count by remember { mutableStateOf(0) }

    Column {
        Text(text = "Count: $count")
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

Composable functions should be side-effect free and fast — they may run many times, so avoid doing network calls or heavy logic directly inside them.

4 State Management in Compose (State Hoisting)

State hoisting means moving state out of a composable and up to its caller, passing the value down and an event callback up. This makes composables stateless, reusable, and easier to test, since the same UI can be driven by different sources of truth (a ViewModel, a parent screen, or a preview).

// Stateless, reusable composable — receives state and an event callback
@Composable
fun NameInput(name: String, onNameChange: (String) -> Unit) {
    TextField(
        value = name,
        onValueChange = onNameChange,
        label = { Text("Your name") }
    )
}

// Parent "hoists" and owns the state
@Composable
fun NameScreen() {
    var name by remember { mutableStateOf("") }
    NameInput(name = name, onNameChange = { name = it })
}

A good rule of thumb: state should live at the lowest common ancestor of all composables that read or write it.

5 Lists: LazyColumn / RecyclerView

LazyColumn is Compose's efficient scrolling list — it only composes and lays out items currently visible on screen, similar to how RecyclerView works in the View system with its Adapter and ViewHolder pattern. For new Compose code, LazyColumn is much simpler to set up.

data class Task(val id: Int, val title: String)

@Composable
fun TaskList(tasks: List<Task>) {
    LazyColumn {
        items(tasks, key = { it.id }) { task ->
            Text(
                text = task.title,
                modifier = Modifier.padding(12.dp)
            )
        }
    }
}

Always pass a stable key (like an ID) to items() so Compose can efficiently track item moves, insertions, and deletions.

7 ViewModel & LiveData / StateFlow

A ViewModel holds UI-related data that survives configuration changes like screen rotation, keeping it separate from the Activity/Composable lifecycle. It commonly exposes state via LiveData (the older, View-system-friendly observable) or StateFlow (the modern Kotlin coroutines-based observable, preferred with Compose).

class CounterViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(0)
    val uiState: StateFlow<Int> = _uiState.asStateFlow()

    fun increment() {
        _uiState.value += 1
    }
}

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val count by viewModel.uiState.collectAsState()

    Button(onClick = { viewModel.increment() }) {
        Text("Count: $count")
    }
}

Never store a reference to an Activity or Composable inside a ViewModel — it will cause memory leaks since the ViewModel can outlive them.

8 Room Database

Room is Android's official SQLite abstraction. You define an @Entity class for each table, a @Dao (Data Access Object) interface for queries, and a @Database class that ties them together. Room generates the SQL and boilerplate for you at compile time and integrates with Coroutines/Flow for reactive queries.

@Entity(tableName = "notes")
data class Note(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val title: String,
    val body: String
)

@Dao
interface NoteDao {
    @Insert
    suspend fun insert(note: Note)

    @Query("SELECT * FROM notes ORDER BY id DESC")
    fun getAllNotes(): Flow<List<Note>>
}

@Database(entities = [Note::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun noteDao(): NoteDao
}

Room checks your SQL queries at compile time, so a typo in a column name fails the build instead of crashing at runtime.

9 Networking with Retrofit

Retrofit turns an HTTP API into a Kotlin interface: you describe endpoints with annotations like @GET and @POST, and Retrofit generates the networking code, handling request building, JSON parsing (often via a converter like Moshi or Gson), and error handling.

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

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

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .addConverterFactory(MoshiConverterFactory.create())
    .build()

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

// Call from a coroutine, e.g. inside a ViewModel's viewModelScope
suspend fun loadUser(id: Int): User {
    return api.getUser(id)
}

Retrofit's suspend functions already run off the main thread, so you don't need to manually wrap them in Dispatchers.IO — Retrofit handles that internally.

10 Coroutines & Suspend Functions

Coroutines are Kotlin's way of writing asynchronous code that reads like sequential code, without callback nesting. A suspend function can pause and resume without blocking the underlying thread. You launch coroutines inside a CoroutineScope (like viewModelScope or lifecycleScope in Android) and choose a Dispatcher for where the work runs.

class UserRepository(private val api: ApiService) {

    suspend fun fetchUserName(id: Int): String = withContext(Dispatchers.IO) {
        // Runs on a background thread pool, safe for network/disk I/O
        val user = api.getUser(id)
        user.name
    }
}

// Inside a ViewModel
fun loadName(id: Int) {
    viewModelScope.launch {
        val name = repository.fetchUserName(id)
        _uiState.value = name // safely resumes on the main thread
    }
}

A coroutine launched with viewModelScope or lifecycleScope is automatically cancelled when the ViewModel or lifecycle owner is destroyed, preventing leaks.

11 Permissions

Dangerous permissions (camera, location, contacts, etc.) must be requested from the user at runtime, not just declared in the manifest. Compose and AndroidX provide the ActivityResultContracts.RequestPermission API to launch the system permission dialog and handle the user's response.

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.CAMERA" />

@Composable
fun CameraPermissionButton() {
    val context = LocalContext.current
    var granted by remember { mutableStateOf(false) }

    val launcher = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { isGranted -> granted = isGranted }

    Button(onClick = {
        launcher.launch(Manifest.permission.CAMERA)
    }) {
        Text(if (granted) "Camera granted" else "Request camera access")
    }
}

Always explain why you need a permission before requesting it — Android may show a rationale prompt, and users are far more likely to grant permissions they understand.

12 Fragments

A Fragment is a reusable portion of UI with its own lifecycle, hosted inside an Activity — historically used to build multi-pane layouts and modular screens in the View system. In new Compose-first apps, fragments are largely unnecessary since composables already provide reusable, independently-testable UI pieces; fragments are mainly relevant when maintaining or migrating existing XML-based apps.

class ProfileFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return inflater.inflate(R.layout.fragment_profile, container, false)
    }
}

// Hosting it inside an Activity
supportFragmentManager.commit {
    replace(R.id.fragment_container, ProfileFragment())
}

If you're starting a new project today, prefer Compose screens and Navigation Compose over Fragments unless you have a specific reason to interoperate with existing Fragment-based code.

13 Material Design Components

Jetpack Compose ships a Material Design 3 component library (androidx.compose.material3) with ready-made building blocks — Button, TextField, Scaffold (which arranges a screen's top bar, bottom bar, floating action button, and content), and more — so you get a consistent, accessible look without hand-rolling styles.

@Composable
fun ProfileScreen() {
    var email by remember { mutableStateOf("") }

    Scaffold(
        topBar = { TopAppBar(title = { Text("Edit Profile") }) },
        floatingActionButton = {
            FloatingActionButton(onClick = { /* save */ }) {
                Icon(Icons.Default.Check, contentDescription = "Save")
            }
        }
    ) { padding ->
        Column(modifier = Modifier.padding(padding)) {
            TextField(
                value = email,
                onValueChange = { email = it },
                label = { Text("Email") }
            )
            Button(onClick = { /* submit */ }) {
                Text("Save changes")
            }
        }
    }
}

The padding value passed into the Scaffold content lambda must be applied to your content — skipping it causes your UI to be drawn underneath the top or bottom bars.

14 Dependency Injection Basics (Hilt)

Dependency injection means a class receives the objects it depends on (like a database or API client) from the outside rather than constructing them itself, which makes code easier to test and reuse. Hilt is Android's official DI framework built on top of Dagger — you annotate classes to declare how dependencies are provided and injected, and Hilt generates the wiring code.

@HiltAndroidApp
class MyApplication : Application()

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides
    @Singleton
    fun provideApiService(): ApiService {
        return Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .build()
            .create(ApiService::class.java)
    }
}

@HiltViewModel
class UserViewModel @Inject constructor(
    private val api: ApiService
) : ViewModel() {
    // api is provided automatically by Hilt — no manual wiring needed
}

Mark your Activity with @AndroidEntryPoint so Hilt knows to inject dependencies into it and any ViewModels or Composables it hosts.

Practice Quiz

Test what you just learned about Android development.

Ready for another track?

Explore iOS, React Native, Flutter, or Dart next.

Back to All Tracks