Android Kotlin Handbook — Senior-
Level (Non-Compose)
A comprehensive, Android-focused Kotlin guide for 10+ years experienced engineers.
Covers language mastery, Android architecture, concurrency, testing, security,
interoperability, performance, and real-world patterns. XML (View-based) UI only; no
Jetpack Compose content.
How to Use This Handbook
Use Part 1 for Kotlin language mastery in Android contexts.
Use Parts 2–4 for concurrency, data, WorkManager, and platform APIs.
Use Parts 5–7 for interoperability, build optimization, security, and testing.
Skim anti-patterns and checklists before code reviews and performance audits.
Fig 1. MVVM with StateFlow in a View-based Android app.
Part 1 — Kotlin Core for Android
1. Null-Safety in Android Contexts
Android APIs return nullable types frequently (e.g., findViewById<T>(),
getSystemService()). Prefer safe-calls and early returns. Avoid !! except at module
boundaries with strong invariants.
// Activity/Fragment example
val toolbar: Toolbar? = findViewById(R.id.toolbar)
toolbar?.let { setSupportActionBar(it) } ?: run {
Log.w("UI", "Toolbar missing; skipping setup")
}
2. Data Classes for Parcelable Models
Use @Parcelize to reduce boilerplate; keep models immutable.
@Parcelize
data class User(
val id: Long,
val name: String,
val email: String?
) : Parcelable
3. Inline Classes for Domain Types
Inline classes reduce allocation and enforce strong typing (e.g., UserId, Email).
@JvmInline
value class UserId(val raw: Long)
@JvmInline
value class Email(val value: String) {
init { require('@' in value) }
}
4. Advanced Generics & Variance for Android
Use variance for producer/consumer APIs and RecyclerView abstractions.
interface Mapper<in I, out O> { fun map(input: I): O }
class UserDtoToUi : Mapper<UserDto, UserUi> {
override fun map(input: UserDto) = UserUi(input.id, input.name)
}
Star projections when you must accept unknown types (rare, prefer type parameters).
5. Delegation Patterns
Use class/property delegation to reuse behavior across Activities/Fragments/ViewModels.
interface Logger { fun log(msg: String) }
class AndroidLogger : Logger { override fun log(msg: String) = Log.d("APP", msg)
}
class Repo(private val api: Api, logger: Logger) : Logger by logger {
suspend fun load() { log("loading…"); /* … */ }
}
Property delegates: lazy, observable, and preference-backed.
class Settings(context: Context) {
private val prefs = context.getSharedPreferences("s", Context.MODE_PRIVATE)
var featureEnabled: Boolean by BooleanPreference(prefs, "f_enabled", false)
}
6. Extension & Higher-Order Functions
Use extensions to encapsulate Android quirks; higher-order functions for callbacks.
inline fun <T> Fragment.requireArgument(key: String): T =
requireNotNull(arguments?.get(key) as? T) { "Missing $key" }
7. Collections & Sequences Performance
Prefer sequences for large pipelines to avoid intermediate lists on the main thread.
val names = users.asSequence()
.filter { it.active }
.map { it.name.trim() }
.sorted() // terminal op; executed lazily
.toList()
Part 2 — Concurrency & Coroutines
8. Structured Concurrency with ViewModelScope
Tie jobs to lifecycle; cancel onCleared to prevent leaks/ANRs.
class UsersViewModel(
private val repo: UsersRepo
) : ViewModel() {
private val _state = MutableStateFlow<List<UserUi>>(emptyList())
val state = _state
fun refresh() = viewModelScope.launch {
val users = withContext(Dispatchers.IO) { repo.loadUsers() }
_state.value = users
}
}
9. Exception Handling: SupervisorJob & CoroutineExceptionHandler
Use SupervisorJob to isolate failures. Log with CoroutineExceptionHandler.
val handler = CoroutineExceptionHandler { _, e -> Log.e("VM", "error", e) }
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate +
handler)
10. Flow vs LiveData in Legacy UI
Prefer Flow/StateFlow; use asLiveData() only when interop with existing code is
mandatory.
lifecycleScope.launchWhenStarted {
viewModel.state.collect { render(it) }
}
11. Hot Flows: SharedFlow & StateFlow
SharedFlow for one-off events; StateFlow for state. Avoid SingleLiveEvent hacks.
private val _events = MutableSharedFlow<UiEvent>(extraBufferCapacity = 1)
val events = _events.asSharedFlow()
suspend fun showToast(msg: String) { _events.emit(UiEvent.Toast(msg)) }
12. Backpressure & Main-Safety
Throttle UI updates; use distinctUntilChanged; switch to IO for heavy ops.
viewModel.state
.distinctUntilChanged()
.flowOn(Dispatchers.Default)
.onEach { render(it) }
.launchIn(lifecycleScope)
Fig 2. Structured concurrency: sibling jobs fail independently under SupervisorJob.
Part 3 — Android Architecture & Patterns
13. MVVM with Events & State
Represent view state explicitly; model events as sealed classes.
data class UiState(
val loading: Boolean = false,
val items: List<Item> = emptyList(),
val error: String? = null
)
sealed interface UiEvent {
data object Retry : UiEvent
data class ClickItem(val id: Long) : UiEvent
}
14. Clean Architecture Layers
Use cases orchestrate; repositories abstract data; avoid Android types in domain.
class GetItems(private val repo: ItemsRepo) {
suspend operator fun invoke(): List<Item> = repo.items()
}
Fig 3. Clean Architecture separation with Android constraints.
15. Dependency Injection (Dagger/Hilt)
Prefer Hilt for Android integration; use assisted injection for runtime args.
@HiltViewModel
class DetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repo: Repo
) : ViewModel() {
private val id: Long = savedStateHandle["id"]!!
}
16. Repository & Mapper Patterns
Keep DTOs out of UI; map in a single place; prefer pure functions for mapping.
class ItemDtoToUi : Mapper<ItemDto, ItemUi> {
override fun map(input: ItemDto) = ItemUi(
id = input.id,
title = input.title ?: "Untitled"
)
}
Part 4 — Advanced Android APIs (XML UI)
17. Room + Coroutines + Flow
Expose Flow from DAO; transform on Dispatchers.Default; collect on Main.
@Dao
interface UserDao {
@Query("SELECT * FROM users ORDER BY name")
fun users(): Flow<List<UserEntity>>
}
TypeConverters for inline classes and complex types.
@TypeConverter
fun emailFromString(raw: String?): Email? = raw?.let(::Email)
18. WorkManager with Kotlin
Chain works; pass data safely; observe with LiveData or Flow.
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(workDataOf("userId" to userId.value))
.build()
WorkManager.getInstance(context).enqueue(request)
19. Networking: Retrofit + OkHttp + Kotlin
Use suspend functions in Retrofit interfaces; add interceptors for auth/logging.
interface Api {
@GET("users")
suspend fun users(): List<UserDto>
}
20. Bluetooth & BLE with Coroutines
Wrap callbacks into suspend/Flow; respect permissions and scanning windows.
suspend fun BluetoothGatt.awaitConnection(): BluetoothGatt =
suspendCancellableCoroutine { cont ->
val cb = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status:
Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED)
cont.resume(gatt) {}
}
}
// connectGatt(..., cb)
}
Fig 4. Flow pipeline from Room to ViewModel with transformation dispatcher.
Part 5 — Interoperability, Build & Optimization
21. Java Interop
SAM conversions, @JvmOverloads, @JvmStatic, and @file:JvmName for clean APIs.
class ImageLoader @JvmOverloads constructor(
private val cache: Cache,
private val network: Network = DefaultNetwork()
) {
@JvmStatic fun version() = 1
}
22. Gradle (Groovy) Tips
Keep modules small; enable configuration on demand where appropriate; cache with Gradle
Enterprise if available.
// build.gradle (app)
android {
compileSdkVersion 35
defaultConfig {
minSdkVersion 24
targetSdkVersion 35
}
buildFeatures { viewBinding true }
}
23. ProGuard/R8 Rules for Kotlin
Keep reflective usages; annotate with @Keep for entry points; keep Kotlin metadata when
needed.
-keep class kotlinx.** { *; }
-keep class kotlin.** { *; }
-keepattributes *Annotation*
24. Performance Patterns
Avoid allocations in tight draw/layout paths; reuse objects.
Use inline + value classes for small wrappers.
Prefer immutable models and diffing in RecyclerView.
Move heavy work off the main thread; measure with Systrace/Perfetto.
25. Memory & Leak Prevention
Never hold long-lived references to Views/Contexts in ViewModels.
Cancel coroutines in onStop/onDestroyView.
Use WeakReference for callbacks tied to Views if necessary.
Part 6 — Security & Testing
26. Permissions & Security
Model permission state as a sealed hierarchy; verify at runtime; defer flows until granted.
sealed interface PermissionState {
data object Granted : PermissionState
data class Denied(val permanently: Boolean) : PermissionState
}
27. Unit Testing (JUnit5, MockK, Coroutines)
Use runTest; inject Dispatchers via CoroutineDispatcherProvider.
@Test
fun `loads users`() = runTest {
val repo = mockk<UsersRepo> { coEvery { loadUsers() } returns
listOf(UserUi(1,"A")) }
val vm = UsersViewModel(repo)
vm.refresh()
assertEquals(listOf(UserUi(1,"A")), vm.state.value)
}
28. UI Testing (Espresso)
Prefer idling resources or coroutines test dispatchers to avoid flakiness.
onView(withId(R.id.button_retry)).perform(click())
onView(withText("Loaded")).check(matches(isDisplayed()))
29. Observability & Debugging Coroutines
Add coroutine name; log transitions; expose diagnostic toggles in debug builds.
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main +
CoroutineName("UsersVM"))
Part 7 — Best Practices, Pitfalls & Checklists
30. Idiomatic Kotlin in Android
Prefer expression bodies and val over var.
Prefer sealed hierarchies over enums with data.
Avoid platform types leaking into domain.
31. Scope Functions: When & Why
Use apply for builders; let for nullable chains; also for side-effects; run for single-expression
blocks.
val dialog = AlertDialog.Builder(context).apply {
setTitle("Title")
setMessage("Message")
}.create()
32. Anti-Patterns
SingleLiveEvent hacks for events; use SharedFlow instead.
Doing IO on Main; use withContext(Dispatchers.IO).
ViewModel keeping View/Fragment references.
Leaking coroutines from adapters or custom views.
33. Migration from Legacy Java
Migrate gradually: module by module; keep interop clean; replace Rx with Flow via
adapters where needed.
// Rx -> Flow adapter (simplified)
fun <T: Any> Observable<T>.asFlow(): Flow<T> = callbackFlow {
val d = subscribe({ trySend(it) }, { close(it) }, { close() })
awaitClose { d.dispose() }
}
Appendix A — Threading & Dispatchers Cheat Sheet
Main: UI updates only; never block.
Default: CPU-bound transformations and reducers.
IO: File/network/db operations.
Unconfined: advanced; avoid in UI code.
Appendix B — Code Review Checklist (Android + Kotlin)
1. Are coroutines lifecycle-aware and cancelable?
2. Is Flow used instead of SingleLiveEvent for UI events?
3. Are repositories pure and side-effect free (except IO boundaries)?
4. Are mappers tested and isolated?
5. Any reflection requiring keep rules?
6. Is null-handling explicit and safe?