| name | kotlin-patterns |
| description | Idiomatic Kotlin patterns for Android AI agents. Use this skill whenever writing Kotlin
code for Android, especially: coroutines, Flow, StateFlow, SharedFlow, viewModelScope,
lifecycleScope, Dispatchers, withContext, suspend functions, sealed classes, sealed interfaces,
data classes, extension functions, scope functions (let, apply, also, run, with), null safety,
!! operator, runCatching, Result, lazy, by delegate, stateIn, flatMapLatest, combine,
error handling, structured concurrency, or any Kotlin-specific patterns. Always apply
before writing coroutines, Flow chains, or data modeling code.
|
Kotlin Patterns for Android
12 rules for idiomatic, production-safe Kotlin on Android.
Rule 1: Coroutine scope — always use structured concurrency
class MyViewModel : ViewModel() {
fun load() {
viewModelScope.launch {
val result = fetchData()
}
}
}
class MyActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
lifecycleScope.launch {
viewModel.events.collect { handleEvent(it) }
}
}
}
GlobalScope.launch { fetchData() }
val scope = CoroutineScope(Dispatchers.IO)
scope.launch { fetchData() }
Rule 2: Dispatcher discipline — always switch off Main
suspend fun fetchUser(id: String): User = withContext(Dispatchers.IO) {
api.getUser(id)
}
suspend fun processLargeList(items: List<Item>): List<Result> = withContext(Dispatchers.Default) {
items.map { processItem(it) }
}
class UserRepository @Inject constructor(
private val api: UserApi,
@IoDispatcher private val dispatcher: CoroutineDispatcher
) {
suspend fun getUser(id: String): User = withContext(dispatcher) {
api.getUser(id)
}
}
suspend fun fetchUser(): User = api.getUser()
Rule 3: StateFlow — expose, never expose MutableStateFlow
private val _uiState = MutableStateFlow(HomeUiState.Loading)
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
_uiState.value = HomeUiState.Success(items)
_uiState.update { current -> current.copy(isLoading = false) }
val uiState = MutableStateFlow(HomeUiState.Loading)
Rule 4: stateIn — convert cold Flow to StateFlow
val items: StateFlow<List<Item>> = repository.getItemsFlow()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
Rule 5: runCatching — safe error handling without try/catch
suspend fun getItems(): Result<List<Item>> = runCatching {
api.getItems().map { it.toDomain() }
}
suspend fun getActiveItems(): Result<List<Item>> =
getItems()
.map { items -> items.filter { it.isActive } }
.onFailure { error -> logger.e(error, "Failed to get items") }
viewModelScope.launch {
getItems()
.onSuccess { items -> _uiState.value = HomeUiState.Success(items) }
.onFailure { error -> _uiState.value = HomeUiState.Error(error.message ?: "Error") }
}
try {
val items = api.getItems()
_uiState.value = HomeUiState.Success(items)
} catch (e: Exception) {
_uiState.value = HomeUiState.Error(e.message ?: "Error")
}
Rule 6: Sealed interfaces — prefer over sealed classes for state/events
sealed interface LoginResult {
data object Success : LoginResult
data class Error(val code: Int, val message: String) : LoginResult
data object NetworkError : LoginResult
}
class AuthError : LoginResult.Error(401, "Unauthorized"), ProfileResult.Unauthorized
sealed class LoginResult {
object Success : LoginResult()
data class Error(val message: String) : LoginResult()
}
Rule 7: Scope functions — use the right one
val length = name?.let { it.trim().length } ?: 0
val intent = Intent(context, MainActivity::class.java).apply {
putExtra("id", itemId)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
val items = repository.getItems().also { logger.d("Loaded ${it.size} items") }
val message = user.run {
if (isPremium) "Welcome, Premium $name!" else "Welcome, $name!"
}
val summary = with(order) {
"Order #$id: $itemCount items, total: $$total"
}
val result = a?.let { b?.let { c?.run { ... } } }
Rule 8: No !! operator in production code
val name = user?.name ?: "Anonymous"
val id = savedStateHandle.get<String>("id") ?: return
val file = getFile() ?: throw IllegalStateException("File required")
val config = requireNotNull(buildConfig) { "BuildConfig must be initialized before use" }
val name = user!!.name
val id = args!!.getString("id")
Rule 9: flatMapLatest — cancel previous on new emission
val searchResults: StateFlow<List<Item>> = searchQuery
.debounce(300L)
.flatMapLatest { query ->
if (query.isBlank()) flowOf(emptyList())
else repository.search(query)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val searchResults = searchQuery.flatMapMerge { repository.search(it) }
Rule 10: Data classes — correct usage
data class Item(
val id: String,
val title: String,
val description: String,
val createdAt: Instant
)
val updated = item.copy(title = "New Title", description = "Updated")
sealed interface AuthState {
data object Unauthenticated : AuthState
data object Loading : AuthState
data class Authenticated(val user: User) : AuthState
}
class Item(val id: String, val title: String)
data class UiState(var isLoading: Boolean = false)
Rule 11: Lazy initialization
val regex: Regex by lazy { Regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$") }
@Inject lateinit var repository: ItemRepository
if (::repository.isInitialized) repository.close()
val cache by lazy(LazyThreadSafetyMode.NONE) { mutableMapOf<String, Item>() }
Rule 12: Extension functions — don't abuse them
fun String.isValidEmail(): Boolean =
android.util.Patterns.EMAIL_ADDRESS.matcher(this).matches()
fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) {
Toast.makeText(this, message, duration).show()
}
fun <T> Flow<T>.throttleFirst(windowDuration: Long): Flow<T> = flow {
var lastEmission = 0L
collect { value ->
val now = System.currentTimeMillis()
if (now - lastEmission >= windowDuration) {
lastEmission = now
emit(value)
}
}
}
fun List<Item>.filterAndSort(): List<Item> {
return filter { it.isActive }.sortedBy { it.title }
}
Common Mistakes Quick Reference
| ❌ Wrong | ✅ Right |
|---|
GlobalScope.launch | viewModelScope.launch |
user!!.name | user?.name ?: "default" |
collectAsState() | collectAsStateWithLifecycle() |
MutableStateFlow exposed | _private.asStateFlow() |
| Try/catch in ViewModel | runCatching in Repository |
var in data class | val — immutable |
flatMapMerge for search | flatMapLatest |
| Computation on Main | withContext(Dispatchers.IO) |