Skip to content

4. Chapter 3 - Introduction to Android and Jetpack Compose

4.1. Sources

Official documentation: https://developer.android.com—in particular the pages “Jetpack Compose” (https://developer.android.com/compose), “Thinking in Compose,” “State and Jetpack Compose,” “Introduction to Activities,” and “ViewModel Overview.” Kotlin Coroutines: https://kotlinlang.org/docs/coroutines-overview.html

4.2. What Is an Android App?

An Android app is an .apk file (or .aab for the Play Store) that contains the compiled code, resources (icons, text, images), and a manifest ([AndroidManifest.xml])—its “identity card” for the system: its identifier, the permissions it requests (network access, etc.), and the list of its screens.

These screens are called “activities” ([Activity]). An activity is not created by the program itself: it is the SYSTEM that creates it when the user taps the app icon, and then calls its methods throughout its lifecycle: [onCreate(...)] upon creation, [onPause()] when it goes into the background, [onDestroy()] upon destruction... Therefore, there is no [main()] function in an Android app—this is the first difference from the [void main()] in the Flutter client. This lifecycle has an important consequence, detailed below: a screen rotation DESTROYS and then RECREATES the activity.

The Kotlin code is compiled into Java bytecode, then converted to the DEX format and executed by the Android virtual machine, [ART] (Android Runtime), which in turn compiles it into machine code at installation and during runtime. There is no JavaScript or [WebView] engine: it is a NATIVE app, just like the apps that come pre-installed on the phone. Like Flutter, Jetpack Compose renders its own components (buttons, input fields, etc.) rather than using the legacy Android UI components (the [View]) — but it does so using Android’s own UI libraries, rather than an in-app rendering engine like Flutter.

Unlike Flutter, this client targets ONLY Android: the same code does not produce a iOS, desktop, or web app. Chapter 5 explains what would be required to do so (Kotlin Multiplatform and Compose Multiplatform).

4.3. The Compose development model: everything is a function

With Jetpack Compose, a UI is described by Kotlin functions annotated with @Composable. A @Composable function does not return anything: it EMITS the interface by calling other @Composable functions—[Text(...)], [Button(...)], [Column(...)]... The complete interface is the tree of these nested calls.

This is the same declarative model as Flutter (“the interface is a function of state”), with one formal difference: whereas Flutter describes each element using a CLASS (a [Widget]) whose [build(...)] method returns a tree of objects, Compose uses only functions. The Compose compilation plugin transforms these functions so that they can be selectively re-executed when the state they read changes: this is called “recomposition.”

Appearance and layout settings (margins, size, alignment, background color, clicks, etc.) are not elements of the tree, like Flutter’s [Padding] or [Center] widgets, but “modifiers,” chained together and passed as parameters: [Modifier.padding(16.dp).fillMaxWidth()]. The unit [dp] (density-independent pixel) is equivalent to Flutter’s logical pixels: 1 dp corresponds to one pixel on a screen with a resolution of 160 dots per inch, regardless of the screen’s actual density.

4.4. State and recomposition: neither StatefulWidget nor setState

Flutter distinguishes, as soon as the class is declared, a [StatelessWidget] from a [StatefulWidget] associated with its [State] class, and requires calling [setState(() { ... })] for a state change to be displayed. Compose does none of this:

  • a local variable is declared within the function itself: [var clicks by remember { mutableStateOf(0) }];
  • [mutableStateOf(...)] creates an OBSERVABLE STATE: Compose automatically registers every @Composable function that READS it, and re-executes (“recomposes”) these functions—and only these functions—when the value changes. There’s nothing to call after a change: [clicks++] is all that’s needed. This is how [ref(...)]/[reactive(...)] works on the Vue.js side, not how [setState(...)] works;
  • [remember { ... }] stores a value from one function execution to the next. Without it, the state would be recreated—and reset to zero—with each call;
  • [rememberSaveable { ... }] does the same thing, and, IN ADDITION, preserves the value when the activity is destroyed and then recreated (screen rotation; see below);
  • The keyword [by] (property delegation; see the Kotlin course) allows you to write [clicks] instead of [clicks.value].

The same mechanism applies to a shared state: a regular class whose properties are declared as [by mutableStateOf(...)] is automatically observed by all functions that read those properties. This is how client services are written ([SettingsService], [AuthService], [TranslationsService]; see Chapter 4): Whereas the Flutter version extended [ChangeNotifier] and manually called [notifyListeners()] after each modification, there is nothing to report here.

4.5. Sharing state: CompositionLocal, the equivalent of provider

To make a service available to an entire tree of @Composable functions without passing it as a parameter from call to call, Compose offers [CompositionLocal]: you declare a “key” ([val LocalAuth = staticCompositionLocalOf<AuthService> { ... }]), assign a value to the top of the tree ([CompositionLocalProvider(LocalAuth provides auth) { ... }]), and any descendant function can READ it with [LocalAuth.current]. This is exactly the role of the [provider] package in the Flutter version ([MultiProvider] / [context.read<...>()]), of [provide]/[inject] on the Vue.js side, or of a React [Context]. Compose does not need the equivalent of [context.watch<...>()]: change subscription is handled by the observable states themselves.

4.6. No template language: conditions and lists

As in Flutter, conditional rendering and lists are written using standard language constructs: a [if] Kotlin expression for a condition, a [forEach] loop for a list. Since a @Composable function is just like any other function, [if (auth.isLoggedIn) { ... }] EMITS its content or not—there is no equivalent to building lists of widgets ([children: [...]]) or to Dart’s “collection if” syntax.

4.7. Coroutines: async/await in the style of Kotlin

An Android app has only one thread responsible for rendering the UI and responding to the user: the main thread. It must NEVER wait; in fact, Android prohibits any network access from this thread (exception [NetworkOnMainThreadException]). Server calls must therefore be made “in the background.” Kotlin provides coroutines for this purpose:

  • a [suspend] function can pause without blocking the thread that is executing it—the equivalent of a Dart [async] or TypeScript function. Calling a [suspend] function is equivalent to waiting for it: there is no [await] keyword; the wait is implicit;
  • [withContext(Dispatchers.IO) { ... }] executes a block on a thread reserved for I/O, then returns to the original thread with the result. This is what allows you to use a “blocking” network library ([OkHttp]) without ever blocking the interface;
  • A coroutine is launched with [launch { ... }] IN a scope ([CoroutineScope]) tied to a lifecycle: [viewModelScope] (the coroutines of a ViewModel, canceled upon its destruction) or [rememberCoroutineScope()] (those of a @Composable function, canceled when it disappears from the screen). There is no longer a risk, as in Flutter, of modifying the state of a screen that has already been destroyed (the [if (mounted)] test): the coroutine is canceled along with it.

4.8. The Lifecycle: Screen Rotation and ViewModel

By default, when the user rotates their phone (or changes the system language, or switches to dark mode), Android DESTROYS the activity and creates a new one, adapted to the new configuration. Everything that was stored in the old activity—and in the states of its @Composable functions—is lost. Flutter doesn’t have this problem: rotating the screen only changes the screen size.

Android offers two solutions, both of which the client uses:

  • [rememberSaveable { ... }] for small UI states (text being entered, selected doctor, etc.): Android saves them before destruction and restores them to the new activity;
  • a [ViewModel] for a screen’s state and logic (lists loaded from the server, displayed calendar, etc.): this is an object that Android KEEPS across activity recreations and passes to the new one. It is destroyed only when the screen is exited permanently. The client in this document places all the state that the Flutter version kept in the [_AppRootState] class into a ViewModel ([AppViewModel]).

Objects that must live as long as the application (services) are not created in the activity or in a ViewModel, but in the application’s [Application] class, which is created only once when the process starts.

4.9. A Simple First Example

Here is a small Compose application: some text, a button that counts clicks, and a message that appears only after the third click. To try it out, simply replace the contents of the [MainActivity.kt] file in a project created with the [Empty Activity] template (see Chapter 1), by modifying the line [package].

package fr.rdvmedecins.example

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                Counter(name = "RdvMedecins")
            }
        }
    }
}

@Composable
fun Counter(name: String) {
    var clicks by remember { mutableIntStateOf(0) }
    Column(modifier = Modifier.padding(32.dp)) {
        Text("Hello, $name!")
        Button(onClick = { clicks++ }) {
            Text("Clicked $clicks times")
        }
        if (clicks >= 3) {
            Text("Well done!")
        }
    }
}

Let’s break down this code:

  • line 19: [class MainActivity : ComponentActivity()]—the activity, the screen launched by the system. [ComponentActivity] is the base class intended for Compose;
  • line 20: [onCreate(...)]—called by Android when the activity is created. This is the starting point of the app’s code, replacing Flutter’s [main()];
  • line 22: [setContent { ... }]—replaces the screen content with the interface described in the block: the equivalent of [runApp(...)] in Flutter;
  • line 23: [MaterialTheme { ... }]—provides the Material 3 theme (colors, typography, shapes) to everything called within the block—the role of Flutter’s [MaterialApp], minus navigation;
  • lines 30–31: [@Composable fun Counter(name: String)]—a `@Composable` function with one parameter. By convention, its name starts with a capital letter, like a class name: it represents an interface element;
  • line 32: [var clicks by remember { mutableIntStateOf(0) }]—a local state: [mutableIntStateOf] is the variant of [mutableStateOf] optimized for an integer;
  • line 33: [Column(modifier = Modifier.padding(32.dp)) { ... }]—arranges its children one below the other, with a margin of 32 dp. [Row] would arrange them side by side;
  • line 35: [Button(onClick = { clicks++ })]—the click handler is a simple lambda. [clicks++] changes the state: Compose immediately recomposes [Counter], which READS this state, and the button text is updated;
  • Lines 38–40: a standard Kotlin [if]—the text “Well done!” is only emitted, and thus displayed, starting with the third click.

4.10. Summary: Flutter to Jetpack Compose mappings

  • [void main()] + [runApp(...)] → activity ([ComponentActivity]) + [setContent { ... }]
  • [StatelessWidget]/[StatefulWidget] (classes) → functions [@Composable]
  • fields from [State<T>] + [setState(...)] → [remember { mutableStateOf(...) }] (automatic recomposition)
  • [ChangeNotifier] + [notifyListeners()] → class with properties [by mutableStateOf(...)] (nothing to report)
  • package [provider] ([MultiProvider], [context.read]/[watch]) → [CompositionLocal] ([CompositionLocalProvider], [LocalXxx.current])
  • widget constructor parameters (inputs), functions passed as parameters (outputs) → @Composable function parameters, lambdas
  • [build(BuildContext)] → the body of the @Composable function
  • widgets [Padding], [Center], [SizedBox]... → modifiers ([Modifier.padding(...)]...)
  • [Future<T>] + [async]/[await] → [suspend] functions + coroutines
  • package [http] → library [OkHttp]
  • handwritten [fromJson(...)] constructors → [@Serializable] ([kotlinx.serialization]) classes
  • package [shared_preferences] → Android’s [SharedPreferences] (the API from which the Flutter package took its name)
  • [showDialog(...)] → a [AlertDialog(...)] function displayed as long as a state requires it
  • [LayoutBuilder] / [MediaQuery] → [BoxWithConstraints] / [LocalConfiguration]
  • [pubspec.yaml] + [flutter pub get] → [build.gradle.kts] + [libs.versions.toml] + Gradle
  • No Flutter equivalent: [ViewModel] and [rememberSaveable], caused by the activity being recreated when the screen rotates