Back to All Sheets
Android / KotlinKotlin 2.x / Modern AndroidBEGINNER

Android Kotlin

Beginner-to-advanced Android application reference covering Kotlin essentials, platform components, lifecycle, state, resources, intents, permissions, storage, and scalable app architecture.

52 min read
Category: Android

Kotlin Essentials for Android

Use Kotlin's type system to make Android boundaries explicit and safe.

Null Safety & Platform Boundaries

Model absence with nullable types, narrow once, and avoid assertions at lifecycle and Java interop boundaries.

kotlin
val userId: String = intent.getStringExtra(EXTRA_USER_ID)
?: return finish()
val title = user?.displayName.orEmpty()
val avatar = user?.avatarUrl?.let(imageLoader::load)
requireNotNull(savedState[KEY_ACCOUNT_ID]) { "Missing account id" }
Code Annotations:
?: return finish()

Fail or exit at the boundary so the rest of the function uses a non-null value.

?.let

Run an operation only when the receiver is non-null.

πŸ›‘
Avoid !!

Lifecycle timing, missing Bundle keys, Java platform types, and process recreation make non-null assertions fragile. Validate at boundaries or model a loading/absent state.

data, sealed, enum & value Types

Use data classes for immutable values, sealed hierarchies for closed alternatives, enums for fixed constants, and value classes to prevent primitive confusion.

kotlin
@JvmInline value class UserId(val value: String)
data class User(val id: UserId, val name: String)
sealed interface LoadState<out T> {
data object Loading : LoadState<Nothing>
data class Ready<T>(val value: T) : LoadState<T>
data class Failed(val message: String) : LoadState<Nothing>
}
fun render(state: LoadState<User>) = when (state) {
LoadState.Loading -> showSpinner()
is LoadState.Ready -> showUser(state.value)
is LoadState.Failed -> showError(state.message)
}
πŸ’‘
Prefer Impossible States to Be Unrepresentable

A sealed state prevents contradictory flags such as loading=true and error!=null. A data class is better when states can coexist independently.

Extensions, Delegation & Scope Functions

Use extensions for discoverable boundary helpers, delegation for reusable behavior, and scope functions only when the receiver/result distinction stays obvious.

kotlin
fun Context.dp(value: Int): Float = value * resources.displayMetrics.density
class Settings(private val prefs: SharedPreferences) {
var darkMode: Boolean by BooleanPreference(prefs, "dark_mode", false)
}
val intent = Intent(context, DetailActivity::class.java).apply {
putExtra(EXTRA_ID, id.value)
}
πŸ“Œ
Extensions Are Statically Dispatched

An extension does not modify or virtually override the receiver type. Prefer an injected collaborator when behavior needs state, substitution, or polymorphism.

Android Components & Process Model

Know which platform component owns entry, UI, background, and cross-app work.

Activity, Service, Receiver & Provider

Activities host user-facing windows; services perform user-visible ongoing work; receivers handle brief broadcasts; providers expose structured data across process boundaries.

text
Activity: screen/window entry point; can be recreated at any time
Foreground service: ongoing user-noticeable work with notification
BroadcastReceiver: short event handler; schedule longer work
ContentProvider: URI-based data contract, often cross-process
WorkManager: persistent deferrable work with constraints
ViewModel: screen state/business-logic holder, not a platform component
πŸ›‘
The Process Is Disposable

Android may kill a background process without calling a final callback. Persist durable state as it changes; never rely on an Application singleton as permanent storage.

Context & Memory Ownership

Activity context carries UI configuration and window identity; application context follows the process and must not own screen objects.

kotlin
class AvatarCache(context: Context) {
private val appContext = context.applicationContext
}
fun showDialog(activity: Activity) {
MaterialAlertDialogBuilder(activity) // UI needs themed Activity context
.setMessage(R.string.confirm)
.show()
}
🧠
Do Not Store Activities

Long-lived objects retaining Activity, Fragment, View, binding, or callback references leak an obsolete UI after configuration changes.

Lifecycle, Configuration & State Restoration

Match every value and task to the lifetime it must survive.

Lifecycle Ownership

Lifecycle callbacks describe transitions, not guaranteed paired events. Use lifecycle-aware APIs and keep callbacks small.

Visual Architecture & Flowchart
Mermaid.js
stateDiagram-v2 [*] --> Created: onCreate Created --> Started: onStart Started --> Resumed: onResume Resumed --> Started: onPause Started --> Created: onStop Created --> [*]: onDestroy
πŸ›‘
onDestroy Is Not Persistence

Process death may bypass cleanup callbacks. Persist important user data immediately or transactionally; use callbacks only to release resources while the process remains alive.

ViewModel, Saved State & Persistent Storage

Choose storage by lifetime, size, and source of truth rather than convenience.

Android State Survival Matrix
State HolderRecompositionConfiguration ChangeProcess DeathDurable RelaunchBest For
rememberYesNoNoNoEphemeral composable-local state
rememberSaveableYesYesUsually, if saveableNoSmall UI state such as input and selection
ViewModelYesYesNo by itselfNoScreen state and orchestration
SavedStateHandleYesYesYes for small saveable valuesNoNavigation arguments and restoration keys
Room / DataStore / fileYes when observedYesYesYesAuthoritative durable application or preference data
text
Configuration change only β†’ ViewModel
Process recreation, small UI input/key β†’ SavedStateHandle / saved-state APIs
App restart, user/application data β†’ Room, DataStore, file, or server
Large/complex state β†’ persist canonical data; save only an ID or query
Ephemeral derived UI β†’ recompute from source of truth
⚠️
ViewModel Does Not Survive Process Death

A ViewModel survives owner recreation such as rotation, not system process termination. SavedStateHandle is for small reconstruction inputs, not a database replacement.

SavedStateHandle

Store small, serializable inputs needed to reconstruct screen state after process recreation.

kotlin
class SearchViewModel(
private val savedState: SavedStateHandle,
private val repository: SearchRepository
) : ViewModel() {
var query: String
get() = savedState["query"] ?: ""
set(value) { savedState["query"] = value }
fun restore() = repository.search(query)
}
πŸ’‘
Save Reconstruction Inputs

Save the query or entity ID, then reload canonical records. Saving whole object graphs risks transaction-size failures and stale duplicated data.

Intents, Results, Deep Links & Permissions

Treat every platform or external-app boundary as untrusted input.

Explicit & Implicit Intents

Use explicit intents for known in-app components and implicit intents for capabilities another app may handle.

kotlin
val internal = Intent(this, DetailActivity::class.java)
.putExtra(EXTRA_ITEM_ID, itemId.value)
val share = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, publicUrl)
}
startActivity(Intent.createChooser(share, getString(R.string.share_with)))
πŸ”
Validate Incoming Data

Intent extras, URIs, ClipData, and deep-link parameters can be absent, malformed, oversized, or attacker-controlled. Parse into domain types before use.

Activity Result Contracts

Register contracts during initialization and keep result handling separate from launching.

kotlin
private val pickImage = registerForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let(viewModel::onImageSelected)
}
fun chooseImage() = pickImage.launch("image/*")
πŸ“Œ
Persist Long-Lived URI Access

If a document URI is needed after restart, use the appropriate document contract and persistable URI permission. A temporary grant may expire.

Runtime Permission Flow

Ask in context, explain value, handle denial gracefully, and request only permissions required by the current platform behavior.

kotlin
val requestCamera = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) openCamera() else showCameraAlternative()
}
when {
checkSelfPermission(Manifest.permission.CAMERA) == PERMISSION_GRANTED -> openCamera()
shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> showRationale()
else -> requestCamera.launch(Manifest.permission.CAMERA)
}
⚠️
Denial Is a Valid State

Users may deny permanently or policy may restrict access. The app must remain usable where possible and should not loop or pressure the user.

Resources, Localization & Configuration

Keep user-facing values in resources and design for runtime configuration changes.

Resource Types & Qualifiers

Use strings, plurals, dimensions, colors, drawables, and qualified alternatives so Android selects the best resource for locale, density, size, and night mode.

kotlin
val message = resources.getQuantityString(
R.plurals.download_count,
count,
count
)
val spacing = resources.getDimensionPixelSize(R.dimen.content_spacing)
🌍
Do Not Concatenate Sentences

Word order and plural rules vary by language. Use formatted string/plural resources with translator context rather than assembling user-visible fragments in Kotlin.

Configuration & Adaptive UI

Rotation, resize, locale, font scale, density, theme, and device posture can change while the app runs.

text
Avoid caching configuration-derived values globally
Use dp for layout and sp for scalable text
Test large font scale and long translations
Use window size classes/adaptive layouts, not device-name checks
Let Activities recreate unless a proven platform integration requires manual handling
πŸ’‘
Recreation Is a Correctness Test

Regularly enable developer options that destroy activities and test rotation/resizing. Bugs found there often reveal misplaced ownership or missing persistence.

Data, Storage & Background Work

Select persistence and scheduling tools based on data shape, consistency, durability, and user visibility.

Room, DataStore, Files & Cache

Room fits relational/queryable data; DataStore fits small settings; files fit opaque or streamed data; cache is disposable and must have a source of truth.

text
Relational entities, joins, migrations β†’ Room
Typed preferences or small settings β†’ DataStore
Media/documents/blobs β†’ files or platform document storage
Temporary reproducible data β†’ cache directory
Sensitive secrets β†’ platform-backed secure design; minimize storage
Cross-device canonical data β†’ server plus explicit offline strategy
πŸ—ƒοΈ
Migrations Are Product Code

Test schema and preference migrations against real historical versions. Destructive fallback can erase user data and should be an explicit product decision.

Coroutine, WorkManager, Alarm or Foreground Service

Use a coroutine for in-process lifetime-bound work, WorkManager for persistent deferrable jobs, alarms for precise user-facing time semantics when allowed, and foreground services for ongoing noticeable work.

text
Needed only while screen exists β†’ ViewModel/lifecycle coroutine
Must finish eventually after app exit β†’ WorkManager
Periodic best-effort maintenance β†’ periodic WorkManager
Exact calendar/user alarm β†’ alarm APIs and current permission rules
Ongoing user-noticeable operation β†’ foreground service + notification
⚠️
Background Limits Change

Android background execution and permission rules evolve. Check official guidance for your target SDK before shipping scheduling, alarms, or foreground services.

Architecture, Security & Production Readiness

Scale through clear ownership and boundaries, not layers added by habit.

UI, Data & Optional Domain Layers

The UI renders state and sends actions; repositories expose application data; use cases are valuable when business logic is reused or a ViewModel becomes complex.

Visual Architecture & Flowchart
Mermaid.js
flowchart LR UI[UI: state + user actions] --> VM[Screen ViewModel] VM --> UC[Optional use cases] VM --> R[Repository] UC --> R R --> L[Local source] R --> N[Network source] R --> P[Platform source]
kotlin
class ProfileViewModel(
repository: ProfileRepository,
private val saveProfile: SaveProfile
) : ViewModel() {
val uiState = repository.observeProfile() // stream details in Flow sheet
fun save(input: ProfileInput) = saveProfile(input)
}
πŸ’‘
Single Source of Truth

Choose one authoritative owner for each piece of data. Other layers observe or derive it rather than maintaining unsynchronized copies.

Dependency Injection & Boundaries

Prefer constructor injection. Use Hilt when component graph complexity warrants it; manual composition is sufficient for small apps.

kotlin
class DefaultUserRepository(
private val api: UserApi,
private val dao: UserDao,
private val clock: Clock
) : UserRepository
⚠️
Inject Behavior, Not Service Locators

Passing Context, a container, or a global singleton deep into business code hides dependencies. Inject the narrow API the class needs.

Production Checklist

Audit boundary validation, exported components, lifecycle ownership, state restoration, accessibility, performance, and observability.

text
β–‘ External Intent/URI/provider input is validated
β–‘ Exported components are intentional and permission-protected
β–‘ Secrets and sensitive logs are minimized
β–‘ UI objects are not retained by long-lived owners
β–‘ Durable state is persisted before process death
β–‘ Rotation, resize, locale, night mode, and large fonts work
β–‘ Blocking/CPU work never stalls the main thread
β–‘ Offline, denial, empty, loading, and retry states are designed
β–‘ App startup and critical journeys are measured
β–‘ Errors have actionable telemetry without personal data
β–‘ Tests cover ViewModels, repositories, and navigation journeys
πŸ”—
Related Deep Dives

Use the dedicated Coroutines, Flows, Compose, Modifiers, Testing, Collections, and Naming sheets for mechanics intentionally excluded here.

Related References

Recommended Next Sheets