100 Android gotchas
The mistakes that come up again and again in Android code review and in interviews. Each one is the snippet people write, next to what it should be.
1. Array field in a data class
Gotcha
data class Packet(val bytes: ByteArray)
val a = Packet(byteArrayOf(1, 2))
val b = Packet(byteArrayOf(1, 2))
a == b // falseCorrect
data class Packet(val bytes: ByteArray) {
override fun equals(other: Any?) =
other is Packet && bytes.contentEquals(other.bytes)
override fun hashCode() = bytes.contentHashCode()
}
// or just: data class Packet(val bytes: List<Byte>)The generated equals() compares arrays by identity, so two identical payloads are never equal. More on kotlin
2. Mutable object used as a map or set key
Gotcha
data class User(var name: String)
val set = mutableSetOf(User("ann"))
set.first().name = "bea"
set.contains(User("bea")) // falseCorrect
data class User(val name: String)
val set = mutableSetOf(User("ann"))
// replace instead of mutate
set.remove(User("ann"))
set.add(User("bea"))Mutating a field after insertion changes hashCode, so the entry is stranded in the wrong bucket. More on kotlin
3. Overriding equals() but not hashCode()
Gotcha
class Id(val value: String) {
override fun equals(other: Any?) =
other is Id && other.value == value
// no hashCode()
}Correct
class Id(val value: String) {
override fun equals(other: Any?) =
other is Id && other.value == value
override fun hashCode() = value.hashCode()
}Two equal objects land in different buckets, so every hash-based collection silently misbehaves. More on kotlin
4. Reaching for !! because a var will not smart-cast
Gotcha
class Screen {
var user: User? = null
fun render() {
if (user != null) {
show(user!!.name) // NPE if cleared mid-flight
}
}
}Correct
class Screen {
var user: User? = null
fun render() {
val u = user ?: return // snapshot once
show(u.name)
}
}A mutable property can change between the check and the use, and !! turns that race into a crash. More on kotlin
5. Expecting extension functions to be polymorphic
Gotcha
open class Animal
class Dog : Animal()
fun Animal.speak() = "..."
fun Dog.speak() = "woof"
val a: Animal = Dog()
a.speak() // "..." not "woof"Correct
open class Animal { open fun speak() = "..." }
class Dog : Animal() { override fun speak() = "woof" }
val a: Animal = Dog()
a.speak() // "woof"Extensions resolve on the static type at the call site, so the subclass version never runs. More on kotlin
6. Using assert() to guard an invariant
Gotcha
fun setProgress(p: Int) {
assert(p in 0..100) // no-op on device
field = p
}Correct
fun setProgress(p: Int) {
require(p in 0..100) { "progress out of range: " + p }
field = p
}
// require -> IllegalArgumentException (bad input)
// check -> IllegalStateException (bad state)JVM assertions are disabled by default on Android, so the check never runs in a real app. More on kotlin
7. Chaining map and filter over a large list
Gotcha
items // 50_000 rows
.map { it.toDomain() } // new list
.filter { it.isActive } // new list
.take(20) // new listCorrect
items.asSequence()
.filter { it.isActive } // cheapest test first
.map { it.toDomain() }
.take(20)
.toList() // one list, 20 itemsEvery operator allocates a whole intermediate list, so an N-step chain walks the data N times. More on kotlin
8. Removing from a list you are iterating
Gotcha
for (task in tasks) {
if (task.done) tasks.remove(task)
}
// ConcurrentModificationExceptionCorrect
tasks.removeAll { it.done }
// or, if you need the iterator:
val it = tasks.iterator()
while (it.hasNext()) if (it.next().done) it.remove()The iterator notices the structural change and throws ConcurrentModificationException. More on kotlin
9. Trusting a List to be immutable
Gotcha
class Cart {
private val _items = mutableListOf<Item>()
val items: List<Item> get() = _items
}
// caller:
(cart.items as MutableList).clear()Correct
class Cart {
private val _items = mutableListOf<Item>()
val items: List<Item> get() = _items.toList()
}
// caller gets a snapshot; the cast failsList is a read-only view, not a copy, so the caller can cast it back and edit your state. More on kotlin
10. Treating a Java return value as non-null
Gotcha
// Java: String getTitle() { return null; }
val len = javaObj.title.length
// NPE, and the compiler never warned youCorrect
val len = javaObj.title?.length ?: 0
// better: annotate the Java side
// @Nullable String getTitle()Unannotated Java types are platform types: Kotlin skips the null check and you get an NPE at the use site. More on kotlin
11. Companion val where you wanted a constant
Gotcha
class Api {
companion object {
val BASE = "https://x.dev" // getter, not inlined
}
}
// @Header(Api.BASE) -> compile errorCorrect
class Api {
companion object {
const val BASE = "https://x.dev"
}
}
// inlined at every call site, usable in annotationsA plain companion val is a getter call on a synthetic class, and it cannot be used in annotations. More on kotlin
12. when as a statement over a sealed type
Gotcha
sealed interface State
// later someone adds State.Error
fun render(s: State) {
when (s) {
is State.Loading -> spinner()
is State.Data -> list(s.rows)
} // Error falls through, no warning
}Correct
fun render(s: State) = when (s) {
is State.Loading -> spinner()
is State.Data -> list(s.rows)
} // expression: adding Error breaks the buildAs a statement, when does not have to be exhaustive, so a new subclass compiles and silently does nothing. More on kotlin
13. else branch on an exhaustive when
Gotcha
when (result) {
is Ok -> show(result.data)
else -> showError() // new Retry case lands here
}Correct
when (result) {
is Ok -> show(result.data)
is Failed -> showError()
is Retry -> showRetry() // added by the compiler's demand
}else absorbs every future subclass, throwing away the one compiler check that makes sealed types worth using. More on kotlin
14. lateinit read before it is assigned
Gotcha
class Frag : Fragment() {
private lateinit var adapter: Adapter
override fun onCreate(b: Bundle?) {
adapter.submit(emptyList()) // crash
}
override fun onViewCreated(...) { adapter = Adapter() }
}Correct
class Frag : Fragment() {
private val adapter by lazy { Adapter() }
override fun onCreate(b: Bundle?) {
adapter.submit(emptyList()) // built on first touch
}
}lateinit removes the null check but not the null, so an early read throws UninitializedPropertyAccessException. More on kotlin
15. Launching in GlobalScope
Gotcha
class UserViewModel : ViewModel() {
fun load() {
GlobalScope.launch {
_state.value = repo.fetch() // still running after onCleared
}
}
}Correct
class UserViewModel : ViewModel() {
fun load() {
viewModelScope.launch {
_state.value = repo.fetch() // cancelled in onCleared
}
}
}Nothing cancels it, so the job outlives the screen and keeps the ViewModel and its captures alive. More on coroutines
16. A hand-rolled CoroutineScope nobody cancels
Gotcha
class MyActivity : AppCompatActivity() {
private val scope = CoroutineScope(Dispatchers.Main)
override fun onCreate(b: Bundle?) {
scope.launch { poll() }
}
// no cancel anywhere
}Correct
class MyActivity : AppCompatActivity() {
override fun onCreate(b: Bundle?) {
lifecycleScope.launch { poll() }
}
}
// if you must own one: override onDestroy { scope.cancel() }A scope you construct is yours to cancel, and every rotation creates another one that never stops. More on coroutines
17. Blocking work inside a main-dispatched coroutine
Gotcha
viewModelScope.launch {
val bytes = File(path).readBytes() // main thread
_state.value = decode(bytes)
}Correct
viewModelScope.launch {
val bytes = withContext(Dispatchers.IO) {
File(path).readBytes()
}
_state.value = decode(bytes)
}launch does not move you off the main thread, so a blocking call still freezes the frame. More on coroutines
18. catch (e: Exception) around suspending code
Gotcha
try {
repo.sync()
} catch (e: Exception) {
log(e) // eats CancellationException
retryLater() // keeps running after cancel
}Correct
try {
repo.sync()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(e); retryLater()
}CancellationException is an Exception, so a blanket catch swallows cancellation and the coroutine refuses to stop. More on coroutines
19. runBlocking to call a suspend function
Gotcha
override fun onCreate(b: Bundle?) {
val user = runBlocking { repo.user() } // ANR
render(user)
}Correct
override fun onCreate(b: Bundle?) {
lifecycleScope.launch {
render(repo.user())
}
}runBlocking parks the calling thread, so on the main thread it is a self-inflicted ANR. More on coroutines
20. async followed straight by await
Gotcha
val user = async { api.user() }.await()
val feed = async { api.feed() }.await()
// 300ms + 300msCorrect
coroutineScope {
val user = async { api.user() }
val feed = async { api.feed() }
render(user.await(), feed.await()) // 300ms total
}Awaiting each call before starting the next makes two parallel requests strictly sequential. More on coroutines
21. try/catch wrapped around an async block
Gotcha
try {
val d = async { api.user() } // throws -> cancels parent
render(d.await())
} catch (e: IOException) {
showError() // never reached
}Correct
supervisorScope {
val d = async { api.user() }
try {
render(d.await())
} catch (e: IOException) {
showError()
}
}async reports failure to its parent job first, so the crash happens outside your catch. More on coroutines
22. Unhandled throw inside launch
Gotcha
viewModelScope.launch {
_state.value = repo.fetch() // IOException -> crash
}Correct
viewModelScope.launch {
_state.value = try {
UiState.Data(repo.fetch())
} catch (e: IOException) {
UiState.Error(e.message)
}
}An exception in launch goes to the scope handler, and with no handler installed it takes the process down. More on coroutines
23. Dispatchers.IO for CPU-bound work
Gotcha
withContext(Dispatchers.IO) {
bitmap.applyBlur(radius = 25) // pure CPU
}Correct
withContext(Dispatchers.Default) {
bitmap.applyBlur(radius = 25)
}
// IO = blocking waits, Default = computationIO is sized for waiting threads (64+), so CPU work there oversubscribes the cores and thrashes. More on coroutines
24. A loop that never checks for cancellation
Gotcha
viewModelScope.launch(Dispatchers.Default) {
for (row in oneMillionRows) {
heavy(row) // no suspend point, cancel ignored
}
}Correct
viewModelScope.launch(Dispatchers.Default) {
for (row in oneMillionRows) {
ensureActive() // or yield()
heavy(row)
}
}Cancellation is cooperative: code with no suspension point keeps burning CPU after cancel() returns. More on coroutines
25. A suspend function that is not main-safe
Gotcha
suspend fun readConfig(): Config =
file.readText().let(::parse) // blocks the caller's thread
// every caller now needs withContext(IO)Correct
suspend fun readConfig(): Config =
withContext(Dispatchers.IO) {
file.readText().let(::parse)
}
// callers just call itsuspend is not a promise about threads, so the burden of withContext lands on every caller instead of once here. More on coroutines
26. SupervisorJob handed to launch
Gotcha
scope.launch(SupervisorJob()) {
launch { a() }
launch { b() } // b fails -> a still cancelled
}Correct
scope.launch {
supervisorScope {
launch { a() }
launch { b() } // b fails, a keeps going
}
}A job passed to launch becomes a child of the scope job, so the supervising behaviour is thrown away. More on coroutines
27. viewModelScope for work that must finish
Gotcha
fun submitOrder(o: Order) {
viewModelScope.launch {
api.submit(o) // user backs out -> cancelled
}
}Correct
fun submitOrder(o: Order) {
WorkManager.getInstance(ctx)
.enqueue(OneTimeWorkRequestBuilder<SubmitWorker>()
.setInputData(o.toData()).build())
}
// survives navigation and process deathLeaving the screen cancels the scope, so the write is lost halfway through. More on coroutines
28. Collecting a Flow without repeatOnLifecycle
Gotcha
override fun onCreate(b: Bundle?) {
lifecycleScope.launch {
vm.state.collect { render(it) }
}
} // still collecting while the app is backgroundedCorrect
override fun onCreate(b: Bundle?) {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.state.collect { render(it) }
}
}
}lifecycleScope only cancels at onDestroy, so the collector keeps running and updating a backgrounded UI. More on flow
29. stateIn(Eagerly) for screen state
Gotcha
val state = repo.observe()
.stateIn(viewModelScope, SharingStarted.Eagerly, Empty)Correct
val state = repo.observe()
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000), // rides out rotation
Empty,
)Eagerly never stops the upstream, so a socket or location feed keeps draining battery with nobody watching. More on flow
30. One-off events modelled as StateFlow
Gotcha
private val _nav = MutableStateFlow<Route?>(null)
val nav = _nav.asStateFlow()
// rotate -> navigates a second timeCorrect
private val _nav = Channel<Route>(Channel.BUFFERED)
val nav = _nav.receiveAsFlow()
// each event delivered exactly onceStateFlow replays its current value, so every rotation shows the same snackbar or fires the same navigation again. More on flow
31. Expecting StateFlow to re-emit an equal value
Gotcha
_state.value = UiState(count = 1)
_state.value = UiState(count = 1) // no emission
// "retry" taps stop workingCorrect
data class UiState(val count: Int, val nonce: Long = 0)
_state.update { it.copy(nonce = it.nonce + 1) }
// or keep events off the state object entirelyStateFlow conflates by equals(), so setting the same data class value again emits nothing. More on flow
32. withContext inside a flow builder
Gotcha
flow {
withContext(Dispatchers.IO) {
emit(db.load()) // IllegalStateException
}
}Correct
flow {
emit(db.load())
}.flowOn(Dispatchers.IO)A flow must emit in the context it was collected in, so this fails the context-preservation check at runtime. More on flow
33. Collecting a flow inside another collect
Gotcha
query.collect { q ->
repo.search(q).collect { render(it) }
// results from an old query still arrive
}Correct
query
.flatMapLatest { q -> repo.search(q) }
.collect { render(it) }
// switching query cancels the previous searchThe inner collector is never cancelled when the outer value changes, so old collectors pile up and race. More on flow
34. Expecting flowOn to affect the whole chain
Gotcha
repo.rows()
.flowOn(Dispatchers.IO)
.map { it.heavyParse() } // runs on Main
.collect { render(it) }Correct
repo.rows()
.map { it.heavyParse() }
.flowOn(Dispatchers.IO) // covers rows() and map
.collect { render(it) }flowOn only moves operators above it, so anything after it runs back on the collector context. More on flow
35. Two collectors on one cold flow
Gotcha
val prices = flow {
while (true) { emit(api.prices()); delay(5_000) }
}
// two screens collect -> two polling loopsCorrect
val prices = flow {
while (true) { emit(api.prices()); delay(5_000) }
}.shareIn(scope, SharingStarted.WhileSubscribed(5_000), replay = 1)A cold flow restarts its producer for every collector, so you pay for the same network call twice. More on flow
37. A slow collector on a fast flow
Gotcha
ticks.collect { tick ->
renderExpensively(tick) // 200ms
}
// emitter blocked, UI falls behindCorrect
ticks.collectLatest { tick ->
renderExpensively(tick) // cancelled on the next tick
}
// or .buffer() if you need every valuecollect is sequential, so a slow body applies backpressure and stalls the producer. More on flow
38. An Activity parked in a static field
Gotcha
class MainActivity : AppCompatActivity() {
companion object { var current: MainActivity? = null }
override fun onCreate(b: Bundle?) { current = this }
}Correct
class MainActivity : AppCompatActivity() {
companion object { var current: WeakReference<MainActivity>? = null }
override fun onCreate(b: Bundle?) { current = WeakReference(this) }
override fun onDestroy() { current = null; super.onDestroy() }
}
// better still: don't reach for the Activity from elsewhereThe companion object outlives the Activity, so the whole view tree is pinned in memory forever. More on android
39. postDelayed with no matching removeCallbacks
Gotcha
handler.postDelayed({ hideBanner() }, 60_000)
// nothing cancels it on destroyCorrect
private val hide = Runnable { hideBanner() }
override fun onStart() { handler.postDelayed(hide, 60_000) }
override fun onStop() { handler.removeCallbacks(hide) }The queued Runnable holds the Activity until it fires, so a 60s delay is a 60s leak. More on android
40. registerReceiver with no unregister
Gotcha
override fun onCreate(b: Bundle?) {
registerReceiver(netReceiver, filter)
}
// no onDestroyCorrect
override fun onStart() { registerReceiver(netReceiver, filter) }
override fun onStop() { unregisterReceiver(netReceiver) }
// or skip the pairing entirely:
// ConnectivityManager.registerNetworkCallback in a lifecycle-aware wrapperThe system keeps the receiver and its enclosing Activity alive, and the next register throws on leak detection. More on android
41. A Fragment holding its ViewBinding past onDestroyView
Gotcha
class Frag : Fragment() {
private lateinit var binding: FragBinding
// never cleared -> leaks on every back-and-forward
}Correct
class Frag : Fragment() {
private var _binding: FragBinding? = null
private val binding get() = _binding!!
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
}A Fragment outlives its view, so a retained binding keeps the whole destroyed view hierarchy alive. More on android
42. Assuming a ViewModel survives process death
Gotcha
class SearchViewModel : ViewModel() {
var query: String = "" // gone after a low-memory kill
}Correct
class SearchViewModel(
private val handle: SavedStateHandle,
) : ViewModel() {
var query: String
get() = handle["query"] ?: ""
set(v) { handle["query"] = v }
}A ViewModel survives rotation, not a background kill, so the user comes back to a blank screen. More on android
43. An Activity Context handed to a singleton
Gotcha
object Analytics {
lateinit var ctx: Context
}
// in onCreate:
Analytics.ctx = this // the ActivityCorrect
object Analytics {
lateinit var ctx: Context
}
Analytics.ctx = applicationContext
// application scope -> application Context
// UI work (themes, dialogs, inflation) -> Activity ContextThe singleton lives for the process, so it holds a destroyed Activity for the life of the app. More on android
44. PendingIntent with no mutability flag
Gotcha
PendingIntent.getActivity(
ctx, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT,
) // IllegalArgumentException on API 31+Correct
PendingIntent.getActivity(
ctx, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
// FLAG_MUTABLE only when the receiver must fill in extrasFrom Android 12 a PendingIntent must declare its mutability, and the app crashes on creation if it does not. More on android
45. Firing an implicit Intent without a handler check
Gotcha
startActivity(Intent(Intent.ACTION_VIEW, uri))
// ActivityNotFoundException on a device with no browserCorrect
runCatching { startActivity(Intent(Intent.ACTION_VIEW, uri)) }
.onFailure { showNoAppMessage() }
// plus a <queries> entry in the manifest for resolveActivity to workIf no app can handle it the call throws ActivityNotFoundException, and package visibility makes that common on API 30+. More on android
46. Using a dangerous permission you only declared
Gotcha
// manifest has ACCESS_FINE_LOCATION
val loc = fusedClient.lastLocation // SecurityExceptionCorrect
val granted = ContextCompat.checkSelfPermission(
ctx, Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
if (granted) use(fusedClient.lastLocation) else launcher.launch(perm)A manifest entry is not a grant, and the user can revoke a permission between two launches. More on android
47. startService from the background
Gotcha
// from a BroadcastReceiver, app in background
context.startService(Intent(context, SyncService::class.java))
// IllegalStateExceptionCorrect
WorkManager.getInstance(context).enqueue(
OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(Constraints(requiredNetworkType = CONNECTED))
.build(),
)Since Android 8 a background app cannot start a background service, and the call throws IllegalStateException. More on android
48. Committing a transaction after onSaveInstanceState
Gotcha
api.load { result ->
supportFragmentManager.beginTransaction()
.replace(R.id.host, ResultFragment())
.commit() // IllegalStateException if backgrounded
}Correct
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.result.collect { showResultFragment(it) }
}
}
// only commits while the state is safe to changeThe state is already written, so the framework refuses the change with IllegalStateException. More on android
49. Expecting finish() to stop the method
Gotcha
override fun onCreate(b: Bundle?) {
if (!loggedIn) finish()
setContentView(R.layout.main)
loadProfile() // still runs, often crashes
}Correct
override fun onCreate(b: Bundle?) {
if (!loggedIn) { finish(); return }
setContentView(R.layout.main)
loadProfile()
}finish() only schedules the teardown, so the rest of the method runs on an Activity that is on its way out. More on android
50. mutableStateOf without remember
Gotcha
@Composable
fun Counter() {
var n by mutableStateOf(0) // new state each pass
Button({ n++ }) { Text("$n") } // always 0
}Correct
@Composable
fun Counter() {
var n by remember { mutableStateOf(0) }
Button({ n++ }) { Text("$n") }
}A fresh state object is created on every recomposition, so the value resets the moment anything changes. More on compose
51. remember with no key over a changing input
Gotcha
@Composable
fun Row(user: User) {
val initials = remember { user.name.initials() }
Text(initials) // still the first user's initials
}Correct
@Composable
fun Row(user: User) {
val initials = remember(user.name) { user.name.initials() }
Text(initials)
}remember caches until it leaves composition, so the value goes stale when the input it was derived from changes. More on compose
52. remember where the state must survive rotation
Gotcha
var query by remember { mutableStateOf("") }
// rotate -> the search box is empty againCorrect
var query by rememberSaveable { mutableStateOf("") }
// survives rotation and process deathremember only survives recomposition, so a config change throws away what the user typed. More on compose
53. LaunchedEffect(Unit) over a changing parameter
Gotcha
@Composable
fun Detail(id: String, vm: VM) {
LaunchedEffect(Unit) { vm.load(id) }
// navigate to another id -> never reloads
}Correct
@Composable
fun Detail(id: String, vm: VM) {
LaunchedEffect(id) { vm.load(id) }
// relaunches (and cancels the old load) per id
}Unit never changes, so the effect runs once and keeps serving the first id forever. More on compose
54. Starting a coroutine in the composable body
Gotcha
@Composable
fun Screen(vm: VM) {
vm.viewModelScope.launch { vm.refresh() } // per recomposition
...
}Correct
@Composable
fun Screen(vm: VM) {
LaunchedEffect(Unit) { vm.refresh() } // once per entry
// for callbacks: val scope = rememberCoroutineScope()
}The body runs on every recomposition, so you fire a new request each frame and none of them get cancelled. More on compose
55. Mutating a list held in mutableStateOf
Gotcha
val items = remember { mutableStateOf(mutableListOf<Item>()) }
items.value.add(item) // no recompositionCorrect
val items = remember { mutableStateListOf<Item>() }
items.add(item)
// or keep it immutable:
// items.value = items.value + itemThe state holds the same list reference, so Compose sees no change and never recomposes. More on compose
56. LazyColumn items with no key
Gotcha
LazyColumn {
items(rows) { row -> Card(row) }
}
// delete row 0 -> row 1 inherits its expanded stateCorrect
LazyColumn {
items(rows, key = { it.id }) { row -> Card(row) }
}Without a key, item state is bound to position, so a reorder or delete moves state onto the wrong row. More on compose
57. A scrolling Column inside a LazyColumn
Gotcha
LazyColumn {
item {
Column(Modifier.verticalScroll(rememberScrollState())) { ... }
}
} // IllegalStateException: infinite maximum heightCorrect
LazyColumn {
item { Header() }
items(rows, key = { it.id }) { Row(it) }
item { Footer() }
}
// one scroller, one listA lazy list gives its children infinite height, and a child that also wants infinite height throws. More on compose
58. A side effect written straight into composition
Gotcha
@Composable
fun Screen() {
analytics.log("screen_view") // once per recomposition
...
}Correct
@Composable
fun Screen() {
LaunchedEffect(Unit) { analytics.log("screen_view") }
...
}Composition can run any number of times and be thrown away, so the analytics event fires on every frame. More on compose
59. Deriving a boolean from scroll position directly
Gotcha
val showTop = listState.firstVisibleItemIndex > 0
// recomposes on every scroll frameCorrect
val showTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
// recomposes only when the boolean flipsReading the raw scroll value recomposes on every pixel, when the answer only changes once. More on compose
60. collectAsState in a screen composable
Gotcha
val state by vm.state.collectAsState()Correct
val state by vm.state.collectAsStateWithLifecycle()
// stops at STOPPED, restarts at STARTEDIt keeps collecting while the app is backgrounded, so the upstream flow never gets a chance to stop. More on compose
61. Registering a listener with no matching dispose
Gotcha
LaunchedEffect(Unit) {
sensor.registerListener(cb) // never unregistered
}Correct
DisposableEffect(sensor) {
sensor.registerListener(cb)
onDispose { sensor.unregisterListener(cb) }
}The composable can leave composition at any time, so an unregistered listener leaks and fires into nothing. More on compose
62. Modifier order treated as a set of properties
Gotcha
Box(
Modifier
.padding(16.dp)
.background(Blue) // blue does NOT cover the padding
.clickable { } // ripple misses the padding too
)Correct
Box(
Modifier
.background(Blue)
.clickable { }
.padding(16.dp) // inner padding, full-size touch target
)Modifiers wrap left to right, so padding before background paints a smaller box than you expect. More on compose
63. Passing the ViewModel down into leaf composables
Gotcha
@Composable
fun UserCard(vm: UserViewModel) {
Text(vm.state.value.name)
Button({ vm.refresh() }) { Text("Refresh") }
}Correct
@Composable
fun UserCard(name: String, onRefresh: () -> Unit) {
Text(name)
Button(onRefresh) { Text("Refresh") }
}
// the ViewModel stays at the screen rootA ViewModel is an unstable type, so it defeats skipping and makes every leaf untestable in isolation. More on compose
64. A View or Activity Context stored in a ViewModel
Gotcha
class ProfileViewModel(
private val activity: Activity,
) : ViewModel() {
fun title() = activity.getString(R.string.profile)
}Correct
class ProfileViewModel(
@ApplicationContext private val ctx: Context,
) : ViewModel() {
fun title() = ctx.getString(R.string.profile)
}
// better: expose a resource id and let the UI resolve itThe ViewModel outlives the Activity by design, so it leaks the exact thing it was built to outlive. More on architecture
65. Exposing the MutableStateFlow itself
Gotcha
class VM : ViewModel() {
val state = MutableStateFlow(UiState())
}
// fragment: vm.state.value = UiState(loading = true)Correct
class VM : ViewModel() {
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow()
fun refresh() { _state.update { it.copy(loading = true) } }
}Any caller can write to it, so state changes stop being traceable to a single owner. More on architecture
66. Business rules living in the Fragment
Gotcha
// in the Fragment
val price = items.sumOf { it.price } *
if (user.isPrime) 0.9 else 1.0
priceView.text = format(price)Correct
// domain
class CartTotal {
operator fun invoke(items: List<Item>, user: User) = ...
}
// fragment
priceView.text = format(state.total)Rules in the view can only be tested with an emulator, and the next screen reimplements them slightly differently. More on architecture
67. Constructing a ViewModel by hand
Gotcha
class Frag : Fragment() {
private val vm = ProfileViewModel() // new one per rotation
}Correct
class Frag : Fragment() {
private val vm: ProfileViewModel by viewModels()
}
// scoped to the fragment; by activityViewModels() to shareA directly constructed instance is not in the store, so it is rebuilt on every rotation and loses all its state. More on architecture
68. A singleton binding that depends on Activity scope
Gotcha
@Singleton
class Tracker @Inject constructor(
private val activity: Activity, // ActivityComponent
)
// error: cannot be provided in SingletonComponentCorrect
@Singleton
class Tracker @Inject constructor(
@ApplicationContext private val ctx: Context,
)
// pass per-screen data in as method argumentsA longer-lived object holding a shorter-lived one is a leak, and Hilt rejects the graph at compile time. More on architecture
69. @HiltViewModel without an @Inject constructor
Gotcha
@HiltViewModel
class FeedViewModel(
private val repo: FeedRepository,
) : ViewModel()Correct
@HiltViewModel
class FeedViewModel @Inject constructor(
private val repo: FeedRepository,
) : ViewModel()Hilt has no way to build it, so the annotation does nothing and by viewModels() falls back to the no-arg factory. More on architecture
70. Injecting Context without a qualifier
Gotcha
class Prefs @Inject constructor(
private val ctx: Context, // which one?
)Correct
class Prefs @Inject constructor(
@ApplicationContext private val ctx: Context,
)
// @ActivityContext for anything that touches UIHilt binds two Contexts, so an unqualified request is ambiguous and fails the build. More on architecture
71. UI state split across several flows
Gotcha
val loading = MutableStateFlow(false)
val data = MutableStateFlow<List<Row>>(emptyList())
val error = MutableStateFlow<String?>(null)
// can be loading AND error at onceCorrect
sealed interface UiState {
data object Loading : UiState
data class Data(val rows: List<Row>) : UiState
data class Error(val message: String) : UiState
}
val state = MutableStateFlow<UiState>(UiState.Loading)Independent flows emit at different times, so the UI renders combinations that are not valid states. More on architecture
72. The network DTO used as the UI model
Gotcha
@Serializable
data class UserDto(val first_name: String?, val avatar_url: String?)
Text(user.first_name ?: "") // in the UICorrect
data class User(val name: String, val avatar: String)
fun UserDto.toDomain() = User(
name = first_name.orEmpty(),
avatar = avatar_url ?: DEFAULT_AVATAR,
)Every backend rename becomes a UI change, and nullable wire fields leak null handling into every composable. More on architecture
73. A repository that returns Retrofit Response
Gotcha
interface Repo {
suspend fun user(): Response<UserDto>
}
// ViewModel: if (r.isSuccessful) r.body()!! else ...Correct
interface Repo {
suspend fun user(): Result<User>
}
// ViewModel: result.fold(::show, ::showError)HTTP details leak all the way to the UI, so every caller repeats the same isSuccessful/body plumbing. More on architecture
74. A blocking Room query on the main thread
Gotcha
@Dao interface UserDao {
@Query("SELECT * FROM user") fun all(): List<User>
}
val users = dao.all() // IllegalStateException on mainCorrect
@Dao interface UserDao {
@Query("SELECT * FROM user") suspend fun all(): List<User>
@Query("SELECT * FROM user") fun observe(): Flow<List<User>>
}Room throws on the main thread by default, and the escape hatch just turns the crash into jank. More on data
75. fallbackToDestructiveMigration in a shipped app
Gotcha
Room.databaseBuilder(ctx, Db::class.java, "app.db")
.fallbackToDestructiveMigration()
.build()Correct
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE user ADD COLUMN nickname TEXT")
}
}
Room.databaseBuilder(...).addMigrations(MIGRATION_1_2).build()A schema bump silently deletes every row the user has, and there is no way to get it back. More on data
76. Call.execute() to get a result now
Gotcha
interface Api { @GET("user") fun user(): Call<UserDto> }
val user = api.user().execute().body() // blocksCorrect
interface Api { @GET("user") suspend fun user(): UserDto }
val user = withContext(Dispatchers.IO) { api.user() }execute() blocks the calling thread, so on the main thread it is a guaranteed NetworkOnMainThreadException. More on data
77. A new OkHttpClient for every request
Gotcha
fun fetch(url: String) =
OkHttpClient().newCall(Request.Builder().url(url).build())Correct
@Singleton
class Http @Inject constructor() {
val client = OkHttpClient.Builder().build()
}
// one client per app; .newBuilder() to vary timeoutsEach client gets its own connection pool and thread pool, so you throw away keep-alive and leak threads. More on data
78. A ResponseBody nobody closes
Gotcha
val res = client.newCall(req).execute()
if (res.isSuccessful) return res.body!!.string()
return null // body never closed on the failure pathCorrect
client.newCall(req).execute().use { res ->
if (res.isSuccessful) res.body?.string() else null
}The connection stays checked out of the pool, so after a few hundred calls the client stalls waiting for one. More on data
79. A parser that rejects unknown keys
Gotcha
val json = Json // ignoreUnknownKeys = false
json.decodeFromString<UserDto>(body)
// SerializationException on a new server fieldCorrect
val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
explicitNulls = false
}The backend adding one field breaks every old client in the field, which is the worst possible time to find out. More on data
80. Creating DataStore more than once
Gotcha
class Prefs(ctx: Context) {
private val store = PreferenceDataStoreFactory.create { ctx.prefsFile() }
}
// two Prefs -> IllegalStateExceptionCorrect
// top level, once per process
val Context.dataStore by preferencesDataStore(name = "settings")
class Prefs @Inject constructor(
@ApplicationContext private val ctx: Context,
) { private val store = ctx.dataStore }DataStore enforces one active instance per file, and a second one throws when it touches the same file. More on data
82. Multi-step writes with no transaction
Gotcha
@Dao interface FeedDao {
suspend fun clear()
suspend fun insert(rows: List<Row>)
}
dao.clear(); dao.insert(rows) // empty list in betweenCorrect
@Dao interface FeedDao {
@Transaction
suspend fun replace(rows: List<Row>) {
clear()
insert(rows)
}
}A failure between the two calls leaves the database half-updated, and observers see the torn state. More on data
83. A base URL without a trailing slash
Gotcha
.baseUrl("https://api.x.dev/v2")
@GET("users") // -> https://api.x.dev/users (v2 dropped)Correct
.baseUrl("https://api.x.dev/v2/")
@GET("users") // -> https://api.x.dev/v2/users
// a leading slash on the path also discards the base pathRetrofit resolves relative paths against the last slash, so the final path segment is silently replaced. More on data
84. runBlocking for a test with delays
Gotcha
@Test fun retries() = runBlocking {
repo.syncWithBackoff() // actually sleeps 30s
assertEquals(3, api.calls)
}Correct
@Test fun retries() = runTest {
repo.syncWithBackoff() // virtual time, instant
assertEquals(3, api.calls)
}runBlocking waits in real time, so a retry with backoff turns a unit test into a thirty second one. More on testing
85. Dispatchers.IO hardcoded inside the class
Gotcha
class Repo {
suspend fun load() = withContext(Dispatchers.IO) { ... }
}Correct
class Repo(private val io: CoroutineDispatcher = Dispatchers.IO) {
suspend fun load() = withContext(io) { ... }
}
// test: Repo(StandardTestDispatcher(testScheduler))The test cannot control the scheduler, so assertions race the background work and the test flakes. More on testing
86. Testing a ViewModel without swapping Main
Gotcha
@Test fun loads() = runTest {
val vm = FeedViewModel(repo) // IllegalStateException
}Correct
@Before fun setUp() = Dispatchers.setMain(StandardTestDispatcher())
@After fun tearDown() = Dispatchers.resetMain()
// or a @get:Rule MainDispatcherRuleDispatchers.Main needs a Looper, so viewModelScope.launch throws before your assertion ever runs. More on testing
87. Reading .value of a WhileSubscribed StateFlow
Gotcha
@Test fun state() = runTest {
vm.load()
assertEquals(Data(rows), vm.state.value) // still Loading
}Correct
@Test fun state() = runTest {
vm.state.test { // Turbine subscribes
assertEquals(Loading, awaitItem())
vm.load()
assertEquals(Data(rows), awaitItem())
}
}With no subscriber the upstream never starts, so .value stays on the initial value forever. More on testing
88. Asserting with first() when order matters
Gotcha
assertEquals(Data(rows), vm.state.first())
// Loading -> Error -> Data would also passCorrect
vm.state.test {
assertEquals(Loading, awaitItem())
assertEquals(Data(rows), awaitItem())
cancelAndConsumeRemainingEvents()
}first() cancels after one value, so a wrong or missing intermediate emission passes the test. More on testing
89. Thread.sleep to wait for the UI
Gotcha
onView(withId(R.id.load)).perform(click())
Thread.sleep(2_000)
onView(withText("Ann")).check(matches(isDisplayed()))Correct
composeRule.onNodeWithTag("load").performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText("Ann")
.fetchSemanticsNodes().isNotEmpty()
}The sleep is either too short on a slow CI machine or wasted time on a fast one, so the suite is flaky and slow. More on testing
90. api used where implementation would do
Gotcha
dependencies {
api(libs.retrofit) // leaks Retrofit to every consumer
api(libs.okhttp)
}Correct
dependencies {
implementation(libs.retrofit)
// api only for types that appear in this module's public signatures
}api puts the dependency on every consumer compile classpath, so one version bump recompiles the whole project. More on build
91. Reflection-based models with no R8 keep rule
Gotcha
# proguard-rules.pro
# (empty)
# debug parses fine, release returns a DTO with every field null,
# because R8 renamed the fields Gson looks up by nameCorrect
# proguard-rules.pro
-keepclassmembers,allowobfuscation class * {
@com.google.gson.annotations.SerializedName <fields>;
}
# or move to kotlinx.serialization, which needs no reflectionR8 renames the fields it cannot see being used, so parsing silently returns nulls in release only. More on build
92. minifyEnabled false on the release build
Gotcha
release {
isMinifyEnabled = false
isShrinkResources = false
}Correct
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}You ship every unused class and readable names, so the APK is larger and trivially decompiled. More on build
93. Decoding a full-resolution bitmap
Gotcha
val bmp = BitmapFactory.decodeFile(path)
imageView.setImageBitmap(bmp) // OutOfMemoryErrorCorrect
imageView.load(File(path)) {
size(imageView.width, imageView.height)
}
// by hand: BitmapFactory.Options(inSampleSize = n)A 12MP photo is roughly 48MB in ARGB_8888, so a couple of them blow the heap on a mid-range device. More on performance
94. notifyDataSetChanged for every update
Gotcha
fun submit(rows: List<Row>) {
this.rows = rows
notifyDataSetChanged()
}Correct
class RowAdapter : ListAdapter<Row, VH>(DIFF) {
companion object {
val DIFF = object : DiffUtil.ItemCallback<Row>() {
override fun areItemsTheSame(a: Row, b: Row) = a.id == b.id
override fun areContentsTheSame(a: Row, b: Row) = a == b
}
}
}It rebinds every visible row and kills item animations, so the list flickers and loses scroll position. More on performance
95. Every SDK initialised in Application.onCreate
Gotcha
class App : Application() {
override fun onCreate() {
Analytics.init(this)
Crash.init(this)
Ads.init(this) // 400ms before any UI
}
}Correct
class App : Application() {
override fun onCreate() {
Crash.init(this) // must be first, keep it
}
}
// the rest: androidx.startup Initializer, or lazy on first useIt all runs before the first frame, so cold start grows by the sum of every SDK you will ever add. More on performance
96. Real work inside onBindViewHolder
Gotcha
override fun onBindViewHolder(h: VH, i: Int) {
val user = db.userDao().byId(rows[i].userId) // disk read
h.name.text = formatDate(rows[i].at) // allocates
}Correct
// map to a ready-to-render model off the main thread
data class RowUi(val name: String, val date: String)
override fun onBindViewHolder(h: VH, i: Int) {
h.name.text = getItem(i).name
h.date.text = getItem(i).date
}Bind runs on the main thread during scroll, so anything beyond assignment shows up as dropped frames. More on performance
97. An auth token in plain SharedPreferences
Gotcha
prefs.edit { putString("access_token", token) }Correct
val keyAlias = MasterKey.Builder(ctx)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
EncryptedSharedPreferences.create(
ctx, "secure", keyAlias, AES256_SIV, AES256_GCM,
).edit { putString("access_token", token) }On a rooted or backed-up device the file is readable, and the token is a full account takeover. More on security
98. A TrustManager that accepts everything
Gotcha
val tm = object : X509TrustManager {
override fun checkServerTrusted(c: Array<X509Certificate>, a: String) {}
override fun checkClientTrusted(c: Array<X509Certificate>, a: String) {}
override fun getAcceptedIssuers() = arrayOf<X509Certificate>()
}Correct
// res/xml/network_security_config.xml for debug-only trust,
// and pin the production host:
val pinner = CertificatePinner.Builder()
.add("api.x.dev", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build()
OkHttpClient.Builder().certificatePinner(pinner).build()It disables the entire TLS chain check, so any proxy on the network can read and rewrite every request. More on security
99. An exported component with no permission
Gotcha
<receiver
android:name=".SyncReceiver"
android:exported="true" />Correct
<receiver
android:name=".SyncReceiver"
android:exported="false" />
<!-- if it genuinely must be public, gate it: -->
<!-- android:permission="com.x.permission.SYNC" -->Any app on the device can send it an Intent, so an internal action becomes a public API by accident. More on security
100. Logging the request or the token
Gotcha
Log.d("Api", "auth=" + token)
HttpLoggingInterceptor().setLevel(Level.BODY) // in releaseCorrect
HttpLoggingInterceptor()
.apply { level = if (BuildConfig.DEBUG) Level.BASIC else Level.NONE }
.apply { redactHeader("Authorization") }Logcat is readable by adb and captured by crash reporters, so the secret leaves the device with the bug report. More on security