Building a fitness app is easy. Building a production-ready, offline-capable, memory-efficient fitness app that survives Android's brutal lifecycle changes? That's a different beast entirely.
Over the past few weeks, Iβve been heavily refactoring my open-source workout tracker, Shredzilla, moving it from a "working prototype" to a rock-solid, production-ready architecture.
In this article, I want to share the critical architectural lessons learned during the v1.1.0 Refactoring Sprint, how we solved some nasty memory leaks, and whatβs coming next in the v1.2.0 Performance Update.
What We Achieved in v1.1.0: The Great Refactoring
Version 1.1.0 was all about Stability, Security, and DRY Principles. Here are the biggest wins:
1. Slaying the Eternal Firestore Memory Leak π§ββοΈ
If you use Firebase Firestore's addSnapshotListener, you might be leaking memory without realizing it. Previously, Shredzilla's listeners stayed alive in the background even after a user logged out, consuming memory and unnecessary quota.
The Fix: We implemented a strict caching system for ListenerRegistration inside our UserDataManager.
// Inside UserDataManager.kt
private val listeners = mutableListOf<ListenerRegistration>()
fun setupFirestoreListeners(currentUser: FirebaseUser?) {
val userDocRegistration = db.collection("users").document(userId)
.addSnapshotListener { snapshot, e -> /* Update UI State */ }
listeners.add(userDocRegistration) // Cache the listener
}
// Called upon Sign Out or Account Deletion
fun clearAllListeners() {
listeners.forEach { it.remove() }
listeners.clear()
}
Takeaway: Never leave a listener hanging. Always cache and kill them when the user session ends.
2. State Hoisting & Surviving Process Death π§ββοΈ
Have you ever tried to update a username, rotated your phone while the loading spinner was spinning, and suddenly the app crashed or the state vanished? We fixed that.
We moved heavy, non-idempotent operations (like Account Deletion and Profile Updates) entirely into the MainViewModel's viewModelScope.
// β BAD: UI handles the coroutine. Dies on rotation.
Button(onClick = {
rememberCoroutineScope().launch { deleteAccount() }
})
// β
GOOD: ViewModel handles the launch. Survives UI recreation.
fun deleteUserAccount(onSuccess: () -> Unit, onError: (Exception) -> Unit) {
viewModelScope.launch {
isDeletingAccount = true
try {
// Delete Firestore Data -> Delete Auth User -> Clean Local Files
// ...
onSuccess()
} finally { isDeletingAccount = false }
}
}
We also swapped standard remember for rememberSaveable in our text fields so users don't lose their typed input during configuration changes!
3. Bulletproof Navigation & Back-Stack Management
Onboarding flows can be tricky. You don't want a user who just finished logging in to press the "Back" button and end up back on the login screen.
In Shredzilla, we utilized Compose Navigation's popUpTo forcefully:
mainNavController.navigate(AppRoutes.FITNESS_MAIN) {
// Obliterate the Auth graph from the backstack!
popUpTo(AppRoutes.AUTH) { inclusive = true }
launchSingleTop = true
}
4. Data Integrity: The DRY Settings Helper
Instead of repeating SetOptions.merge() everywhere in the UI when a user changes their Unit System (Kg/Lbs) or Theme, we abstracted it into a single, type-safe generic function in the data layer:
fun updateUserSetting(userId: String?, key: String, value: Any?) {
if (userId.isNullOrEmpty()) return
db.collection("users").document(userId)
.set(hashMapOf(key to value), SetOptions.merge())
}
What's Next: The v1.2.0 Roadmap
With the foundation secured, the upcoming v1.2.0 Sprint is focused purely on Speed, Analytics, and Gym-floor UX.
Here is what we are building next:
1. The Active Workflow Engine (Today Screen)
- Offline-First Sync: Gyms have notoriously bad Wi-Fi. We are building sophisticated offline caching so weightlifters can log intense sets seamlessly in dead-zones, with implicit background syncing upon reconnection.
- Zero-Latency Timers: Decoupling the rest timer mechanics from Compose UI recomposition cycles to guarantee frame-perfect countdowns even when the app is minimized.
2. Exercise Database & 60-FPS Filtering
-
Debounced Search Flows: Users shouldn't experience UI lag while typing "Bench Press". We are migrating the search logic to a debounced Kotlin
StateFlowemitted directly from the ViewModel to prevent ANRs. - LazyColumn Optimization: Applying standard pagination blocks and keying for buttery smooth 60-FPS scrolling through massive exercise arrays.
-
Shared Element Transitions: Building fluid, polished animations that morph from a standard list item directly into the
ExerciseDetailScreen.
3. Analytics Processing & Smart Charting
-
Thread Shifting: Heavyweight calculations (like total volume summarization over months of temporal data) will be firmly shifted off the Main Thread and onto
Dispatchers.Default. -
Dynamic Y-Axis Clamping: Designing an algorithmic bounds calculator for the
SetGraphScreento intelligently clamp ranges. This will prevent extreme one-off graphical spikes from masking micro-progressions in strength. - SavedStateHandle for Analytics: Caching mathematical aggregations so that complex graphs render instantly during spontaneous orientation pivots (Portrait -> Landscape).
Let's Build Together!
Shredzilla is an open-source project built with β€οΈ for the Android community. If you are learning Jetpack Compose, Firebase, or Clean Architecture, diving into the codebase is a great way to see these concepts in action.
Check out the repository here: github.com/kareem2099/Shredzilla
- Drop a star if you find the architecture helpful!
- Found a bug or have an idea? Open an issue!
- Want to tackle one of the v1.2.0 roadmap items? PRs are always welcome.
*What is your favorite way to handle State Hoisting in Jetpack Compose? Let's discuss in the comments below! *
Top comments (0)