cyberangles guide

Best Practices for Building Android Apps with Kotlin

Kotlin has emerged as the de facto language for Android development, thanks to its conciseness, safety features, and seamless interoperability with Java. Since Google declared it the "preferred language for Android" in 2019, the ecosystem has matured, with tools like Jetpack Compose, Hilt, and coroutines becoming staples for modern app development. However, writing *working* code is easy—writing **maintainable, performant, and secure** code requires discipline and adherence to best practices. This blog dives into actionable best practices for building Android apps with Kotlin, covering project setup, architecture, concurrency, UI development, testing, and more. Whether you’re a beginner or an experienced developer, these guidelines will help you avoid common pitfalls and build apps that scale.

Table of Contents

  1. Project Setup and Configuration
  2. Leveraging Kotlin Language Features
  3. Architectural Patterns
  4. Concurrency with Coroutines and Flows
  5. Modern UI Development with Jetpack Compose
  6. Testing Strategies
  7. Performance Optimization
  8. Security Best Practices
  9. Tooling and Automation
  10. References

1. Project Setup and Configuration

A well-configured project is the foundation of a maintainable app. Start with these practices to set yourself up for success:

Use the Latest Versions

Always use the latest stable versions of:

  • Android Gradle Plugin (AGP): Ensures compatibility with new Kotlin features and Android APIs.
  • Kotlin: Take advantage of new language features (e.g., context receivers in Kotlin 1.6+) and performance improvements.
  • Jetpack Libraries: Components like Compose, Hilt, and Lifecycle are regularly updated with bug fixes and new capabilities.

Example: In build.gradle (Project level):

ext {  
    kotlin_version = "1.9.0"  
    agp_version = "8.1.0"  
}  

Adopt Kotlin Gradle DSL

Replace Groovy with Kotlin for Gradle scripts (build.gradle.kts). Kotlin DSL offers better type safety, auto-completion, and maintainability.

Example: app/build.gradle.kts:

plugins {  
    id("com.android.application") version agp_version  
    id("org.jetbrains.kotlin.android") version kotlin_version  
    id("com.google.dagger.hilt.android") version "2.44"  
}  

android {  
    namespace = "com.example.myapp"  
    compileSdk = 34  

    defaultConfig {  
        applicationId = "com.example.myapp"  
        minSdk = 24  
        targetSdk = 34  
    }  
}  

Centralize Dependencies with Version Catalogs

Use Gradle’s Version Catalogs to manage dependencies in a single file (libs.versions.toml), avoiding duplication and simplifying updates.

Example: libs.versions.toml:

[versions]  
hilt = "2.44"  
compose = "1.4.3"  

[libraries]  
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }  
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }  

In build.gradle.kts:

dependencies {  
    implementation(libs.hilt.android)  
    implementation(libs.compose.ui)  
}  

2. Leveraging Kotlin Language Features

Kotlin’s syntax and features are designed to reduce boilerplate and improve safety. Use them effectively:

Enforce Null Safety

Kotlin’s null safety eliminates NullPointerException by distinguishing nullable (Type?) and non-null (Type) types.

  • Avoid !! (not-null assertion): It crashes if the value is null. Use safe calls (?.) or the Elvis operator (?:) instead.
  • Use lateinit for non-null properties initialized later: Ideal for Activity/Fragment views or dependencies injected at runtime.
  • Prefer val over var: Immutable variables are safer and easier to reason about.

Example:

// Bad: Risky!  
val name: String? = null  
val length = name!!.length // Crashes with NPE  

// Good: Safe  
val length = name?.length ?: 0 // Returns 0 if name is null  

// Better: Use lateinit for non-null properties initialized later  
lateinit var binding: ActivityMainBinding  
override fun onCreate(savedInstanceState: Bundle?) {  
    super.onCreate(savedInstanceState)  
    binding = ActivityMainBinding.inflate(layoutInflater)  
}  

Use Extension Functions

Avoid utility classes (e.g., StringUtils) by adding methods to existing types via extension functions.

Example: Format a Date as a string:

fun Date.formatToReadableString(): String {  
    return SimpleDateFormat("dd MMM yyyy", Locale.getDefault()).format(this)  
}  

// Usage:  
val today = Date()  
println(today.formatToReadableString()) // Output: "05 Oct 2023"  

Prefer Data Classes and Sealed Classes

  • Data classes: Auto-generate equals(), hashCode(), toString(), and copy() for model classes, reducing boilerplate.
  • Sealed classes: Restrict inheritance to a fixed set of subclasses, ideal for state management (e.g., UI states like Loading, Success, Error).

Example: Sealed class for UI state:

sealed class UiState<out T> {  
    object Loading : UiState<Nothing>()  
    data class Success<out T>(val data: T) : UiState<T>()  
    data class Error(val message: String) : UiState<Nothing>()  
}  

// Usage in ViewModel:  
private val _uiState = MutableStateFlow<UiState<List<User>>>(UiState.Loading)  
val uiState: StateFlow<UiState<List<User>>> = _uiState  

3. Architectural Patterns

A clean architecture ensures separation of concerns, making code testable and maintainable. The MVVM (Model-View-ViewModel) pattern, paired with Jetpack components, is widely adopted:

Adopt MVVM with Jetpack Components

  • Model: Data classes or domain entities (e.g., User, Post).
  • View: Activity/Fragment or Compose UI—observes ViewModel state and triggers actions.
  • ViewModel: Manages UI data, survives configuration changes, and delegates business logic to a repository.

Example: ViewModel with StateFlow:

class UserViewModel(  
    private val userRepository: UserRepository  
) : ViewModel() {  
    private val _users = MutableStateFlow<UiState<List<User>>>(UiState.Loading)  
    val users: StateFlow<UiState<List<User>>> = _users  

    fun fetchUsers() {  
        viewModelScope.launch {  
            _users.value = UiState.Loading  
            try {  
                val result = userRepository.getUsers()  
                _users.value = UiState.Success(result)  
            } catch (e: Exception) {  
                _users.value = UiState.Error(e.message ?: "Unknown error")  
            }  
        }  
    }  
}  

Use the Repository Pattern

Decouple data sources (API, database) from the ViewModel with a repository. It centralizes data logic and provides a single source of truth.

Example: Repository with local and remote data sources:

class UserRepository(  
    private val apiService: UserApiService,  
    private val userDao: UserDao  
) {  
    suspend fun getUsers(): List<User> {  
        // Fetch from local DB first  
        val localUsers = userDao.getUsers()  
        if (localUsers.isNotEmpty()) return localUsers  

        // If empty, fetch from remote and cache  
        val remoteUsers = apiService.getUsers()  
        userDao.insertAll(remoteUsers)  
        return remoteUsers  
    }  
}  

Dependency Injection with Hilt

Hilt (built on Dagger) simplifies dependency injection by generating code to provide instances (e.g., ViewModel, Repository).

Example: Hilt module for providing a repository:

@Module  
@InstallIn(SingletonComponent::class)  
object AppModule {  
    @Provides  
    fun provideUserRepository(  
        apiService: UserApiService,  
        userDao: UserDao  
    ): UserRepository = UserRepository(apiService, userDao)  
}  

// Inject ViewModel into Activity/Fragment  
@AndroidEntryPoint  
class UserActivity : AppCompatActivity() {  
    private val viewModel: UserViewModel by viewModels()  
}  

4. Concurrency with Coroutines and Flows

Kotlin coroutines simplify background tasks (e.g., network calls, database operations). Pair them with flows for reactive data streams.

Use Structured Concurrency

Avoid GlobalScope—use lifecycle-aware scopes to prevent leaks:

  • viewModelScope: Cancels when the ViewModel is destroyed.
  • lifecycleScope: Cancels when the Activity/Fragment is destroyed.
  • coroutineScope: Cancels child coroutines if any fail.

Example: Fetch data in a ViewModel:

// Good: Uses viewModelScope (automatically cancelled)  
viewModelScope.launch(Dispatchers.IO) {  
    val data = repository.fetchData()  
    // Update UI state  
}  

// Bad: GlobalScope leaks coroutines if ViewModel is destroyed  
GlobalScope.launch { ... } // Never do this!  

Use Flows for Reactive Data

  • StateFlow: Holds a single value and emits updates (ideal for UI state).
  • SharedFlow: Emits values to multiple collectors (e.g., event streams like navigation events).
  • Cold Flows: Emit data only when collected (e.g., database queries with Room).

Example: Room DAO returning a Flow:

@Dao  
interface UserDao {  
    @Query("SELECT * FROM users")  
    fun getUsers(): Flow<List<User>> // Cold flow: emits when data changes  
}  

// Collect in ViewModel  
viewModelScope.launch {  
    userDao.getUsers().collect { users ->  
        _uiState.value = UiState.Success(users)  
    }  
}  

Test Coroutines with runTest

Use kotlinx-coroutines-test to test coroutines. The runTest function handles coroutine dispatching and time control.

Example: Unit test for a ViewModel:

@Test  
fun `fetchUsers returns Success state`() = runTest {  
    // Mock repository to return test data  
    val mockRepo = mock<UserRepository> {  
        onBlocking { getUsers() } doReturn listOf(User("1", "John"))  
    }  
    val viewModel = UserViewModel(mockRepo)  

    viewModel.fetchUsers()  
    advanceUntilIdle() // Process all coroutines  

    assertThat(viewModel.uiState.value).isInstanceOf(UiState.Success::class.java)  
}  

5. Modern UI Development with Jetpack Compose

Jetpack Compose is Android’s modern toolkit for building UIs declaratively. Follow these practices for clean, efficient UIs:

Keep Composables Pure

Composables should be stateless (no internal state) and deterministic (same input → same output). Hoist state to parent composables.

Example: State hoisting (good):

// Child composable: No internal state  
@Composable  
fun UserProfile(name: String, onNameClick: () -> Unit) {  
    Text(  
        text = name,  
        modifier = Modifier.clickable { onNameClick() }  
    )  
}  

// Parent composable: Owns and passes state down  
@Composable  
fun ProfileScreen() {  
    var userName by remember { mutableStateOf("John") }  
    UserProfile(  
        name = userName,  
        onNameClick = { userName = "Updated John" }  
    )  
}  

Avoid Side Effects in Composables

Side effects (e.g., network calls, logging) should be handled in LaunchedEffect, DisposableEffect, or rememberCoroutineScope.

Example: Load data on composable launch:

@Composable  
fun UserListScreen(viewModel: UserViewModel) {  
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()  

    // Side effect: Trigger data fetch when screen is launched  
    LaunchedEffect(Unit) {  
        viewModel.fetchUsers()  
    }  

    when (uiState) {  
        is UiState.Loading -> CircularProgressIndicator()  
        is UiState.Success -> UserList(users = (uiState as UiState.Success).data)  
        is UiState.Error -> Text("Error: ${(uiState as UiState.Error).message}")  
    }  
}  

Use Material3 for Design Consistency

Adopt Material3 for modern, accessible UI components (e.g., Button, Card, NavigationBar). It supports dynamic color and dark theme.

Example: Material3 Button:

Button(  
    onClick = { /* Handle click */ },  
    colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)  
) {  
    Text("Click Me")  
}  

6. Testing Strategies

Testing ensures your app works as expected and prevents regressions.

Write Unit Tests for Business Logic

Test ViewModel, Repository, and utility classes with JUnit and MockK (for mocking dependencies).

Example: Testing a repository with MockK:

class UserRepositoryTest {  
    @get:Rule  
    val coroutineRule = MainCoroutineRule() // Controls coroutine dispatcher  

    @Test  
    fun `getUsers returns local data if available`() = runTest {  
        // Mock DAO to return local data  
        val mockDao = mock<UserDao> {  
            onBlocking { getUsers() } doReturn listOf(User("1", "John"))  
        }  
        val mockApi = mock<UserApiService>()  
        val repo = UserRepository(mockApi, mockDao)  

        val result = repo.getUsers()  

        assertThat(result).containsExactly(User("1", "John"))  
        verify(exactly = 0) { mockApi.getUsers() } // API not called  
    }  
}  

Test UI with Compose Testing

Use androidx.compose.ui.test to test Composables. Simulate user interactions (clicks, text input) and verify UI state.

Example: Testing a login button:

@RunWith(AndroidJUnit4::class)  
class LoginScreenTest {  
    @Test  
    fun `login button is enabled when fields are filled`() {  
        composeTestRule.setContent {  
            LoginScreen(viewModel = mock())  
        }  

        // Enter text in fields  
        composeTestRule.onNodeWithTag("email_field").performTextInput("[email protected]")  
        composeTestRule.onNodeWithTag("password_field").performTextInput("password123")  

        // Verify button is enabled  
        composeTestRule.onNodeWithTag("login_button").assertIsEnabled()  
    }  
}  

Test Flows with Turbine

Use Turbine to test flows by collecting emissions and asserting values.

Example: Testing a StateFlow:

@Test  
fun `uiState emits Loading then Success`() = runTest {  
    val viewModel = UserViewModel(mockRepo)  
    viewModel.uiState.test {  
        assertThat(awaitItem()).isInstanceOf(UiState.Loading::class.java)  
        assertThat(awaitItem()).isInstanceOf(UiState.Success::class.java)  
        cancelAndIgnoreRemainingEvents()  
    }  
}  

7. Performance Optimization

Poor performance leads to user frustration. Optimize your app with these practices:

Minimize Object Allocations

Avoid creating objects (e.g., StringBuilder, ArrayList) in loops or frequently called methods (e.g., onDraw in views). Reuse objects instead.

Example: Reuse a StringBuilder in a loop:

// Bad: Creates a new StringBuilder in each iteration  
for (i in 0..1000) {  
    val sb = StringBuilder()  
    sb.append("Item $i")  
}  

// Good: Reuse a single StringBuilder  
val sb = StringBuilder()  
for (i in 0..1000) {  
    sb.clear() // Reuse the same instance  
    sb.append("Item $i")  
}  

Optimize Composables

  • Avoid expensive operations in composables: Move calculations to LaunchedEffect or remember.
  • Use remember for expensive objects: Cache computed values (e.g., bitmaps, parsed data).
  • Limit recompositions: Use remember with keys to recompute only when inputs change.

Example: Remember a parsed list:

@Composable  
fun UserList(jsonString: String) {  
    // Recomputes only if jsonString changes  
    val users = remember(jsonString) {  
        Json.decodeFromString<List<User>>(jsonString) // Expensive parsing  
    }  
    LazyColumn {  
        items(users) { UserItem(it) }  
    }  
}  

Profile with Android Studio Profilers

Use the CPU Profiler to identify slow methods, Memory Profiler to detect leaks, and Compose Profiler to find unnecessary recompositions.

8. Security Best Practices

Protect user data and your app from attacks:

Secure Sensitive Data

  • Use Jetpack Security: Encrypt data with EncryptedFile or EncryptedSharedPreferences.
  • Avoid SharedPreferences for sensitive data: Use EncryptedSharedPreferences instead.

Example: EncryptedSharedPreferences:

val masterKey = MasterKey.Builder(applicationContext)  
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)  
    .build()  

val sharedPrefs = EncryptedSharedPreferences.create(  
    "secure_prefs",  
    masterKey,  
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,  
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM  
)  

// Store sensitive data  
sharedPrefs.edit().putString("auth_token", "secret_token").apply()  

Use HTTPS and Validate Certificates

  • Enforce HTTPS with android:usesCleartextTraffic="false" in AndroidManifest.xml.
  • Use certificate pinning to prevent man-in-the-middle attacks (e.g., with Retrofit and OkHttp).

Obfuscate Code with R8

Enable R8 (enabled by default in release builds) to shrink, optimize, and obfuscate code, making reverse engineering harder.

9. Tooling and Automation

Leverage tools to streamline development and ensure code quality:

Use Static Analysis Tools

  • Detekt: Enforce code style and find anti-patterns (e.g., unused variables, long methods).
  • Ktlint: Check Kotlin code formatting consistency.
  • Android Lint: Catch Android-specific issues (e.g., unused resources, missing permissions).

Example: Detekt config (detekt.yml):

rules:  
  complexity:  
    LongMethod:  
      threshold: 30  
  style:  
    UnusedPrivateMember:  
      active: true  

Automate Testing with CI/CD

Use GitHub Actions, GitLab CI, or Firebase App Distribution to run tests, build APKs, and distribute beta versions automatically.

Example: GitHub Actions workflow to run tests:

name: Run Tests  
on: [push]  
jobs:  
  test:  
    runs-on: ubuntu-latest  
    steps:  
      - uses: actions/checkout@v4  
      - name: Set up JDK 17  
        uses: actions/setup-java@v4  
        with:  
          java-version: 17  
          distribution: 'temurin'  
      - name: Run unit tests  
        run: ./gradlew test  

10. References

By following these best practices, you’ll build Android apps that are maintainable, performant, secure, and a joy to work on. Happy coding! 🚀