Skip to main content

24 posts tagged with "Jetpack Compose"

View All Tags

Tracking Frozen Frames (>700ms) and UI Jank in Compose Using JankStats and Android Vitals

Published: · 10 min read
Don Peter
Cofounder and CTO, Appxiom

Few issues damage an Android app's user experience faster than UI jank. A user scrolls through a catalog, taps a checkout button, and the screen locks for nearly a second. To the operating system, the application has not crashed. But to the user, the app feels broken, unresponsive, and untrustworthy.

Google monitors mobile user experience through Android Vitals. While Google Play's core vitals focus on user-perceived crash rate, user-perceived ANR rate, and battery usage (excessive partial wake locks), Android Vitals also tracks rendering performance through Slow Frames (>16ms) and Frozen Frames (>700ms). Although frozen frames do not carry a standalone store discoverability penalty or documented bad behavior threshold like core vitals, prolonged frame hangs directly damage user retention, spark negative reviews, and frequently cascade into full-blown Application Not Responding (ANR) errors.

In declarative UI toolkits like Jetpack Compose, diagnosing frame hangs is challenging because recomposition, layout passes, and drawing occur on a unified render pipeline. Traditional crash reporting tools capture uncaught exceptions, but remain blind to real-world Jetpack Compose jank and 700ms frame drops on the main thread.

To eliminate this blind spot, modern frame drop profiling requires Android's JankStats library (androidx.metrics:metrics-performance). By pairing JankStats with contextual UI state and telemetry, engineering teams can track, isolate, and eliminate frozen frames before they degrade the user experience.

How Android Vitals Measures Rendering: Slow Frames vs. Frozen Frames​

Google Play continuously collects system rendering metrics across your active production install base via Android Vitals. Google tracks two key rendering metrics:

  1. Slow Frames (>16ms): Frames that miss the hardware vsync interval (exceeding ~16.6ms on 60Hz displays or ~8.3ms on 120Hz panels). Android Vitals defines this metric as the percentage of daily sessions in which more than 50% of frames took longer than 16ms to render.
  2. Frozen Frames (>700ms): Severe rendering stalls where a single frame takes longer than 700 milliseconds to render (equivalent to dropping more than 42 consecutive frames at 60Hz). Android Vitals measures this as the percentage of daily sessions during which more than 0.1% of frames took longer than 700ms to render.

Notice the distinction: the 0.1% metric in Android Vitals represents the share of frames within a session that freeze, not a percentage of overall user sessions.

Core Vitals vs. Rendering Metrics​

It is important to understand how Google Play treats rendering metrics relative to Core Vitals:

  • Core Vitals: Google Play designates four core vitals: user-perceived ANR rate, user-perceived crash rate, and battery metrics (excessive partial wake locks and excessive background network/Wi-Fi usage). Only Core Vitals carry documented "bad behavior thresholds" that directly trigger algorithmic search deprioritization, disqualification from promotional placement, and warning banners on store listings.
  • Rendering Metrics: Slow rendering and frozen frames are not core vitals, and Google Play does not document an official discoverability penalty or bad behavior threshold for them.

However, treating frozen frames as benign is a critical mistake. When a frame takes 700ms or longer to render, the main looper is completely starved. If a user touches the screen or the system sends an input event during this freeze, the input queue stalls. If the main thread remains blocked for 5 seconds, Android triggers a User-Perceived ANR, immediately triggering Google Play's strictest Core Vital penalties.

The Business and Reliability Impact of Frozen Frames​

While slow frames cause perceptible micro-stutter during scrolling, frozen frames represent complete UI paralysis.

Even without a direct store discoverability penalty, frozen frames severely degrade an application's product health:

  • Escalation to User-Perceived ANRs: A 700ms freeze is often symptomatic of synchronous disk I/O, main-thread serialization, or heavy layout passes. Under real-world device throttling or thermal load, these 700ms stalls easily stretch into multi-second hangs that trigger ANRs.
  • Conversion and Task Abandonment: When a tap on a "Pay Now" or "Add to Cart" button freezes the screen for 700ms+, users assume the action failed. They either rage-tap (causing duplicate actions or further freezing) or leave the application entirely.
  • Review Sentiment and Play Store Ratings: Users rarely distinguish between a frozen frame and an actual crash. Stalls over 700ms are the number one driver of reviews reporting "app keeps freezing" or "unresponsive," dragging down overall star ratings and organically reducing install conversions.

Fixing frozen frames is essential engineering hygiene that directly defends your app's Core Vitals and user retention.

Setting Up JankStats in Jetpack Compose​

The AndroidX JankStats library (androidx.metrics:metrics-performance) tracks per-frame render times using platform Window.OnFrameMetricsAvailableListener on API 24+ while maintaining backward compatibility down to API 16.

Add the dependency to your module's build.gradle.kts:

dependencies {
implementation("androidx.metrics:metrics-performance:1.0.0")
}

Initializing JankStats in Your Main Activity​

JankStats must be attached to the current Window. In a Compose-based Activity, initialize the listener inside onCreate() or a lifecycle-aware effect:

package com.example.app.performance

import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.metrics.performance.JankStats

class MainActivity : ComponentActivity() {

private lateinit var jankStats: JankStats

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Initialize JankStats listener for the Activity Window
jankStats = JankStats.createAndTrack(window) { frameData ->
if (frameData.isJank) {
val durationMs = frameData.frameDurationUiNanos / 1_000_000L
val isFrozen = durationMs >= 700L

// Log or forward frame telemetry to your observability client
Log.w(
"JankStats",
"Jank detected: duration=${durationMs}ms, frozen=$isFrozen, states=${frameData.states}"
)
}
}

setContent {
AppNavigation()
}
}

override fun onResume() {
super.onResume()
jankStats.isTrackingEnabled = true
}

override fun onPause() {
super.onPause()
jankStats.isTrackingEnabled = false
}
}

frameData.isJank is determined dynamically by comparing the frame render duration against dynamic device refresh rate baselines calculated by the library.

Enriching Telemetry with PerformanceMetricsState​

Knowing that a frame took 820ms is useless if your telemetry cannot answer: what was the app doing when the frame froze?

JankStats provides PerformanceMetricsState, allowing developers to attach dynamic, contextual key-value pairs to the current view hierarchy. When a frame drops, JankStats snapshots all active states into frameData.states.

In Jetpack Compose, access the state holder using rememberMetricsStateHolder():

package com.example.app.ui.components

import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.metrics.performance.PerformanceMetricsState
import androidx.metrics.performance.rememberMetricsStateHolder

@Composable
fun ProductListScreen(
items: List<ProductItem>,
modifier: Modifier = Modifier
) {
val metricsHolder = rememberMetricsStateHolder()
val listState = rememberLazyListState()

// 1. Attach screen-level contextual state
DisposableEffect(metricsHolder) {
val state = metricsHolder.state
state?.putState("current_screen", "product_catalog")
state?.putState("item_count", items.size.toString())

onDispose {
state?.removeState("current_screen")
state?.removeState("item_count")
}
}

// 2. Attach dynamic scroll state during list interaction
LaunchedEffect(listState, metricsHolder) {
snapshotFlow { listState.isScrollInProgress }
.collect { isScrolling ->
val state = metricsHolder.state
if (isScrolling) {
state?.putState("scroll_status", "scrolling")
} else {
state?.removeState("scroll_status")
}
}
}

LazyColumn(
state = listState,
modifier = modifier
) {
items(
items = items,
key = { it.id }
) { item ->
ProductRow(item = item)
}
}
}

When an 800ms frozen frame triggers, your JankStats listener output includes full operational context:

{
"durationMs": 812,
"isJank": true,
"isFrozen": true,
"states": [
{ "key": "current_screen", "value": "product_catalog" },
{ "key": "item_count", "value": "150" },
{ "key": "scroll_status", "value": "scrolling" }
]
}

Instead of an isolated number, engineering receives immediate clarity on where and when the stall occurred.

Optimization Tactics: Fixing 700ms+ Frozen Frames in Compose​

A 700ms frame hang is rarely caused by standard rendering overhead. It indicates that the main thread was completely blocked. Here are the three primary causes in production Compose applications:

1. Synchronous Room or DataStore Reads During Recomposition​

Reading disk-backed persistent storage directly within a composable body or remember block stalls the main UI thread during disk I/O.

Incorrect: Blocking the Main Thread During Recomposition​

@Composable
fun UserBadge(userId: String, userDao: UserDao) {
// Blocks main thread for 100ms-800ms during SQLite disk access!
val user = remember(userId) {
userDao.getUserSync(userId)
}
Text(text = user?.name ?: "Loading...")
}

Correct: Offload to ViewModel and Collect Asynchronously​

@Composable
fun UserBadge(viewModel: UserBadgeViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

when (val state = uiState) {
is UserUiState.Success -> Text(text = state.name)
is UserUiState.Loading -> CircularProgressIndicator()
}
}

// In ViewModel:
class UserBadgeViewModel(private val userDao: UserDao) : ViewModel() {
val uiState: StateFlow<UserUiState> = userDao.getUserFlow(userId)
.map { UserUiState.Success(it.name) }
.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UserUiState.Loading)
}

2. Complex Vector Parsing & Uncached Canvas Paths​

Re-parsing complex SVG paths or creating Path and Paint allocations inside Modifier.drawWithContent or Canvas blocks forces garbage collection and CPU rendering freezes on every frame:

// Avoid: Allocating Path and Matrix inside drawing passes on every frame
Canvas(modifier = Modifier.fillMaxSize()) {
val path = Path().apply { /* complex dynamic SVG path calculation */ }
drawPath(path, color = Color.Red)
}

// Recommended: Cache drawing objects outside composition using drawWithCache
Modifier.drawWithCache {
val cachedPath = Path().apply { /* calculated once per size change */ }
onDrawBehind {
drawPath(cachedPath, color = Color.Red)
}
}

3. Subcomposition Overhead in Lazy Lists Without Stable Keys​

When Lazy lists omit stable keys, small state mutations cause Compose to discard and recreate items rather than reordering them.

  • Always provide stable keys: Use key = { item.id } in items() calls.
  • Defer state reads: If a composable only needs to react to scrolling offsets, use lambda-based modifiers (Modifier.offset { IntOffset(x, y) }) to skip recomposition and execute directly in the layout/draw phase.
  • Use derivedStateOf: Wrap high-frequency scroll calculations to prevent triggering unnecessary recomposition passes across child nodes.

Correlating UI Jank with Session Context and User Impact​

JankStats provides high-precision data on which frame froze and what contextual state was present via PerformanceMetricsState. However, in distributed production environments, knowing that a user was on product_catalog with scroll_status: scrolling still does not explain:

  • What prior network calls or memory spikes led to the main-thread contention?
  • Did an unhandled coroutine or disk lock earlier in the session precipitate the freeze?
  • Did the user abandon their workflow or exit the funnel as a result of the freeze?

This is where Appxiom complements frame-level metrics by bridging runtime failures to complete user-journey observability:

  1. Chronological Activity Trails: When an ANR or application failure occurs, Appxiom reconstructs the user's preceding activity trail by capturing user taps, lifecycle events, and network responses leading up to the issue, removing guesswork when reproducing complex hangs.
  2. Goal Friction Impact (GFI): Appxiom measures whether performance freezes directly caused funnel abandonment (e.g., measuring cart drop-offs immediately following an ANR or frozen screen).
  3. Appxiom Quality Score (QS): Rather than treating crash and ANR rates in isolation, Appxiom factors overall application stability and user friction into a unified 0-10 release health index across your application versions.

Common Mistakes to Avoid​

Anti-PatternOperational ConsequenceRecommended Fix
Initializing JankStats inside Composable functionsRecreated on every recomposition; duplicates tracking listeners.Initialize once on Window in Activity onCreate().
Unbounded PerformanceMetricsState stringsHigh memory overhead; slows down frame metric dispatching.Use concise identifiers (screen: home, not full JSON strings).
Omitting removeState() in DisposableEffectStale state persists across subsequent screens, poisoning telemetry.Always remove states inside onDispose {}.
Ignoring Baseline ProfilesJIT compilation pauses main thread during initial screen loads.Ship automated Baseline Profiles to precompile Compose code.

Best Practices Checklist​

  1. Gate Releases by Frame Metrics: Benchmark frame rendering in CI/CD macrobenchmark runs to catch 700ms+ freezes before production rollout.
  2. Isolate Main-Thread Disk Access: Run StrictMode in debug builds to catch accidental I/O operations inside composable trees.
  3. Keep Context Lightweight: Limit PerformanceMetricsState to high-leverage dimensions: screen name, active scroll state, and data set size.
  4. Correlate with User Journey Observability: Pair JankStats frame profiling with session-level observability like Appxiom to measure the downstream business and retention impact of unresponsiveness.

Conclusion​

Frozen frames (>700ms) are not just visual blemishes; they are severe reliability defects that starve the main UI thread, degrade user trust, and act as a primary catalyst for user-perceived ANRs.

By implementing androidx.metrics:metrics-performance in Jetpack Compose, enriching frame telemetry with PerformanceMetricsState, and eliminating main-thread blocking operations, you can eliminate UI freezes, safeguard your Android Vitals, and maintain a responsive user experience.

Polishing the UX: Implementing Declarative Shared Element Transitions with Jetpack Compose Navigation

Published: · 11 min read
Don Peter
Cofounder and CTO, Appxiom

Smooth, context-preserving transitions are a subtle but powerful way to make your app feel polished and intentional. Users instantly understand the continuity between a list item and its detail screen when that item “morphs” into place. In the View system, shared element transitions were notoriously finicky. In modern Compose, we can implement them declaratively with clean, testable code.

This post shows a production-ready way to add shared element transitions between destinations using:

  • Compose’s SharedTransition API (declarative, type-safe, designable)
  • Navigation with animations (to keep both sources and targets composed during transitions)
  • Real-world considerations like image loading, shapes, and scroll position

You’ll leave with an end-to-end feature you can drop into your app.

Prerequisites​

  • Android Studio: Koala Feature Drop or newer (2024.1.2+)
  • Min SDK: 23+
  • Kotlin: 1.9.24+
  • Compose BOM: 2024.10.00+ (includes Compose 1.7.x; required for SharedTransition)
  • Navigation Compose: 2.7.0+ (graph)
  • Accompanist Navigation-Animation: version compatible with your Compose BOM (check release notes)
  • Material3, Lifecycle, Coil (optional but recommended)

Gradle (Kotlin DSL) example:

dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.10.00")
implementation(composeBom)

// Compose
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")

// Navigation
implementation("androidx.navigation:navigation-compose:2.7.7")

// Accompanist navigation animation (provides AnimatedVisibilityScope across destinations)
implementation("com.google.accompanist:accompanist-navigation-animation:<match-your-compose>")

// Lifecycle + coroutines
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")

// Images
implementation("io.coil-kt:coil-compose:2.6.0")
}

Note: Use an Accompanist version aligned with your Compose BOM. Check the Accompanist release notes to pick the matching artifact.

What we’re building​

  • A photo grid → photo detail flow
  • The tapped image will morph from its position/size in the grid into a larger image on the detail screen
  • We’ll also morph container bounds (e.g., card to full-bleed) and handle back navigation

High-level architecture:

  • Single-activity app
  • NavHost inside a SharedTransitionLayout
  • Each shared element declares a stable key across both screens
  • Transitions are synchronized by AnimatedVisibilityScope provided by the navigation animation host

The mental model: SharedTransitionLayout + AnimatedVisibilityScope​

Shared transitions in Compose rely on two things:

  1. A parent SharedTransitionLayout that owns the transition orchestration and caches shared content state by key.
  2. An AnimatedVisibilityScope that provides animation progress for entering/exiting destinations. Both the “from” and “to” Composables must be composed at the same time during the transition.

Accompanist Navigation-Animation gives each destination’s content an AnimatedVisibilityScope receiver, ensuring both screens are in the composition during transitions. That’s the key to making shared elements work with Navigation.

Project structure​

  • ui/navigation/AppNavHost.kt
  • ui/feature/photos/PhotosGridRoute.kt
  • ui/feature/photos/PhotoDetailRoute.kt
  • data/PhotoRepository.kt (fake or real)
  • model/Photo.kt
  • util/ImageLoader.kt (optional)
@OptIn(androidx.compose.animation.ExperimentalSharedTransitionApi::class)
@Composable
fun AppNavHost() {
// Animated nav controller from accompanist
val navController = com.google.accompanist.navigation.animation.rememberAnimatedNavController()

// SharedTransitionLayout must wrap the nav host so both screens share the same scope
androidx.compose.animation.SharedTransitionLayout {
com.google.accompanist.navigation.animation.AnimatedNavHost(
navController = navController,
startDestination = "grid",
enterTransition = { fadeIn() },
exitTransition = { fadeOut() },
popEnterTransition = { fadeIn() },
popExitTransition = { fadeOut() }
) {
com.google.accompanist.navigation.animation.composable("grid") {
// `this` is AnimatedVisibilityScope
GridRoute(
onPhotoClick = { id -> navController.navigate("detail/$id") },
animatedVisibilityScope = this // pass to children
)
}
com.google.accompanist.navigation.animation.composable(
route = "detail/{id}",
arguments = listOf(
androidx.navigation.navArgument("id") { type = androidx.navigation.NavType.StringType }
)
) {
val id = it.arguments?.getString("id")!!
DetailRoute(
photoId = id,
onBack = { navController.popBackStack() },
animatedVisibilityScope = this
)
}
}
}
}

Why fade in/out? We’re delegating the bulk of motion to shared elements. A gentle fade avoids double motion or competing slide animations.

Grid screen with shared elements​

Key points:

  • Keep item keys stable (use your domain id).
  • Use rememberSharedContentState(key) for each shared element.
  • Apply Modifier.sharedElement and optionally sharedBounds on a parent container to morph shapes/clipping.
  • Optionally make sure the tapped item is visible before navigating, so the start layout exists.
@OptIn(androidx.compose.animation.ExperimentalSharedTransitionApi::class)
@Composable
private fun SharedTransitionScope.GridRoute(
onPhotoClick: (String) -> Unit,
animatedVisibilityScope: AnimatedVisibilityScope,
viewModel: PhotosViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
) {
val state by viewModel.uiState.collectAsState()

val gridState = rememberLazyGridState()

// Top app bar, search, etc. omitted for brevity
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp),
state = gridState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(
items = state.photos,
key = { it.id } // critical: stable key equals the detail’s key
) { photo ->
val shared = rememberSharedContentState(key = photo.id)

// Optional: Card container morph (rounded → full in detail)
Card(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.sharedBounds(
state = shared,
animatedVisibilityScope = animatedVisibilityScope,
// Morph bounds using fast-out-slow-in tween
boundsTransform = androidx.compose.animation.SharedTransitionScope.BoundsTransform { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
},
clipInOverlayDuringTransition = true // prevents drawing outside while morphing
)
.clickable {
viewModel.onPhotoWillOpen(photo.id)
onPhotoClick(photo.id)
},
shape = RoundedCornerShape(16.dp),
) {
// The hero image that will morph
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(photo.url)
.diskCacheKey(photo.id) // keep memory/disk cache stable
.memoryCacheKey(photo.id)
.crossfade(true)
.build(),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.sharedElement(
state = shared,
animatedVisibilityScope = animatedVisibilityScope
),
contentScale = ContentScale.Crop
)
}
}
}
}

Notes:

  • sharedBounds on the Card morphs the container corner radius/bounds.
  • sharedElement on the AsyncImage morphs the image layer position/size.
  • Use the same key (photo.id) in both screens for both states.

Detail screen with shared elements​

Mirror the same key, and apply sharedBounds/sharedElement. You can customize shapes and layout independently - the system will interpolate bounds.

@OptIn(androidx.compose.animation.ExperimentalSharedTransitionApi::class)
@Composable
private fun SharedTransitionScope.DetailRoute(
photoId: String,
onBack: () -> Unit,
animatedVisibilityScope: AnimatedVisibilityScope,
viewModel: PhotosViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
) {
val photo by remember(photoId) {
mutableStateOf(viewModel.getById(photoId))
}

val shared = rememberSharedContentState(key = photoId)

// Scaffold with collapsing toolbar etc. omitted for brevity
Box(modifier = Modifier.fillMaxSize()) {
// Full-bleed container morph (roundness → 0dp here)
Surface(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f) // keep aspect to reduce jump; can be dynamic
.align(Alignment.TopCenter)
.sharedBounds(
state = shared,
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = androidx.compose.animation.SharedTransitionScope.BoundsTransform { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
},
clipInOverlayDuringTransition = true
),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(0.dp)
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(photo.url)
.diskCacheKey(photo.id)
.memoryCacheKey(photo.id)
.crossfade(true)
.build(),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.sharedElement(
state = shared,
animatedVisibilityScope = animatedVisibilityScope
),
contentScale = ContentScale.Crop
)
}

// Top app bar overlay
SmallTopAppBar(
title = { Text(text = photo.title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
}
},
modifier = Modifier
.align(Alignment.TopStart)
.statusBarsPadding()
)
}
}

Customizing the motion​

  • Duration and easing: Set per sharedBounds/sharedElement via boundsTransform.
  • Clip behavior: clipInOverlayDuringTransition guards against one element drawing over others. For tightly curved shapes, keep it true.
  • ContentScale: Using Crop on both sides reduces visual jumps. If targets have very different aspect ratios, consider a scrim or fade to mask stretch.

Example of a spring-based boundsTransform:

boundsTransform = androidx.compose.animation.SharedTransitionScope.BoundsTransform { _, _ ->
spring(dampingRatio = 0.85f, stiffness = 500f)
}

Ensuring the source item exists when navigating​

Shared transitions require both ends to be composed simultaneously. If the source item is off-screen or gets disposed by the LazyGrid, the start state might be missing and the animation will pop.

Recommended pattern:

  • Before navigating, request the item to be visible.
  • Store last-clicked id in ViewModel (so it survives recomposition).

Example with BringIntoViewRequester:

@Composable
fun GridItem(
photo: Photo,
onClick: () -> Unit,
animatedVisibilityScope: AnimatedVisibilityScope
) {
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val coroutineScope = rememberCoroutineScope()

Card(
modifier = Modifier
.bringIntoViewRequester(bringIntoViewRequester)
.clickable {
coroutineScope.launch {
// Make sure it's in view before nav so it's measured
bringIntoViewRequester.bringIntoView()
onClick()
}
}
) {
/* content with shared elements */
}
}

If the grid is already visible and items are not too large, you may skip this, but it’s a solid guard for long lists.

Production tips and pitfalls​

  • Stable keys are non-negotiable. The sharedContentState key must be identical on both sides (ideally a domain id).
  • Put SharedTransitionLayout as high as possible, typically above NavHost. If you scope it too low, the source/target may not share the same transition scope.
  • Avoid double animations. If you also slide the entire destination in/out, the shared element’s motion may look erratic. Prefer subtle fades or no-op transitions for the rest of the scene.
  • Image flicker: Use the same memory/disk cache keys with your image loader. Avoid resizing that produces different cached artifacts per screen.
  • Shape morphing: If you’re changing corner sizes or clipping, put sharedBounds on an outer container and sharedElement on a child image to decouple clipping from content movement.
  • Performance: Prefer clipInOverlayDuringTransition for tight masks; for very large images, ensure you downsample on the request to fit the device size to avoid heavy overdraw during transitions.
  • Testing: UI tests won’t “see” the animation, but they should verify that both screens render with the same stable keys and no crashes. Write a unit test around your ViewModel for id stability.

Common errors and how to fix them​

  • IllegalStateException: Modifier.sharedElement used outside of a SharedTransitionScope

    • Cause: Not inside SharedTransitionLayout or lost the receiver.
    • Fix: Wrap your NavHost with SharedTransitionLayout and declare your screen composables as extension functions on SharedTransitionScope (or pass the scope down).
  • sharedElement requires AnimatedVisibilityScope

    • Cause: Using plain NavHost. It doesn’t keep both screens composed during transitions.
    • Fix: Use Accompanist Navigation-Animation or host your destinations in AnimatedContent/AnimatedVisibility manually. With Accompanist, the destination content lambda has AnimatedVisibilityScope as receiver.
  • The element “jumps” instead of animating

    • Cause: Keys differ or the source element is not currently composed (e.g., scrolled out).
    • Fix: Use the same stable key and ensure visibility (BringIntoView). Keep aspectRatio consistent between states to minimize reflow differences.
  • Overlapping or clipping artifacts

    • Cause: Complex z-ordering or shape clipping mismatch.
    • Fix: Use sharedBounds on an outer container with clipInOverlayDuringTransition = true and match shapes. Avoid elevation changes during transition; prefer zIndex where needed.
  • Image briefly empties then appears at destination

    • Cause: Separate loads with different cache keys or resizing.
    • Fix: Use identical cache keys and consistent ImageRequest. Consider placeholderMemoryCacheKey for the same id if using transformations.

Beyond the Code: Monitoring Transition Jank and Drop-Offs in Production​

While declarative shared element transitions look flawless on high-end test hardware, production reality is messier. Because shared transitions require dual-destination composition, memory cache hits, and heavy layout passes simultaneously, low-to-mid tier devices can experience dropped frames (jank), UI freezes, or silent navigation drops.

If a customer taps a featured item in your product catalogue and experiences a 600ms frame freeze instead of a smooth morph, they perceive the app as sluggish - and frequently bounce before the detail screen finishes loading.

Traditional crash reporters won't help you here: they only log fatal exceptions, ignoring micro-stutters and navigation bottlenecks.

This is where Appxiom Real User Monitoring (RUM) comes in:

  1. Goal Friction Impact (GFI): Appxiom tracks actual user journeys (such as Product Browse ➔ Detail View ➔ Checkout) and correlates UI stutters, slow transitions, and memory spikes directly to conversion drop-offs.
  2. Zero-PII & High Performance: The native Appxiom SDK runs with a lightweight footprint consuming only 4% of the RAM required by traditional crash reporting tools - ensuring the monitor itself never induces frame drops during your animations.
  3. Release Quality Score: Benchmark every app update on a 0–10 scale to ensure your new UI enhancements don't introduce performance regressions across real-world device fleets.

Why this approach​

  • Declarative and testable: No Fragment transactions or imperative hero mappings.
  • Predictable: Animation progress is driven by the navigation animation host, avoiding timing races.
  • Composable and composable: You can theme, refactor, and preview components independently while keeping transitions opt-in per element.

Next steps​

  • Add predictive back support: Ensure your NavHost integrates system back progress; keep shared transitions subtle during swipe-to-back.
  • Animate more than one element: Text, chips, and FABs can also use sharedElement with the same pattern.
  • Material motion: Layer in fade-through or shared-axis transitions for non-shared parts of the scene.
  • Accessibility: Ensure motion is reduced when the user has “remove animations” enabled. Provide semantic continuity via contentDescription and headings.

Key takeaways​

  • Put SharedTransitionLayout above your NavHost so both destinations share the same transition scope.
  • Use Accompanist Navigation-Animation to keep both screens composed and expose AnimatedVisibilityScope to content.
  • Drive shared transitions with rememberSharedContentState(key) and apply sharedElement/sharedBounds on both sides with the same stable key.
  • Keep motion focused: fade the rest of the scene, and let the hero element tell the story.
  • Harden for production with stable keys, cache consistency, and edge-case handling for lists and shapes.

Polished motion is a trust signal. With Compose’s SharedTransition API and animated navigation, you can ship it without hacking around the framework.

Turn Smooth Motion into Measurable Business Impact​

Polished motion is a trust signal, but only when it runs smoothly for every user. Don't let invisible client-side jank and navigation drops erode your conversion funnels.

Explore Appxiom plans and pricing to start tracking Goal Friction Impact, or check out our developer documentation to integrate our lightweight SDK in under 5 minutes. Protect your app's performance with a 30-day free trial.

On-Device LLMs: Building Responsive Android Features with Gemini Nano and Google AI Edge SDK

Published: · 13 min read
Sandra Rosa Antony
Software Engineer, Appxiom

Modern Android apps increasingly need AI features that feel instant, private, and resilient offline. Cloud LLMs are great for heavy lifting, but even with streaming, latency and connectivity are real-world constraints. On-device LLMs like Gemini Nano close that gap by keeping inference local: lower latency, no data egress, and graceful offline behavior.

In this post, we’ll build a production-ready, end-to-end “Summarize Note” feature that runs on-device with Gemini Nano via Google’s AI Edge SDK, and automatically falls back to the cloud when on-device isn’t available. We’ll focus on a cohesive feature with modern Android patterns, streaming UI, cancellation, model readiness, and failure handling.

Prerequisites​

  • Android Studio: Koala (2024.1.1) or newer
  • Kotlin: 2.0.x
  • Coroutines: 1.8.x
  • Min SDK: 26
  • Target/Compile SDK: 34 or newer
  • Device: Pixel with AICore and Gemini Nano support (e.g., compatible Pixel devices); or Emulator for cloud fallback
  • Network: Only needed for the fallback path and for initial model download by the system

Dependencies (Gradle)​

  • Kotlin coroutines
    • implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
  • Lifecycle
    • implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.3"
  • Compose (optional for the UI)
    • implementation platform("androidx.compose:compose-bom:2024.06.00")
    • implementation "androidx.compose.ui:ui"
    • implementation "androidx.compose.material3:material3"
  • Hilt (optional, used below)
    • implementation "com.google.dagger:hilt-android:2.51.1"
    • kapt "com.google.dagger:hilt-android-compiler:2.51.1"
  • Google AI Client (cloud fallback)
    • implementation "com.google.ai.client.generativeai:generativeai:0.9.0"
  • Google AI Edge SDK (on-device)
    • Add the latest AI Edge SDK dependency as specified in the official Google AI Edge SDK documentation for Gemini Nano on Android (AICore). Use the latest stable version available at the time you integrate. This provides the on-device text generation client you’ll wire into the Edge provider shown below.

Important: Version names for AI Edge SDK evolve. Keep them updated from the official docs to ensure compatibility with your Android version and AICore.

What we’re building​

We’ll implement an MVVM-based feature that:

  • Accepts a long note
  • Streams a concise summary into the UI as it’s generated
  • Uses Gemini Nano on-device when available
  • Falls back to Gemini API in the cloud when Nano is not ready/available
  • Supports cancellation and timeouts
  • Handles edge cases like huge inputs, missing models, and low-memory

We’ll keep the LLM integration behind a small abstraction so you can test, swap providers, and evolve capabilities without refactoring the app.

Architecture overview​

  • UI (Compose): Displays text, progress, and tokens as they arrive; provides Cancel/Retry
  • ViewModel: Owns UI state, starts/cancels work, debounces input
  • Repository: Chooses the best LLM provider (Edge vs Cloud), streams results
  • Providers:
    • EdgeTextGenerator: On-device via AI Edge SDK (Gemini Nano)
    • CloudTextGenerator: Cloud via Gemini API SDK
  • Model readiness: EdgeCapabilityChecker verifies on-device availability before use

The domain API​

We define a small, streaming-first API that both on-device and cloud providers implement.

data class GenerationParams(
val maxOutputTokens: Int = 256,
val temperature: Float = 0.2f, // deterministic summaries
val systemInstruction: String? = null
)

sealed interface StreamEvent {
data class Token(val text: String) : StreamEvent
data class Complete(val fullText: String) : StreamEvent
data class Error(val throwable: Throwable) : StreamEvent
}

interface TextGenerator {
fun streamSummary(
input: String,
params: GenerationParams = GenerationParams()
): kotlinx.coroutines.flow.Flow<StreamEvent>
}

This API allows us to:

  • Stream partial tokens
  • Signal completion with the cumulative text
  • Handle errors distinctly
  • Keep configuration centralized

Provider selection and capability checks​

We gate the on-device path safely and transparently.

interface EdgeCapabilityChecker {
suspend fun isOnDeviceReady(): Boolean
suspend fun reasonIfUnavailable(): String? // optional details for analytics/UX
}

Implementation notes:

  • Prefer the AI Edge SDK/AICore-provided capabilities check (e.g., model availability, downloaded/versioned) rather than ad-hoc package checks.
  • If the model is not ready, either prompt the user to enable the feature or silently use cloud fallback depending on your UX policy.
  • If you want proactive model availability, use a background task (WorkManager) to trigger/verify model readiness while on unmetered Wi‑Fi and charging, respecting user settings.

On-device provider (Gemini Nano via AI Edge SDK)​

Below is a skeleton that demonstrates how you wire the AI Edge SDK into our TextGenerator interface. Replace the “TODO” calls with the actual AI Edge SDK client construction and streaming calls exactly as described in the SDK docs you’re using.

class EdgeTextGenerator(
private val appContext: android.content.Context
) : TextGenerator {

// Consider reusing a single client/session instance (lifecycle-aware) to amortize model load
// cost and reduce memory thrash. Build it lazily and close when not needed.
private val lock = Any()
@Volatile private var client: /* Edge SDK text generation client type */ Any? = null

private fun ensureClient(): /* client type */ Any {
synchronized(lock) {
val cached = client
if (cached != null) return cached as /* client type */

// TODO: Construct the Edge/Nano text generation client.
// This typically requires:
// - Obtaining an AICore/AI Edge SDK session bound to Gemini Nano
// - Supplying generation configuration
// - Handling model download/readiness if the SDK exposes it here

val created = /* Edge client construction */
client = created
return created
}
}

override fun streamSummary(
input: String,
params: GenerationParams
) = kotlinx.coroutines.flow.callbackFlow<StreamEvent> {
try {
val prompt = buildPromptForSummary(input)

// TODO: Start streaming generation using the Edge SDK.
// Pseudocode:
// val session = ensureClient().startSession(params.toEdgeConfig())
// session.generateStream(prompt).collect { token ->
// trySend(StreamEvent.Token(token))
// }
// trySend(StreamEvent.Complete(collectedText))

// Make sure to respect coroutine cancellation (use isActive checks or the SDK’s cancel APIs)
} catch (t: Throwable) {
trySend(StreamEvent.Error(t))
}
awaitClose {
// TODO: cancel active streaming request if the SDK supports it
}
}

private fun buildPromptForSummary(input: String): String {
// Keep prompts short and deterministic for on-device models
return """
Summarize the following note into 3 concise bullet points.
Focus on key facts and decisions. Avoid filler.

Note:
$input
""".trimIndent()
}
}

Key production considerations for the on-device path:

  • Session reuse: Create and reuse a session to avoid repeated warm-up time. Close it when the feature is idle.
  • Cancellation: Expose cancellation so swipes/back/rotations don’t keep the model busy.
  • Memory: Gemini Nano is lightweight relative to cloud LLMs, but still watch memory. Consider an upper bound on input size; pre-truncate to a token budget and inform the user if needed.
  • Threading: The SDK typically is non-blocking, but keep heavy work off the main thread.
  • Version pin: If the SDK allows specifying a runtime model version, pin to a tested version and roll forward with QA.

Cloud fallback provider (Gemini API via Google AI Client)​

The cloud path uses the official Gemini client and supports streaming. This code is production-grade and known-good.

import com.google.ai.client.generativeai.GenerativeModel
import com.google.ai.client.generativeai.type.generationConfig
import com.google.ai.client.generativeai.type.content
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.isActive

class CloudTextGenerator(
apiKey: String,
modelName: String = "gemini-1.5-flash"
) : TextGenerator {

private val model = GenerativeModel(
modelName = modelName,
apiKey = apiKey
)

override fun streamSummary(
input: String,
params: GenerationParams
): Flow<StreamEvent> = callbackFlow {
val prompt = buildPromptForSummary(input)
val config = generationConfig {
temperature = params.temperature.toDouble()
maxOutputTokens = params.maxOutputTokens
}
val request = content { text(prompt) }

// Streaming
try {
val sb = StringBuilder()
val stream = model.generateContentStream(
request,
generationConfig = config
)

// Collect chunks; the SDK provides a Flow-like stream
val job = kotlinx.coroutines.GlobalScope.launch {
try {
stream.collect { chunk ->
if (!isActive) return@collect
val delta = chunk.text.orEmpty()
if (delta.isNotEmpty()) {
sb.append(delta)
trySend(StreamEvent.Token(delta))
}
}
trySend(StreamEvent.Complete(sb.toString()))
} catch (t: Throwable) {
trySend(StreamEvent.Error(t))
}
}

awaitClose { job.cancel() }
} catch (t: Throwable) {
trySend(StreamEvent.Error(t))
close(t)
}
}

private fun buildPromptForSummary(input: String): String {
return """
Summarize this note into 3 concise bullet points.
Use neutral tone and keep each bullet under 20 words.

Note:
$input
""".trimIndent()
}
}

Notes:

  • Use 1.5-Flash for UI-latency-sensitive features; bump to Pro only when necessary and you can tolerate higher latency/cost.
  • Set a max token budget and temperature appropriate for deterministic summaries.

Repository: choose Edge vs Cloud at runtime​

class SummarizerRepository(
private val edgeChecker: EdgeCapabilityChecker,
private val edge: TextGenerator,
private val cloud: TextGenerator
) {

fun streamSummary(input: String, params: GenerationParams): kotlinx.coroutines.flow.Flow<StreamEvent> =
kotlinx.coroutines.flow.flow {
// Guardrails
val sanitized = sanitizeInput(input)
require(sanitized.isNotBlank()) { "Input is empty after sanitization." }

val useOnDevice = runCatching { edgeChecker.isOnDeviceReady() }.getOrElse { false }
val source = if (useOnDevice) edge else cloud

emitAll(source.streamSummary(sanitized, params))
}

private fun sanitizeInput(input: String): String {
// Boundary control: prevent absurdly large prompts, avoid OOM
val maxChars = 6_000 // tune for your app; align with token limits
return input.trim().take(maxChars)
}
}

ViewModel with cancellation, timeouts, and UI state​

data class SummaryUiState(
val inProgress: Boolean = false,
val streamedText: String = "",
val error: String? = null
)

@dagger.hilt.android.lifecycle.HiltViewModel
class SummaryViewModel @javax.inject.Inject constructor(
private val repository: SummarizerRepository,
@javax.inject.Named("defaultParams") private val defaultParams: GenerationParams
) : androidx.lifecycle.ViewModel() {

private val _state = kotlinx.coroutines.flow.MutableStateFlow(SummaryUiState())
val state: kotlinx.coroutines.flow.StateFlow<SummaryUiState> = _state

private var currentJob: kotlinx.coroutines.Job? = null

fun summarize(note: String) {
currentJob?.cancel()
_state.value = SummaryUiState(inProgress = true)

currentJob = viewModelScope.launch {
// Optional timeout for UX responsiveness (tune as needed)
withTimeoutOrNull(15_000L) {
repository.streamSummary(note, defaultParams)
.collect { event ->
when (event) {
is StreamEvent.Token -> {
_state.update { it.copy(streamedText = it.streamedText + event.text) }
}
is StreamEvent.Complete -> {
_state.update { it.copy(inProgress = false, streamedText = event.fullText) }
}
is StreamEvent.Error -> {
_state.update { it.copy(inProgress = false, error = event.throwable.message) }
}
}
}
} ?: run {
_state.update { it.copy(inProgress = false, error = "Timed out. Try again.") }
}
}
}

fun cancel() {
currentJob?.cancel()
_state.update { it.copy(inProgress = false) }
}

override fun onCleared() {
currentJob?.cancel()
}
}

Compose UI that streams cleanly​

@Composable
fun SummarizeNoteScreen(
viewModel: SummaryViewModel = androidx.hilt.navigation.compose.hiltViewModel()
) {
val state by viewModel.state.collectAsState()
var noteText by remember { mutableStateOf("") }

Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = noteText,
onValueChange = { noteText = it },
label = { Text("Paste note") },
modifier = Modifier.fillMaxWidth().weight(1f, fill = false),
minLines = 6
)
Spacer(Modifier.height(12.dp))
Row {
Button(
onClick = { viewModel.summarize(noteText) },
enabled = noteText.isNotBlank() && !state.inProgress
) { Text("Summarize") }

Spacer(Modifier.width(8.dp))

OutlinedButton(
onClick = { viewModel.cancel() },
enabled = state.inProgress
) { Text("Cancel") }
}
Spacer(Modifier.height(16.dp))

when {
state.inProgress -> {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(8.dp))
Text(
text = state.streamedText.ifBlank { "Generating..." },
style = MaterialTheme.typography.bodyLarge
)
}
state.error != null -> {
Text(
text = state.error ?: "Something went wrong",
color = MaterialTheme.colorScheme.error
)
}
state.streamedText.isNotBlank() -> {
Text(
text = state.streamedText,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}

Wiring it all up (DI)​

@Module
@InstallIn(SingletonComponent::class)
object AiModule {

@Provides
@Singleton
fun provideEdgeCapabilityChecker(
app: Application
): EdgeCapabilityChecker = object : EdgeCapabilityChecker {
override suspend fun isOnDeviceReady(): Boolean {
// Prefer AI Edge SDK’s official readiness check, which may expose:
// - model availability
// - version compatibility
// - minimum device capabilities
// Avoid guessing via package manager.
// return edgeSdk.checkModelReady(model = GeminiNano)

// Placeholder until you wire the real call:
return false
}

override suspend fun reasonIfUnavailable(): String? = null
}

@Provides
@Singleton
@Named("defaultParams")
fun provideDefaultParams(): GenerationParams = GenerationParams(
maxOutputTokens = 256,
temperature = 0.2f
)

@Provides
@Singleton
fun provideEdgeTextGenerator(app: Application): TextGenerator =
EdgeTextGenerator(app)

@Provides
@Singleton
fun provideCloudTextGenerator(): TextGenerator =
CloudTextGenerator(
apiKey = BuildConfig.GEMINI_API_KEY,
modelName = "gemini-1.5-flash"
)

@Provides
@Singleton
fun provideSummarizerRepository(
checker: EdgeCapabilityChecker,
edge: TextGenerator,
cloud: TextGenerator
): SummarizerRepository = SummarizerRepository(checker, edge, cloud)
}

Security note: Store API keys securely (do not hardcode). Use remote config or secure storage, and apply network privacy best practices. For on-device/Nano-only experiences, you do not need an API key.

Prompt engineering for on-device​

  • Keep prompts short, explicit, and deterministic. Lower temperature.
  • Constrain output with explicit format (e.g., “3 bullet points”, length limits).
  • Pre-truncate inputs to a safe size.
  • Avoid complex multi-step instructions that explode token usage and latency.

Example prompt snippet (used above):

  • “Summarize into 3 concise bullet points”
  • “Each bullet under 20 words”
  • “Neutral tone; focus on key facts and decisions”

Performance, UX, and reliability​

  • Warm-up: First request may be slower as the model initializes. Consider lazy pre-warm when user navigates to the screen.
  • Streaming: Always stream to keep the UI alive; avoid waiting for the final token for first paint.
  • Cancellation: Support cancellation when navigating away or if the user edits text mid-generation.
  • Timeouts: Use a user-facing timeout; surface actionable failures (“Model not available offline”, “Timed out”, “No network”).
  • Battery and thermal: Don’t loop long generations in the background; use the SDK’s guidance for batching and scheduling. Prefer generating during user interaction, not in the background.

Privacy and data handling​

  • On-device path: Data stays on-device; no internet needed. Communicate this clearly in your UX for sensitive notes.
  • Cloud fallback: Clearly disclose network usage and handle PII responsibly (consent, redaction).
  • Logging: Never log raw user content in production logs. Gate debug logs behind build flags.

Testing strategy​

  • Unit test repository selection logic (Edge available vs not).
  • Contract test the provider interface with fake generators that emit controlled streams.
  • Integration test on devices with and without Nano support.
  • Load test with large notes to uncover memory/latency issues.
  • UI tests: Assert that streaming renders incrementally and that Cancel works.

Common mistakes and how to fix them​

  • Model not ready on-device:
    • Symptom: Immediate error from Edge SDK or null client/session.
    • Fix: Use the SDK’s readiness check and expose a “Download/Enable” affordance or auto-fallback to cloud. Avoid relying on package presence checks.
  • Jank during first token:
    • Symptom: 200–600 ms UI hitch when session warms up.
    • Fix: Pre-warm lazily (e.g., when screen becomes visible) on a background dispatcher. Reuse sessions.
  • OOM or ANR with huge inputs:
    • Symptom: Memory spikes or freezes processing long notes.
    • Fix: Enforce strict input size limits. Chunk and summarize incrementally if needed (map-reduce pattern).
  • No token streaming in UI:
    • Symptom: Empty UI until completion.
    • Fix: Ensure you’re using the streaming API; append partial tokens; flush to Compose state.
  • Timeout too aggressive:
    • Symptom: Frequent timeouts on low-end devices.
    • Fix: Increase timeout and/or reduce max output tokens. Consider device-class heuristics.
  • Cloud API key exposed:
    • Symptom: Static key in client or VCS.
    • Fix: Use server-side proxy or secured remote config, obfuscation, and app attestation.

Rolling out safely​

  • Feature flags: Gate the entire feature behind a remote flag for staged rollout.
  • Device targeting: Gradually enable on supported devices first.
  • Metrics: Track latency, token count, session warm-up time, error rates, cloud fallback rate, and cancellations.
  • Crash/ANR monitoring: Pay attention to OOM signatures and binder-related issues if the Edge SDK uses a system service.

When to choose on-device vs cloud​

  • On-device (Gemini Nano) is ideal for:

    • Short-form generation and rewriting (summaries, smart replies, autofill hints)
    • Privacy-sensitive content
    • Latency-critical UX
    • Offline and spotty connectivity
  • Cloud (Gemini API) is better for:

    • Larger context windows
    • Heavier reasoning or multimodal tasks
    • Team workflows where server-side policy and auditing are required

A hybrid approach (as we implemented) is the most resilient.

Final notes on the AI Edge SDK integration​

Because the AI Edge SDK and AICore are evolving, refer to the official SDK documentation for:

  • Exact Gradle artifacts and versions
  • Model/download readiness APIs
  • Client/session construction code and lifecycle
  • Streaming function names and cancellation hooks
  • Supported Android/Device matrix and required permissions (generally none additional for on-device)

Keep all these calls isolated in your EdgeTextGenerator and EdgeCapabilityChecker. This limits churn when you upgrade the SDK.

Key takeaways​

  • On-device LLMs like Gemini Nano unlock responsive, private, and offline-friendly features.
  • Architect behind a small TextGenerator interface to swap Edge and Cloud without refactoring.
  • Stream results to the UI for fast first paint; handle cancellation, timeouts, and input limits.
  • Proactively check model readiness and implement a clean cloud fallback path.
  • Treat performance, privacy, and reliability as first-class concerns - just like any other production feature.

Next steps​

  • Wire the AI Edge SDK client code into EdgeTextGenerator using the latest docs and test on a Nano-capable device.
  • Add a WorkManager task to prime model readiness on unmetered Wi‑Fi and charging.
  • Extend the feature with “Actionable Summary” output format (e.g., JSON with decisions, dates, and owners) and render structured chips in the UI.
  • Measure and optimize: instrument warm-up time, token throughput, and fallback frequency; iterate on prompts and parameters for quality and speed.

How to Implement the Decorator Pattern in Jetpack Compose

Published: · Last updated: · 3 min read
Don Peter
Cofounder and CTO, Appxiom

How to Implement the Decorator Pattern in Jetpack Compose

Jetpack Compose gives you an incredible amount of freedom when building Android UIs. You describe what the UI should look like, and Compose takes care of the rest. But even with this flexibility, there are moments where you want to add behavior or styling around a component - without rewriting it or making it harder to maintain.

That's where the Decorator Pattern fits in beautifully.

The decorator pattern allows you to wrap additional behavior or visual enhancements around an existing component without changing its core implementation. In Jetpack Compose, this aligns perfectly with composable functions and modifiers, letting you layer responsibilities in a clean, reusable, and scalable way.

How to Test Jetpack Compose UIs Using Espresso

Published: · Last updated: · 5 min read
Don Peter
Cofounder and CTO, Appxiom

UI bugs are sneaky. Everything looks fine on your device, animations feel smooth, and then - someone reports that a button doesn't respond, a screen doesn't load, or a critical flow breaks on a specific device. By the time you hear about it, the damage is already done.

This is where UI testing earns its keep.

With Jetpack Compose becoming the standard way to build Android UIs, testing strategies need to evolve as well. Espresso is still a powerful UI testing tool - but testing Compose-based UIs requires a slightly different mindset.

Let's walk through how to test Jetpack Compose UIs using Espresso, step by step, in a way that actually makes sense when you sit down to write tests.

Prerequisites​

Before jumping into writing tests, make sure you have the basics in place:

  • An Android project using Jetpack Compose
  • Android Studio Arctic Fox or newer
  • Basic familiarity with:
    • Jetpack Compose
    • Espresso
    • JUnit
  • UI tests enabled in your project (androidTest source set)

If you already have a Compose screen running, you're good to go.

Setting Up Espresso for a Compose Project​

Jetpack Compose doesn't replace Espresso - it complements it. Espresso still handles UI synchronization and assertions, while Compose provides its own testing APIs.

In your app module, make sure you have the required dependencies:

androidTestImplementation 'androidx.test.espresso:espresso-core:<version>'
androidTestImplementation 'androidx.test.ext:junit:<version>'

This setup allows Espresso and Compose Test APIs to work together seamlessly.

Writing Your First Espresso Test with Jetpack Compose​

Let's put theory into practice and write a simple UI test. The goal here isn't to be fancy - it's to understand how Espresso and Jetpack Compose work together in a real test scenario.

We'll create a test that checks whether a button is visible on the screen and then performs a click on it.

Step 1: Create a UI test class​

Start by creating a new Kotlin file inside your app's androidTest directory. You can name it something like ExampleEspressoTest.

This file will hold all your UI test logic.

Step 2: Import the required dependencies​

You'll need imports from both Jetpack Compose testing and Espresso:

import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.*
import androidx.test.espresso.Espresso.*
import androidx.test.espresso.matcher.ViewMatchers.*
import org.junit.Rule
import org.junit.Test

These give you access to Compose test rules, UI matchers, and Espresso actions.

Step 3: Set up the Compose test rule​

The test rule is what launches your Compose content in a controlled testing environment:

class ExampleEspressoTest {
@get:Rule
val composeTestRule = createComposeRule()
}

This rule tells the test runner how to render Compose UI before running assertions.

Step 4: Write your first test​

Now for the actual test. We'll render a simple button and verify two things:

  1. The button is visible
  2. The button can be clicked
@Test
fun testButtonVisibilityAndClick() {
// Launch the Compose screen/activity
composeTestRule.setContent {
// Compose UI code here
Button(
onClick = { /* Button click action */ }
) {
Text("Click Me")
}
}

// Check if the button is displayed
onView(withText("Click Me")).check(matches(isDisplayed()))

// Perform a click action on the button
onView(withText("Click Me")).perform(click())
}

What's happening here:

  • setContent renders a Compose UI just for this test
  • Espresso verifies the button exists on screen
  • Espresso simulates a real user click

This might look simple - and that's the point. UI tests should clearly describe user behavior, not hide it behind complexity.

Step 5: Run the test​

You can run the test directly from Android Studio or use the test runner to execute it as part of your test suite.

Once it passes, you've officially written and executed your first Espresso test for a Jetpack Compose UI.

From here, you can expand into testing state changes, navigation, error states, and full user flows.

Working with Matchers and Actions​

Even when you're testing Jetpack Compose UI, Espresso's core ideas - matchers and actions - still apply. The difference is what you're interacting with. Instead of traditional View objects, you're now targeting Compose-based UI elements.

Matchers help Espresso find the UI element you care about, while actions define what you want to do with it - just like a real user would.

Commonly Used Matchers​

Matchers are used to locate Compose components based on their properties:

  • withText("text") - Finds a composable that displays the given text.
  • isDisplayed() - Ensures the composable is currently visible on the screen.

These matchers make your tests readable and expressive, almost like describing what a user sees.

Commonly Used Actions​

Actions simulate user interactions:

  • click() - Performs a tap on the matched Compose component.

When combined, matchers and actions let you write tests that read like user behavior:

"Find this button, make sure it's visible, then tap it."

This approach keeps your tests focused on what the user does, not on internal implementation details - which is exactly how good UI tests should behave.

Testing Jetpack Compose Components​

When testing Compose components, you can use the onNode method to target specific components.

For example, to test a Button component:

onNode(hasText("Click Me")).performClick()

Verifying Assertions the Right Way​

Assertions tell you whether your UI behaves as expected. For example:

  • isDisplayed(): Checks if the Compose component is currently visible on the screen.
  • hasText("text"): Checks if the Compose component contains the specified text.

Conclusion​

Testing Jetpack Compose UI with Espresso isn't complicated - but it does require a shift in how you think about UI testing.

Compose simplifies UI structure.

Espresso ensures stability.

Assertions keep regressions in check.

Together, they help you ship UIs that behave correctly - not just in demos, but on real devices, under real conditions.

Because the best UI bug is the one your users never see.

Happy testing.

How to Build an Offline-Capable Android App with Jetpack Compose and Kotlin

Published: · Last updated: · 6 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

The streak broke. So did the flow.

It wasn't that I forgot. I remembered, just a little too late.

Right before midnight, I opened the app to log my progress. But the screen just sat there, trying to connect. No internet. No log. No streak.

It sounds small, but if you've ever built a habit one day at a time, you know what a streak can mean. It's not just numbers. It's proof. And losing it? That stings.

That moment made one thing very clear: apps that help you grow should work with you, not against you, especially when the internet doesn't cooperate.

So let's build something better.

How to Avoid Memory Leaks in Jetpack Compose: Real Examples, Causes, and Fixes

Published: · Last updated: · 5 min read
Andrea Sunny
Marketing Associate, Appxiom

"Hey… why does this screen freeze every time I scroll too fast?"

That's what my QA pinged me at 11:30 AM on a perfectly normal Tuesday.

I brushed it off. "Probably a one-off," I thought.

But then the bug reports started trickling in:

  • "The app slows down after using it for a while."
  • "Navigation feels laggy."
  • "Sometimes it just… dies."

That's when the panic set in.

Building Offline-Capable Android Apps with Kotlin and Jetpack Compose

Published: · Last updated: · 5 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

In today's mobile-first world, users expect apps to work seamlessly, even when there's no internet connection. This blog post will guide you through the process of building an offline-capable Android app using Kotlin and Jetpack Compose. We'll use a ToDo app as our example to illustrate key concepts and best practices.

Architecture Overview​

Before diving into the code, let's outline the architecture we'll use:

  • UI Layer: Jetpack Compose for the user interface

  • ViewModel: To manage UI-related data and business logic

  • Repository: To abstract data sources and manage data flow

  • Local Database: Room for local data persistence

  • Remote Data Source: Retrofit for API calls (when online)

  • WorkManager: For background synchronization

Setting Up the Kotlin Project​

First, ensure you have the necessary dependencies in your build.gradle file:

dependencies {
implementation("androidx.core:core-ktx:1.10.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
implementation("androidx.activity:activity-compose:1.7.2")
implementation("androidx.compose.ui:ui:1.4.3")
implementation("androidx.compose.ui:ui-tooling-preview:1.4.3")
implementation("androidx.compose.material3:material3:1.1.1")

// Room
implementation("androidx.room:room-runtime:2.5.2")
implementation("androidx.room:room-ktx:2.5.2")
kapt("androidx.room:room-compiler:2.5.2")

// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")

// WorkManager
implementation("androidx.work:work-runtime-ktx:2.8.1")
}

Implementing the Local Database​

We'll use Room to store ToDo items locally. First, define the entity:

@Entity(tableName = "todos")
data class ToDo(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val title: String,
val description: String,
val isCompleted: Boolean = false,
val lastModified: Long = System.currentTimeMillis()
)

Next, create the DAO (Data Access Object):

@Dao
interface ToDoDao {
@Query("SELECT * FROM todos")
fun getAllToDos(): Flow<List<ToDo>>

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertToDo(todo: ToDo)

@Update
suspend fun updateToDo(todo: ToDo)

@Delete
suspend fun deleteToDo(todo: ToDo)
}

Finally, set up the Room database:

@Database(entities = [ToDo::class], version = 1)
abstract class ToDoDatabase : RoomDatabase() {
abstract fun todoDao(): ToDoDao

companion object {
@Volatile
private var INSTANCE: ToDoDatabase? = null

fun getDatabase(context: Context): ToDoDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
ToDoDatabase::class.java,
"todo_database"
).build()
INSTANCE = instance
instance
}
}
}
}

Implementing the Repository​

The repository will manage data operations and decide whether to fetch from the local database or the remote API:

class ToDoRepository(
private val todoDao: ToDoDao,
private val apiService: ApiService
) {

val allToDos: Flow<List<ToDo>> = todoDao.getAllToDos()

suspend fun refreshToDos() {
try {
val remoteToDos = apiService.getToDos()
todoDao.insertAll(remoteToDos)
} catch (e: Exception) {
// Handle network errors
}
}

suspend fun addToDo(todo: ToDo) {
todoDao.insertToDo(todo)
try {
apiService.addToDo(todo)
} catch (e: Exception) {
// Handle network errors, maybe queue for later sync
}
}

// Implement other CRUD operations similarly
}

Setting Up the ViewModel​

The ViewModel will handle the UI logic and interact with the repository:

class ToDoViewModel(private val repository: ToDoRepository) : ViewModel() {
val todos = repository.allToDos.asLiveData()

fun addToDo(title: String, description: String) {
viewModelScope.launch {
val todo = ToDo(title = title, description = description)
repository.addToDo(todo)
}
}

fun refreshToDos() {
viewModelScope.launch {
repository.refreshToDos()
}
}
// Implement other operations
}

Creating the UI with Jetpack Compose​

Now, let's create the UI for our ToDo app:

@Composable
fun ToDoScreen(viewModel: ToDoViewModel) {
val todos by viewModel.todos.collectAsState(initial = emptyList())
LazyColumn {
items(todos) { todo ->
ToDoItem(todo)
}
item {
AddToDoButton(viewModel)
}
}
}

@Composable
fun ToDoItem(todo: ToDo) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = todo.isCompleted,
onCheckedChange = { /* Update todo */ }
)
Column(modifier = Modifier.weight(1f)) {
Text(text = todo.title, fontWeight = FontWeight.Bold)
Text(text = todo.description)
}
}
}
}

@Composable
fun AddToDoButton(viewModel: ToDoViewModel) {
var showDialog by remember { mutableStateOf(false) }
Button(onClick = { showDialog = true }) {
Text("Add ToDo")
}
if (showDialog) {
AddToDoDialog(
onDismiss = { showDialog = false },
onConfirm = { title, description ->
viewModel.addToDo(title, description)
showDialog = false
}
)
}
}

@Composable
fun AddToDoDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {
// Implement dialog UI here
}

Implementing Background Sync with WorkManager​

To ensure our app stays up-to-date even when it's not actively running, we can use WorkManager for background synchronization:

class SyncWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
private val repository = ToDoRepository(
ToDoDatabase.getDatabase(context).todoDao(),
ApiService.create()
)
override suspend fun doWork(): Result {
return try {
repository.refreshToDos()
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
}

Schedule the work in your application class or main activity:

class ToDoApplication : Application() {
override fun onCreate() {
super.onCreate()
setupPeriodicSync()
}

private fun setupPeriodicSync() {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()

val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
.setConstraints(constraints)
.build()

WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"ToDo_Sync",
ExistingPeriodicWorkPolicy.KEEP,
syncRequest
)
}
}

Handling Conflicts​

When working offline, conflicts may arise when syncing data. Implement a conflict resolution strategy:

suspend fun syncToDo(todo: ToDo) {
try {
val remoteToDo = apiService.getToDo(todo.id)
if (remoteToDo.lastModified > todo.lastModified) {
// Remote version is newer, update local
todoDao.insertToDo(remoteToDo)
} else {
// Local version is newer, update remote
apiService.updateToDo(todo)
}
} catch (e: Exception) {
// Handle network errors
}
}

Testing Offline Functionality​

To ensure your app works offline:

  • Implement a network utility class to check connectivity.

  • Use this utility in your repository to decide whether to fetch from local or remote.

  • Write unit tests for your repository and ViewModel.

  • Perform UI tests with network on and off to verify behavior.

Conclusion​

Building an offline-capable Android app requires careful consideration of data flow, synchronization, and conflict resolution. By using Room for local storage, Retrofit for API calls, and WorkManager for background sync, you can create a robust offline experience for your users.

Remember to handle edge cases, such as first-time app usage without internet, and always provide clear feedback to users about the sync status of their data.

Would you like me to explain or break down any part of this code?

Gradle Flavors: Building Multiple Android App Variants with Single Codebase

Published: · Last updated: · 4 min read
Don Peter
Cofounder and CTO, Appxiom

Gradle Flavors, also known as product flavors, allow developers to create multiple variants of their app within a single codebase. Each flavor can have its own unique configuration, resources, and dependencies, enabling customization based on factors such as branding, feature sets, or target audiences.

If you're developing free and paid versions of your app, adapting it for different languages or regions, or incorporating variations for testing purposes, Gradle Flavors offer unparalleled flexibility.

Why Use Gradle Flavors?​

  • Customization: With Gradle Flavors, developers can easily tailor their app to suit specific user segments or market requirements. This level of customization fosters better user engagement and satisfaction.

  • Efficiency: Rather than maintaining separate codebases for different app variants, Gradle Flavors streamline the development process by centralizing code while allowing for variant-specific configurations. This results in reduced complexity and faster iteration cycles.

  • Consistency: By defining variant-specific resources and dependencies within the Gradle build script, developers ensure consistency across different app versions while minimizing the risk of errors or inconsistencies.

  • Market Segmentation: For businesses targeting diverse demographics or regions, Gradle Flavors facilitate the creation of specialized versions of the app tailored to each market segment's preferences and needs.

Free and Pro Versions​

Let's illustrate the power of Gradle Flavors with the above scenario – creating free and pro versions of an app. Suppose you have an app called "WeatherApp" and want to offer both a free version with basic features and a paid pro version with additional functionalities.

android {
...
productFlavors {
free {
dimension "tier"
applicationId "com.example.weather.free"
versionCode 1
versionName "1.0"
// Define flavor-specific configurations
buildConfigField "boolean", "IS_PRO_VERSION", "false"
}
pro {
dimension "tier"
applicationId "com.example.weather.pro"
versionCode 1
versionName "1.0"
// Define flavor-specific configurations
buildConfigField "boolean", "IS_PRO_VERSION", "true"
}
}

buildTypes {
debug {
// Debug-specific configurations
...
}
release {
// Release-specific configurations
...
}
}
...
}

In this example, we define two product flavors: 'free' and 'pro', each with its own unique applicationID. We can then customize the behavior, features, and resources specific to each flavor, such as limiting certain features to the pro version or displaying different branding elements.

In the above example, we define two build types. "debug" and "release," each with its own configurations. These configurations might include signing configurations, proguard rules, or other build-specific settings.

With the flavors and build variants defined, Gradle will generate the following build variants:

  • freeDebug

  • freeRelease

  • proDebug

  • proRelease

Developers can then use these variants to build and test different versions of the app. For instance, they can build the "pro" release variant to generate a signed APK for distribution to users who have purchased the pro version of the app. Similarly, they can build the "free" debug variant to test new features or changes specific to the free version of the app.

Android Project Structure with Gradle Flavors​

In the above example, the folder structure for the Android project would typically look like this:

- app
- src
- free
- java
- com
- example
- weather
- MainActivity.java
- ...
- res
- layout
- drawable
- values
- ...
- pro
- java
- com
- example
- weather
- MainActivity.java
- ...
- res
- layout
- drawable
- values
- ...
- main
- java
- com
- example
- weather
- MainActivity.java
- ...
- res
- layout
- drawable
- values
- ...

Here's a breakdown of the folder structure:

  • app: This is the main module of the Android project.

  • src: This directory contains the source code and resources for different build variants.

  • free: This directory contains the source code and resources specific to the "free" flavor.

java: Java source code files for the "free" flavor.

  • com.example.weather: Package directory.

  • MainActivity.java: Example activity class.

  • Other Java files specific to the "free" flavor.

  • res: Resource directory for the "free" flavor.

  • layout: XML layout files.

  • drawable: Image resources.

  • values: Resource files such as strings, colors, dimensions, etc.

  • Other resource directories specific to the "free" flavor.

  • pro: This directory contains the source code and resources specific to the "pro" flavor. The structure is similar to the "free" flavor but with resources and code specific to the "pro" variant.

  • main: This directory contains the main source code and resources shared among all flavors and build types. It serves as the base for all variants and contains code and resources common to both "free" and "pro" flavors.

By organizing the source code and resources in this way, Gradle can easily build different variants of the app by combining the contents of the "main" directory with the specific contents of each flavor directory (free and pro).

Leveraging Gradle flavors and build variants empowers Android developers to efficiently manage and customize multiple versions of their apps, catering to diverse user preferences and market requirements while maintaining codebase integrity and flexibility.

Integrating Coil in Kotlin Based Android Apps

Published: · Last updated: · 3 min read
Don Peter
Cofounder and CTO, Appxiom

Picture this: You are developing a slick new Kotlin-based Android app. The UI is coming together nicely, but those image thumbnails just won't agree to load as quickly as you would like them to. How to solve this?

Enter Coil, a high-performance library for image loading.

We will jump the gun here to tell you how to go about integrating Coil into your Kotlin-based Apps.

Integrating Coil in Android Kotlin Apps​

Integrating Coil onto your Android project using Maven-based dependency management is as easy as 1-2-3. Coil is available by calling the function mavenCentral(). Add the Coil Dependency to your build.gradle file. Open your module-level build.gradle and add the Coil dependency.

dependencies {
implementation(“io.coil-kt:coil:x.y.z”) // Use the latest version
}

Jetpack Compose on Coil​

Using Jetpack Compose for building your UI and integrating with Coil for image loading comes with its advantages. This modern Android UI toolkit is designed to simplify and accelerate UI development on Android. Simply, Import the Jetpack Compose extension library and use the following code: 

implementation("io.coil-kt:coil-compose:x.y.z")

And later to  use the AsyncImage composable which comes as a part of coil-compose, to load an image, use the:

AsyncImage(
model = "https://example.com/image.jpg",
contentDescription = null
)

Why use Coil in Android Apps?​

Now that we have spoken in detail and how easily you can integrate Coil, let’s also understand why you as an Android App Developer should use Coil. We will fast forward to the functionalities of Coil and how features like memory and disk caching alongside customisations help achieve minimal boilerplate. 

  • Fast Image Loading: Coil helps avoid the complexities of handling image loading manually by focusing on efficiently loading and caching images from various sources, such as URLs, local files etc. This simplistic feature avoids verbose code or any complex configurations. 
// Example of loading an image with Coil
val imageView: ImageView = findViewById(R.id.imageView)
val imageUrl = "https://example.com/image.jpg"
imageView.load(imageUrl)
  • Built-in Transformation Support: Coil allows developers to apply various modifications to images, such as resizing, cropping, or applying filters. This reduces the need for additional code when manipulating images. 
// Example with image transformations
imageView.load(imageUrl) {
transformations(CircleCropTransformation())
}
  • Disk Caching: Caching reduces the need to repeatedly download or load images, enhancing the overall responsiveness of the app.

  • Automatic Request Management: Coil handles the retrieval, decoding, and displaying of the image without requiring extensive manual intervention.

  • ImageView Integration: With Coil, you can easily load an image into an ImageView using Coil's API, making it straightforward to display images in UI components.

// Example of loading an image with Coil
val imageView: ImageView = findViewById(R.id.imageView)
val imageUrl = "https://example.com/image.jpg"
imageView.load(imageUrl)
  • Customisation & Configuration: Developers can configure options such as placeholder images, error images, and image transformations to meet specific requirements.
// Example with placeholder and error handling
imageView.load(imageUrl) {
placeholder(R.drawable.placeholder)
error(R.drawable.error)
}
  • Small Library Size: Coil is designed to be lightweight, making it beneficial for projects where minimising the app's size is a priority. It also makes use of modern libraries including Coroutines, OkHttp, Okio, and AndroidX Lifecycles.

  • Kotlin-Friendly API: Coil is written in Kotlin and offers a Kotlin-friendly API, making it particularly well-suited for projects developed in Kotlin.

For more info and use cases that would make your life easier as a developer, do check out this link. This has an entire repository on how to seamlessly integrate Coil into your Apps. Happy Coding!

What Are the Best Practices in Kotlin to Avoid Crashes in Android Apps

Published: · Last updated: · 4 min read
Appxiom Team
Mobile App Performance Experts

In the world of Android app development, crashes are an unfortunate reality. No matter how well you write your code, there's always a chance that something unexpected will happen on a user's device, leading to a crash. These crashes can result in a poor user experience, negative reviews, and lost revenue. To build robust Android apps, it's crucial to not only prevent crashes but also monitor and report them when they occur.

In this blog post, we'll explore how to avoid crashes in Android apps using Kotlin and how to report crashes using Appxiom, a powerful APM tool.

Avoiding Crashes​

1. Null Safety with Kotlin​

Kotlin, as a modern programming language, brings a significant advantage to Android development - null safety. Null pointer exceptions (NPEs) are one of the most common causes of app crashes. Kotlin's null safety features, such as nullable types and safe calls, help you prevent NPEs at compile time.

Here's an example of how to use nullable types:

var name: String? = null // Declare a nullable String
name?.length // Safe call: returns null if 'name' is null

By using nullable types and safe calls, you can catch potential null references early in the development process.

2. Exception Handling​

While you can't always prevent exceptions, you can handle them gracefully to avoid app crashes. Use try-catch blocks to catch exceptions and provide a fallback or error message to the user.

For example:

try {
// Code that might throw an exception
} catch (e: Exception) {
// Handle the exception, e.g., log it or display an error message
}

By handling exceptions properly, you can prevent crashes and provide a better user experience.

3. Defensive Programming​

Adopt defensive programming practices by validating inputs, using assertions, and adding proper checks throughout your code. For instance, when accessing an array or list, ensure that you're within the bounds to avoid index out of bounds exceptions.

val list = listOf(1, 2, 3)
if (index >= 0 && index < list.size) {
val item = list[index]
// Use 'item' safely
} else {
// Handle the out-of-bounds condition
}

4. Robust API Calls​

When making network requests or interacting with external services, always assume that the network may fail or the data may be invalid. Implement retry mechanisms, timeouts, and data validation to handle unexpected scenarios gracefully.

Reporting Crashes with Appxiom​

Even with the best preventative measures, crashes may still occur. When they do, it's essential to gather detailed information about the crash to diagnose and fix the issue. Appxiom is a powerful tool for crash reporting and analysis.

1. Integrating Appxiom into Your App​

To get started, sign up for a Appxiom account. Then, add the Appxiom SDK to your Android project. You can do this by adding the following dependency to your app's build.gradle file:

dependencies {
implementation 'com.appxiom:appxiomcore:x.x.x'
}

Initialize Appxiom in your app's Application class:

import android.app.Application

class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
Ax.init(this, appKey, platformKey)
}
}

​

2. Capturing and Reporting Crashes​

Appxiom automatically captures crashes and unhandled exceptions in your app. When a crash occurs, it collects valuable information, including the stack trace, device details, and user actions leading up to the crash.

You can also manually report non-fatal errors and exceptions using the following code:

try {
// Code that might throw a non-fatal exception
} catch (e: Exception) {
Ax.reportException(this, e, Severity.MAJOR)
}

For more on how to use Appxiom to detect crashes and other issues like memory leak and frame rate issues, check the Appxiom documentation at https://docs.appxiom.com.

​

3. Analyzing Crash Reports​

Once crashes are reported to Appxiom, you can log in to your Appxiom dashboard to view and analyze crash reports. Appxiom provides detailed insights into the root cause of crashes, enabling you to prioritize and fix issues quickly. You can see stack traces, device information, and the activity trail of the user that led to the crash.

Conclusion​

Building crash-resilient Android apps is a critical aspect of delivering a positive user experience. By following best practices in Kotlin for avoiding crashes and integrating crash reporting and analysis tools like Appxiom, you can significantly reduce the impact of crashes on your app and ensure that your users have a smooth and trouble-free experience.

Remember that continuous monitoring and improvement are essential for maintaining the reliability of your Android app.

How to Avoid Memory Leaks in Jetpack Compose

Published: · Last updated: · 4 min read
Appxiom Team
Mobile App Performance Experts

Jetpack Compose is a modern Android UI toolkit introduced by Google, designed to simplify UI development and create more efficient and performant apps. While it offers numerous advantages, like a declarative UI syntax and increased developer productivity, it's not immune to memory leaks.

Memory leaks in Android can lead to sluggish performance and even app crashes. In this blog post, we'll explore the possibilities of causing memory leaks in Jetpack Compose and common reasons behind them. We'll also provide code examples and discuss strategies to prevent and fix these issues.

Understanding Memory Leaks​

Before diving into Jetpack Compose-specific issues, let's briefly understand what a memory leak is. A memory leak occurs when objects that are no longer needed are not released from memory, causing a gradual increase in memory consumption over time. In Android, this is typically caused by retaining references to objects that should be garbage collected.

How to Avoid Memory Leaks in Jetpack Compose​

1. Lambda Expressions and Captured Variables​

Jetpack Compose heavily relies on lambda expressions and function literals. When these lambdas capture references to objects, they can unintentionally keep those objects in memory longer than necessary. This often happens when lambdas capture references to ViewModels or other long-lived objects.

@Composable
fun MyComposable(viewModel: MyViewModel) {
// This lambda captures a reference to viewModel
Button(onClick = { viewModel.doSomething() }) {
Text("Click me")
}
}

In this example, the lambda passed to Button captures a reference to the viewModel parameter. If MyComposable gets recomposed, a new instance of the lambda will be created, but it still captures the same viewModel reference. If the old MyComposable instance is no longer in use, the captured viewModel reference will keep it from being garbage collected, potentially causing a memory leak.

To avoid this, you can use the remember function to ensure that the lambda captures a stable reference:

@Composable
fun MyComposable(viewModel: MyViewModel) {
val viewModelState by remember { viewModel.state }

Button(onClick = { viewModelState.doSomething() }) {
Text("Click me")
}
}

Here, remember is used to cache the value of viewModel.state. This ensures that the lambda inside Button captures a stable reference to viewModelState. As a result, even if MyComposable is recomposed, it won't create unnecessary new references to viewModel, reducing the risk of memory leaks.

2. Composable Functions and State​

Composables are functions that can rebuild when their inputs change. If you're not careful, unnecessary recompositions can lead to memory leaks. Composable functions that create and hold onto state objects, especially those with a long lifecycle, can cause memory leaks.

@Composable
fun MyComposable() {
val context = LocalContext.current
val database = Room.databaseBuilder(context, MyDatabase::class.java, "my-database").build()

// ...
}

To mitigate this, prefer creating and closing resources within a DisposableEffect:

@Composable
fun MyComposable() {
val context = LocalContext.current

DisposableEffect(Unit) {
val database = Room.databaseBuilder(context, MyDatabase::class.java, "my-database").build()
onDispose {
database.close()
}
}

// ...
}

​

3. Forgetting to Dispose of Observers​

Jetpack Compose's LiveData and State are commonly used for observing and updating UI. However, not removing observers correctly can result in memory leaks. When a Composable is removed from the UI hierarchy, you should ensure that it no longer observes any LiveData or State.

@Composable
fun MyComposable(viewModel: MyViewModel) {
val data = viewModel.myLiveData.observeAsState()

// ...
}

To address this, use the DisposableEffect to automatically remove observers when the Composable is no longer needed:

@Composable
fun MyComposable(viewModel: MyViewModel) {
DisposableEffect(viewModel) {
val data = viewModel.myLiveData.observeAsState()
onDispose {
// Remove observers or do necessary cleanup here
}
}

// ...
}

Conclusion​

Jetpack Compose is a powerful tool for building modern Android user interfaces. However, like any technology, it's essential to be aware of potential pitfalls, especially regarding memory management.

By understanding the common causes of memory leaks and following best practices, you can create efficient and performant Compose-based apps that delight your users.