Skip to main content

119 posts tagged with "Android"

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.

Why Flutter Deep Links Break in Release & How to Fix Them

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

In local development, deep links feel deceptively simple. You run an adb command or test in an iOS simulator, and Flutter immediately opens /products/492.

Yet once the app ships to Google Play or the App Store, bug reports surface: links open the browser instead of the app, users land on an empty home screen, or iOS redirects to a web login.

When flutter deep linking release not working occurs in production, the root cause is rarely the Flutter framework itself. In development, local environments bypass the strict domain verification enforced by mobile operating systems. In production, keystore re-signing, Apple CDN caching, and native routing collisions cause links to fail silently.

Here is how to diagnose and resolve these failures across Android App Links, iOS Universal Links, and Flutter engine routing.

A deep link traverses three layers before rendering a Flutter screen:

Failures occur at two boundaries:

  1. OS Domain Verification Failure: Cryptographic checks fail against hosted domain manifests, prompting Android or iOS to abort app delegation and fall back to the browser.
  2. Native Route Interception Conflict: The OS opens the app, but Flutter’s native engine deep linking collides with application routers like go_router, dropping users onto fallback routes.

Android App Links require intent filters with android:autoVerify="true" and a verification file at https://yourdomain.com/.well-known/assetlinks.json. On Android 12+ (API 31+), failed verification silently opens Chrome without an app chooser.

The Trap: Google Play App Signing Keystore Mismatch​

The primary cause of flutter app links assetlinks json failure in release is a signature mismatch caused by Google Play App Signing.

When testing locally, developers extract the SHA-256 fingerprint from their local keystore:

keytool -list -v -keystore android/app/upload-keystore.jks -alias upload

Adding only this fingerprint to assetlinks.json works for local release builds. But when you upload an Android App Bundle (.aab), Google Play re-signs the app with Google's managed App Signing Key.

Users installing from Google Play receive an APK signed by Google's key. Android's IntentFilterVerificationReceiver detects an SHA-256 mismatch against assetlinks.json and rejects the domain.

The Correct assetlinks.json Configuration​

Your assetlinks.json must include fingerprints for both your local/upload keys and Google Play App Signing.

In the Google Play Console, go to Release > Setup > App integrity > App Signing > App signing key certificate. Copy the SHA-256 certificate fingerprint and update https://yourdomain.com/.well-known/assetlinks.json:

[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.appxiomstore",
"sha256_cert_fingerprints": [
"F3:2B:64:18:...:AA:01",
"8A:E2:59:C1:...:9B:44"
]
}
}
]

Hosting Rules: Serve over HTTPS with valid SSL, return HTTP 200 OK with Content-Type: application/json, and avoid redirects (301/302).

Diagnosing Android Verification via adb​

Inspect verification state directly on a connected release device:

# Check domain verification status (Android 12+)
adb shell pm get-app-links com.example.appxiomstore

# Force re-verification without reinstalling
adb shell pm verify-app-links --re-verify com.example.appxiomstore

# Stream verification logs
adb logcat -s IntentFilterVerificationReceiver domain_verification

Verify endpoint accessibility using Google's Digital Asset Links API:

curl "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://yourdomain.com&relation=delegate_permission/common.handle_all_urls"

On iOS, Universal Links associate your Apple Team ID and Bundle ID with your web domain via the Apple App Site Association (apple-app-site-association or AASA) file.

When debugging universal links aasa flutter configurations, check these failure points:

In Xcode, under Signing & Capabilities > Associated Domains, domains require the applinks: scheme:

  • ❌ https://yourdomain.com
  • ✅ applinks:yourdomain.com

Without applinks:, Xcode builds successfully, but iOS ignores domain association.

2. HTTP Redirects and MIME Types​

Apple's scraper queries https://yourdomain.com/.well-known/apple-app-site-association. If the server returns a redirect (301/302), Apple's bot discards the file.

  • Return direct HTTP 200 OK.
  • Serve with Content-Type: application/json.
  • Do not append a .json file extension.

3. Apple CDN Proxy Caching​

Since iOS 14, devices fetch AASA manifests from Apple's CDN proxy (app-site-association.cdn-apple.com). Updates can take 24 to 48 hours to propagate.

Bypass the CDN during staging via developer mode in your entitlement:

<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:yourdomain.com?mode=developer</string>
</array>

(Remove ?mode=developer before production App Store submission).

4. Modern AASA Format​

Ensure the manifest uses the modern components schema with your exact <TeamID>.<BundleID>:

{
"applinks": {
"apps": [],
"details": [
{
"appIDs": [ "ABCDE12345.com.example.appxiomstore" ],
"components": [
{
"/": "/products/*",
"comment": "Product detail routes"
}
]
}
]
}
}

Engine Routing Conflicts: Resolving flutter_deeplinking_enabled​

Once domain verification passes, the OS hands the URL to the native runner (FlutterActivity on Android, FlutterAppDelegate on iOS).

Here, projects frequently hit a flutter_deeplinking_enabled conflict.

The Collision Mechanism​

By default, Flutter's native engine captures inbound deep links and dispatches them across SystemChannels.navigation.

If your app uses declarative routing (go_router) or link stream packages (app_links), both systems handle the event at once:

  1. Flutter's native engine pushes the raw URI directly to Navigator.
  2. GoRouter or app_links parses the URI to run route guards, query parameters, or authentication hydration.

This race condition causes classic release bugs:

  • Query parameters stripped: The engine routes /products?id=12 against /products, dropping query strings and landing on fallback screens.
  • Premature navigation before auth: The engine forces navigation before authentication states or token caches finish loading, bouncing users to /login.
  • Duplicate transitions: The destination screen animates twice or triggers navigation stack conflicts.

When to Disable Flutter's Built-in Deep Linking​

If you manage deep-link streams manually using app_links or custom platform channels, disable Flutter's default engine routing.

In android/app/src/main/AndroidManifest.xml:

<activity
android:name=".MainActivity"
android:launchMode="singleTop"
android:exported="true">

<meta-data
android:name="flutter_deeplinking_enabled"
android:value="false" />

<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="yourdomain.com" />
</intent-filter>
</activity>

In ios/Runner/Info.plist:

<key>FlutterDeepLinkingEnabled</key>
<false/>

Resilient Declarative Routing with GoRouter​

When using GoRouter without secondary link listeners, keep flutter_deeplinking_enabled active, but provide explicit error boundaries:

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

final GoRouter appRouter = GoRouter(
initialLocation: '/',
errorBuilder: (context, state) => Scaffold(
body: Center(
child: Text('Unable to load: ${state.uri.path}'),
),
),
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/products/:id',
builder: (context, state) => ProductDetailScreen(
productId: state.pathParameters['id'] ?? '',
promoCode: state.uri.queryParameters['promo'],
),
),
],
);

Measuring Goal Friction Impact (GFI) with Appxiom​

When a deep link fails in release, traditional APM tools report zero crashes and a 99.9% crash-free session rate. The app simply booted to / or remained in the browser.

However, if marketing spent an illustrative $50,000 on a campaign and high-intent users land on an empty home screen or an unhandled 404, those users abandon immediately.

This is the blind spot Appxiom's Goal Friction Impact (GFI) measures:

  1. User Journey Attribution: Appxiom monitors deep-link entry points to detect native route failures, broken fallbacks, and routing friction before users abandon critical paths.
  2. Detecting Silent Fallbacks: When users enter via a deep link but stall on fallback screens or bounce within seconds, Appxiom flags the friction event.
  3. Quantifying Revenue Loss: GFI calculates the financial impact of broken routes, turning a vague bug report into actionable data - such as identifying a missing Google Play fingerprint that resulted in an illustrative $24,000 in lost revenue.

Troubleshooting Reference​

IssueRoot CauseFix
Android: Link opens Chrome directlyKeystore mismatch in assetlinks.json.Add Google Play App Signing SHA-256 to assetlinks.json.
Android: Verification stuck in legacy_failureHTTP redirect or invalid SSL on assetlinks.json.Serve direct 200 OK via HTTPS with application/json.
iOS: Universal Link opens SafariMissing applinks: prefix in Xcode capabilities.Use applinks:yourdomain.com in Associated Domains.
iOS: AASA changes not taking effectApple CDN proxy caching (up to 48 hours).Add ?mode=developer to entitlement during testing.
Flutter: App opens to blank screen or 404Collision between native link handling and package router.Set flutter_deeplinking_enabled to false when managing streams via app_links.

Best Practices​

  1. Include Both Key Fingerprints: Keep both upload and Play App Signing SHA-256 fingerprints in assetlinks.json.
  2. Eliminate Manifest Redirects: Ensure CDN rules serve assetlinks.json and apple-app-site-association directly without redirects.
  3. Provide Fallback Route Handlers: Configure resilient errorBuilder pages in GoRouter to prevent white screens when deep links fail.
  4. Track Funnel Drop-offs: Use Appxiom GFI to monitor deep-link conversion funnels on every release track.

Conclusion​

Release deep-linking failures stem from configuration mismatches across operating system domain verification, CDN caching, and Flutter's native routing layer.

By aligning your Google Play App Signing keys, configuring zero-redirect AASA endpoints, resolving engine routing conflicts with flutter_deeplinking_enabled, and monitoring Goal Friction Impact with Appxiom, you can ensure users transition smoothly into your app on every release.

Production Memory Diagnostics: Tracking Leaks, OOMs, and Jetsam in Mobile Apps

Published: · 13 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

In local development, diagnosing a memory leak is a solved problem.

You fire up Android Studio Memory Profiler or Xcode Instruments Allocations, attach a debugger, record a trace, inspect the object graph, or let LeakCanary dump a heap snapshot. Within minutes, you can pinpoint the exact retain cycle or static reference pinning an Activity or UIViewController in RAM.

In production, that luxury vanishes.

Running a heap dump in a live production app is an engineering non-starter. A standard Android heap dump (Debug.dumpHprofData()) freezes the main thread for 10 to 30 seconds, creates an uncompressed 500MB+ .hprof file on user storage, risks exfiltrating unredacted Personally Identifiable Information (PII), and frequently triggers operating system watchdogs. On iOS, you cannot capture memory graphs or attach Instruments to App Store binaries at all.

Even worse, Out-Of-Memory (OOM) events are the most silent killers in mobile engineering.

When an iOS device runs out of physical RAM, Apple's Jetsam kernel daemon terminates your process with a SIGKILL. On Android, the LowMemoryKiller daemon (lmkd) sends a silent kill to reclaim pages.

Neither operating system gives your application time to execute an uncaught exception handler. Traditional crash reporting SDKs register zero crash logs. To your backend and analytics dashboards, the user simply disappeared - or worse, the app restarts cleanly on next launch, disguising catastrophic memory exhaustion as ordinary user session churn.

Here is how modern engineering teams move beyond development-only profilers to implement Production Memory Diagnostics - tracking runaway allocations, memory leaks, and OS termination events without degrading app performance.

Production Memory Diagnostics is the practice of continuously monitoring runtime memory health (Resident Set Size, dirty physical footprint, system memory pressure warnings, and screen-by-screen memory deltas) on live user devices without capturing heavy heap dumps. By correlating memory growth with user journeys and lifecycle events, production diagnostics identify retain cycles, native cache bloat, and LowMemoryKiller (LMK)/Jetsam terminations before they cause silent churn.

The Anatomy of Silent Memory Kills​

To diagnose production memory issues, you must first understand how modern mobile operating systems enforce memory limits and terminate processes.

1. iOS: The Jetsam Mechanism (FOOM)​

iOS devices operate without a traditional disk swap file. To manage memory pressure, iOS relies on memory compression and aggressive process termination enforced by the kernel daemon memorystatus (commonly known as Jetsam).

When an app's dirty physical memory footprint crosses the system limit (which varies by device model and memory tier - often between 1.2GB and 2.2GB on modern iPhones, but as low as 350MB on older iPads or memory-constrained extensions), Jetsam sends an uncatchable SIGKILL.

The Virtual Memory Trap​

Many developers attempt to track memory in Swift by reading task_basic_info.virtual_size. This metric is virtually useless: virtual size includes clean mapped read-only files (such as .dylib frameworks and asset catalogs) that the OS can evict at zero cost.

Jetsam only cares about Physical Footprint - dirty pages that cannot be compressed or paged out.

Low-Overhead Physical Footprint Telemetry in Swift​

To capture actual memory pressure in production, you must query the Mach kernel's task_vm_info:

import Foundation
import MachO

public struct MemoryDiagnostics {

/// Returns the exact physical memory footprint (in Megabytes) tracked by Jetsam.
public static func getPhysicalMemoryFootprintMB() -> Double? {
var vmInfo = task_vm_info_data_t()
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size)

let result = withUnsafeMutablePointer(to: &vmInfo) { ptr in
ptr.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { intPtr in
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), intPtr, &count)
}
}

guard result == KERN_SUCCESS else { return nil }

// phys_footprint is the exact metric used by iOS Jetsam to trigger kills
let footprintBytes = Double(vmInfo.phys_footprint)
return footprintBytes / (1024.0 * 1024.0)
}

/// Queries remaining memory available to the process before system intervention
public static func getAvailableMemoryMB() -> Double {
return Double(os_proc_available_memory()) / (1024.0 * 1024.0)
}
}

By querying os_proc_available_memory() upon entering critical funnels, your app can dynamically evict caches before Jetsam strikes.

2. Android: The LowMemoryKiller (LMK) & The OOM Paradox​

On Android, memory management is split across two layers:

  1. The ART (Android Runtime) Java/Kotlin Virtual Heap: Subject to garbage collection with an upper limit defined by Runtime.getRuntime().maxMemory().
  2. The Linux Kernel & Native Allocations: Governed by the lmkd daemon, which monitors system-wide RAM and kills processes based on their oom_score_adj priority.

The "Straw That Broke the Camel's Back" Fallacy​

When an Android app crashes with a fatal java.lang.OutOfMemoryError, developers almost always misdiagnose the root cause by staring at the stack trace:

java.lang.OutOfMemoryError: Failed to allocate a 32784 byte allocation with 12582912 free bytes and 12MB until OOM
at java.lang.StringFactory.newStringFromChars(StringFactory.java:328)
at java.lang.StringBuilder.toString(StringBuilder.java:407)
at com.example.app.ui.cart.CartAdapter.onBindViewHolder(CartAdapter.kt:42)

The stack trace points to a 32KB string allocation inside CartAdapter. Developers spend days optimizing StringBuilder usages in the cart screen.

In reality, the cart adapter was blameless. Five minutes earlier, a background image pipeline leaked five 50MB uncompressed Bitmap instances because an Activity failed to unsubscribe from an EventBus. The heap was already at 99.8% capacity; the 32KB string just happened to be the unlucky allocation that tripped the wire.

Production Memory Telemetry in Kotlin​

To monitor real memory consumption and intercept OS warnings before LMK strikes:

package com.appxiom.diagnostics

import android.app.ActivityManager
import android.content.ComponentCallbacks2
import android.content.Context
import android.content.res.Configuration
import android.os.Debug
import android.os.Process

class ProductionMemoryObserver(private val context: Context) : ComponentCallbacks2 {

fun captureMemorySnapshot(): MemorySnapshot {
val runtime = Runtime.getRuntime()
val javaHeapUsedMB = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024)
val javaHeapMaxMB = runtime.maxMemory() / (1024 * 1024)

// Native allocation tracking (Bitmaps, C++ libs, OpenGL/Vulkan buffers)
val nativeHeapAllocatedMB = Debug.getNativeHeapAllocatedSize() / (1024 * 1024)

// PSS (Proportional Set Size): The most accurate representation of app footprint
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val memoryInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memoryInfo)

return MemorySnapshot(
javaHeapUsedMB = javaHeapUsedMB,
javaHeapMaxMB = javaHeapMaxMB,
nativeHeapAllocatedMB = nativeHeapAllocatedMB,
systemAvailableMemMB = memoryInfo.availMem / (1024 * 1024),
isSystemLowMemory = memoryInfo.lowMemory
)
}

override fun onTrimMemory(level: Int) {
when (level) {
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> {
// The app is running, but device is critically low on memory.
// Evict non-essential bitmap caches immediately.
}
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> {
// UI moved to background. Free heavy UI resources to lower LMK kill priority.
}
}
}

override fun onConfigurationChanged(newConfig: Configuration) {}
override fun onLowMemory() {
// Legacy callback: system is running out of memory
}
}

data class MemorySnapshot(
val javaHeapUsedMB: Long,
val javaHeapMaxMB: Long,
val nativeHeapAllocatedMB: Long,
val systemAvailableMemMB: Long,
val isSystemLowMemory: Boolean
)

3. Flutter & React Native: The Split-Heap Blindspot​

Cross-platform frameworks introduce a dangerous architectural duality: The VM Heap vs. The Native Graphics Engine.

In Flutter, Dart manages object lifecycle on the Dart VM Heap. However, images, shaders, Skia/Impeller render pipelines, and texture backings live in native C++ memory.

// DANGEROUS: Decodes a 4032x3024 12MB camera photo into raw RGBA memory
// Dart heap allocates only a tiny metadata pointer (~120 bytes)
// Skia / Native heap allocates 4032 * 3024 * 4 bytes = ~48.7 MB of uncompressed bitmap!
Image.network(
"https://cdn.example.com/products/4k_photo.jpg",
width: 150,
height: 150,
)

// SAFE: Resizes bitmap during decode step, saving 95% native RAM
Image.network(
"https://cdn.example.com/products/4k_photo.jpg",
width: 150,
height: 150,
cacheWidth: 300, // 2x density for 150dp
cacheHeight: 300,
)

If a list view displays 30 unconstrained images, the Dart VM reports a healthy 45MB heap, while the native iOS/Android process footprint spikes past 1.4GB. Within seconds, Jetsam or LMK kills the app. A standard Dart crash handler will never capture a stack trace.

Why Traditional Monitoring Tools Fail in Production​

CapabilityLocal Dev Profilers (Xcode / Android Studio)LeakCanaryStandard Crash Reporters (Crashlytics, Sentry)Production Memory Diagnostics (Appxiom)
Run in Production App Store / Play Store❌ No (Debug builds only)❌ Dangerous (5-30s freeze, PII risk)✅ Yes✅ Yes
Zero UI Jitter / Overhead❌ High overhead❌ High pause time✅ Low✅ Zero-overhead passive telemetry
Capture Jetsam / FOOM Kills❌ N/A❌ N/A❌ Misses SIGKILL terminations✅ Correlates last known footprint
Capture Native vs Managed Heap✅ Yes❌ Java heap only❌ None✅ Complete physical footprint
Attribution to User Action⚠️ Manual profiling session❌ Static reference path only❌ Single stack frame at crash✅ Full historical Activity Trail

The Appxiom Solution: Memory Activity Trail​

Diagnosing production memory leaks does not require capturing every allocated byte in RAM. It requires understanding Memory Delta Attribution.

A leak is not defined by high memory usage; it is defined by memory that is allocated during a user journey and fails to reclaim after that journey terminates.

Correlating Memory Deltas with User Journeys​

The Appxiom SDK records lightweight memory snapshots attached to each discrete step in the Activity Trail. By comparing memory footprint across screen transitions, network invocations, and user interactions, Appxiom calculates the exact memory delta ($\Delta$) per step.

Activity Trail Trace: Diagnosing a Retain Cycle in Production​

Consider this telemetry log captured by Appxiom immediately before an iOS Jetsam event:

TimestampScreen / Lifecycle EventAction / PayloadPhysical RAMMemory Delta ($\Delta$)Status
14:22:01.100AppLaunchCold start from springboard98 MB+98 MBNormal
14:22:04.250HomeScreenFeed fetch (GET /v2/feed - 200)132 MB+34 MBNormal
14:22:15.800CatalogScreenGrid populated (48 products)168 MB+36 MBNormal
14:22:28.400ARProductViewerCamera initialized + 3D model load495 MB+327 MBHigh Allocation
14:22:42.100HomeScreenUser navigates BACK to Home490 MB-5 MB (LEAK ALERT)CRITICAL
14:22:49.000CartScreenUser taps Cart icon510 MB+20 MBWarning
14:22:53.600CheckoutScreenTap "Pay Now"542 MB+32 MBJETSAM KILL

The Root Cause Revealed​

Without Appxiom, your team would be looking at an unhandled session termination during checkout. You might assume the payment gateway SDK crashed.

With the Appxiom Activity Trail, the bug is obvious in five seconds:

  1. When the user entered ARProductViewer, RAM jumped from 168 MB to 495 MB (+327 MB).
  2. When the user navigated back to HomeScreen, memory should have dropped back toward 168 MB. Instead, memory remained pinned at 490 MB (only -5 MB reclaimed).
  3. The ARProductViewer controller or its Metal texture buffers had a strong retain cycle (e.g., an uncancelled closure capture or unreleased camera delegate), keeping 320MB of native resources permanently alive in background memory.
  4. When the user reached Checkout, the normal memory requirement of the payment sheet pushed the app over the device's Jetsam threshold.

Connecting Memory Diagnostics to Business Health​

Memory diagnostics is not merely an infrastructure concern; it directly impacts revenue and customer retention.

1. Goal Friction Impact (GFI)​

When an app terminates due to an OOM during an onboarding or checkout flow, users rarely re-enter payment credentials - they abandon the transaction. Appxiom's Goal Friction Impact (GFI) quantifies the precise financial loss attributable to memory degradation, allowing engineering leads to prioritize memory fixes with hard ROI data.

2. Quality Score (QS)​

Rather than relying solely on the deceptive crash-free rate, Appxiom calculates a holistic Quality Score (QS). Devices experiencing excessive memory pressure warnings (TRIM_MEMORY_RUNNING_CRITICAL or didReceiveMemoryWarningNotification) trigger a score penalty, giving you an early warning system days before crashes escalate.

3. Version Analytics​

During staged rollouts, Appxiom's Version Analytics compares memory distributions across app versions:

  • v2.4.0: 90th percentile physical footprint = 185 MB.
  • v2.5.0: 90th percentile physical footprint = 340 MB (+83% regression detected in first 2% rollout).

You can halt the release before the regression compromises millions of devices.

Actionable Production Memory Hardening Checklist​

Follow these engineering best practices to eliminate memory leaks and build resilience against LMK and Jetsam:

iOS (Swift / SwiftUI)​

  • Enforce Weak Capture in Async Closures: Audit all Task { [weak self] in ... } blocks and delegates to prevent retain cycles holding view controllers in memory.
  • Drain Autorelease Pools in Heavy Loops: Wrap batch data transforms or file processing in autoreleasepool { ... } so intermediate objects are reclaimed immediately.
  • Listen to Memory Pressure: Implement UIApplication.didReceiveMemoryWarningNotification to flush image memory caches and clear transient view states.
  • Audit Background Tasks: Ensure background tasks end promptly with UIApplication.shared.endBackgroundTask(). Pinned background allocations are the #1 trigger for Jetsam priority kills.

Android (Kotlin / Jetpack Compose)​

  • Observe ComponentCallbacks2: Evict Glide/Coil bitmap caches upon receiving TRIM_MEMORY_RUNNING_CRITICAL or TRIM_MEMORY_RUNNING_LOW.
  • Beware of Static View/Context References: Never store a Context or View in a companion object, singleton, or long-lived coroutine scope.
  • Bind Coroutines to Lifecycle: Use viewLifecycleOwner.lifecycleScope in Fragments and rememberCoroutineScope() in Compose to ensure allocations cancel with UI teardown.
  • Optimize Large Bitmaps: Always decode images using BitmapFactory.Options.inSampleSize or rely on modern vector drawables and hardware-accelerated rendering.

Cross-Platform (Flutter / React Native)​

  • Constrain Image Cache Dimensions: Never use unconstrained Image.network() without specifying cacheWidth and cacheHeight.
  • Dispose Controllers Explicitly: Always invoke .dispose() on AnimationController, TextEditingController, FocusNode, and ScrollController in the widget dispose() lifecycle.
  • Verify Native Bridge Cleanup: Ensure custom native platform channels (MethodChannels) release native listeners and texture handles when widgets unmount.

Eliminate Memory Blindspots with Appxiom​

You cannot fix what you cannot measure, and you cannot debug production memory with development-only profilers.

By combining low-overhead physical footprint telemetry with the Appxiom Activity Trail, your engineering team gains complete visibility into every memory spike, retain cycle, and silent OOM kill across iOS, Android, and Flutter.

Stop losing users to invisible memory kills. Explore Appxiom's Activity Trail or start monitoring your mobile applications today.

Silent Failures, ANRs, and App Hangs: How to Detect and Fix the Bugs That Never Crash

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

Your crash reporting dashboard displays a pristine 99.9% crash-free session rate.

Your engineering team celebrates a successful deployment. The release metrics look flawless. But on the App Store and Google Play, your reviews tell a completely different story:

"The app freezes every time I tap the checkout button. Completely unusable."
"It just spins forever on the login screen. Had to force close and uninstall."
"Buttons become unresponsive after opening my transaction history."

Meanwhile, your product manager pulls up analytics: checkout funnel completion has plummeted by 22%, and customer churn is spiking. Yet your error monitor reports zero fatal crashes.

How can an application be catastrophic for users while registering as 100% healthy on your APM dashboard?

Welcome to the blind spot of modern software monitoring: Silent Failures, Application Not Responding (ANR) events, and iOS App Hangs.

The Dangerous Illusion of the Crash-Free Rate​

For more than a decade, mobile engineering teams have used Crash-Free Session Rate (CFSR) as their primary release quality benchmark.

The flaw with CFSR is structural: it measures process survivability, not user experience.

When a mobile app freezes, the process is still running. When an API call fails and leaves a progress indicator spinning indefinitely, no unhandled exception is thrown. When a user force-closes the app in frustration, the operating system registers a user termination, not a crash.

To your crash logger, that session was a victory. To your user, it was an infuriating failure.

Deconstructing the Three Horsemen of Silent Decay​

To eliminate silent failures, engineering teams must understand the three distinct technical mechanisms that cause them:

1. Android ANRs (Application Not Responding)​

On Android, the operating system monitors UI responsiveness via the system server. If your application cannot process input events or lifecycle callbacks within designated timeframes, Android displays the dreaded Application Not Responding (ANR) dialog:

app is not responding

Close app
Wait

What Triggers an ANR?​

  • Input Dispatching Timeout: The app fails to respond to an input event (e.g., key press or screen touch) within 5 seconds.
  • BroadcastReceiver Timeout: A BroadcastReceiver fails to finish executing within 10 seconds in foreground mode.
  • Service Execution Timeout: A Service executed by the foreground app fails to complete its lifecycle tasks within 20 seconds.

The Business Penalty: Google Play Vitals​

Google Play monitors your app's User-Perceived ANR Rate (ANRs occurring when your app is in the foreground). If your ANR rate exceeds Google's bad behavior threshold of 0.47% overall (or 8% on specific phone models):

  • Google demotes your app in Play Store search rankings.
  • Your app is disqualified from store featuring and promotional carousels.
  • Google Play displays a warning directly on your store listing: "Recent data shows that this app may stop working on your device."

2. iOS App Hangs​

iOS does not display an ANR dialog. Instead, when the main run loop is blocked, the interface simply stops accepting touch input. Tapping buttons produces no ripple effect, scrolling locks up, and animations stutter to a halt.

What Triggers an App Hang?​

The iOS main thread operates as an event run loop (CFRunLoop). When an operation performs synchronous disk I/O, heavy computation, or waits on a dispatch semaphore on the main thread, the run loop cannot process new touch events.

  • Micro-Hangs (250ms – 500ms): Perceived by users as "jank," dropped frames, or sluggishness.
  • Severe Hangs (> 2,000ms): Users perceive the app as completely frozen. Over 70% of users will force-quit an app if an interaction takes longer than 3 seconds without visual feedback.

Apple Xcode Organizer Telemetry​

Apple tracks your Hang Rate (measured as milliseconds of hang time per hour of active use). High hang rates directly suppress your visibility in App Store search results and elevate user churn.

3. Silent Functional Failures & Swallowed Exceptions​

Unlike ANRs and App Hangs, silent functional failures often involve zero thread contention. Instead, they are caused by defensive programming gone wrong:

// The Classic "Silent Killer" Pattern:
try {
val response = paymentApi.confirmPurchase(orderId)
updateUiWithReceipt(response)
} catch (e: Exception) {
// Exception caught and logged to console or an unmonitored analytics tag
Log.e("Payment", "Purchase failed", e)
// The spinner continues to animate, or the screen never transitions!
}

The network request times out, or the backend returns a 503 Service Unavailable. Because the exception was caught, no crash occurred. But the user is left looking at an unresponsive checkout button. They tap it three more times, get no feedback, and leave your app forever.

Why Traditional APMs Fail to Diagnose ANRs and Hangs​

Most engineering teams rely on crash loggers to debug ANRs. When an ANR occurs, these tools capture a point-in-time thread dump. But in production, these thread dumps are notoriously misleading:

1. The "Innocent Bystander" Stack Trace​

When an operating system triggers an ANR dump at second 5, the thread dump records whatever code happens to be executing at that exact millisecond. Frequently, this is an innocent operating system wait call:

"main" prio=5 tid=1 Native
#00 pc 000000000004b344 /apex/com.android.runtime/lib64/bionic/libc.so (syscall+28)
#01 pc 00000000001ae4f8 /apex/com.android.art/lib64/libart.so (art::ConditionVariable::WaitHoldingLocks)

The stack trace tells you the main thread is waiting for a lock. But which thread holds the lock? And what action caused the lock to be acquired in the first place? The thread dump cannot answer either question.

2. Lack of Preceding User Interaction Context​

Did the user tap a filter button? Did they background the app and return? Did their network connection drop from Wi-Fi to cellular right before the freeze? Standard thread dumps provide zero historical sequence.

3. No Correlation to Business Impact​

If an ANR occurs during app startup on an obsolete Android 8 device, it is an annoyance. If an App Hang occurs every time a high-value customer taps "Complete Purchase" on iOS 18, it is a business catastrophe. Traditional tools treat both events as isolated technical entries.

How Appxiom Solves Silent Failures: Continuous Forensics​

Appxiom tackles silent failures by bridging the gap between technical thread execution and user experience:

1. Proactive Main-Thread Watchdogs​

Appxiom continuously monitors UI thread responsiveness and runloop latency. If a task stalls the UI thread for longer than your defined threshold, Appxiom captures the event before the user force-closes the application.

2. The Activity Trail: Reconstructing the Crime Scene​

Instead of an isolated thread dump, Appxiom attaches the complete Activity Trail to every ANR and App Hang report:

APPXIOM ANR REPORT

Issue: ANR Detected (Main thread blocked for 5,240ms)
Version: v3.4.1 (Build 92)  |  Device: Samsung Galaxy S23  |  OS: Android 14

TIME

TYPE

DETAILS

FREE MEM

14:10:02.115LIFECYCLEOrdersActivity:onResume52.1%
14:10:03.450USER ACTIONClicked "Sync Offline Orders"51.8%
14:10:03.480CUSTOMlocal_db_query_started51.8%
14:10:03.490HTTPPOST /api/v2/orders/sync → 200 OK (310ms)51.2%
14:10:03.810CUSTOMparsing_5000_json_items49.5%
14:10:08.730ISSUE

ANR: Main thread stalled during JSON parsing for 5,240ms

48.9%

What this tells the engineer:​

In 15 seconds, the developer sees what happened:

  1. The user tapped 'Sync Offline Orders'.
  2. The network request succeeded quickly (200 OK in 310ms).
  3. The response contained 5,000 JSON items.
  4. The JSON parsing and database insertion were executed synchronously on the main thread instead of offloaded to a background coroutine or thread pool.
  5. The UI froze for 5.2 seconds, triggering the ANR.

3. Goal Friction Impact (GFI)​

Through Goal Friction Impact, Appxiom correlates silent ANRs and hangs directly to user drop-offs in critical funnels (such as checkout, signup, or subscription).

If a silent freeze occurs in a settings menu, its GFI score is low (e.g., 1.2/10). If an App Hang occurs during credit card validation, its GFI spikes to 9.6/10, immediately alerting your team to a revenue-critical blocker.

4. Appxiom Quality Score (QS)​

Unlike binary crash-free rates, the Appxiom Quality Score evaluates version health on a unified 0–10 scale that penalizes ANRs, App Hangs, and slow screen loads alongside fatal crashes.

Technical Best Practices: Preventing Main-Thread Freezes​

Eliminating ANRs and App Hangs requires architectural discipline across your codebase. Here are the core patterns to enforce:

Android: Moving Work Off the Main Thread​

1. Enforce Background Dispatchers for I/O​

Never perform disk reads, SharedPreferences writes, Room queries, or network operations on Dispatchers.Main:

// BAD: Runs on Main Thread, causes ANR under heavy DB load
fun loadUserTransactions() {
val transactions = database.transactionDao().getAll() // BLOCKS UI THREAD
updateUi(transactions)
}

// GOOD: Offloaded to Dispatchers.IO with Kotlin Coroutines
fun loadUserTransactions() = viewModelScope.launch {
val transactions = withContext(Dispatchers.IO) {
database.transactionDao().getAll()
}
updateUi(transactions)
}

2. Enable StrictMode in Debug Builds​

Catch disk and network violations during development before they ever reach production:

if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build()
)
}

iOS: Eliminating Runloop Blocking in Swift​

1. Leverage Swift 6 Concurrency & Actor Isolation​

Ensure heavy computations are decoupled from the @MainActor:

// BAD: Synchronous heavy parsing on the main actor
@MainActor
func processOrderHistory(data: Data) {
let decoder = JSONDecoder()
// Heavy JSON parsing locks the main runloop
let orders = try? decoder.decode([Order].self, from: data)
self.displayOrders(orders)
}

// GOOD: Background task processing
@MainActor
func processOrderHistory(data: Data) async {
let orders = await Task.detached(priority: .userInitiated) {
let decoder = JSONDecoder()
return try? decoder.decode([Order].self, from: data)
}.value

self.displayOrders(orders)
}

2. Avoid Synchronous Semaphore Waits on the Main Queue​

Never call dispatch_semaphore_wait or DispatchGroup.wait() on the main thread while waiting for a background completion handler. If the background thread requires main-thread access to complete, your app enters an immediate deadlock.

Flutter: Isolates for Heavy Computation​

In Flutter, the Dart engine runs on a single event loop. If you perform heavy operations on that loop, you freeze the UI across both Android and iOS simultaneously:

// BAD: Blocks Flutter root isolate
void parseLargeJson(String rawJson) {
final data = jsonDecode(rawJson); // Drops frames or triggers ANR
setState(() => items = data);
}

// GOOD: Offload to background Isolate using compute()
Future<void> parseLargeJson(String rawJson) async {
final data = await compute(jsonDecode, rawJson);
setState(() => items = data);
}

Comparison: Fatal Crash Monitoring vs. Appxiom Observability​

CapabilityTraditional Crash Loggers (Crashlytics, Bugsnag)Appxiom Performance & Error Platform
Fatal Exception Tracking✅ Yes✅ Yes
Android ANR Detection⚠️ Post-mortem thread dumps only✅ Real-time watchdog + Activity Trail
iOS App Hang Detection❌ Limited or omitted✅ Micro-hang & severe hang tracking
Silent API & State Failures❌ Omitted (process stays alive)✅ Automatic 4xx/5xx & flow block detection
User Interaction Footsteps❌ Generic breadcrumbs✅ Auto-tracked taps, gestures & lifecycles
Business Impact Metric❌ Occurrence count only✅ Goal Friction Impact (0–10 GFI Score)
Holistic Version Quality❌ Binary Crash-Free Rate✅ Appxiom Quality Score (0–10 QS)

The End of Invisible Bugs​

A crash that affects 10 users in an obscure background service is easy to spot on your dashboard.

An App Hang that freezes 5,000 customers during checkout is far more devastating—yet traditional monitoring platforms let it slip by in complete silence.

Stop relying on vanity metrics that disguise broken user experiences as healthy releases.

With Appxiom, you detect the failures that matter most:

  • Pinpoint the exact line of code causing your ANRs and App Hangs.
  • Retrace the user footsteps that led to the freeze.
  • Protect your App Store rankings and Google Play Vitals.
  • Fix release regressions before they cost you revenue.

Stop Letting Silent Failures Drain Your Revenue​

See how Appxiom gives mobile engineering teams the clarity they need to eliminate ANRs, App Hangs, and invisible regressions.

Start Your Free Appxiom Trial Today or explore our types of bugs detected in mobile and web apps.

Stop Guessing 'How to Reproduce': Debugging Release Regressions with Appxiom's Activity Trail

Published: · 17 min read
Andrea Sunny
Marketing Associate, Appxiom

You just shipped version v4.2.0 of your mobile application. Every unit test passed in CI, staging looked rock-solid, and the release train left the station on schedule.

Twenty-four hours later, the Slack notifications start pouring in.

Customer support is escalating tickets. App Store reviews are dropping from 4.8 to 3.2 stars. Product managers are asking why checkout conversion dropped by 18%. And on Jira, you are assigned a ticket titled:

"App crashes or freezes when user tries to place an order after applying coupon."
Steps to Reproduce: "Unknown. Just tap around in the cart."

You pull up your error tracker - Firebase Crashlytics, Sentry, or Bugsnag. You find the stack trace:

Fatal Exception: java.lang.IllegalStateException: 
Can not perform this action after onSaveInstanceState
at androidx.fragment.app.FragmentManager.checkStateLoss(FragmentManager.java:1844)
at androidx.fragment.app.FragmentManager.enqueueAction(FragmentManager.java:1884)
at androidx.fragment.app.BackStackRecord.commitInternal(BackStackRecord.java:329)
at com.example.store.checkout.CheckoutFragment.showOrderConfirmation(CheckoutFragment.kt:342)

You open the codebase. You launch the emulator on your workstation. You add an item to the cart, apply a coupon, tap checkout, and... it works completely fine.

You test it on five different test devices. It works fine on every single one.

You reply to the ticket: "Unable to reproduce locally. Need more info." QA replies: "We saw it twice on staging, but can't replicate it consistently." Support asks the customer for a screen recording. The customer ignores the email and uninstalls the app.

Welcome to the most demoralizing, soul-crushing ritual in modern software engineering: debugging production release regressions without knowing what the user actually did.

Why Stack Traces Fail: The Point-of-Impact Fallacy in Production Regressions​

For the past fifteen years, engineering teams have relied on stack traces as the primary source of truth for software defects. But mobile applications have evolved into complex, asynchronous, multi-threaded state machines.

When an issue occurs in production, a stack trace only tells you where the process failed (the point of impact). It tells you virtually nothing about the sequence of events that created the corrupted state (the crime scene).

Traditional Crash ReportingVisibility / Details
Point-of-Impact Stack TraceLine 342 in CheckoutFragment
"What broke?"IllegalStateException
"What was the user doing 5 seconds before?"❓ Unknown
"Which buttons were tapped?"❓ Unknown
"Did the network drop or switch?"❓ Unknown
"Was the app backgrounded and restored?"❓ Unknown

Consider the typical root causes of production release regressions that make them so notoriously hard to reproduce:

1. Asynchronous Race Conditions & Network Latency Spikes​

The user taps a button while an HTTP request is still in flight. A fast 5G connection in your office resolves the promise in 80 milliseconds. But a user on a commuter train with fluctuating 4G encounters a 1,800-millisecond latency spike. During that delay, they tap the button a second time, background the app to check an SMS verification code, and return. The response arrives when the UI fragment is already detached. The crash happens in an Android lifecycle callback, but the root cause was the unhandled button re-tap during a cellular latency spike.

2. Silent Failures, ANRs, and UI Freezes (App Hangs)​

A staggering percentage of release regressions never generate a fatal exception.

  • Android ANRs (Application Not Responding): The main thread stalls on disk I/O, lock contention, or heavy computation for more than 5 seconds.
  • iOS App Hangs: The main run loop blocks, rendering touch targets completely unresponsive.
  • Network Dead-Ends: An API responds with a 404 or 500 error that was swallowed in a generic try/catch block, leaving the loading spinner spinning indefinitely.

Because the operating system didn't terminate the process with a fatal signal, traditional crash loggers record either nothing or an unhelpful system thread dump that points to an idle OS loop.

3. The Breadcrumb Noise Problem​

Some APM tools attempt to capture "breadcrumbs," but they typically suffer from two extremes:

  • The Firehose: Millions of unindexed log lines that pollute dashboards, cost a fortune in ingestion fees, and drown engineers in useless console chatter.
  • The Desert: Generic system events like Activity Resumed without details on button clicks, touch targets, memory pressure, or offline state changes.

When a regression strikes, engineers don't need a million lines of unstructured text. They need a clean, chronological flight recorder that reconstructs the exact user footsteps prior to the failure.

What is Appxiom's Activity Trail? The Black Box Flight Recorder for Apps​

Appxiom was engineered to eliminate the "Cannot Reproduce" ticket entirely.

At the heart of Appxiom's diagnostic engine is the Activity Trail: a chronologically ordered, high-fidelity timeline of events, user interactions, lifecycle shifts, and network state changes captured immediately prior to any issue occurrence.

[!NOTE] What is an Activity Trail?
An Activity Trail is a chronologically ordered, microsecond-accurate timeline of user interactions (taps, clicks, gestures), screen and component lifecycle transitions, network connectivity changes, and custom developer markers captured immediately prior to an issue occurrence. It enables engineers to retrace exact user steps and reproduce release regressions without guesswork or screen recordings.

When an issue occurs - whether it is an uncaught exception, a memory leak, an ANR, an iOS App Hang, or an API failure - Appxiom packages the stack trace together with the preceding Activity Trail.

Developers and QA teams can open the issue report in the Appxiom dashboard and immediately inspect the exact breadcrumb trail leading up to the failure.

The Four Pillars of the Activity Trail​

Unlike ad-hoc logging solutions that require writing boilerplate logging statements across every view, the Activity Trail is built on four automated capabilities:

1. Auto-Tracked User Interactions​

Appxiom automatically captures UI touch events - clicks, taps, button presses, and gestures - without requiring developers to manually bind click listeners or insert logging hooks.

When a customer encounters a regression, you don't have to wonder whether they tapped "Submit", "Cancel", or the back arrow. The Activity Trail explicitly records:

  • App:TextButton - 'view order' tapped
  • App:ElevatedButton - 'Get Location' tapped
  • App:ElevatedButton - 'Place Order' tapped

2. Screen Navigation & Lifecycle Transitions​

Mobile state corruption almost always stems from screen transitions and lifecycle changes. Appxiom automatically records:

  • Activity and Fragment lifecycle events in Android (onCreate, onStart, onResume, onPause, onDestroy).
  • UIViewController lifecycle events in iOS (viewDidLoad, viewWillAppear, viewDidAppear).
  • Widget and component mounts in Flutter and React Native.
  • Foreground/background transitions (e.g., when the user switches apps or locks their screen).

3. Environment & Network State Changes​

Network connectivity on mobile devices is inherently unstable. Appxiom monitors device connectivity state changes and captures:

  • When the device drops from Wi-Fi to Cellular or goes completely offline.
  • When connectivity is restored.
  • Outgoing HTTP and HTTPS requests, including target endpoints, HTTP methods, latency in milliseconds, and status codes (e.g., 200 OK (86ms) or 404 Not Found (142ms)).

4. Custom Developer Markers (Ax.setActivityMarker)​

While auto-tracking captures the technical and UI baseline, developers often need to stamp domain-specific business context into the trail.

Appxiom provides a clean, single-line API that can be called anywhere in your codebase:

// Android (Kotlin)
Ax.setActivityMarker(this@CheckoutActivity, "checkout_step_coupon_applied: SUMMER26")
// iOS (Swift)
Ax.setActivityMarkerAt(self, marker: "biometric_authentication_verified")
// Flutter
Ax.setActivityMarker("cart_recalculated_with_tax: $total");

These custom markers are woven directly into the chronological stream alongside taps, screen transitions, and HTTP calls.

Activity Trail vs. Heavy Session Replay: Why Lightweight Breadcrumbs Win​

A common question engineers ask is: "Why not just use a full session replay tool like LogRocket, FullStory, or Sentry Session Replay?"

While session replay video recordings sound appealing on paper, engineering teams frequently abandon them in mobile production for several critical reasons:

Feature / ConsiderationHeavy Session Replay ToolsAppxiom Activity Trail
CPU & Frame Rate ImpactHigh (captures DOM/Canvas frames, causes UI jank)Zero (idle-frame execution, smooth 60/120fps)
Network & Battery DrainHeavy (megabytes of compressed video/wireframes)Negligible (ultra-compressed text payloads)
Data Privacy & PII RiskHigh risk of leaking passwords, cards, or user dataZero PII by design (captures events, not inputs)
Offline ResilienceBuffers fail or drop video chunks on bad networksSandboxed local cache with seamless resend
Engineering SignalRequires watching 5-minute videos to find a bugInstant 10-second scan of the exact causal chain

Activity Trail gives developers the precise technical forensics they need to fix code without the privacy nightmares, compliance headaches, or performance penalties of video replays.

Visualizing the Crime Scene: Chronological Activity Trail Walkthrough​

Below is an example of what an engineer sees in the Appxiom dashboard when inspecting an issue report. Notice how each event includes a microsecond-accurate timestamp, event category, event payload, and the device's free memory percentage at that exact moment:

APPXIOM ACTIVITY TRAIL TIMELINE

Issue: POST /api/v2/orders/process → 409 Conflict
Exception: IllegalStateException: Order already locked
Version: v4.2.0 (Build 184) | Device: Google Pixel 8 | OS: Android 15

TIMECATEGORYEVENT DETAILSFREE MEM
10:14:02.115LIFECYCLECartActivity:onResume48.2%
10:14:03.450USER ACTIONClicked "Apply Promo Code"48.0%
10:14:03.538HTTPPOST /api/v2/promos/validate → 200 OK (88ms)47.9%
10:14:04.102CUSTOMAx.setActivityMarker: promo_applied_SUMMER2647.9%
10:14:06.890USER ACTIONClicked "Proceed to Checkout"47.5%
10:14:07.012LIFECYCLECheckoutActivity:onCreate46.8%
10:14:07.410LIFECYCLECheckoutActivity:onResume46.8%
10:14:08.115NETWORKWIFI → CELLULAR_OFFLINE46.8%
10:14:08.840USER ACTIONClicked "Place Order" (Tap 1)46.5%
10:14:09.120USER ACTIONClicked "Place Order" (Tap 2)46.5%
10:14:09.650NETWORKCELLULAR_OFFLINE → 4G_ONLINE46.4%
10:14:10.200HTTPPOST /api/v2/orders/process → 200 OK (110ms)46.2%
10:14:10.315HTTPPOST /api/v2/orders/process → 409 Conflict (98ms)46.2%
10:14:10.420ISSUEIllegalStateException: Cannot process duplicate46.1%

Dissecting the Crime Scene in Under 60 Seconds​

Without the Activity Trail, all the engineer would see is a stack trace showing IllegalStateException: Cannot process duplicate order at CheckoutActivity.kt:188.

The engineer would spend days trying to understand why duplicate orders are being sent. Is the backend caching stale idempotency tokens? Is Retrofit duplicating headers?

With the Activity Trail, the answer is immediately obvious:

  1. At 10:14:08.115, the user's connection dropped to offline.
  2. The user tapped 'Place Order'. Nothing visibly happened because the request was queued.
  3. Frustrated, the user tapped 'Place Order' a second time at 10:14:09.120.
  4. Half a second later, network connectivity was restored.
  5. Both queued requests fired simultaneously. The first succeeded (200 OK), but the second was rejected by the server (409 Conflict).
  6. The client code lacked button debouncing and did not handle the 409 conflict gracefully, triggering an unhandled exception.

Time to diagnose: 45 seconds.
Fix: Add a UI debounce on the checkout button and catch 409 status responses to show an idempotent confirmation banner.

Solving Three Infuriating Release Regressions with Activity Trail​

Let's look at three notorious categories of regressions that plague engineering teams and how Activity Trail resolves them.

Case 1: State Restoration & Race Conditions After App Backgrounding​

The Bug: Users complain that after receiving a phone call or switching to their banking app to copy a one-time password (OTP), returning to your app causes a blank screen or a crash.

Why It's a Nightmare to Reproduce: On developer devices, Android and iOS have ample RAM. When you switch apps during testing, your application remains alive in memory. But in production, users with 20 background apps, low-end devices, or aggressive OEM battery optimizers experience immediate process death.

How Activity Trail Solves It: The trail clearly records:

14:22:10.100  LIFECYCLE    PaymentActivity:onPause
14:22:10.350 LIFECYCLE PaymentActivity:onStop
14:22:10.400 LIFECYCLE App went to BACKGROUND
14:22:35.800 LIFECYCLE App returned to FOREGROUND
14:22:36.100 LIFECYCLE PaymentActivity:onCreate (savedInstanceState != null)
14:22:36.210 ISSUE NullPointerException: ViewModelFactory not initialized

The developer instantly realizes: the activity restoration flow assumed the DI singleton was still warm. When the OS killed the process in the background, onCreate ran with a non-null savedInstanceState, but the singleton was re-initialized to null.

Case 2: Silent ANRs and UI Freezes on the Main Thread​

The Bug: Google Play Console flags a spike in your ANR rate, threatening your app's search visibility and store ranking. The trace provided by Google Play Vitals simply says:

"main" prio=5 tid=1 Native
#00 pc 000000000004b344 /apex/com.android.runtime/lib64/bionic/libc.so (syscall+28)
#01 pc 00000000001ae4f8 /apex/com.android.art/lib64/libart.so (art::ConditionVariable::WaitHoldingLocks)

This tells you the main thread was waiting on a lock. Which lock? Triggered by what? Google Play Vitals cannot tell you.

How Activity Trail Solves It: In Appxiom, the ANR issue report displays the chronological trail right before the freeze:

12:22:50.110  USER ACTION  Clicked - button - 'Filter Transactions'
12:22:50.215 CUSTOM Ax.setActivityMarker: filter_dataset_size_45000_items
12:22:50.220 LIFECYCLE TransactionListFragment:onPause
12:22:55.300 ISSUE ANR Detected: Main thread blocked for 5,080ms

The correlation is unmistakable: the user clicked 'Filter Transactions' with a dataset of 45,000 items. The sorting operation ran synchronously on the main thread instead of being offloaded to Dispatchers.Default or a background worker.

Case 3: Progressive Memory Leaks Disguised as Out-Of-Memory Crashes​

The Bug: You see an OutOfMemory (OOM) crash on a simple profile update screen. Developers insist: "There's no way updating an avatar consumes 512 MB of heap!"

How Activity Trail Solves It: Because Appxiom records the free memory percentage with every single lifecycle event and marker, you can trace the memory trajectory over time:

09:15:10  ProductDetailActivity:onCreate       54.2% FREE MEMORY
09:15:32 ProductDetailActivity:onDestroy 48.1% FREE MEMORY
09:16:04 ProductDetailActivity:onCreate 41.3% FREE MEMORY
09:16:28 ProductDetailActivity:onDestroy 35.0% FREE MEMORY
09:17:02 ProductDetailActivity:onCreate 28.4% FREE MEMORY
09:17:40 ProductDetailActivity:onDestroy 21.1% FREE MEMORY
09:18:12 ProfileActivity:onCreate 12.5% FREE MEMORY
09:18:15 ISSUE: java.lang.OutOfMemoryError 4.1% FREE MEMORY

The profile screen didn't cause the leak. The ProductDetailActivity was failing to unregister an image caching listener, leaking 30 MB of bitmap memory every time it was opened. The profile screen was merely the unlucky innocent bystander when memory ran out.

Instrumenting Activity Trail Across Android, iOS, and Flutter​

Instrumenting your application with Appxiom doesn't require rewriting your codebase. The SDK is engineered for effortless setup and zero friction.

Android (Kotlin & Java)​

import com.appxiom.android.Ax

class CheckoutActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_checkout)

// Custom marker that appears in the Activity Trail
Ax.setActivityMarker(this, "entered_checkout_flow")

// Debug-only marker (automatically stripped from release builds)
Ax.setActivityMarkerForDebug(this, "debug_cart_hash: ${cart.hashCode()}")
}
}

ProGuard & R8 Protection: When using aggressive code shrinking and obfuscation, class names in standard crash reports turn into unreadable alphabet soup (a.b.c). Appxiom provides the @AX annotation to maintain human-readable identifiers in your Activity Trail even in release builds:

@AX("CheckoutScreen")
class CheckoutActivity : AppCompatActivity() { ... }

iOS (Swift & SwiftUI)​

import Appxiom

class PaymentViewController: UIViewController {

override fun viewDidLoad() {
super.viewDidLoad()

// Single-line marker injection
Ax.setActivityMarkerAt(self, marker: "stripe_payment_sheet_presented")
}

@IBAction func didTapPayButton(_ sender: UIButton) {
Ax.setActivityMarkerAt(self, marker: "pay_button_pressed")
processPayment()
}
}

Flutter (Dart)​

import 'package:appxiom_flutter/appxiom_flutter.dart';

void main() async {
WidgetsFlutterBinding.ensureInitialized();

// Initialize Appxiom for Android & iOS
Ax.init("android_app_key", "android_platform_key");
Ax.initIOS();

// Wrap app runner for automatic widget and navigation tracking
AxApp.run(
child: const MyApp(),
screenName: 'MainApp',
widgetName: 'MyApp',
);
}

// Anywhere in your business logic:
Ax.setActivityMarker("user_applied_filter_category_electronics");

Production Reliability: Idle-Frame Processing, Offline Sandboxing, and Zero-PII Privacy​

Mobile engineers are rightly skeptical of third-party SDKs that monitor user interactions. If a diagnostic tool causes frame drops or drains the battery, it becomes part of the problem.

Appxiom was designed with three strict production guarantees:

1. Idle-Frame Execution​

Data collection, timestamp calculation, and event buffering run strictly during CPU idle frames. Event batching never interrupts the UI rendering pipeline, ensuring your 60fps and 120fps ProMotion animations remain buttery smooth.

2. Offline Sandbox Buffering​

Mobile users enter tunnels, board subways, and toggle airplane mode. When network connectivity drops, Appxiom securely caches breadcrumbs and activity markers in a local sandbox. Once connectivity is re-established, the data is compressed, batched, and transmitted in the background without dropping a single event.

3. Privacy-First by Design​

Appxiom tracks interaction geometry, button identifiers, and component lifecycles. It never captures sensitive user keystrokes, passwords, or PII. You get the technical context required to reproduce the defect without compromising user privacy or violating GDPR and CCPA compliance.

Beyond Isolated Fixes: Version Analytics, Quality Score, and Release Regression Prevention​

The Activity Trail is a powerful debugging tool for individual bugs, but its true superpower emerges when connected to Appxiom's full observability suite:

  1. Appxiom Quality Score (QS): Evaluates release health on a unified 0–10 scale across crashes, ANRs, app hangs, and latency. If your Quality Score drops immediately after a release, you know a regression has hit production.
  2. Goal Friction Impact (GFI): Quantifies exactly how much money and conversion volume a regression is costing your business by measuring drop-offs in critical funnels (signups, subscriptions, purchases).
  3. Version Analytics: Compares error distributions across releases to verify whether a hotfix genuinely resolved the targeted regression without introducing secondary anomalies.
  4. Activity Trail: Provides the engineering team with the exact user footsteps needed to replicate, diagnose, and fix the root cause immediately.

Instead of arguing in cross-functional meetings about whether a bug is reproducible or how severe it is, engineering, QA, and product teams have a shared, unambiguous timeline of reality.

Stop Guessing. Start Reproducing.​

Every hour your engineering team spends guessing how an edge case happened is an hour stolen from building features that grow your product.

Every ticket closed as "Cannot Reproduce" is a ticking time bomb waiting to detonate in your app store ratings.

With Appxiom's Activity Trail, you never have to ask:

  • "What did the user click before it crashed?"
  • "Can QA get a screen recording?"
  • "Why doesn't this happen on my machine?"

You open the issue, read the trail, and fix the bug.

Ready to Eliminate "Cannot Reproduce" From Your Sprints?​

Stop letting release regressions derail your team's momentum. See how Appxiom's Activity Trail, Quality Score, and Goal Friction Impact give mobile teams superpowers.

Start Your Free Appxiom Trial Today or explore our comprehensive documentation to see how easy integration really is.

Patrol for End-to-End Flutter Testing: Interacting with Native Permission Dialogs and OS Features

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

End-to-end tests often fall apart the moment your app requests a system permission. The standard integration_test package can only see your Flutter widget tree - it can’t tap “Allow” on Android’s permission controller or “Allow While Using App” on iOS. That’s exactly where Patrol shines. With Patrol, you can write true end-to-end tests that drive both Flutter widgets and native UI, so you can reliably automate permission requests, notifications prompts, backgrounding, and other OS features.

This guide is a practical, production-focused walkthrough of patrol flutter integration testing. We’ll build a minimal permission flow, drive the native dialogs on Android and iOS, run tests locally via Patrol CLI, and wire it all into GitHub Actions CI. Along the way we’ll cover real-world pitfalls, stability tactics, and cross-platform differences.

What you’ll build​

  • A minimal Flutter screen that requests location permission using permission_handler
  • A Patrol end-to-end test that: ◦ Launches the app ◦ Taps the request button in Flutter ◦ Interacts with the native permission dialog on Android and iOS ◦ Verifies the in-app result
  • Local runs using Patrol CLI on Android emulators and iOS simulators
  • CI pipeline to integrate Patrol in GitHub Actions (Android and iOS)

Prerequisites​

  • Flutter 3.22+ (stable) and Dart 3.x
  • Xcode 15+ for iOS simulator runs (macOS only)
  • Android SDK 34+, an Android emulator or device
  • Cocoapods for iOS builds (if on macOS)
  • A recent Patrol package and Patrol CLI

Tip: When upgrading Flutter/Dart, also update patrol and permission_handler to their latest versions for best compatibility.

Why Patrol?​

  • Interact with native UI: permission dialogs, settings panes, notifications, app switcher, etc.
  • Drive Flutter widgets and native elements from a single Dart test API.
  • Works on real devices, emulators, and simulators.
  • Fits into your existing integration_test workflow, but extends it to true end-to-end coverage.

Project setup​

1. Add dependencies​

pubspec.yaml (dev deps for tests + permission handler for demo):

name: patrol_permission_demo
description: Demo of Patrol end-to-end testing with native permission dialogs
publish_to: "none"

environment:
sdk: ">=3.3.0 <4.0.0"

dependencies:
flutter:
sdk: flutter
permission_handler: ^11.3.1

dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
patrol: ^3.0.0 # use latest stable

flutter:
uses-material-design: true

Then:

flutter pub get

2. iOS and Android permission configuration​

  • iOS: add a usage description to Info.plist so iOS can show the system dialog.

ios/Runner/Info.plist:

<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to show nearby content.</string>
  • Android: declare the permission in AndroidManifest.

android/app/src/main/AndroidManifest.xml (inside <manifest>):

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

Note:

  • On Android 12+ the system may also consider coarse location. Keep UX consistent in your app code if you require fine accuracy.
  • Ensure your app actually triggers a runtime permission request (done via permission_handler in our demo).

3. Install Patrol CLI​

dart pub global activate patrol_cli
patrol doctor

Make sure $HOME/.pub-cache/bin is on your PATH so the patrol command is available.

Minimal app under test​

We’ll build a tiny screen that asks for location permission and reports the result.

lib/main.dart:

import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
String _status = 'Permission not requested';

Future<void> _requestLocation() async {
final status = await Permission.locationWhenInUse.request();

setState(() {
if (status.isGranted) {
_status = 'Location granted';
} else if (status.isPermanentlyDenied) {
_status = 'Location permanently denied';
} else if (status.isDenied) {
_status = 'Location denied';
} else {
_status = 'Location status: $status';
}
});
}

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Patrol Permission Demo',
home: Scaffold(
appBar: AppBar(title: const Text('Patrol Permission Demo')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_status, key: const Key('permission_status')),
const SizedBox(height: 16),
ElevatedButton(
key: const Key('request_location'),
onPressed: _requestLocation,
child: const Text('Request location'),
),
],
),
),
),
);
}
}

Writing the Patrol end-to-end test​

We’ll write a test that:

  1. Launches the app
  2. Taps the button in Flutter
  3. Accepts the native permission dialog on each platform
  4. Verifies widget state updates to “Location granted”

Create integration_test/permission_flow_test.dart:

import 'dart:io' show Platform;

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';

import 'package:patrol_permission_demo/main.dart' as app;

void main() {
patrolTest(
'requests location permission and accepts the dialog',
($) async {
// Start the app like a user would
app.main();
await $.pumpAndSettle();

// Trigger the permission request
await $.tap(find.byKey(const Key('request_location')));

// Interact with native permission dialogs
if (Platform.isAndroid) {
// Depending on Android version, text can differ:
// - "While using the app" / "Allow" (API <= 30)
// - "While using the app" or "Allow only while using the app" (API 31+)
// We'll use a broad "Allow" contains to keep it resilient.
await $.native.tap(Selector(textContains: 'Allow'));

// Some OEMs or versions show a two-step dialog:
// If another "Allow" or "While using" appears, try tapping it again.
// This extra tap is safe if nothing is visible.
await $.native.tap(Selector(textContains: 'Allow'), optional: true);
} else if (Platform.isIOS) {
// iOS commonly displays "Allow While Using App" and "Don’t Allow".
// Use labelContains for accessibility labels and variations across versions.
await $.native.tap(Selector(labelContains: 'Allow While Using'));
// On some iOS versions or languages, it might just be "Allow"
await $.native.tap(Selector(label: 'Allow'), optional: true);
}

// Wait for the app to react and re-render
await $.pumpAndSettle();

// Validate the in-app state after permission is granted
expect(find.text('Location granted'), findsOneWidget);
},
);
}

Notes:

  • patrolTest gives you $, a PatrolTester instance that can drive both Flutter and native UI.
  • $.tap(Finder) interacts with Flutter widgets.
  • $.native.tap(Selector(...)) interacts with native UI elements.
  • We use textContains/labelContains to guard against minor UI text variations across OS versions. You can also localize these strings if your CI runs in non-English locales.
  • The optional: true parameter allows the tap to be skipped if that selector is not found, improving resilience.

Running locally with Patrol CLI​

  • Android (emulator or device connected):
# List Android devices to get an ID
adb devices

# Run the end-to-end test against a specific device
patrol test -t integration_test/permission_flow_test.dart -d emulator-5554
  • iOS (simulator; macOS only):
# List iOS simulators
xcrun simctl list devices

# Run on a specific simulator
patrol test -t integration_test/permission_flow_test.dart -d "iPhone 15"

If you have multiple Flutter flavors, build configs, or targets, Patrol supports passing build options (e.g., --flavor, --dart-define) similar to standard Flutter build/test commands. Run patrol --help for details.

Production tips for stable end-to-end testing​

  • Centralize native selectors: Create “robots” or “page objects” for permission flows so text/label changes are updated in one place.
  • Prefer contains/matches selectors: OEM skins and OS updates tweak exact button labels. Use textContains, labelContains, or regex matches when appropriate.
  • Handle both first-run and subsequent runs: If permission was already granted, the dialog won’t appear - make your test robust by verifying the outcome rather than assuming the dialog always shows.
  • Pin emulators/simulators in CI: Use consistent device profiles and API levels to reduce flakiness caused by UI differences.
  • Test denial flows: CI often needs both “Allow” and “Don’t Allow” scenarios for analytics, error handling, and fallback UX.
  • Reset state when necessary: On CI, consider wiping the simulator/device between runs so the permission prompt always appears, or explicitly revoke the permission via platform tools.

Integrate Patrol in GitHub Actions​

Below is a minimal setup for a two-job matrix: Android on Ubuntu and iOS on macOS. It installs Flutter, activates the Patrol CLI, boots devices, and runs the same Patrol end-to-end test.

.github/workflows/patrol-e2e.yml:

name: Patrol E2E

on:
push:
branches: [ main ]
pull_request:

jobs:
android:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"

- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true

- name: Flutter pub get
run: flutter pub get

- name: Activate Patrol CLI
run: dart pub global activate patrol_cli

- name: Run Patrol tests on Android emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: google_apis
arch: x86_64
profile: pixel_6
script: |
export PATH="$PATH:$HOME/.pub-cache/bin"
flutter pub get
patrol doctor
# Optional: clean/wipe to ensure permission dialog is shown every run
adb uninstall com.example.patrol_permission_demo || true
patrol test -t integration_test/permission_flow_test.dart

ios:
runs-on: macos-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4

- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true

- name: Install CocoaPods
run: |
sudo gem install cocoapods --no-document
pod repo update

- name: Flutter pub get
run: flutter pub get

- name: Activate Patrol CLI
run: dart pub global activate patrol_cli

- name: Boot iOS Simulator and run Patrol
env:
PATH: ${{ env.PATH }}:$HOME/.pub-cache/bin
run: |
SIM="iPhone 15"
xcrun simctl boot "$SIM" || true
xcrun simctl bootstatus "$SIM" -b
patrol doctor
# Optional: uninstall to re-trigger permission prompt each run
xcrun simctl uninstall "$SIM" com.example.patrolPermissionDemo || true
patrol test -t integration_test/permission_flow_test.dart -d "$SIM"

Notes:

  • Use consistent device profiles to avoid UI discrepancies.
  • Uninstalling the app between runs ensures fresh permission prompts in CI.
  • If you localize your app or the runner’s locale is not English, adapt selectors accordingly.

This satisfies the “integrate patrol in github actions” search intent with a practical, copy-pasteable workflow.

Extending to other OS prompts​

The same pattern applies to:

  • Notifications: tap “Allow” vs “Don’t Allow” on iOS; handle Android 13+ notification permission dialog.
  • Photos/camera/microphone: buttons usually vary slightly by version; use textContains/labelContains.
  • Backgrounding and returning: after granting permission, you can continue your flow (e.g., open camera, capture media) and assert postconditions.

Keep OS and OEM variance in mind - prefer flexible selectors over exact text when possible, and add optional taps for multi-step prompts.

Common pitfalls and troubleshooting​

  • The system dialog never shows:
    • Ensure Info.plist/AndroidManifest entries are correct.
    • Make sure your code calls request() at runtime (not just checks status).
    • If permission was already granted, the OS will not prompt again; uninstall the app or revoke permission before the test.
  • Flaky or slow CI:
    • Increase timeouts and add await $.pumpAndSettle() after significant UI transitions.
    • Use stable emulator/simulator versions and pin API levels.
  • Text doesn’t match across OS versions or locales:
    • Prefer textContains/labelContains, regex matches, or separate selectors by platform/OS version.
  • Build/system errors on iOS:
    • Ensure Cocoapods is installed and pod install runs if needed.
    • Validate Xcode Command Line Tools path: sudo xcode-select -switch /Applications/Xcode.app/Contents/Developer.
  • Android permission controller differences:
    • OEMs sometimes customize wording. Keep selectors loose (contains/matches) and consider adding a second optional tap.

Architecture and maintainability tips​

  • Use the Robot pattern. Example:
class PermissionRobot {
final PatrolTester $;
PermissionRobot(this.$);

Future<void> allowLocationDialog() async {
if (Platform.isAndroid) {
await $.native.tap(Selector(textContains: 'Allow'));
await $.native.tap(Selector(textContains: 'Allow'), optional: true);
} else if (Platform.isIOS) {
await $.native.tap(Selector(labelContains: 'Allow While Using'));
await $.native.tap(Selector(label: 'Allow'), optional: true);
}
}

Future<void> denyLocationDialog() async {
if (Platform.isAndroid) {
await $.native.tap(Selector(textContains: 'Don’t allow'), optional: true);
await $.native.tap(Selector(textContains: 'Deny'), optional: true);
} else if (Platform.isIOS) {
await $.native.tap(Selector(labelContains: 'Don’t Allow'));
}
}
}
  • Keep OS logic centralized. If a new OS version changes wording, you only update one place.
  • Combine with your app’s architecture (Clean Architecture/BLoC/Riverpod) to inject fakes and run richer end-to-end scenarios with server mocks or local fixtures.

What about other test types?​

  • Unit tests: Validate pure Dart logic and view models.
  • Widget tests: Validate widget trees without the OS.
  • Integration/end-to-end tests with Patrol: Validate real device flows including native UI and system features.

Use all three tiers for defense-in-depth quality.

Beyond CI: Monitoring Real-World Permission Drop-Offs with Appxiom​

Automated tests in Patrol prove that your happy and unhappy paths work under ideal, simulated conditions. But in production, native OS permissions are one of the biggest conversion drop-off points in mobile apps.

Real users behave unpredictably: they dismiss system dialogs, hit OEM-specific battery/permission quirks (e.g., Xiaomi MIUI or Samsung OneUI), revoke permissions mid-session in system settings, or encounter silent native bridge failures that never register as fatal crashes.

To close the loop between pre-release automation and live user experience, pair Patrol with Appxiom Real User Monitoring (RUM)

Conclusion​

Patrol elevates end to end testing flutter apps from widget-only checks to real device automation. With patrol flutter integration testing you can reliably handle flutter testing native permission dialogs, notifications prompts, and OS interactions that used to be out of reach. The Patrol CLI makes local runs simple and enables a clean patrol cli tutorial android ios workflow you can lift straight into CI. In GitHub Actions, you can integrate patrol in github actions to run on Android emulators and iOS simulators with predictable results.

Key takeaways:

  • Use Patrol when your tests need to interact with native UI or OS features.
  • Keep native selectors resilient using contains/matches and optional taps.
  • Reset app state in CI to consistently trigger first-run dialogs.
  • Centralize platform differences using Robots for maintainability.

Next steps:

  • Add denial and “Ask Next Time” flows.
  • Extend tests to notifications and camera/photo permissions.
  • Parallelize device matrices (API levels, iOS versions) to increase confidence before release.

With Patrol in place, your test suite will cover the last mile - where your app meets the OS.

Bridge the Gap Between Test Automation and Production Health​

Patrol protects your releases in CI, but what happens when real users hit your native permissions in the wild? Don't let silent onboarding bottlenecks and permission friction destroy your funnel conversion.

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.

Applying Systrace for Low-Level Performance Tuning in Android Apps

Published: · 6 min read
Andrea Sunny
Marketing Associate, Appxiom

Introduction: The Unseen Cost of Poor Mobile Performance​

In mobile development, app speed and reliability aren't luxuries-they're the price of entry. Even small performance issues-UI jank, input lag, or unresponsive screens-directly translate to user churn and negative reviews. For engineers, these aren’t “just bugs”; they are signals of deeper systemic issues, often buried within OS layers and not easily uncovered with surface-level profiling.

This is where Systrace steps in. Far from a basic profiling tool, Systrace delivers deep, OS-level observability, empowering developers, QA engineers, and engineering leaders to find the real root causes of performance cliffs in Android apps. In this post, we’ll dive into how to leverage Systrace for actionable low-level performance tuning, focusing on practical debugging, observability, and reliability strategies for all skill levels.

Why Systrace: Beyond Studio Profiler and Logcat​

While tools like Android Studio Profiler and Logcat provide critical insights, their granularity often ends at the process or framework level. Issues like “mysterious” jank, dropped frames, background thread bottlenecks, or cross-process contention often stay hidden. Systrace fills these gaps by capturing a system-wide, time-stamped trace of what every thread and process (including the kernel and system services) are doing.

Common real-world issues Systrace helps uncover:

  • UI thread blocked on I/O, mistakenly assumed to be a CPU bottleneck
  • Long GC (Garbage Collection) pauses causing animation stutter
  • Synchronization deadlocks or lock contention on background workers
  • Misuse of the main thread for expensive operations (leading to ANR)
  • Resource contention between your app and system daemons

Key Systrace Features:

  • Visualizes thread states and events over time
  • Points directly to which code or system resource is the true bottleneck
  • Offers microsecond-level temporal accuracy

Using Systrace in Practice: Step-by-Step Workflow​

1. Setup and Capture a Trace​

Systrace is available via adb or the Android Studio Profiler:

adb shell 'setprop debug.atrace.tags.enableflags 0xFFFFFFFF'
adb shell atrace -z -b 4096 -t 10 gfx view wm input sched freq idle am res dalvik > trace.html
  • -t 10 captures 10 seconds.
  • The event categories (gfx, view, etc.) control which subsystems to trace.
  • Output is a self-contained HTML for Chrome’s trace viewer.

Pro tip: Always capture a few seconds before and after the incident. Many performance problems are effects, not causes.

2. Reading the Trace: Key Patterns to Spot​

Load trace.html in Chrome. Here’s what to look for:

  • Jank & Frame Drops: Look for long red blocks or gaps in Choreographer, RenderThread or MainThread bars.
  • Long CPU Burst: Examine “sched” lanes; excessive CPU time on main/UI thread can signal unoptimized code.
  • Blocking on I/O or Locks: “Uninterruptible sleep” or “mutex_wait” in thread state-a sign your UI thread is waiting for disk/network or locks.
  • GC Events: GC activity (seen as “GC” or “Dalvik” events) overlapping frame rendering often correlates with visible UI stutter.

Actionable Debugging: Practical Examples​

Let’s explore concrete scenarios and how Systrace provides answers where other tools fall short.

Case 1: UI Jank on List Scrolling​

Problem: Users report laggy scrolling when images load in a RecyclerView.

With Systrace:

  • You see MainThread blocked for ~60ms, coinciding perfectly with dequeueBuffer in RenderThread.
  • Zooming in, you spot “disk_read” in a worker thread initiated by the image loader, but a lock contention with the main thread.

Root Cause: The image loader’s result is being posted synchronously back to the UI thread, causing it to wait unnecessarily.

Solution: Refactor to fully decouple image loading and UI update, perhaps via AsyncListDiffer or separate UI handler.

Case 2: Random, Infrequent ANRs (App Not Responding)​

Problem: Sporadic ANRs in production with no clear thread in ANR reports.

With Systrace:

  • You find that several background threads are hitting heavy disk I/O at the same time the main thread tries to commit SharedPreferences synchronously.
  • The “sched” lane shows the main thread is runnable but not scheduled-starved by system load.

Root Cause: Too many concurrent background jobs are blocking system-level I/O.

Solution: Batch writes, use apply() for async SharedPreferences commits, and set sensible thread pool limits.

Building Observability Into Your App: Making Systrace Even Stronger​

Systrace supports custom trace markers. Annotate critical parts of your code to trace business logic, not just framework operations.

Example: Annotating long-running code

import android.os.Trace

fun loadData() {
Trace.beginSection("LoadData:fetchFromApi") // Custom marker
// Expensive network or DB code here
Trace.endSection()
}

These custom sections become visible in traces, making it much easier to map expensive operations to code changes, releases, and business features.

Tips for actionable observability:

  • Use markers for large DB queries, network calls, and custom rendering.
  • Combine Systrace with app-level logging to correlate user-level events and system-level performance.

Reliability: Preemptive Tuning and Guardrails​

Engineering leaders and QA teams can leverage Systrace as a proactive safeguard in release cycles:

  • Baseline creation: Regular Systrace captures from “stable” releases create a performance baseline. Compare traces after major merges to spot regressions before rollout.
  • CI Integration: Automated smoke tests can trigger Systrace captures for key user flows, alerting engineers to invisible performance regressions early.
  • Production forensics: Ship lightweight Systrace collectors (with user opt-in) to capture post-mortem traces for irreproducible bugs.

Takeaways and Next Steps​

Systrace is not just another profiling tool-it’s your OS-level microscope for Android performance. By surfacing kernel, framework, and application events side-by-side, it empowers developers and leaders to:

  • Precisely diagnose the source of jank, ANR, or mysterious slowdowns.
  • Implement observability with custom trace markers.
  • Leverage traces to proactively guard reliability across engineering teams.

Action Items:

  • Integrate Systrace captures into your regular performance debugging toolkit-not just for “crash” bugs, but for every major user flow.
  • Start annotating your code with custom markers today for business-relevant observability.
  • Encourage team-wide familiarity with reading and interpreting Systrace outputs as an engineering best practice.

Looking forward: As Android frameworks become more complex and performance expectations rise, deep system observability is not optional. Systrace enables you to build not just faster apps, but fundamentally more reliable and predictable mobile experiences.

Further Reading & Resources:

Stay curious, stay precise-happy tracing!

Memory Management Strategies to Reduce GC Pauses in Android Applications

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

Introduction​

Garbage Collection (GC) pauses are among the most common sources of frame drops, UI jank, and app unresponsiveness in Android applications. For developers, QA engineers, and engineering leaders, understanding and managing memory efficiently is not a mere academic interest-it's the bedrock of delivering smooth, reliable user experiences. In this post, we’ll explore actionable, real-world strategies for optimizing memory usage, reducing GC-induced performance hitches, improving observability, and enhancing app reliability. Whether you’re just starting with Android or leading a team through the trenches of mobile performance, this guide provides technical depth and practical solutions that you can apply immediately.


Understanding the Real-World Impact of GC Pauses​

Before diving into optimizations, let’s ground our approach in real-world challenges:

  • GC Pauses Harm Perceived Performance: Even a 100-200ms pause-barely perceptible on paper-can nullify the painstaking work behind silky-smooth animations or lightning-fast data fetches.
  • Memory Leaks = Reliability Nightmares: Unchecked leaks can eventually destabilize the entire app, leading to crashes or system-driven terminations.
  • Mobile Constraints Multiply Trouble: With limited RAM, slower CPUs, and frequent app switching, Android apps feel the sting of poor memory management more acutely than their desktop counterparts.

Performance Optimization: Strategies to Minimize GC Pauses​

1. Use Object Pools Carefully​

While Java/Kotlin's GC ultimately collects unused objects, object pooling can significantly reduce allocation churn, especially for short-lived, high-frequency objects (e.g., Bitmap, custom buffer types). However, misuse can lead to stale references and leaks.

object ByteArrayPool {
private val pool = ArrayDeque<ByteArray>()

fun acquire(size: Int): ByteArray =
pool.find { it.size == size } ?: ByteArray(size)

fun release(array: ByteArray) {
pool.add(array)
}
}

Tip: Only pool objects with measurable allocation overhead and ensure timely release back to the pool after use.

2. Prefer Value Types and Primitives​

Reducing heap allocations for simple data can decrease GC pressure:

  • Use primitive arrays (IntArray, FloatArray) instead of boxed counterparts.
  • Favor Kotlin's data classes judiciously and be aware that excessive nesting creates more GC work.

3. Limit Use of Anonymous Inner Classes and Lambdas in Hot Paths​

Each lambda or anonymous inner class may result in an object allocation. In performance-critical sections (adapters, frequent callbacks), prefer function references or static singletons where applicable.

// Instead of:
button.setOnClickListener { /* ... */ }

// Use:
val sharedClickListener = View.OnClickListener { /* ... */ }
button.setOnClickListener(sharedClickListener)

4. Control Bitmap Memory Proactively​

  • Always recycle() unused bitmaps.
  • Prefer inBitmap option with BitmapFactory.Options to reuse existing memory.
  • Downsample images based on actual display size.
val options = BitmapFactory.Options().apply {
inSampleSize = calculateSampleSize(...)
inMutable = true
}
BitmapFactory.decodeResource(resources, R.drawable.large_image, options)

5. Defer and Batch Allocations​

Whenever feasible, allocate memory-intensive objects off the UI thread and batch allocations to avoid small, frequent heap pressure bursts.


Debugging Memory Issues: Effective Strategies​

Tools of the Trade​

  • Android Studio Profiler: Inspect heap allocations, GC events, and allocation timelines for your app in real-time.
  • LeakCanary: Automatically detects memory leaks and provides stack traces to frequently-leaked objects.
  • MAT (Memory Analyzer Tool): Analyze heap dump files (.hprof) for deep inspection and dominator tree analysis.

Practical Debugging Steps​

  1. Capture Heap Dumps at Key Interactions: Especially after navigating back from a memory-heavy screen, to ensure expected GC and deallocation.
  2. Track Lifecycle-Related Leaks: Fragments, views, and background jobs often outlive their welcome-ensure you sever references in proper lifecycle methods.
override fun onDestroyView() {
super.onDestroyView()
binding = null // Prevents view holding reference in Fragment
}
  1. Instrument and Annotate Code: Use loggers and custom tooling to record object lifecycle events, making it easier to spot trends that lead to memory bloat.

Implementing Observability for Memory Health​

Observability isn’t just for server-side systems. Reliable Android apps demand similar discipline.

Expose and Monitor Key Metrics​

  • Heap Usage: Sample heap size periodically and log spikes. Android’s Debug.getNativeHeapAllocatedSize() is useful for tracking native memory leaks.
  • GC Frequency and Duration: React to repeated GC cycles (known as GC thrashing) that often precede crashes or OOM errors.
val runtime = Runtime.getRuntime()
Log.d("Memory", "Used: ${(runtime.totalMemory() - runtime.freeMemory()) / 1024} KB")

In-App and Remote Monitoring​

  • Integrate lightweight reporting (e.g., via Firebase Performance Monitoring, custom endpoints) to capture memory health in production, not just in dev builds.
  • For QA teams: automate checks for memory peaks during exploratory testing flows.

Ensuring Application Reliability​

Reliability means your app remains stable under stress, high user load, and edge conditions.

Defensive Engineering Approaches​

  • Graceful Degradation: Catch and report OutOfMemoryError in isolated threads/components; avoid bringing down the whole app.
  • Low-Memory Handling: Leverage ComponentCallbacks2.onTrimMemory() to proactively release caches or scale down memory use when signaled by the OS.
  • Automated Stress Testing: Regularly run soak and stress tests that simulate extended usage, background/foreground rapid switching, and large input datasets.
override fun onTrimMemory(level: Int) {
if (level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE) {
// Free up in-memory caches, clear bitmap pools, etc.
imageCache.clear()
}
}

Key Takeaways and Next Steps​

GC pauses are inevitable, but their impact can be drastically mitigated with disciplined, context-aware memory management. Proactive optimization, rigorous debugging, robust observability, and defensive engineering collectively build critically reliable mobile experiences.

Next steps for your team:

  • Audit object allocation and deallocation patterns-single out hot paths.
  • Instrument your builds with observability and leak detection from early development to production.
  • Encourage “fail early, recover gracefully” approaches to memory errors.
  • Bake memory stress into your automated QA workflows.

Building responsive, crash-free Android apps isn’t just about writing correct code-it’s about owning the full lifecycle of memory within your app, from allocation to deallocation, visibility to resilience. Start small, measure often, and empower your team to deliver apps users can trust-even under pressure.


Further Reading:

Happy debugging and optimizing!

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 Use Vulkan for GPU Acceleration in Kotlin Android Apps

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

Modern Android applications are expected to deliver smooth animations, rich visuals, and real-time graphical effects. However, executing heavy graphical operations on the CPU can quickly lead to performance bottlenecks, increased battery consumption, and poor user experience. For a broader look at tools that can elevate your Android development workflow, check out our guide on 10 Android libraries you really need.

Earlier, Android developers relied on RenderScript for GPU-accelerated workloads. With RenderScript now deprecated, Vulkan has emerged as the most powerful and future-ready alternative for high-performance graphics and compute operations on Android.

In this blog, we'll explore how to utilize GPU capabilities using Vulkan in Kotlin-based Android apps to efficiently handle intensive graphical workloads and unlock next-level performance.

Avoid Android App Crashes: Kotlin Best Practices

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

You know that moment when you're rushing to book a cab, the payment is about to go through, and suddenly the app freezes? For a few seconds, you're stuck - did the payment go through or not? Do you retry? Do you close the app? That tiny moment of uncertainty is enough to frustrate most users. And more often than not, they don't come back.

That's exactly how silent damage begins in mobile apps. Not with big disasters-but with small, unexpected failures in moments that matter most. On Android, even one crash in a critical flow like login, checkout, or onboarding can quietly push users away, hurt your ratings, and impact revenue. While no app can ever be completely crash-proof, Kotlin gives you a strong safety net to reduce these risks long before users feel them.