Skip to content
Commitwise
Menu
Android Development

Kotlin for Android

The Kotlin you need for Android development, from null safety, data classes and lambdas to scope functions, lateinit and lazy, and the Java interop traps that come from the Android SDK, with Java side by side where it helps.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

Android was a Java platform for ten years, and its SDK still is. Kotlin sits on top of it: every Android API you call was written in Java, and every Kotlin feature you use compiles down to the same bytecode. That’s why Kotlin on Android has two sides: the language features that make app code short and safe, and the seams where Java code, written without Kotlin’s rules, comes back in.

New to some of these? Open what you need, or skip ahead.

Basics:What are variables and types?

A variable is a named box that holds a value: let age = 30 (Swift), val age = 30 (Kotlin), int age = 30; (Java), const age = 30 (TypeScript). The type says what kind of value fits in the box: Int / int for whole numbers, Double for decimals, String for text, Bool / Boolean for true or false.

  • let (Swift), val (Kotlin) and final (Java) mean the variable can’t be pointed at another value later. var means it can.
  • Swift, Kotlin and TypeScript can often infer the type from the value, so you don’t have to write it. Java needs it written (or var for local variables). TypeScript’s const is like let / val, and its let is like var.
Basics:What are classes and objects?

A class is a blueprint; an object (or instance) is one thing built from it. A User class says every user has a name and an email; User(name: "Ana", email: "ana@example.com") creates one specific user.

  • Properties (fields) are the data each object holds.
  • Methods are functions that belong to the class and work with that data.
  • Swift also has struct, Kotlin has data class and Java has record: shorter ways to define types that mostly hold data. TypeScript often skips classes for data and uses plain objects described by an interface.
Basics:What is an Activity?

An Activity is an Android app screen, or, in modern Compose apps, often the single container that hosts all the screens. Android creates it, calls its methods (onCreate, onStart, …) as the user moves around, and destroys it when it’s no longer needed. The Activity lifecycle tutorial covers it in detail.

Part Not started

The Kotlin you’ll write every day

If you’ve written Java, Kotlin will feel like Java with the boring parts removed: no semicolons, no new, no getters and setters you write by hand, and a compiler that refuses to let you forget about null. If you’re coming from Swift, it will feel familiar: val is let, ? means optional, and when is switch.

Kotlin’s value on Android is less about syntax and more about the type system: nullability in types, sealed hierarchies with exhaustive when, value semantics via data classes, and coroutines (covered in Coroutines and Flow). Everything compiles to JVM bytecode with predictable costs; the things to know are what each feature generates (data class methods, lambdas as objects or inlined, companion objects as static holders) and where the Java boundary weakens the guarantees.

data class Product(
    val id: String,
    val name: String,
    val priceCents: Long,
    val discountPercent: Int = 0,          // default value: callers may leave it out
    val imageUrl: String? = null,          // ? means "may be null"
) {
    val finalPriceCents: Long
        get() = priceCents * (100 - discountPercent) / 100

    fun label(): String = when {
        discountPercent >= 50 -> "Big sale"
        discountPercent > 0 -> "On sale"
        else -> "Regular"
    }
}

val mug = Product(id = "7", name = "Mug", priceCents = 950)     // no "new"
val title = "${mug.name}: ${mug.finalPriceCents} cents"          // string template
val copy = mug.copy(discountPercent = 20)                        // a changed copy
public final class Product {
    private final String id;
    private final String name;
    private final long priceCents;
    private final int discountPercent;
    @Nullable private final String imageUrl;

    public Product(String id, String name, long priceCents, int discountPercent, @Nullable String imageUrl) {
        this.id = id; this.name = name; this.priceCents = priceCents;
        this.discountPercent = discountPercent; this.imageUrl = imageUrl;
    }

    public String getName() { return name; }
    public long getPriceCents() { return priceCents; }
    // …getters for the rest, plus equals, hashCode, toString, and a copy method by hand

    public long getFinalPriceCents() { return priceCents * (100 - discountPercent) / 100; }

    public String label() {
        if (discountPercent >= 50) return "Big sale";
        if (discountPercent > 0) return "On sale";
        return "Regular";
    }
}

What Kotlin gave you for free:

  • data class generates equals, hashCode, toString and copy from the properties in parentheses.
  • val properties can’t be reassigned; Kotlin writes the getters for you (Java code still sees getName()).
  • Default and named arguments replace a pile of overloaded constructors.
  • String? says, in the type, that imageUrl may be missing. A plain String can never be null.
  • when is a switch that can test conditions and return a value.

Null safety in practice

val url: String? = product.imageUrl

val length = url?.length                  // Int?: null if url is null
val shown = url ?: "placeholder.png"      // a fallback when it's null
if (url != null) {
    loadImage(url)                        // smart cast: here url is a String
}
url?.let { loadImage(it) }                // run only when it's not null

val sure = url!!                          // "I know it's not null": crashes if it is
@Nullable String url = product.getImageUrl();

Integer length = url != null ? url.length() : null;
String shown = url != null ? url : "placeholder.png";
if (url != null) {
    loadImage(url);
}
// Java has no compile-time help: forgetting a check is a NullPointerException at runtime.

The !! operator turns Kotlin’s compile-time safety back into a runtime crash. Every !! is a promise that something can’t be null; when that promise is wrong, the app crashes with a NullPointerException. The Null safety tutorial goes deeper.

Spot the bugA crash when opening a product from a notification

Tap the line with the bug.

class ProductActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val productId = intent.getStringExtra("product_id")!!
        setContent { ProductScreen(productId) }
    }
}
public class ProductActivity extends AppCompatActivity {
    @Override protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        String productId = getIntent().getStringExtra("product_id");
        loadProduct(productId.trim());
    }
}

Lambdas and trailing lambdas

Android code passes functions around constantly: click listeners, callbacks, list transformations. In Kotlin, a lambda is written in braces, and when it’s the last argument it goes after the parentheses:

button.setOnClickListener { view -> openDetails() }     // a trailing lambda
button.setOnClickListener { openDetails() }             // the parameter can be left out

val names = products
    .filter { it.discountPercent > 0 }                   // it: the single parameter
    .sortedBy { it.finalPriceCents }
    .map { it.name }

Button(onClick = { cart.add(product) }) {                // Compose: content as a trailing lambda
    Text("Add to cart")
}
button.setOnClickListener(view -> openDetails());

List<String> names = products.stream()
        .filter(p -> p.getDiscountPercent() > 0)
        .sorted(Comparator.comparingLong(Product::getFinalPriceCents))
        .map(Product::getName)
        .toList();

Predict the output

What does this print?

val products = listOf(
    Product("1", "Mug", 950, discountPercent = 15),
    Product("2", "Lamp", 4900, discountPercent = 60),
    Product("3", "Pen", 200),
)
println(products.sortedByDescending { it.discountPercent }.map { it.label() })
List<Product> products = List.of(
        new Product("1", "Mug", 950, 15, null),
        new Product("2", "Lamp", 4900, 60, null),
        new Product("3", "Pen", 200, 0, null));
System.out.println(products.stream()
        .sorted(Comparator.comparingInt(Product::getDiscountPercent).reversed())
        .map(Product::label).toList());

What the compiler generates

  • data class: equals/hashCode/toString/copy/componentN from primary-constructor properties only; properties declared in the body are ignored by all of them (a common bug when someone moves a property into the body).
  • Lambdas: non-inline lambdas that capture variables become objects (an allocation per call site evaluation); inline higher-order functions (map, filter, let, apply) are inlined, so return inside them is non-local.
  • Companion objects: a static nested class plus an instance; use @JvmStatic/@JvmField/const val when Java callers need real statics.
  • Default arguments: a synthetic method with a bitmask; Java callers see only the full signature unless you add @JvmOverloads.
  • Data classes: generated members from primary-constructor properties only; copy is shallow.
  • Nullability: ?., ?:, smart casts on stable values, let for scoped non-null; every !! is a crash site to justify.
  • Inline higher-order functions avoid lambda allocations and allow non-local returns; capturing non-inline lambdas allocate.
  • Java callers: @JvmStatic, @JvmField, const val, @JvmOverloads, @file:JvmName.

Check this part

  1. What does a Kotlin data class generate for you?

  2. intent.getStringExtra("id")!! and the extra is missing. What happens?

  3. A property is declared in a data class's body, not its constructor. Is it part of equals and copy?

Kotlin's everyday features on Android: val and var, nullable types with ?, ?. and ?:, data classes, when, string templates, default and named arguments, and lambdas with trailing-lambda syntax. Each replaces a common Java pattern with something shorter and safer.

Part Not started

Kotlin meets the Android framework

lateinit and lazy

Android creates Activities, Fragments and Services itself, so you can’t pass everything into a constructor. Some values only exist after onCreate. Kotlin has two tools for “not yet, but soon”:

class ScannerActivity : ComponentActivity() {
    private lateinit var camera: CameraController       // set in onCreate, never null after that

    private val prefs by lazy {                          // computed on first use, then remembered
        getSharedPreferences("scanner", MODE_PRIVATE)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        camera = CameraController(this)
        if (prefs.getBoolean("torch", false)) camera.enableTorch()
    }
}
public class ScannerActivity extends ComponentActivity {
    private CameraController camera;                     // null until onCreate
    private SharedPreferences prefs;                     // lazy by hand

    private SharedPreferences prefs() {
        if (prefs == null) prefs = getSharedPreferences("scanner", MODE_PRIVATE);
        return prefs;
    }

    @Override protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        camera = new CameraController(this);
        if (prefs().getBoolean("torch", false)) camera.enableTorch();
    }
}
  • lateinit var says “I’ll set this before anyone reads it”. Reading it before that crashes with UninitializedPropertyAccessException, which at least names the property, unlike a plain NullPointerException.
  • by lazy computes the value the first time it’s read and keeps it. Here it matters because getSharedPreferences needs a fully created Activity: running it in the property initializer (before onCreate) would crash.

Scope functions

Kotlin’s apply, also, let, run and with run a block of code “on” an object. They look alike; what differs is what the object is called inside the block (this or it) and what the block returns:

FunctionInside, the object isReturnsTypical use
applythisthe objectConfigure an object: Intent(...).apply { putExtra(...) }
alsoitthe objectSide effects in a chain: .also { log(it) }
letitthe block’s resultWork with a nullable: user?.let { show(it) }
runthisthe block’s resultCompute something from an object
with(x)thisthe block’s resultSeveral calls on one object
val intent = Intent(this, ProductActivity::class.java).apply {
    putExtra(EXTRA_PRODUCT_ID, product.id)               // "this" is the Intent
    addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
startActivity(intent)

product.imageUrl?.let { url -> imageLoader.load(url) }  // only when not null

val saved = repository.save(order).also { Log.d("Orders", "Saved ${it.id}") }
Intent intent = new Intent(this, ProductActivity.class);
intent.putExtra(EXTRA_PRODUCT_ID, product.getId());
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
startActivity(intent);

if (product.getImageUrl() != null) imageLoader.load(product.getImageUrl());

Order saved = repository.save(order);
Log.d("Orders", "Saved " + saved.getId());

They’re shortcuts, not requirements. Use apply to configure an object and let for nullables; when you find yourself nesting two or three of them, a few plain lines are easier to read.

Predict the output

What happens when this Activity starts?

class WelcomeActivity : ComponentActivity() {
    private lateinit var userName: String
    private val greeting = "Hello, $userName!"

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        userName = intent.getStringExtra("name") ?: "friend"
        setContent { Text(greeting) }
    }
}
Exerciseeasy

Convert a Java class to idiomatic Kotlin

Convert this Java class to Kotlin, the way a Kotlin developer would write it: properties instead of getters, a data class if it fits, nullability in the types, and when instead of the if chain.

public class Order {
    private final String id;
    private final String status;      // "new", "paid", "shipped"; never null
    private final String trackingCode; // null until shipped

    public Order(String id, String status, String trackingCode) {
        this.id = id; this.status = status; this.trackingCode = trackingCode;
    }
    public String getId() { return id; }
    public String getStatus() { return status; }
    public String getTrackingCode() { return trackingCode; }

    public String statusText() {
        if (status.equals("new")) return "Waiting for payment";
        else if (status.equals("paid")) return "Being packed";
        else if (status.equals("shipped")) return "On its way: " + trackingCode;
        else return "Unknown";
    }
}
›

trackingCode can be null, status can’t: say so in the types. A fixed set of statuses is better as an enum class.

SolutionTry it yourself first
enum class OrderStatus { NEW, PAID, SHIPPED }

data class Order(
    val id: String,
    val status: OrderStatus,
    val trackingCode: String? = null,      // only set once shipped
) {
    fun statusText(): String = when (status) {
        OrderStatus.NEW -> "Waiting for payment"
        OrderStatus.PAID -> "Being packed"
        OrderStatus.SHIPPED -> "On its way: ${trackingCode ?: "tracking soon"}"
    }
}
  • The enum makes the compiler check that when covers every status, so the “Unknown” branch disappears; parse strings from the server into the enum at the boundary.
  • String? documents that the tracking code may be missing, and ?: decides what to show instead of printing “null”.
  • data class gives equals, toString (useful in logs and tests) and copy for updates like order.copy(status = OrderStatus.PAID).
  • Java code can still call order.getId() and order.getStatus(): Kotlin generates the getters.

Context and resources

  • Pass Context where it’s needed and keep its lifetime in mind: an Activity context in a long-lived object leaks the Activity; use applicationContext for app-scoped things (database, preferences, image loaders).
  • Read resources through the context (getString(R.string.title), stringResource(R.string.title) in Compose) so configuration (language, dark mode) applies; never cache formatted strings across configuration changes.
  • lazy is synchronized by default; for UI-thread-only values, lazy(LazyThreadSafetyMode.NONE) skips the lock. Don’t lazy anything tied to an Activity into an object that outlives it.
  • lateinit for framework-injected or onCreate-set non-null properties (crashes with a named exception if read early; can’t be primitive); by lazy for deferred, cached values (synchronized by default, NONE for UI thread).
  • Property initializers run at construction, before onCreate: no Context-dependent work there.
  • Scope functions: apply/also return the receiver, let/run/with return the block result; avoid nesting.
  • Context lifetime: applicationContext for app-scoped singletons; never hold an Activity in long-lived objects.

Check this part

  1. What happens if a lateinit property is read before it's set?

  2. Which scope function is typical for configuring an Intent before starting it?

  3. Why not store an Activity context in an app-wide singleton?

Android-specific Kotlin: lateinit for values set after construction (crashes if read too early), by lazy for values computed on first use, scope functions (apply, also, let, run, with) for configuring objects, and Context and resources passed where they're needed.

Part Not started

Where Java comes back

Kotlin’s null safety works because the compiler knows, for every value, whether it can be null. Code written in Java doesn’t say so, unless it’s annotated. When Kotlin calls such code, it gets a value of unknown nullability, and you have to decide.

Java types reach Kotlin as platform types (String!): the compiler allows treating them as nullable or non-null and inserts no checks until you assign one to a non-null declared type, where it adds a runtime assertion. Annotated APIs (@Nullable/@NonNull from AndroidX, JSpecify, JetBrains, or the Android SDK’s own annotations, which most of the platform now has) become proper Kotlin types. Unannotated libraries are where nulls leak in.

// A Java SDK without nullability annotations:
//   public String getToken();   // may return null when logged out

val a = legacyAuth.token                // type String! (platform type): no check yet
val b: String? = legacyAuth.token       // you decided: may be null, handle it
val c: String = legacyAuth.token        // you decided: never null. If it is, crash HERE
                                        // with "… must not be null"
a.length                                // compiles, crashes later with a plain NPE if null
// The Java side
public final class LegacyAuth {
    public String getToken() {           // no @Nullable, so Kotlin can't know
        return session == null ? null : session.token;
    }
}

The safe habit: when you call Java code that isn’t annotated, write the type yourself, as nullable (String?) if there’s any chance of null, and handle it right there. Letting Kotlin infer a platform type moves the crash somewhere else, far from the call, where it’s hard to understand.

Production puzzles

Spot the bugA crash in the analytics code after logout

Tap the line with the bug.

class SessionRepository(private val legacyAuth: LegacyAuth) {
    fun currentToken(): String = legacyAuth.token

    fun authHeader() = mapOf("Authorization" to "Bearer ${currentToken()}")
}

// Somewhere else, much later:
analytics.setUserProperty("token_hash", sessionRepository.currentToken().hashCode())
public final class LegacyAuth {
    private Session session;

    public String getToken() {
        return session == null ? null : session.token;
    }

    public void logOut() { session = null; }
}

Exercisemedium

Review a Kotlin pull request

Review this Android code and list what you’d change and why:

class FeedViewModel(private val api: FeedApi) : ViewModel() {
    var items: MutableList<Post> = mutableListOf()
    lateinit var context: Context

    fun load() {
        viewModelScope.launch {
            val posts = api.posts()!!
            items = posts.filter { it.isVisible!! }.toMutableList()
            items.forEach { post -> post.let { it.also { analytics.log(context, it.id) } } }
        }
    }
}
›

Look for: exposed mutable state, a Context in a ViewModel, !! on values from outside, nested scope functions doing a simple job, and how the UI would observe changes.

SolutionTry it yourself first
  • Exposed mutable state: var items: MutableList<Post> lets the UI (or anyone) change it, and nothing notifies the UI when load replaces it. Expose val state: StateFlow<FeedUiState> backed by a private MutableStateFlow, holding an immutable List<Post>.
  • lateinit var context in a ViewModel: a ViewModel outlives Activities; holding one leaks it, and lateinit means a crash if load() runs first. Analytics shouldn’t need a UI context; inject an Analytics that holds applicationContext itself, or use AndroidViewModel only if truly needed.
  • !! on network data: api.posts()!! and it.isVisible!! turn server quirks into crashes. Make the API return non-null types (Retrofit with Kotlin models and a converter that enforces them) or handle null explicitly (isVisible == true), and catch network errors to set an error state.
  • Scope function soup: post.let { it.also { … } } does nothing that analytics.log(post.id) doesn’t. Nesting let/also hides intent.
  • Main-safety and errors: api.posts() should be a suspend function that’s main-safe; wrap the call in try/catch (or runCatching, rethrowing CancellationException) and update a loading/error state.
data class FeedUiState(val posts: List<Post> = emptyList(), val isLoading: Boolean = false, val error: String? = null)

class FeedViewModel(private val api: FeedApi, private val analytics: Analytics) : ViewModel() {
    private val _state = MutableStateFlow(FeedUiState())
    val state: StateFlow<FeedUiState> = _state

    fun load() = viewModelScope.launch {
        _state.update { it.copy(isLoading = true, error = null) }
        try {
            val posts = api.posts().filter { it.isVisible == true }
            posts.forEach { analytics.log(it.id) }
            _state.update { it.copy(posts = posts, isLoading = false) }
        } catch (e: IOException) {
            _state.update { it.copy(isLoading = false, error = "Couldn't load the feed") }
        }
    }
}
  • Platform types (T!) from unannotated Java: no checks until assigned to a declared non-null type, then a runtime assertion. Declare nullability explicitly at the call site or in a Kotlin wrapper.
  • Annotate your Java (@Nullable/@NonNull, JSpecify); enable strict JSR-305/JSpecify handling so annotated APIs are enforced.
  • Kotlin seen from Java: generated getters, @JvmStatic/@JvmField/@JvmOverloads, Unit returns as void; nullable Kotlin types appear as annotated Java types.

Check this part

  1. You call an unannotated Java method that may return null. What's the safe habit?

  2. What is a platform type like String! ?

  3. fun token(): String = javaAuth.token and the Java getter returns null. Where does it crash?

Android's SDK and many libraries are written in Java, so Kotlin sees their values as platform types (String!) with unknown nullability. Annotated APIs are safe; unannotated ones can put null into a non-null Kotlin variable and crash far from the cause. Decide nullability at the boundary.

Key takeaways

  • Data classes, default arguments, val, when and string templates replace most Java boilerplate.
  • Nullable types (String?) make “may be missing” part of the type; handle them with ?., ?: and let, and avoid !!.
  • lateinit is for values set in onCreate; by lazy for values computed on first use; property initializers run before onCreate.
  • Java code without annotations can still give you null: declare the type yourself.
  • Know what the compiler generates: data class members from the primary constructor, inlined vs allocated lambdas, synthetic default-argument methods.
  • Context lifetime is the most common Android Kotlin leak; use applicationContext for app-scoped objects.
  • Platform types are the hole in null safety; fix it at the boundary with annotations, wrappers and strict checking.
  • Keep scope functions shallow and state immutable and observable.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.