Kotlin / Android Development
Project Setup
Gradle Kotlin DSL
// settings.gradle.kts
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "MyApp"
include(":app")
build.gradle.kts (app)
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.example.myapp"
compileSdk = 35
defaultConfig {
applicationId = "com.example.myapp"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.material3)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
Version Catalog (libs.versions.toml)
[versions]
kotlin = "2.0.0"
compose-bom = "2024.06.00"
lifecycle = "2.8.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.13.1" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
[plugins]
android-application = { id = "com.android.application", version = "8.5.0" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Type Patterns
Null Safety
// Safe call operator
val length = name?.length
// Elvis operator
val displayName = user?.name ?: "Guest"
// Safe cast
val number = value as? Int
// Not-null assertion (use sparingly)
val name = user!!.name
// let for null checks
user?.let { safeUser ->
println(safeUser.name)
}
Sealed Classes
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: Throwable) : Result<Nothing>()
data object Loading : Result<Nothing>()
}
// Exhaustive when
fun handleResult(result: Result<User>) = when (result) {
is Result.Success -> showUser(result.data)
is Result.Error -> showError(result.exception)
Result.Loading -> showLoading()
}
Data Classes
data class User(
val id: String,
val email: String,
val name: String,
val createdAt: Instant = Instant.now()
)
// Copy with modifications
val updatedUser = user.copy(name = "New Name")
// Destructuring
val (id, email, name) = user
Value Classes
@JvmInline
value class UserId(val value: String)
@JvmInline
value class Email(val value: String) {
init {
require(value.contains("@")) { "Invalid email" }
}
}
Error Handling
Result Type
fun parseNumber(input: String): Result<Int> {
return try {
Result.success(input.toInt())
} catch (e: NumberFormatException) {
Result.failure(e)
}
}
// Usage
parseNumber("123")
.onSuccess { number -> println("Parsed: $number") }
.onFailure { error -> println("Error: ${error.message}") }
// Transform
val doubled = parseNumber("42")
.map { it * 2 }
.getOrDefault(0)
runCatching
val result = runCatching {
riskyOperation()
}.getOrElse { error ->
logError(error)
defaultValue
}
// Chain operations
runCatching { fetchUser(id) }
.mapCatching { user -> processUser(user) }
.onSuccess { result -> display(result) }
.onFailure { error -> showError(error) }
Coroutines
Basic Coroutines
// Suspend function
suspend fun fetchUser(id: String): User {
return withContext(Dispatchers.IO) {
api.getUser(id)
}
}
// Launch coroutine
viewModelScope.launch {
try {
val user = fetchUser("123")
_uiState.value = UiState.Success(user)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message)
}
}
Flow
// Create flow
fun observeUsers(): Flow<List<User>> = flow {
while (true) {
emit(repository.getUsers())
delay(5000)
}
}.flowOn(Dispatchers.IO)
// Collect flow
viewModelScope.launch {
observeUsers()
.catch { e -> emit(emptyList()) }
.collect { users ->
_users.value = users
}
}
StateFlow
class UserViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun loadUser(id: String) {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
val user = repository.getUser(id)
_uiState.value = UiState.Success(user)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "Unknown error")
}
}
}
}
Jetpack Compose
Basic Composable
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello, $name!",
modifier = modifier.padding(16.dp),
style = MaterialTheme.typography.headlineMedium
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
MyAppTheme {
Greeting("Android")
}
}
State Management
@Composable
fun Counter() {
var count by remember { mutableIntStateOf(0) }
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Count: $count",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { count++ }) {
Text("Increment")
}
}
}
ViewModel Integration
@Composable
fun UserScreen(
viewModel: UserViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (val state = uiState) {
is UiState.Loading -> CircularProgressIndicator()
is UiState.Success -> UserContent(state.user)
is UiState.Error -> ErrorMessage(state.message)
}
}
@Composable
fun UserContent(user: User) {
Column(modifier = Modifier.padding(16.dp)) {
Text(user.name, style = MaterialTheme.typography.titleLarge)
Text(user.email, style = MaterialTheme.typography.bodyMedium)
}
}
Side Effects
@Composable
fun UserDetailScreen(userId: String, viewModel: UserViewModel = hiltViewModel()) {
// Run once when userId changes
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
// Run on every recomposition
SideEffect {
analytics.trackScreen("UserDetail")
}
// Cleanup when leaving composition
DisposableEffect(Unit) {
val listener = viewModel.addListener()
onDispose {
listener.remove()
}
}
}
Testing
Unit Tests (JUnit 5)
class UserViewModelTest {
@Test
fun `loadUser updates state to success`() = runTest {
val repository = mockk<UserRepository>()
coEvery { repository.getUser("123") } returns User("123", "test@example.com")
val viewModel = UserViewModel(repository)
viewModel.loadUser("123")
assertEquals(
UiState.Success(User("123", "test@example.com")),
viewModel.uiState.value
)
}
@Test
fun `loadUser updates state to error on failure`() = runTest {
val repository = mockk<UserRepository>()
coEvery { repository.getUser(any()) } throws IOException("Network error")
val viewModel = UserViewModel(repository)
viewModel.loadUser("123")
assertTrue(viewModel.uiState.value is UiState.Error)
}
}
Compose UI Tests
class UserScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun displaysUserName() {
val user = User("1", "test@example.com", "John Doe")
composeTestRule.setContent {
MyAppTheme {
UserContent(user = user)
}
}
composeTestRule.onNodeWithText("John Doe").assertIsDisplayed()
composeTestRule.onNodeWithText("test@example.com").assertIsDisplayed()
}
@Test
fun buttonClickIncrementsCounter() {
composeTestRule.setContent {
MyAppTheme {
Counter()
}
}
composeTestRule.onNodeWithText("Count: 0").assertIsDisplayed()
composeTestRule.onNodeWithText("Increment").performClick()
composeTestRule.onNodeWithText("Count: 1").assertIsDisplayed()
}
}
Tooling
# Build
./gradlew build
./gradlew assembleDebug
./gradlew assembleRelease
# Run tests
./gradlew test # Unit tests
./gradlew connectedAndroidTest # Instrumented tests
# Linting
./gradlew detekt # Code smells
./gradlew ktlintCheck # Style check
./gradlew ktlintFormat # Auto-fix style
# Code analysis
./gradlew lint # Android Lint
# Clean
./gradlew clean
detekt.yml
build:
maxIssues: 0
complexity:
LongMethod:
threshold: 30
ComplexCondition:
threshold: 4
style:
MaxLineLength:
maxLineLength: 120
WildcardImport:
active: true
naming:
FunctionNaming:
functionPattern: '[a-z][a-zA-Z0-9]*'
.editorconfig (ktlint)
[*.{kt,kts}]
indent_size = 4
max_line_length = 120
ktlint_code_style = android_studio