Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 100 / 100

100 days of modern Android and Kotlin: the recap explained

The lessons that matter most.

Expona Admin7 min read

Introduction

A hundred days of focused Android and Kotlin development teaches you something that no short course or tutorial can: what actually works in production. We've spent the last quarter building features, fixing bugs, and learning which patterns scale and which ones crumble under real user load. 100 days of modern Android and Kotlin: the recap explained distils the lessons that matter most—the ones that keep your app fast, your codebase maintainable, and your team shipping with confidence.

The modern Android ecosystem has matured significantly. Kotlin is now the default language, Compose has reshaped UI development, and architectural patterns have converged toward clear best practices. What we've learned over this journey is that mastery isn't about knowing every framework; it's about understanding the why behind each tool and knowing when to use it.

Kotlin makes Android safer

Null pointer exceptions used to be the silent killer of Android stability. We'd ship a feature, it would crash on some edge case, and we'd spend hours in crash logs trying to reconstruct what went wrong. Kotlin's null safety changes that fundamentally.

When you declare a variable in Kotlin, the type system forces you to decide: is this nullable or not? A String is never null. A String? can be null, and the compiler demands you handle it before you use it.

kotlin
// Kotlin enforces null safety at compile time
val username: String = "alice"        // Never null
val nickname: String? = null          // Can be null

// This won't compile—the compiler catches it
// val length = nickname.length

// You must handle the null case explicitly
val safeLength = nickname?.length ?: 0

Beyond null safety, Kotlin's data classes eliminate boilerplate that used to be a source of bugs. In Java, creating a simple model meant writing getters, setters, equals, hashCode, and toString by hand—each one a place to make a mistake. Kotlin data classes generate all of that:

kotlin
data class User(
    val id: Long,
    val email: String,
    val createdAt: Long
)

// Automatically includes: equals, hashCode, toString, copy, componentN
val user1 = User(1, "alice@example.com", 1000L)
val user2 = user1.copy(email = "alice.new@example.com")

Coroutines handle the async work that Android demands—network calls, database queries, heavy computation—without the callback hell that older async patterns created. A coroutine is lightweight, cancellable, and reads like synchronous code:

kotlin
viewModelScope.launch {
    try {
        val user = userRepository.fetchUser(userId)
        _uiState.value = UiState.Success(user)
    } catch (e: Exception) {
        _uiState.value = UiState.Error(e.message ?: "Unknown error")
    }
}

Coroutines are structured—they're tied to a scope, so when your UI disappears, the work is automatically cancelled. You don't need to manually manage subscription cleanup; the scope does it for you.

Compose changes UI work

Traditional Android Views were declarative in intent but imperative in practice. You'd create a layout XML file, reference it in code, and then manually update each View when state changed. It was error-prone: forget to call textView.setText() on one path and your UI would show stale data.

Jetpack Compose inverts that model. You describe your UI as a pure function of state. Give it the same state, you get the same UI. Change the state, the UI recomposes automatically.

kotlin
@Composable
fun UserProfileScreen(userId: Long, viewModel: ProfileViewModel) {
    val uiState by viewModel.uiState.collectAsState()
    
    when (val state = uiState) {
        is UiState.Loading -> {
            CircularProgressIndicator()
        }
        is UiState.Success -> {
            Column {
                Text(state.user.name, style = MaterialTheme.typography.headlineSmall)
                Text(state.user.email)
            }
        }
        is UiState.Error -> {
            Text("Error: ${state.message}", color = Color.Red)
        }
    }
}

The key principle is state hoisting: keep state as high up the composition tree as needed, but no higher. A small component manages its own local UI state (like whether a dropdown is open). Shared state—like the user data—lives in the ViewModel, where it can be observed by multiple UI branches.

This separation of concerns makes your UI code testable and reusable. You can compose the same screen in different contexts, pass different state, and verify it renders correctly:

kotlin
@Preview
@Composable
fun UserProfileScreenPreview_Loading() {
    UserProfileScreen(
        userId = 1L,
        viewModel = FakeProfileViewModel(UiState.Loading)
    )
}

@Preview
@Composable
fun UserProfileScreenPreview_Success() {
    UserProfileScreen(
        userId = 1L,
        viewModel = FakeProfileViewModel(
            UiState.Success(User(1, "alice@example.com"))
        )
    )
}

Architecture keeps apps maintainable

A hundred days of development will reveal any architectural cracks quickly. Apps without clear separation of concerns become increasingly difficult to navigate and modify. The modern Android stack converges on MVVM (Model-View-ViewModel) with a few foundational principles:

  • Unidirectional data flow: UI state flows down from ViewModel, user events flow up from UI.
  • Dependency injection: Your ViewModel doesn't create its own dependencies; they're provided (usually via Hilt).
  • Repository pattern: Data access is abstracted behind a repository, so swapping between network and local sources is seamless.
  • Offline-first thinking: Assume the network is unreliable. Cache locally, sync when possible.

Here's a concrete flow: a ViewModel needs user data. It delegates to a repository. The repository checks the local database first—if the data is fresh, return it immediately. If it's stale or missing, fetch from the network and update the cache before returning:

kotlin
class UserRepository @Inject constructor(
    private val localDataSource: UserLocalDataSource,
    private val remoteDataSource: UserRemoteDataSource
) {
    fun getUser(userId: Long): Flow = flow {
        // Check local cache
        val cached = localDataSource.getUser(userId)
        if (cached != null && !isStale(cached)) {
            emit(cached)
            return@flow
        }
        
        // Fetch from network
        val remote = remoteDataSource.fetchUser(userId)
        localDataSource.saveUser(remote)
        emit(remote)
    }
}

Dependency injection removes tight coupling. Your ViewModel doesn't instantiate the repository—it receives it as a constructor parameter. In tests, you inject a fake repository that returns predictable data. In production, Hilt injects the real one. This flexibility is what keeps a codebase maintainable as it grows.

Ship with confidence

Building features is only half the job. Shipping them reliably—knowing you won't wake up to a crash dashboard full of red—requires discipline around testing, profiling, and rollout strategy.

Unit tests are cheap insurance. They run fast, give instant feedback, and catch regressions before they reach production. Focus on testing business logic—repositories, ViewModels, and state management—where a single bug can affect thousands of users:

kotlin
class UserRepositoryTest {
    @Test
    fun getUser_returnsCached_whenDataIsFresh() = runTest {
        val cachedUser = User(1, "alice@example.com")
        val repository = UserRepository(
            localDataSource = FakeUserLocalDataSource(cachedUser),
            remoteDataSource = FakeUserRemoteDataSource()
        )
        
        val result = repository.getUser(1L).first()
        
        assertEquals(cachedUser, result)
        // Verify remote wasn't called
        assertFalse(remoteDataSource.wasCalled)
    }
}

Integration tests validate that your layers talk to each other correctly. Compose UI tests verify that your composables render and respond to input as expected. But don't aim for 100% test coverage; aim for coverage of the paths that matter most.

Performance profiling is non-negotiable. Android Studio's Profiler shows you exactly where your app is spending CPU, memory, and battery. If your list scrolls at 40 fps instead of 60, the profiler will show you which composable is recomposing too often or which function is allocating memory unnecessarily.

Staged rollouts protect against the unknown unknowns. Launch to 1% of users first. Monitor crash rates, ANR (Application Not Responding) rates, and key business metrics. If something's wrong, you catch it on a tiny user base and roll back instantly. Only after 24–48 hours of clean data do you expand to 10%, then 50%, then 100%.

Common mistakes

  • Hoisting state too high: Putting every small UI detail in the ViewModel creates a god object and makes recomposition expensive. Keep local state local (in Compose state), share only what needs to be shared.
  • Ignoring coroutine cancellation: Launching a coroutine in a ViewModel without specifying a scope will leak work when the user navigates away. Always use viewModelScope or similar lifecycle-aware scopes.
  • No offline strategy: Assuming the network is always available leads to poor UX the moment connectivity drops. Cache aggressively and build UIs that degrade gracefully.
  • Skipping the profiler: You can't optimize what you don't measure. If your app feels slow, profile it. The CPU, memory, and battery profilers will show you exactly where to focus.
  • Rolling out too fast: The temptation to go 100% immediately is real, but one staged phase usually catches the bugs that slip through testing. The extra 24 hours is worth the risk reduction.

Key takeaways

  • Kotlin's type system (null safety, data classes) eliminates entire categories of runtime bugs and boilerplate.
  • Compose's declarative model—state in, UI out—makes UI code predictable, testable, and maintainable.
  • Clear architecture (MVVM, DI, repository pattern) scales with team and codebase size; invest in it early.
  • Offline-first thinking and caching are not optional; they're fundamental to good user experience on mobile networks.
  • Test the paths that matter. Profile before optimizing. Rollout gradually to catch the unknowns.
  • The modern Android stack is mature and stable; the gaps in production apps are usually architectural or strategic, not technical.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles