Skip to main content

2 posts tagged with "Mobile App Monitoring"

View All Tags

Appxiom Now Supports Kotlin Multiplatform: One SDK for Android and iOS

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

Kotlin Multiplatform lets you write business logic once and ship it to Android and iOS. Monitoring has not kept up. Most teams end up wiring two separate tools into one shared codebase, then reading two dashboards that disagree about what a "screen" or a "checkout" even is.

Today we are fixing that. The Appxiom Kotlin Multiplatform SDK is live, and it brings crash reporting, ANR and app hang detection, memory leak detection, network monitoring and Goal Friction Impact (GFI) to KMP apps through one set of APIs you call from commonMain.

Why KMP apps were awkward to monitor​

A KMP app is one codebase running on two very different runtimes. The Android side runs on the JVM. The iOS side runs Kotlin/Native compiled into a Mach-O framework. Failures look different on each, and they surface in different places.

  • A coroutine that throws on Dispatchers.Default can cancel a whole job hierarchy without ever reaching a thread-level crash handler.
  • An iOS crash can start in Swift, Objective-C or Kotlin/Native, and you need all three covered.
  • A release build on Android is obfuscated by R8, while the same code on iOS is stripped into a dSYM. Each needs its own symbol file to make a stack trace readable.
  • Your shared ViewModel is one class, but it can leak on one platform and not the other.

Platform-specific tools handle their own half well. What a KMP team needs is one SDK that understands the shared layer and still uses native detection underneath.

What the Appxiom KMP SDK covers​

The SDK is split into three modules, so you only take what your app uses.

ModuleWhat it does
appxiom-kmp-coreCrashes, ANRs, app hangs, handled errors, memory leaks, activity trails, goals
appxiom-kmp-composeCompose Multiplatform screen tracking, navigation breadcrumbs, click tracking, composable leak detection
appxiom-kmp-ktorNetwork telemetry for Ktor Client

Crashes, ANRs and app hangs, with native detection on each side​

On Android, Appxiom attaches an unhandled exception handler and watches the main looper for ANRs, which it flags when the main thread is frozen for more than 5 seconds. On iOS, it intercepts Mach exceptions and POSIX signals such as SIGSEGV and SIGABRT across Swift, Objective-C and Kotlin/Native code, and watches the main run loop for hangs.

Each report carries the full stack trace, device details, memory state and the activity trail that led up to the failure.

For errors you catch yourself, report them without crashing the app:

try {
processPayment(order)
} catch (t: Throwable) {
Ax.reportError(
throwable = t,
fatal = false,
customInfo = "Failed during payment processing for orderId: ${order.id}"
)
}

Coroutine errors that no longer fail silently​

Attach Ax.coroutineExceptionHandler to a scope and any uncaught exception inside it reaches your dashboard with a full stack trace:

val workerScope = CoroutineScope(
SupervisorJob() + Dispatchers.Default + Ax.coroutineExceptionHandler
)

If your app does background sync, uploads or polling on a worker scope, this is the line that stops those failures from vanishing.

Memory leak detection without heap dumps​

Register an object once its lifecycle is over, and Appxiom verifies whether it is still retained. No heap dump is needed, which keeps the check light enough to run in production.

class UserProfileViewModel : ViewModel() {
override fun onCleared() {
super.onCleared()
Ax.watchLeaks(this, tag = "UserProfileViewModel")
}
}

In Compose Multiplatform, AxWatchLeakOnDispose does the same when a screen leaves composition. A confirmed leak shows up as an issue with the retained class, how long it stayed alive after disposal, and the screen and device memory context.

Compose Multiplatform: screens and taps with real names​

Wrap your root composable in AxApp, call AxCompose.TrackLifecycleAndScreens(navController) in your navigation host, and screen entries and exits are recorded as breadcrumbs automatically. Hangs, frame drops and exceptions get tied to the screen that was active.

Raw touch tracking only gives you coordinates, like Tapped at (800, 1294). That tells nobody what the user pressed. Add Modifier.axTrackClick to interactive elements and the trail reads like a story:

Button(
onClick = { onSubmit() },
modifier = Modifier.axTrackClick("SubmitOrderButton")
) {
Text("Submit Order")
}

Network monitoring on both platforms​

Install AppxiomKtorPlugin in your shared HttpClient and you get request and response durations, status codes, payload sizes and failures such as timeouts, DNS errors and dropped connections.

val apiClient = HttpClient {
install(AppxiomKtorPlugin)
}

A few settings matter for real production traffic:

  • URL grouping. Observe.setUrlPatterns collapses /users/123 and /users/456 into one /users/{id} endpoint, so one slow route does not appear as thousands.
  • Header masking. Observe.setMaskedHeaders hides values like Authorization and Cookie before anything leaves the device.
  • Domain whitelisting and timeouts. Capture only your own API domains and set a timeout threshold per endpoint.

On iOS with the Darwin engine or native URLSession, calls are intercepted natively, and the same host whitelist, path patterns, masked headers and timeouts are configured in the AX dictionary in Info.plist.

Goal Friction Impact: which bug is costing you users​

Crash counts tell you what fails often. They do not tell you what fails where it hurts. A Goal is a timed measurement of a business journey such as signup, checkout or KYC. You bracket it in shared code:

activeGoalId = Ax.beginGoal("CheckoutViewModel", "cart_checkout")

// on success
activeGoalId?.let { Ax.completeGoal("CheckoutViewModel", it) }

If a crash, ANR or API failure happens while a goal is active, Appxiom attributes it to that goal. A goal that never completes is tracked as friction, so you can rank fixes by the journey they break instead of by raw event count. Because the goal code lives in commonMain, one instrumentation covers both platforms. If you want the thinking behind this, read about Goal Friction Impact.

Activity trails that fit the logger you already use​

Beyond automatic screen and tap breadcrumbs, Ax.setActivityMarkerAt records domain events from repositories, workers and state machines. The axLog helper prints to the console and adds a breadcrumb at the same time. If your team logs through Kermit or Napier, a small LogWriter or Antilog forwards those messages into the trail, and the docs include both.

Symbolication for Android and iOS​

The docs cover both sides of readable traces. For Android, keep the R8 mapping.txt from every release build, with a Gradle task to archive it automatically. For iOS, enable Kotlin/Native light debug info and produce the dSYM for each build. Without these files, a production stack trace is a list of short tokens and addresses.

Set up in a few steps​

Requirements: Kotlin 1.9.20 or higher, Gradle 8.0 or higher, JDK 17, Android minSdk 23, and an iOS deployment target of 15.0 or higher. Compose Multiplatform 1.6.0+ and Ktor Client 2.3.0+ are needed only if you use those modules.

  1. Create your application in the Appxiom dashboard and link your Android application ID and iOS bundle identifier to get an App Key and Platform Key for each.
  2. Add the Appxiom Maven repository and the tech.appxiom.kmp Gradle plugin, then add the modules to commonMain.
  3. Initialize on Android with Ax.init(...) in Application.onCreate, and on iOS with Ax.shared.startIOS() after adding your keys to Info.plist.
  4. Call Ax.test() once to confirm a test issue reaches the dashboard, then remove it before release.

One rule to remember: keep appxiom-kmp-core as an api dependency and export it in your iOS framework, but keep appxiom-kmp-compose and appxiom-kmp-ktor as plain implementation dependencies and never export them to the iOS framework.

The full walkthrough is in the Kotlin Multiplatform documentation.

Let your coding agent do the setup, and the debugging​

KMP setup touches Gradle, two platform entry points and a plist, which makes it a good job to hand to a coding agent. The KMP skills package teaches the agent how to do it correctly:

npx @appxiom/kmp-skills add all

It installs skills for core integration, Compose, network, goals, trails, verification, symbolication and leaks into the folder your agent already uses, whether that is Claude Code, Cursor, GitHub Copilot or Antigravity. Then ask your agent to integrate Appxiom using those skills, and give it your dashboard keys.

Once your app is live, connect the Appxiom MCP server at https://app.appxiom.com/ax/app/api/mcp. Your agent can then pull top crashes and ANRs, read the activity trail and telemetry for a specific occurrence, find which bugs drive drop-offs in a goal like cart_checkout, and check your Quality Score. Add the diagnostic runbooks with npx @appxiom/mcp-skills add all. The agent proposes the fix, and you review it like any other pull request.

Get started​

The Kotlin Multiplatform SDK is available today for Android and iOS targets.

If you ship a KMP app, we would like to hear what you find. Try Appxiom and tell us what your first run turns up.

Detecting and Reducing Excessive Dart Widget Rebuilds Impacting Flutter App Performance

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

In large Flutter applications, developers often encounter frame rate drops and UI jank - observable as stuttering during scroll or sluggish widget animations. Profiling these symptoms typically exposes main-thread CPU spikes coinciding with unexpected spikes in widget build durations, even when no major state changes should trigger heavy UI updates. Analysis of Flutter DevTools timeline traces frequently points to excessive and unnecessary widget rebuilds as the underlying mechanism exacerbating rendering costs, leading to measurable frame-budget overruns (e.g., build steps exceeding 16ms on 60Hz displays).

Understanding the Flutter Rendering and Rebuild Pipeline​

Flutter’s UI system relies on a three-layer construct: widgets, elements, and render objects. The build() method of a widget is a core part of this cycle - it constructs a widget subtree that is compared for changes every time Flutter marks a widget as "dirty." A widget rebuild and a render object repaint are distinct: rebuilds traverse construction logic and can be computationally expensive, while repainting is optimized and often hardware-accelerated (e.g., GPU texture updates).

A common misconception is that calling setState() only updates visible pixels. In practice, setState() marks the current element (and often many descendants) as dirty, causing those widgets to re-execute their build() methods. Developers need to distinguish between three distinct events:

  • Rebuild: The widget’s build() method runs, potentially recreating a broad subtree.
  • Relayout: Render objects marked for geometry recalculation (e.g., after size-affecting changes).
  • Repaint: Only visual pixels update on the screen.

Monitoring the Flutter DevTools “Rebuild Stats” panel during interaction reveals how quickly the cost of unnecessary rebuilds accumulates. For example, scrolling a large list whose items are not optimized can easily trigger hundreds of redundant build calls per frame.

Root Causes: Why Unnecessary Rebuilds Occur​

Excessive widget rebuilds typically result from a combination of architectural and micro-level coding decisions:

  • State Placement: State kept too high in the widget tree (e.g., app-wide state in the root StatefulWidget) propagates builds broadly when only a small descendant actually changed.
  • Ineffective const Use: Omitting const before widget constructors causes the widget to be recreated every time, instead of being reused, even when its configuration is unchanged.
  • Widget Composition: Large, monolithic widgets contain logic for disparate UI concerns, making localized state changes trigger full subtree rebuilds.
  • Unoptimized State Management: Using setState without scoping or change notifications that lack selectivity (e.g., with a basic Provider not using selectors) causes wide rebuilds.
  • Expensive Logic in Build: Placing heavy computation (sorting, mapping, filtering) directly inside build() exacerbates rebuild cost because such operations re-run on every build, regardless of necessity.

The following Flutter code demonstrates a common pitfall:

class CounterWidget extends StatefulWidget {
@override
_CounterWidgetState createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
int counter = 0;

@override
Widget build(BuildContext context) {
print('CounterWidget rebuilt');
return Column(
children: [
Text('$counter'),
ElevatedButton(
onPressed: () => setState(() => counter++),
child: Text('Increment'),
),
ExpensiveChildWidget(), // Rebuilt every time, even if not needed
],
);
}
}

Every tap on the button rebuilds the entire subtree including ExpensiveChildWidget, regardless of whether its state actually depends on counter.

Instrumenting and Analyzing Rebuild Behavior​

Observable symptoms - such as dropped frames or reduced UI responsiveness - should prompt investigation using Flutter’s profiling tools. Engineers should look for:

  • Frame Timeline Spikes: The Flutter DevTools timeline view displays long “Build” or “Layout” sections exceeding 16ms, revealing bottlenecks.
  • Widget Rebuild Stats: The “Rebuild Stats” tool overlays counts directly on widgets as you interact with them, exposing hotspots.
  • Performance Overlay: The in-app FPS and GPU/CPU line graphs surface performance degradation and jank rates in real time.

An excerpt from a Flutter DevTools timeline trace might look like:

Frame 987:
Build: 21.3ms <-- Exceeds frame budget
Layout: 4.4ms
Paint: 2.2ms
...
Frame dropped (budget: 16.67ms)

A widget’s rebuilding can also be programmatically tracked with:

class LoggingWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
debugPrint('LoggingWidget rebuilt');
return Container(); // Replace with real UI
}
}

When embedded throughout the widget tree, these prints show exactly when and how often rebuilds are occurring, which can be correlated with user interactions and state changes.

Optimization Strategies for Reducing Rebuilds​

Reducing rebuild cost requires both architectural and tactical code-level interventions.

Granular State Placement and Splitting Widgets​

Engineering for minimal rebuild impact means moving state as close to the directly affected widget as possible. Reorganizing widget hierarchies to split large widgets into smaller, focused components is essential. Each StatefulWidget should manage only the state it needs, preventing top-level state from unnecessarily rebuilding children that do not depend on it.

Refactoring the earlier CounterWidget to isolate ExpensiveChildWidget from counter changes:

class CounterWidget extends StatefulWidget {
// as before
}

class _CounterWidgetState extends State<CounterWidget> {
int counter = 0;

@override
Widget build(BuildContext context) {
return Column(
children: [
Text('$counter'),
ElevatedButton(
onPressed: () => setState(() => counter++),
child: Text('Increment'),
),
const ExpensiveChildWidget(), // Use const, and ensure it holds its own state if needed
],
);
}
}

const Constructors and Widget Reuse​

Whenever possible, declare widgets as const, avoiding unnecessary recreation. This signals to Flutter that the subtree can be reused without further processing - critical in long lists or static UI sections.

const ListTile(
title: Text('Label'),
trailing: Icon(Icons.arrow_forward),
)

Selective Rebuilding via State Management Patterns​

Modern Flutter state management frameworks - such as Provider, Riverpod, and Bloc - offer mechanisms for more selective rebuilds. With Provider, use Selector or Consumer to scope rebuilds. With Riverpod, select granular providers or use ref.watch on fine-grained state. Bloc users should leverage individual BlocBuilder instances, each scoping to the part of the state that actually changes.

Example using Provider’s Selector, which only rebuilds when the selected value changes:

Selector<MyModel, int>(
selector: (_, model) => model.counter,
builder: (_, counter, __) => Text('$counter'),
)

Avoid Heavy Work in build()​

Computationally expensive operations, such as filtering or sorting large lists, should never be performed inside build(). These should be precomputed in event handlers, state setters, or offloaded to background isolates. Repeated expensive work in build() rapidly amplifies rebuild overhead.

Rendering Optimization and Repaint Boundaries​

For complex UIs with frequent sub-tree updates, the RepaintBoundary widget partitions the render tree, reducing unnecessary repaints. While it doesn’t prevent rebuilds, it restricts GPU updates to only the portion of the screen that actually changed. Improper or excessive use, however, can increase memory usage and reduce batching efficiency, so it must be applied judiciously - typically around widgets that animate or redraw independently of the rest of the UI.

Measuring the Effects and Ensuring Sustainable Performance​

To validate improvements, monitor frame drop rates and application jank using:

  • flutter run --profile metrics: Summarizes frame times and dropped frame counts
  • DevTools “Performance” tab: Visualizes frame budgets over time, allowing before/after comparison
  • Custom metrics: Insert Dart Timeline or custom logging to collect per-interaction build durations

Standard engineering practice in large-scale Flutter systems incorporates automated performance regression testing, with thresholds for allowed rebuild counts and frame performance, surfaced in CI pipelines.

Connecting the Dots: System-Wide Diagnosis and Resolution​

In real systems, symptoms such as UI stutters or sustained main-thread CPU spikes often point to excessive widget rebuilds as a critical performance constraint. Engineers are advised to monitor high-signal metrics - build time logs, frame budget overruns, and widget rebuild statistics - using Flutter DevTools like Appxiom, logging, and structured metrics collection. Addressing the problem comprehensively requires a combination of hierarchical state scoping, leveraging const constructors, widget splitting, and tuning state management for selective notifications, all validated via targeted performance measurement.

Conclusion​

Understanding and managing widget rebuilds is essential for large-scale Flutter UI performance. Engineers who proactively identify rebuild hotspots, apply architectural and tactical optimizations, and continuously monitor real production symptoms are best equipped to maintain responsive, scalable Flutter applications under real-world load and complexity.