DEV Community

A0mineTV
A0mineTV

Posted on

Building a High-Performance Stopwatch with Kotlin & Jetpack Compose: Real-Time State Management & Smart Architecture

When building timer applications with Kotlin and Jetpack Compose, precision and performance are crucial. But how do you create a stopwatch that's not only accurate to the millisecond but also demonstrates advanced Kotlin features like coroutines, smart state management, and elegant UI patterns ?

In this comprehensive guide, I'll walk you through a production-ready Stopwatch application that showcases the power of Kotlin's concurrency model, Compose's reactive state management, and pragmatic architectural decisions. We'll explore advanced patterns like coroutine-based timers, state persistence, haptic feedback integration, and dual-mode functionality (stopwatch/countdown timer).

By the end of this article, you'll understand how to build precise, responsive timer applications that leverage Kotlin's strengths for real-time applications.

🏗️ Architecture Philosophy: Pragmatic Kotlin Design

@Composable
fun StopwatchScreen() {
    // All state managed locally with Compose
    var isRunning by rememberSaveable { mutableStateOf(false) }
    var baseMs by rememberSaveable { mutableLongStateOf(0L) }
    var elapsedMs by rememberSaveable { mutableLongStateOf(0L) }

    // Timer logic with Kotlin coroutines
    LaunchedEffect(isRunning, baseMs) {
        if (!isRunning) return@LaunchedEffect
        while (isRunning) {
            val now = SystemClock.elapsedRealtime()
            elapsedMs = now - baseMs
            delay(50) // 20 FPS precision
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Why This Architecture Choice ?

🎯 Appropriate Complexity:

  • Timer Logic: Self-contained, no external data dependencies
  • State Scope: All state is UI-related and local to the screen
  • Performance: Direct Compose state updates are optimal for real-time updates

🔧 Kotlin-First Benefits:

  • Coroutines: Native async support without callback complexity
  • Smart State: rememberSaveable survives configuration changes
  • Type Safety: Long timestamps prevent overflow issues
  • Local Functions: Clean encapsulation of timer operations

⚡ When NOT to Use MVVM:

  • No external data sources (API, database)
  • State doesn't need sharing between screens
  • Business logic is simple and UI-focused
  • Real-time performance is critical

📊 State Management: Kotlin's Reactive Excellence

Let's explore how Kotlin and Compose handle complex timer state with precision and elegance:

@Composable
fun StopwatchScreen() {
    // Core timer state with configuration survival
    var isRunning by rememberSaveable { mutableStateOf(false) }
    var baseMs by rememberSaveable { mutableLongStateOf(0L) }
    var elapsedMs by rememberSaveable { mutableLongStateOf(0L) }

    // Dual-mode functionality
    var countdownMode by rememberSaveable { mutableStateOf(false) }
    var countdownTotalMs by rememberSaveable { mutableLongStateOf(60_000L) }

    // Lap tracking with immutable collections
    var laps by rememberSaveable { mutableStateOf(listOf<Long>()) }

    // System integration
    val snack = remember { SnackbarHostState() }
    val scope = rememberCoroutineScope()
    val haptics = LocalHapticFeedback.current
}
Enter fullscreen mode Exit fullscreen mode

Kotlin State Management Highlights:

🔒 Type-Safe State Declaration:

var baseMs by rememberSaveable { mutableLongStateOf(0L) }  // Long for precision
var laps by rememberSaveable { mutableStateOf(listOf<Long>()) }  // Immutable collections
Enter fullscreen mode Exit fullscreen mode

💎 Kotlin Features at Work:

  • Property Delegates: by rememberSaveable creates clean, readable state
  • Type Inference: Compiler ensures correct types without verbose declarations
  • Default Values: 0L and listOf<Long>() provide safe initial states
  • Immutable Collections: listOf() prevents accidental mutations

📱 Configuration Survival:

  • rememberSaveable automatically handles screen rotation
  • State persists across process death and recreation
  • No manual save/restore logic needed

⏱️ Real-Time Timer Logic: Kotlin Coroutines Mastery

The heart of our stopwatch demonstrates advanced coroutine patterns for precise timing:

LaunchedEffect(isRunning, baseMs, countdownMode, countdownTotalMs) {
    if (!isRunning) return@LaunchedEffect

    while (isRunning) {
        val now = SystemClock.elapsedRealtime()  // System monotonic time
        elapsedMs = now - baseMs

        if (countdownMode) {
            val remaining = (countdownTotalMs - elapsedMs).coerceAtLeast(0L)
            if (remaining == 0L) {
                isRunning = false
                haptics.performHapticFeedback(HapticFeedbackType.LongPress)
                scope.launch { snack.showSnackbar("Minuteur terminé ⏰") }
            }
        }

        delay(50)  // 20 FPS update rate (smooth but efficient)
    }
}
Enter fullscreen mode Exit fullscreen mode

Advanced Kotlin Coroutine Techniques:

🎯 Smart Dependency Tracking:

LaunchedEffect(isRunning, baseMs, countdownMode, countdownTotalMs)
Enter fullscreen mode Exit fullscreen mode
  • Automatic Restart: Timer restarts when key dependencies change
  • Efficient Cancellation: Previous coroutine cancelled when parameters change
  • No Memory Leaks: Coroutine lifecycle tied to composable

⚡ Performance Optimizations:

delay(50)  // 50ms = 20 FPS
Enter fullscreen mode Exit fullscreen mode
  • Balance: Smooth visuals without excessive CPU usage
  • Battery Friendly: Lower frequency than 60 FPS reduces power consumption
  • User Perception: 20 FPS feels fluid for timer displays

🔄 Cooperative Cancellation:

while (isRunning) {
    // Coroutine checks isRunning each iteration
    // Responds immediately to state changes
}
Enter fullscreen mode Exit fullscreen mode

🎯 Precise Time Calculation:

val now = SystemClock.elapsedRealtime()  // Monotonic clock
elapsedMs = now - baseMs
Enter fullscreen mode Exit fullscreen mode
  • Monotonic Time: Unaffected by system time changes
  • Accuracy: Compensates for processing delays
  • Reliability: Works during device sleep/wake cycles

🎛️ Dual-Mode Functionality: Kotlin's Expressive Power

Our stopwatch doubles as a countdown timer, showcasing Kotlin's ability to handle complex state transitions elegantly:

// Unified display logic
val displayMs = if (countdownMode)
    (countdownTotalMs - elapsedMs).coerceAtLeast(0L)
else
    elapsedMs

// Dynamic progress calculation
val progress = when {
    countdownMode && countdownTotalMs > 0 ->
        (displayMs.toFloat() / countdownTotalMs).coerceIn(0f, 1f)
    !countdownMode && elapsedMs > 0 ->
        ((elapsedMs % 60_000).toFloat() / 60_000f).coerceIn(0f, 1f)
    else -> 0f
}

// Mode switch with state reset
Switch(
    checked = countdownMode,
    onCheckedChange = { newMode ->
        countdownMode = newMode
        elapsedMs = 0L      // Clean state transition
        isRunning = false   // Stop any running timer
    }
)
Enter fullscreen mode Exit fullscreen mode

Kotlin Expression Excellence:

🎯 When Expressions for Complex Logic:

val progress = when {
    countdownMode && countdownTotalMs > 0 -> /* countdown logic */
    !countdownMode && elapsedMs > 0 -> /* stopwatch logic */
    else -> 0f  // Safe default
}
Enter fullscreen mode Exit fullscreen mode

🔧 Extension Functions for Safety:

(countdownTotalMs - elapsedMs).coerceAtLeast(0L)  // Never negative
(displayMs.toFloat() / countdownTotalMs).coerceIn(0f, 1f)  // Always 0-1 range
Enter fullscreen mode Exit fullscreen mode

💡 Smart State Management:

  • Clean Transitions: Mode changes reset relevant state
  • Consistent Behavior: Same timer logic for both modes
  • Type Safety: Float calculations with safe bounds

2. Intelligent Button States

Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
    if (!isRunning) {
        Button(onClick = { start() }) { Text("Démarrer") }
    } else {
        Button(onClick = { pause() }) { Text("Pause") }
        OutlinedButton(
            onClick = { lap() },
            enabled = !countdownMode  // Laps only for stopwatch
        ) {
            Text("Tour")
        }
    }
    OutlinedButton(
        onClick = { reset() },
        enabled = elapsedMs > 0L  // Only when there's something to reset
    ) {
        Text("Reset")
    }
}
Enter fullscreen mode Exit fullscreen mode

🔧 Kotlin-Powered Button Logic:

  • Conditional Rendering: if-else expressions for button switching
  • Smart Enabling: enabled = !countdownMode prevents invalid operations
  • State-Based UI: Buttons reflect current timer state automatically

3. Lap Tracking with Performance

if (!countdownMode && laps.isNotEmpty()) {
    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        contentPadding = PaddingValues(vertical = 8.dp)
    ) {
        itemsIndexed(laps) { index, lapTime ->
            ElevatedCard(modifier = Modifier.fillMaxWidth()) {
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(12.dp),
                    horizontalArrangement = Arrangement.SpaceBetween,
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Text("Tour #${laps.size - index}")  // Reverse numbering
                    Text(formatMs(lapTime))
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

⚡ Performance Optimizations:

  • LazyColumn: Only renders visible lap items
  • Conditional Rendering: Laps only shown when relevant
  • Immutable State: listOf<Long>() enables efficient diffing
  • Smart Indexing: laps.size - index for reverse chronological order

🎯 Local Functions: Clean Kotlin Architecture

Our timer operations demonstrate Kotlin's local functions for clean encapsulation:

@Composable
fun StopwatchScreen() {
    // ... state declarations ...

    // Local functions with closure access
    fun start() {
        if (!isRunning) {
            baseMs = SystemClock.elapsedRealtime() - elapsedMs
            isRunning = true
        }
    }

    fun pause() {
        isRunning = false
    }

    fun reset() {
        isRunning = false
        elapsedMs = 0L
        laps = emptyList()
    }

    fun lap() {
        if (isRunning) laps = listOf(elapsedMs) + laps  // Prepend new lap
    }

    // ... UI code uses these functions ...
}
Enter fullscreen mode Exit fullscreen mode

Why Local Functions Excel Here:

🔒 Encapsulation Benefits:

  • Scope Limitation: Functions only available within composable
  • State Access: Direct access to local state variables
  • No Boilerplate: No need for separate classes or interfaces

⚡ Performance Advantages:

  • Inline Optimization: Compiler can inline these calls
  • No Object Creation: Functions are compiled as local methods
  • Direct State Mutation: No indirection through ViewModels

🎯 Readability Wins:

  • Clear Intent: Each function has a single, obvious purpose
  • Consistent Patterns: All functions follow same state mutation approach
  • Easy Testing: Could be extracted to ViewModel if needed

🔧 Kotlin Time Formatting: Extension Function Excellence

Our time display showcases Kotlin's string formatting capabilities:

private fun formatMs(ms: Long): String {
    val totalSeconds = ms / 1000
    val minutes = totalSeconds / 60
    val seconds = totalSeconds % 60
    val hundredths = (ms % 1000) / 10
    return "%02d:%02d.%02d".format(minutes, seconds, hundredths)
}
Enter fullscreen mode Exit fullscreen mode

Advanced Formatting Techniques:

🎯 Potential Kotlin Extensions:

// Could be enhanced with extension functions
fun Long.toTimerFormat(): String {
    val totalSeconds = this / 1000
    val minutes = totalSeconds / 60
    val seconds = totalSeconds % 60
    val hundredths = (this % 1000) / 10
    return "%02d:%02d.%02d".format(minutes, seconds, hundredths)
}

// Usage: displayMs.toTimerFormat()
Enter fullscreen mode Exit fullscreen mode

💡 Kotlin String Templates Alternative:

private fun formatMs(ms: Long): String {
    val totalSeconds = ms / 1000
    val minutes = totalSeconds / 60
    val seconds = totalSeconds % 60
    val hundredths = (ms % 1000) / 10
    return "${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${hundredths.toString().padStart(2, '0')}"
}
Enter fullscreen mode Exit fullscreen mode

🧪 Testing Challenges & Kotlin Solutions

While our current StopwatchScreen doesn't include tests, let's explore how Kotlin would make testing elegant:

Testable Architecture Refactoring

// Extractable timer logic for testing
class StopwatchLogic {
    private var isRunning = false
    private var baseMs = 0L
    private var elapsedMs = 0L

    fun start(currentTimeMs: Long = SystemClock.elapsedRealtime()) {
        if (!isRunning) {
            baseMs = currentTimeMs - elapsedMs
            isRunning = true
        }
    }

    fun updateTime(currentTimeMs: Long): Long {
        return if (isRunning) {
            elapsedMs = currentTimeMs - baseMs
            elapsedMs
        } else elapsedMs
    }
}
Enter fullscreen mode Exit fullscreen mode

Kotlin Testing Advantages:

🧪 Coroutine Testing:

@Test
fun `timer updates correctly with virtual time`() = runTest {
    // Kotlin's virtual time testing
    val logic = StopwatchLogic()

    logic.start(0L)
    assertEquals(1000L, logic.updateTime(1000L))
    assertEquals(2500L, logic.updateTime(2500L))
}
Enter fullscreen mode Exit fullscreen mode

⚡ Property-Based Testing:

@Test
fun `timer is always monotonic`() {
    val logic = StopwatchLogic()
    var lastTime = 0L

    logic.start()
    repeat(100) { iteration ->
        val currentTime = logic.updateTime(iteration * 100L)
        assertTrue("Time should be monotonic", currentTime >= lastTime)
        lastTime = currentTime
    }
}
Enter fullscreen mode Exit fullscreen mode

🚀 Production Considerations & Kotlin Best Practices

Performance Optimizations

⚡ Memory Efficiency:

  • Primitive Collections: Use LongArray for many laps instead of List<Long>
  • Object Pooling: Reuse formatting strings for frequent updates
  • Coroutine Scoping: Proper cleanup prevents memory leaks

🔋 Battery Optimization:

// Adaptive update rate based on precision needs
val updateDelayMs = if (elapsedMs < 10_000) 50L else 100L  // Higher precision for first 10 seconds
delay(updateDelayMs)
Enter fullscreen mode Exit fullscreen mode

Kotlin Coroutine Excellence

🎯 Structured Concurrency:

  • Lifecycle Awareness: LaunchedEffect ties coroutine to composable lifecycle
  • Cancellation Propagation: Parent cancellation stops timer automatically
  • Exception Handling: Coroutine exceptions don't crash the app

⚡ Advanced Patterns:

// Could use Flow for reactive timer updates
val timerFlow = flow {
    while (currentCoroutineContext().isActive) {
        emit(SystemClock.elapsedRealtime())
        delay(50)
    }
}.map { currentTime -> currentTime - baseMs }
Enter fullscreen mode Exit fullscreen mode

🎓 Key Takeaways & Kotlin Insights

🏗️ Architecture Lessons

  1. Appropriate Complexity: Not every feature needs MVVM - choose architecture based on requirements
  2. Kotlin Coroutines: Perfect for real-time applications with precise timing needs
  3. Local State: Compose + Kotlin state management can handle complex scenarios elegantly
  4. Performance First: Direct state updates outperform complex abstractions for real-time UI

Kotlin Language Features

  1. Property Delegates: by rememberSaveable creates clean state management
  2. Coroutines: Natural async programming without callback complexity
  3. Local Functions: Clean encapsulation within composables
  4. Extension Functions: Enhance readability and reusability
  5. When Expressions: Handle complex conditional logic elegantly

🎯 Compose Integration

  1. Reactive State: Automatic UI updates with Kotlin property delegates
  2. Lifecycle Integration: LaunchedEffect provides perfect coroutine scoping
  3. Performance: Direct state updates enable smooth real-time interfaces
  4. Configuration Survival: rememberSaveable handles Android lifecycle automatically

💭 Final Thoughts

This Stopwatch application demonstrates that architecture should match complexity. While MVVM excels for complex features with external dependencies, self-contained composables with Kotlin coroutines can be more appropriate for real-time, performance-critical features.

The combination of Kotlin's coroutines, Compose's reactive state management, and pragmatic architectural decisions creates a timer that's both precise and maintainable. By leveraging Kotlin's language features—from property delegates to when expressions—we've built a feature that feels native to both the language and the platform.

Top comments (0)