mobile-performance-fix
Skill15 starsChanged 22 days agoApache-2.0View on GitHub
Use when a Kotlin Multiplatform (Android + iOS) app is slow, laggy or janky, drops frames while scrolling, starts slowly, keeps a loader spinning after a tap, freezes (ANR, iOS hang, watchdog 0x8badf00d), grows in memory or is killed for it (jetsam, OutOfMemoryError), leaks objects between Kotlin and Swift, drains battery, or ships an oversized APK or framework — including Compose Multiplatform and SwiftUI screens over shared code.
In this repository34 other files it gives its agents.
- AGENTS.mdAGENTS.md
- android-to-kmp-migrationSkill
- compose-design-system-componentSkill
- compose-multiplatform-localizationSkill
- compose-multiplatform-navigationSkill
- compose-multiplatform-screenSkill
- figma-to-mobile-uiSkill
- kmp-api-endpointSkill
- kmp-build-checkSkill
- kmp-build-failure-triageSkill
- kmp-ci-github-actionsSkill
- kmp-code-reviewSkill
- kmp-crash-triageSkill
- kmp-data-layerSkill
- kmp-dev-machine-cleanupSkill
- kmp-expect-actualSkill
- kmp-feature-moduleSkill
- kmp-flaky-test-triageSkill
- kmp-gradle-version-catalogSkill
- kmp-koin-diSkill
- kmp-module-healthSkill
- kmp-room-databaseSkill
- kmp-secure-storageSkill
- kmp-swiftui-interopSkill
- kmp-test-coverage-backfillSkill
- kmp-testing-patternsSkill
- kmp-xcframework-distributionSkill
- mobile-accessibility-test-idsSkill
- mobile-bug-fixSkill
- mobile-feature-implementSkill
- mobile-feature-planSkill
- mobile-platform-paritySkill
- mobile-releaseSkill
- mobile-security-reviewSkill
The file
We split this skill into the rules it gives an agent
6 rules. About 0 are common, 4 are distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 87
Write the baseline into the report before touching code.
- Distinctive: line 100
Treat "pending" as success: If the bound expires, return the pending record.
- Very specific: line 130
Compose (Android, and iOS with Compose Multiplatform): use the Compose performance skills linked below rather than rules from memory.
- Very specific: line 171
Follow the project's style.
- Distinctive: line 173
If the fix changes a published library, say so: consumers see it only after a new version is published or they build against the local copy.
- Distinctive: line 208
Never report a check as passed that you did not run.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Mobile performance fix — measure, fix, prove
Evidence first. Never change code on a hunch, and never report a fix without a before and an after number measured the same way. If you cannot measure it, say so instead of guessing. In apps with offline storage and background sync, the biggest wins are usually I/O and waiting on user-gesture paths, not recomposition.
When to use
- "Slow", "laggy", "janky", "stutters", "freezes", "takes forever to open", "spinner after I tap Save", "battery drain", "memory keeps growing", "the app gets killed in the background".
- ANRs, iOS hangs and watchdog terminations once
kmp-crash-triagehas identified the blocked thread. - Memory growth or leaks on either platform, including objects shared between Kotlin and Swift.
- APK, app bundle or iOS framework size.
When not to use
- The app crashes →
kmp-crash-triagefirst. - Wrong results rather than slow ones →
mobile-bug-fix. - Gradle or Xcode build speed →
kmp-build-failure-triagecovers build problems; build-time tuning is out of scope here.
Rule 0 — what invalidates a measurement
- Debug builds lie. Debuggable Android builds turn off runtime optimisations and Compose does
extra work in debug; Kotlin/Native debug binaries and Swift
-Ononecode are unoptimised. Measure a release build, or a release-like variant (for examplestagingwith minify on anddebuggable false). Debug builds are fine for reproducing a functional bug, never for numbers. - The first run lies. JIT, disk caches and (on Android) profile installation warm up over the first launches. Discard the first one or two runs.
- Instrumentation lies. An attached debugger, an in-app HTTP inspector (e.g. Chucker on Android), verbose logging, StrictMode penalties, and Xcode diagnostics (Zombies, Address Sanitizer) all add overhead per frame or per request.
- Emulators and simulators are directional only. The iOS Simulator runs on the Mac's CPU and memory; profile iOS on a device. Android emulator jank reproduces, absolute milliseconds do not.
- One run is not a measurement. Five or more runs; report the median and the spread.
Step 0 — Read the project first
- Build variants. Which variants are release-like? Is R8 enabled for them? Is there a
:benchmark/ Macrobenchmark module or a Baseline Profile? - UI stack. Compose Multiplatform on both platforms, or SwiftUI over shared ViewModels on iOS? Which state holders feed the UI, and how Swift observes them (SKIE, KMP-NativeCoroutines, a hand-written callback API).
- Data and sync. Database (Room KMP, SQLDelight), the HTTP client, image loading (Coil, Kamel, platform loaders), and whether writes go through a background sync queue.
- Startup. Android
Application.onCreate, iOSApp.init/didFinishLaunching, and DI setup (KoincreatedAtStart, eager singletons, database opening). - Instrumentation. The project's logger (Kermit, Napier or other) and any analytics events that already record latency — find out exactly what they measure.
- Style. The project's code conventions for the fix; keep existing diagnostic log lines.
Workflow
1. Fix the target
Pin down, one line each: app, screen or interaction, platform and device, build variant, and what
"slow" means as a number — frames dropped while scrolling the orders list, milliseconds from tap to
the saved state, TotalTime at cold start, MB after ten minutes. Ask if the user has not said. Do
not profile the whole app.
2. Capture evidence and a baseline
| Symptom | Android | iOS | Minimum evidence |
| Jank, scroll stutter | System trace (Android Studio profiler / Perfetto); Macrobenchmark FrameTimingMetric | Instruments: Time Profiler, Animation Hitches | The janky frames and the slice or stack that dominates them |
| Slow start | adb shell am start -W (TotalTime), Macrobenchmark StartupTimingMetric | Instruments App Launch; Organizer launch metrics; XCTApplicationLaunchMetric | ≥5 cold starts, median + spread, plus one trace |
| Slow gesture (tap → result) | Logger timestamps at tap and at state change; then a trace | Same, plus Time Profiler | Local-ack latency per run; what the time is spent waiting on |
| Freeze / ANR / hang | ANR traces (see kmp-crash-triage) | Instruments Hangs; Organizer Hangs; MetricKit MXHangDiagnostic | The blocked main-thread stack |
| Memory growth / leak | Heap dumps before/after; LeakCanary | Allocations (generations), Leaks, Xcode Memory Graph | Retained objects that grow per repetition of the flow |
| Battery / wakeups | adb shell dumpsys batterystats, dumpsys power | Organizer energy reports; MetricKit | The wake lock, alarm, job or timer responsible |
| Size | APK Analyzer; R8 reports | App Thinning Size Report; framework size per architecture | Size by component before/after |
Capture commands and tool details: references/measurement.md.
Write the baseline into the report before touching code.
3. Diagnose by layer
Work outward from the measurement, in this order.
3a. Gesture and I/O paths — check first
- Never block a user gesture on draining an entire background sync queue. A drain processes every pending item (uploads, other records) with network timeouts on each, and may wait behind a drain that is already running. The user's tap then waits on unrelated backlog. Instead: write the record locally as pending, trigger the queue without awaiting the drain, and wait — with a short bound — only for this record's acknowledgement.
- Treat "pending" as success. If the bound expires, return the pending record. The UI shows it from the local database stream and updates when the server acknowledges it. Offline is the same path.
- Do not wrap the whole drain in a timeout at the call site: cancelling it can cancel an HTTP request mid-flight. Do not post the record inline from the repository to "make it fast" when records depend on each other — ordering belongs to the queue.
- Cap location fetches on gesture paths with
withTimeoutOrNull. A platform location fix can take several seconds; location attached to a record is metadata. Make any blocking decision that needs location (for example a geofence check) explicitly, before the gesture's work starts. - Measure gesture latency as local-ack latency (tap → UI shows the saved or pending state) and record server-ack latency separately. Mixing them makes a good fix look like a regression.
- Waiting for the full queue is right only when that is the point (an explicit "Sync now", a final submit that requires everything uploaded) — and then show progress, not an unexplained spinner.
- Also: main-thread database or file I/O (the repository should use an injected I/O dispatcher),
runBlockingon the main thread, and N sequential requests on screen entry that could run concurrently withasync.
Worked example and tests: references/gesture-latency.md.
3b. Data layer
- A missing index on a filtered or sorted column;
SELECT *into wide rows for a list. - A database
Flowthat re-emits the whole list on any write to the table, feeding an expensive mapping — usedistinctUntilChanged()and keep mapping cheap or off the main thread. - Unbounded list fetches where the API supports paging.
- Large JSON parsed on the main thread; images decoded at full size — downsample to the display size before decoding.
3c. UI
- Compose (Android, and iOS with Compose Multiplatform): use the Compose performance skills
linked below rather than rules from memory. Highest-yield checks: stable
keyandcontentTypeon lazy items; state reads deferred into layout/draw lambdas for scroll- or animation-driven values; no allocation or heavy work in composition; state collected with lifecycle awareness. Some AndroidX Compose APIs are not available in Compose Multiplatform, or only in newer versions — check the CMP version ingradle/libs.versions.tomlbefore recommending one. - SwiftUI over shared state: a Kotlin state object republished on every small change can invalidate large view trees. Observe the narrowest value a view needs; see the SwiftUI performance skills linked below.
3d. Startup
- Android
Application.onCreateand iOS launch code: logging setup, database open, sync scheduling, DI graph creation. Anything not needed for the first frame moves off the launch path or behind first use. - Eager DI singletons (Koin
createdAtStart = true, modules that construct clients at registration) cost the same on iOS — the shared graph is built in Kotlin/Native. - Android: R8 on the measured variant; Baseline Profiles for startup and critical journeys.
- iOS: each dynamic framework adds load work at launch; a static Kotlin framework avoids one — measure before switching, since it changes linking for the whole app.
3e. Memory
Details, tools and code: references/memory.md. The KMP-specific causes:
- Retain cycles across Kotlin and Swift are never collected. Kotlin's GC reclaims cycles of
Kotlin objects, but a cycle that includes at least one Swift/ObjC object cannot be reclaimed. The
usual shape: a Kotlin object stores a Swift closure that captures
self, andselfholds the Kotlin object. Use[weak self]and an explicit cancel/close. - Collection started from Swift that nothing cancels keeps the collector, its closure and everything they capture alive, and keeps doing work. Return a cancellation handle, or use an integration (SKIE, KMP-NativeCoroutines) whose Swift task cancellation reaches Kotlin.
- Swift/ObjC objects released by Kotlin are freed only after a GC, so memory can look higher
than expected without being a leak; a long loop that creates interop objects may need an
autoreleasepool. - Android: the usual leaks — an
ActivityorViewheld by a ViewModel, a singleton, or a never-cancelled scope.
4. Fix
- One cause per change, at the smallest responsible layer. No drive-by refactors.
- Follow the project's style. Keep existing diagnostic log lines; never add
println/Log.d/NSLog. - If the fix changes a published library, say so: consumers see it only after a new version is published or they build against the local copy.
5. Prove it
Re-run the same measurement: same device, build variant, iterations and data.
Symptom <what the user saw>
Measured <metric> = <before> (median of n=<runs>, <device>, <variant>)
Cause <one sentence, with the trace slice, stack or code path that proves it>
Fix <file:line> — <what changed>
Result <metric> = <after> (same conditions) -> <delta>
Not fixed <anything still slow, with its number>A fix with no after-number is not done. If the change did not move the number, say so and revert it rather than keeping speculative churn. Then run the affected module's tests on both platforms.
Quick reference
| Symptom | First suspect | Evidence to get |
| Spinner after tap that grows with offline backlog | Gesture awaits a full sync-queue drain | Logger timestamps around the await; queue length |
| Spinner after tap, worse indoors | Uncapped location fetch on the gesture path | Time spent in the location call |
| Janky list scroll | Unstable or missing keys, heavy item composition, image decode size | System trace / Time Profiler over the scroll |
| Slow cold start on both platforms | Eager DI graph, database open, network on launch | Startup trace; time per init step |
| Freeze when opening a screen | Main-thread I/O or runBlocking | Main-thread stack during the freeze |
| iOS memory grows per visit to a screen | Kotlin↔Swift retain cycle or uncancelled collection | Allocations generations; Memory Graph |
| Android memory grows per visit | Leaked Activity/View/ViewModel | LeakCanary trace or heap dump diff |
| Killed in background (iOS) | High memory at suspension | JetsamEvent logs; Allocations |
Verify
Report each check as PASS, FAIL or NOT RUN (reason), and each measurement with its numbers. Never report a check as passed that you did not run.
./gradlew :composeApp:assembleRelease # or the release-like variant from Step 0
adb shell am force-stop com.example.app && adb shell am start -W -n com.example.app/.MainActivity
./gradlew :benchmark:tasks --all | grep -i connected # Macrobenchmark task name, if the module exists
xcrun xctrace record --template 'Time Profiler' --device '<device name or UDID>' \
--attach '<app process name>' --time-limit 20s --output before.trace
./gradlew :shared:allTests # behaviour unchanged on every target
./gradlew :shared:iosSimulatorArm64Test # macOS onlyName the measurement commands you actually ran in the report, with device and variant. A number from a debug build or a simulator is reported as directional, not as a result.
Common mistakes
| Mistake | Why it bites | Fix |
| Measuring a debug build | Debug costs dominate and hide the real bottleneck | Release or release-like variant |
| Optimising recomposition first | Most user-visible waits are I/O on the gesture path | Check gesture and I/O paths first (3a) |
| Awaiting the whole sync queue from a button | Latency scales with unrelated backlog and network timeouts | Trigger the queue, bounded wait on the caller's own record |
withTimeoutOrNull around the full drain | Cancels in-flight requests mid-upload | Bound only the wait for the caller's record |
| Reporting server-ack time as tap latency | A correct fix looks like no change | Record local-ack and server-ack separately |
| Profiling iOS on the Simulator | Mac CPU and memory are not a phone's | Profile on a device |
| Keeping a change that did not move the number | Adds risk and churn with no benefit | Revert it and report "no gain" |
[self] captured in a closure stored by Kotlin | Cross-runtime cycle, never collected | [weak self] plus an explicit cancel |
Related skills
kmp-crash-triage— ANRs, hangs and watchdog kills start there.mobile-bug-fix— behaviour bugs found while profiling.mobile-platform-parity— when one platform is slow and the other is not because behaviour differs.- android/skills
profilers/android-profiler— recording and analysing Android traces, heap dumps and CPU profiles. - android/skills
jetpack-compose/jetpack-compose-performance— Compose stability, lazy layouts, phase deferral. - android/skills
performance/r8-analyzerandperformance/anr-audit-and-fix. - skydoves/compose-performance-skills —
measurement/testing-compose-in-release-mode,measurement/generating-baseline-profiles,audit/auditing-compose-performance,lists/optimizing-lazy-layouts. - Dimillian/Skills
swiftui-performance-auditand CharlesWiltgen/Axiom (axiom-profile-performance,axiom-audit-memory) — the SwiftUI and Instruments side. - Baseline Profiles overview and Kotlin/Native and Swift/ObjC ARC integration.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

