reis-mobile 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +16 -0
  4. package/README.md +37 -19
  5. package/agents/android-architect.md +3 -3
  6. package/agents/android-performance-engineer.md +1 -1
  7. package/agents/flutter-architect.md +3 -3
  8. package/agents/flutter-performance-engineer.md +1 -1
  9. package/agents/ios-architect.md +3 -3
  10. package/agents/ios-performance-engineer.md +1 -1
  11. package/agents/kmp-architect.md +3 -3
  12. package/agents/kmp-performance-engineer.md +1 -1
  13. package/agents/lead-mobile.md +3 -0
  14. package/agents/mobile-release-engineer.md +3 -3
  15. package/agents/mobile-staff-engineer.md +1 -1
  16. package/agents/rn-architect.md +3 -3
  17. package/agents/rn-performance-engineer.md +1 -1
  18. package/core/router/intents.mjs +15 -0
  19. package/package.json +1 -1
  20. package/skills/mobile-accessibility/SKILL.md +3 -0
  21. package/skills/mobile-accessibility/references/localization.md +77 -0
  22. package/skills/mobile-observability/SKILL.md +137 -0
  23. package/skills/mobile-observability/references/android.md +105 -0
  24. package/skills/mobile-observability/references/flutter.md +86 -0
  25. package/skills/mobile-observability/references/ios.md +88 -0
  26. package/skills/mobile-observability/references/kotlin-multiplatform.md +76 -0
  27. package/skills/mobile-observability/references/react-native.md +62 -0
  28. package/skills/mobile-offline-sync/SKILL.md +132 -0
  29. package/skills/mobile-offline-sync/references/android.md +123 -0
  30. package/skills/mobile-offline-sync/references/flutter.md +103 -0
  31. package/skills/mobile-offline-sync/references/ios.md +103 -0
  32. package/skills/mobile-offline-sync/references/kotlin-multiplatform.md +114 -0
  33. package/skills/mobile-offline-sync/references/react-native.md +108 -0
  34. package/skills/mobile-performance/SKILL.md +88 -0
  35. package/skills/mobile-performance/references/android.md +96 -0
  36. package/skills/mobile-performance/references/background.md +87 -0
  37. package/skills/mobile-performance/references/flutter.md +101 -0
  38. package/skills/mobile-performance/references/ios.md +82 -0
  39. package/skills/mobile-performance/references/kotlin-multiplatform.md +62 -0
  40. package/skills/mobile-performance/references/react-native.md +85 -0
  41. package/skills/mobile-release/SKILL.md +2 -1
  42. package/skills/mobile-release/references/billing.md +92 -0
  43. package/skills/mobile-security/SKILL.md +3 -0
  44. package/skills/mobile-security/references/auth.md +92 -0
@@ -0,0 +1,108 @@
1
+ # Offline sync in React Native and Expo
2
+
3
+ The JavaScript runtime is the weakest link here: it is torn down when the app is killed, and it gets little or no background time. So the durable part of the design — the store and the outbox — must be on disk, and the drain must be able to start from nothing on the next launch. An in-memory queue in a Redux store, or a promise chain waiting for the network, does not survive the swipe-up that ends the process.
4
+
5
+ ## The store
6
+
7
+ | Option | Fits when |
8
+ |---|---|
9
+ | `expo-sqlite` or `op-sqlite` | You want SQL and full control; the usual base for a real outbox |
10
+ | `WatermelonDB` | Large local datasets with a defined sync protocol (pull/push with a `last_pulled_at` checkpoint) |
11
+ | `RxDB`, `PowerSync`, Legend-State, Firestore, Realm | The sync engine comes with the product — read its conflict rules instead of writing your own |
12
+ | `react-native-mmkv` / `AsyncStorage` | Settings and small caches. Not a store for syncable records |
13
+
14
+ TanStack Query is a cache, not a store: `persistQueryClient` plus `onlineManager` and paused mutations give you read-offline and replay-on-reconnect, which is enough for the "read cache" and simple "queued writes" levels in the skill's §2. It is not enough when the queue must survive process death with guaranteed ordering and per-entity collapsing — that needs rows in SQLite.
15
+
16
+ ## The outbox in SQLite
17
+
18
+ ```sql
19
+ CREATE TABLE IF NOT EXISTS outbox (
20
+ id TEXT PRIMARY KEY, -- client UUID = idempotency key
21
+ entity_type TEXT NOT NULL,
22
+ entity_id TEXT NOT NULL,
23
+ operation TEXT NOT NULL, -- create | update | delete
24
+ payload TEXT NOT NULL, -- JSON of changed fields only
25
+ attempts INTEGER NOT NULL DEFAULT 0,
26
+ next_attempt_at INTEGER NOT NULL,
27
+ last_error TEXT,
28
+ created_at INTEGER NOT NULL
29
+ );
30
+ CREATE INDEX IF NOT EXISTS outbox_ready ON outbox (next_attempt_at, created_at);
31
+ ```
32
+
33
+ Insert the outbox row and apply the optimistic local change in one SQLite transaction. Generate the id with a UUID implementation that works in Hermes — `crypto.randomUUID` is not guaranteed in every runtime version, so `expo-crypto`, `react-native-uuid` or `react-native-get-random-values` + `uuid` is the safe route. Do not use `Math.random()` for an idempotency key.
34
+
35
+ ## Draining
36
+
37
+ ```ts
38
+ let draining = false;
39
+
40
+ export async function drain(): Promise<void> {
41
+ if (draining) return; // NetInfo fires in bursts on reconnect
42
+ draining = true;
43
+ try {
44
+ for (const entry of await dueEntriesInOrder()) {
45
+ try {
46
+ await api.send(entry, { idempotencyKey: entry.id });
47
+ await db.withTransactionAsync(async () => {
48
+ await deleteEntry(entry.id);
49
+ await markSynced(entry.entity_id);
50
+ });
51
+ } catch (error) {
52
+ if (isPermanent(error)) await deadLetter(entry.id, String(error));
53
+ else {
54
+ await scheduleRetry(entry.id, backoff(entry.attempts + 1));
55
+ if (isOffline(error)) break;
56
+ }
57
+ }
58
+ }
59
+ } finally {
60
+ draining = false;
61
+ }
62
+ }
63
+ ```
64
+
65
+ Triggers: `AppState` returning to `'active'`, a `NetInfo` event, an explicit pull-to-refresh, and a background task where one is available. Not `setInterval` — a JS timer does not run when the app is backgrounded, and when it does run it is the polling pattern the platform penalizes.
66
+
67
+ ## Connectivity
68
+
69
+ `@react-native-community/netinfo` exposes `isConnected` (an interface is up) and `isInternetReachable` (a reachability probe, which may be `null` while unknown). Only the second approximates "the server can be reached", and neither is proof — a successful API call is. Use the event as a hint to call `drain()`.
70
+
71
+ With TanStack Query, wire NetInfo into `onlineManager` so queries and mutations pause and resume with connectivity instead of failing:
72
+
73
+ ```ts
74
+ onlineManager.setEventListener((setOnline) =>
75
+ NetInfo.addEventListener((state) => setOnline(Boolean(state.isConnected))),
76
+ );
77
+ ```
78
+
79
+ ## Background
80
+
81
+ JavaScript gets very little background time, and the rules are the platform's, not React Native's — see [background.md](../../mobile-performance/references/background.md).
82
+
83
+ - **Expo:** `expo-task-manager` with the SDK's background task/fetch module. The module name and API have changed across SDK versions; check the docs for the pinned SDK rather than copying an older snippet.
84
+ - **Bare React Native:** Headless JS on Android (started from a native service or a data push), and a community bridge to `BGTaskScheduler` on iOS.
85
+ - Whatever runs in the background must boot its own dependencies: no navigation state, no hydrated store, no React context. Write the drain so it can be called with nothing but a database handle and an API client.
86
+ - For file uploads that must finish after the app is suspended, use a library backed by the platform's background transfer rather than `fetch`.
87
+
88
+ ## Idempotency and conflicts
89
+
90
+ - Send the entry id as `Idempotency-Key`; `fetch` and `axios` will not retry for you, but the platform layer and proxies can re-send, and the user will retry by hand.
91
+ - `axios-retry` or an interceptor is fine for *transient* in-session retries, but the durable retry schedule must live in the outbox, because the interceptor dies with the process.
92
+ - Send `If-Match` with the stored ETag on updates and treat `412` as the conflict signal, applying the per-entity strategy from the skill's §6. Send only changed fields; a full-object PUT turns every concurrent edit into a conflict.
93
+
94
+ ## Testing
95
+
96
+ - Unit-test the outbox against an in-memory or temp-file SQLite database; ordering, collapsing, dead-lettering and transaction atomicity need no device.
97
+ - Fake the API module (`jest.mock`) or use MSW to express the cases that matter: a response lost after the server committed, and a `412` conflict. Mocking `fetch` at the global level is enough for both.
98
+ - Use fake timers for backoff, and inject the clock rather than calling `Date.now()` inside the retry logic.
99
+ - End-to-end: Detox and Maestro can toggle device connectivity around a scripted flow; combine with `adb shell am kill` or Detox's `device.terminateApp()` to cover process death mid-drain.
100
+
101
+ ## Official documentation
102
+
103
+ - Expo SQLite: https://docs.expo.dev/versions/latest/sdk/sqlite/
104
+ - Expo TaskManager: https://docs.expo.dev/versions/latest/sdk/task-manager/
105
+ - NetInfo: https://github.com/react-native-netinfo/react-native-netinfo
106
+ - React Native AppState: https://reactnative.dev/docs/appstate
107
+ - TanStack Query offline support: https://tanstack.com/query/latest/docs/framework/react/guides/network-mode
108
+ - WatermelonDB sync: https://watermelondb.dev/docs/Sync/Intro
@@ -0,0 +1,88 @@
1
+ ---
2
+ name: mobile-performance
3
+ description: Diagnoses and prevents mobile performance regressions on every stack — Flutter, native Android, native iOS, React Native and Kotlin Multiplatform. Covers startup time, rendering and jank, memory, battery and background work, app size, the profiler and benchmark to use on each platform, and regression detection in CI. Use when an app is slow, janky, heavy or draining the battery, when setting performance budgets, or when a release must not regress.
4
+ intents: [performance]
5
+ stacks: ["*"]
6
+ ---
7
+
8
+ # Mobile Performance
9
+
10
+ One performance process for every mobile stack. This file holds the method and the budgets; each platform's profilers, benchmark harnesses and fixes live in its reference.
11
+
12
+ ## 1. Pick the reference
13
+
14
+ | Project | Read |
15
+ |---|---|
16
+ | Flutter | [references/flutter.md](references/flutter.md) |
17
+ | Native Android: Jetpack Compose or Views | [references/android.md](references/android.md) |
18
+ | Native iOS: SwiftUI or UIKit | [references/ios.md](references/ios.md) |
19
+ | React Native or Expo | [references/react-native.md](references/react-native.md) |
20
+ | Kotlin Multiplatform, shared code and hosts | [references/kotlin-multiplatform.md](references/kotlin-multiplatform.md) |
21
+ | Battery drain, background jobs, wake locks, scheduled sync | [references/background.md](references/background.md) |
22
+
23
+ In Flutter, React Native and KMP apps, the native reference applies to the host side of the problem: the Android or iOS process still owns startup, frame delivery and battery accounting.
24
+
25
+ ## 2. Never optimize before you measure
26
+
27
+ A performance task that starts with a code change is a guess. Produce these four things, in order, before touching anything:
28
+
29
+ 1. **A journey.** "Cold start to the feed", "scrolling the order list", "typing in the search field". Not "the app is slow".
30
+ 2. **A number and a device.** The current value, the device model, the OS version, and the build type. A debug build proves nothing: Flutter and React Native run unoptimized in debug, and Android/iOS debug builds disable compiler optimizations. Measure in profile or release.
31
+ 3. **A budget.** The value that makes the journey acceptable (see §3).
32
+ 4. **A repeatable measurement.** The exact command, trace or benchmark that produced the number, so the fix can be proven and the regression can be caught later.
33
+
34
+ If the request arrives without a measurement, say so and give the command that produces one for this stack before proposing fixes. Reporting "I optimized X" without a before and after number is not a result.
35
+
36
+ ## 3. The budgets
37
+
38
+ **Startup.** Distinguish the three: *cold* (process created), *warm* (process alive, activity/scene recreated) and *hot* (resume). Cold start is the one users judge. Measure to first frame and to the first frame the user can actually use — these differ whenever the first frame is a skeleton or spinner.
39
+
40
+ | Signal | Usable budget |
41
+ |---|---|
42
+ | Cold start to first frame | under ~1.5 s on a mid-range device; Android vitals flags 5 s as excessive for cold start |
43
+ | Cold start to interactive content | under ~2.5 s, with a meaningful placeholder before it |
44
+ | Warm start | under ~1 s |
45
+
46
+ **Rendering.** The budget is one frame period: about 16.7 ms at 60 Hz, about 8.3 ms at 120 Hz. Two numbers matter, and an average hides both:
47
+
48
+ - **p90/p99 frame time** — the slow frames are the ones the user sees.
49
+ - **Frozen frames** — anything over ~700 ms of unresponsiveness reads as a freeze; Android vitals counts frames over 700 ms as frozen, and Apple counts a main thread blocked beyond ~250 ms as a hang.
50
+
51
+ Never report "average frame time is fine". Report the percentile and the count of dropped frames over the journey.
52
+
53
+ **Memory.** Track the steady-state footprint after the journey has run several times, not the peak during one load. The failure modes are: growth that never comes back down (a leak), a peak that gets the process killed on low-memory devices, and bitmap/image decoding at a size far larger than the view. Compare the same journey repeated three times: flat is healthy, monotonically rising is a leak.
54
+
55
+ **Battery and network.** Energy is not measured in the app's UI; it is measured in wakeups, wake locks, jobs, and bytes transferred while the screen is off. Anything periodic — polling, a foreground service kept alive, location updates, an unbatched analytics flush — is the first suspect. See [references/background.md](references/background.md).
56
+
57
+ **App size.** Download size, not the size of the build directory. Size is a conversion and update-adoption problem, and on Android it is also a Play Console limit. Every stack ships a size analyzer; the reference names it.
58
+
59
+ ## 4. Find the cause, not a symptom
60
+
61
+ Rendering problems come from one of four places. Identify which before proposing a fix:
62
+
63
+ 1. **Too much work per frame on the UI thread** — layout of a deep tree, a synchronous decode, JSON parsing, a database read, a `List`/`ListView` that builds all children.
64
+ 2. **Too many updates** — a state container that emits on every keystroke, a widget/composable/component that rebuilds because its input is not stable, an animation driven from the JS or UI thread instead of the compositor.
65
+ 3. **Expensive painting** — overdraw, shadows and blurs over large areas, clipping with antialiasing and saved layers, oversized images.
66
+ 4. **Blocking the main thread from elsewhere** — an await that resolves on the main queue, a lock, a plugin/native module call that is synchronous, a file or network call that should be off-thread.
67
+
68
+ Startup problems are usually: work done eagerly in the application/AppDelegate entry point that could be deferred, dependency-injection graphs built at launch, a synchronous read of preferences or a database, third-party SDKs initialized before first frame, and — on Android — missing Baseline Profiles so hot code runs interpreted.
69
+
70
+ ## 5. Detect the regression before the user does
71
+
72
+ A performance fix that is not guarded comes back. Every stack has a benchmark harness that produces a number in CI; the reference names it. Set it up so that:
73
+
74
+ - The benchmark runs on a fixed device or emulator/simulator model, in release or profile mode, with the app warmed the same way each run.
75
+ - The result is compared against a stored baseline, and the build fails (or flags) past a threshold — an absolute budget for startup and frame time, and a percentage delta for size.
76
+ - Percentiles, not averages, are compared, and the run count is high enough that the noise band is known. Report the noise band with the threshold; a 5% threshold on a benchmark with 15% variance only produces false alarms.
77
+ - Field data backs the lab data: Android vitals and Play Console, Xcode Organizer and MetricKit, or the app's own tracing. Lab benchmarks catch a regression before release; field metrics tell you whether it mattered. See [mobile-observability](../mobile-observability/SKILL.md) for wiring these signals into production.
78
+
79
+ ## 6. Evidence and severity
80
+
81
+ Point to `file:line` for every finding, name the measurement that proves it, and give the fix in the platform's own API.
82
+
83
+ - **Critical:** the journey is unusable or the process dies — ANR or a hang over a few seconds, an out-of-memory kill, a cold start past 5 s, a leak that grows until the app is terminated.
84
+ - **High:** the user perceives it every time — visible jank on the main scroll, cold start over budget, battery drain flagged by the platform, an app size jump that breaks a store limit.
85
+ - **Medium:** measurable but intermittent — occasional dropped frames, a redundant rebuild, a cache miss that costs a network round trip.
86
+ - **Low:** headroom — work that could be deferred or batched without a current user-visible cost.
87
+
88
+ Say which findings came from reading code only. Code review can prove an unbounded list build or a main-thread file read; it cannot prove frame time, memory growth or energy use. Those need the profiler run named in the reference.
@@ -0,0 +1,96 @@
1
+ # Performance on native Android
2
+
3
+ Android gives you more first-party measurement than any other mobile platform: a lab harness (Macrobenchmark), a system trace (Perfetto), and field data from every install (Android vitals). Use them in that order — reproduce in the lab, explain with a trace, confirm in the field.
4
+
5
+ Deep guides already in this plugin: [`mobile-android/references/build-and-performance.md`](../../mobile-android/references/build-and-performance.md), which links the Android Studio profiler and R8 analyzer material from the Android team.
6
+
7
+ ## Measure
8
+
9
+ ```bash
10
+ adb shell am start -W -S com.example/.MainActivity # TotalTime / WaitTime per cold start
11
+ adb shell dumpsys gfxinfo com.example framestats # per-frame timings
12
+ adb shell dumpsys meminfo com.example # PSS breakdown
13
+ adb shell dumpsys batterystats --charged com.example # wakeups, wake locks, jobs
14
+ ```
15
+
16
+ | Question | Tool |
17
+ |---|---|
18
+ | Startup, frame time, memory, power in a repeatable benchmark | Macrobenchmark (`androidx.benchmark:benchmark-macro-junit4`) |
19
+ | Cost of one function or algorithm | Microbenchmark (`androidx.benchmark:benchmark-junit4`) |
20
+ | Where the time actually goes inside a slow frame or a slow start | System trace / Perfetto (Android Studio "Capture System Activities", or `perfetto` on device) |
21
+ | Allocation and leak hunting | Android Studio Profiler: Memory, heap dump, and LeakCanary in debug builds |
22
+ | Which composables recompose | Layout Inspector recomposition counts, and composition tracing in a system trace |
23
+ | Field jank, ANRs, wakeups, crash rate | Play Console → Android vitals; `JankStats` for your own aggregation |
24
+ | Download size | Android Studio APK Analyzer; the Play Console size report for the delivered app |
25
+
26
+ ## Startup
27
+
28
+ Cold start cost is dominated by four things:
29
+
30
+ 1. **`Application.onCreate` and content providers.** Every library that self-initializes through a `ContentProvider` runs before your first activity. Use `androidx.startup` (`App Startup`) to sequence and lazily defer initializers, and audit the merged manifest for providers you did not add yourself.
31
+ 2. **Dependency graph construction.** Hilt/Dagger graph building, a Room database opened synchronously, `SharedPreferences` read on the main thread (use `DataStore`, which is async by design).
32
+ 3. **Missing Baseline Profiles.** Without them, the hot startup and scroll paths run interpreted until JIT catches up. Generate them with `androidx.benchmark`'s `BaselineProfileRule`, ship them through `androidx.profileinstaller`, and add a Startup Profile so the DEX layout matches the startup path. This is often the single largest startup win and needs no application code change.
33
+ 4. **A first frame that is not the usable frame.** Call `reportFullyDrawn()` (or `FullyDrawnReporter` when several components must finish) so the platform, Macrobenchmark and Play vitals measure time to *usable* content, not time to a spinner.
34
+
35
+ ```kotlin
36
+ @RunWith(AndroidJUnit4::class)
37
+ class StartupBenchmark {
38
+ @get:Rule val rule = MacrobenchmarkRule()
39
+
40
+ @Test fun coldStart() = rule.measureRepeated(
41
+ packageName = "com.example",
42
+ metrics = listOf(StartupTimingMetric()),
43
+ iterations = 10,
44
+ startupMode = StartupMode.COLD,
45
+ ) {
46
+ pressHome()
47
+ startActivityAndWait()
48
+ }
49
+ }
50
+ ```
51
+
52
+ `StartupTimingMetric` reports `timeToInitialDisplayMs` and, when `reportFullyDrawn()` is called, `timeToFullDisplayMs`.
53
+
54
+ ## Rendering and recomposition
55
+
56
+ With `FrameTimingMetric` in Macrobenchmark you get `frameDurationCpuMs` percentiles and `frameOverrunMs` (how far past the deadline a frame went) over a scripted scroll. Anything with a positive P90 `frameOverrunMs` is visible jank.
57
+
58
+ Compose-specific causes, in the order they usually bite:
59
+
60
+ - **Unstable parameters.** A composable whose parameter type the compiler cannot prove stable is never skipped. `List<T>` is unstable; `kotlinx.collections.immutable`'s `ImmutableList`, or a `@Immutable`/`@Stable` annotated holder, is stable. Enable the compiler's stability report to see which composables are skippable.
61
+ - **Reading state too high.** Reading a `MutableState` in a parent recomposes the parent and everything it emits. Pass a lambda (`() -> Int`) instead of a value, or read it in the smallest child — deferred reads are the standard Compose fix.
62
+ - **A derived value recomputed on every frame.** `derivedStateOf` recomputes only when the result changes, which is what you want for `listState.firstVisibleItemIndex > 0`.
63
+ - **Lambdas recreated each composition** captured into `remember`-less callbacks, forcing children to recompose.
64
+ - **Backwards writes** — writing state during composition — which force an immediate extra pass.
65
+
66
+ For Views: a deep hierarchy, `ConstraintLayout` nested inside `ConstraintLayout`, `wrap_content` in a `RecyclerView` row forcing remeasure, `notifyDataSetChanged` where `DiffUtil`/`ListAdapter` would update in place, and overdraw (Developer options → Debug GPU overdraw).
67
+
68
+ ## Memory
69
+
70
+ - A heap dump after the journey, repeated three times, diffed. LeakCanary in debug catches the common lifecycle leaks: an `Activity`/`Fragment` captured in a static field, a listener never unregistered, an inner-class `Handler`, a coroutine launched outside a lifecycle scope.
71
+ - Bitmaps dominate mobile heaps. Load at the target size (`BitmapFactory.Options.inSampleSize`, or let Coil/Glide size the request from the view). A full-resolution camera image decoded into a thumbnail is the classic OOM.
72
+ - `onTrimMemory`/`ComponentCallbacks2` is a signal, not a fix, but caches should honour it.
73
+ - `MemoryUsageMetric` in Macrobenchmark gives a comparable number per run for CI.
74
+
75
+ ## Battery
76
+
77
+ See [background.md](background.md) for the scheduling rules. On Android specifically, Play Console → Android vitals reports **excessive wakeups** and **stuck partial wake locks** for your installed base; both are actionable without reproducing anything locally. `PowerMetric` in Macrobenchmark measures energy per benchmark on supported devices, and Battery Historian parses a `batterystats` dump into a timeline.
78
+
79
+ ## App size
80
+
81
+ - `isMinifyEnabled = true` and `isShrinkResources = true` on release, with R8 full mode, plus per-ABI/density/language splits through the App Bundle — Play generates them automatically from an AAB.
82
+ - APK Analyzer shows the DEX method counts and resource weight; compare two builds directly in it.
83
+ - Check the size delta in CI by building the release AAB and comparing the `.aab` size with the previous baseline. Set the threshold as a percentage, since ordinary feature work moves it slowly.
84
+
85
+ ## Regression detection in CI
86
+
87
+ Macrobenchmark emits JSON (`*-benchmarkData.json`) with the metric percentiles. Run it on a fixed emulator image or a device farm profile, store the JSON per build, and compare `timeToInitialDisplayMs` P50/P90 and `frameOverrunMs` P90 against the previous baseline. Microbenchmark output is more sensitive to CPU throttling; pin the device and discard the first iterations.
88
+
89
+ ## Official documentation
90
+
91
+ - App startup time: https://developer.android.com/topic/performance/vitals/launch-time
92
+ - Macrobenchmark: https://developer.android.com/topic/performance/benchmarking/macrobenchmark-overview
93
+ - Baseline Profiles: https://developer.android.com/topic/performance/baselineprofiles/overview
94
+ - Compose performance: https://developer.android.com/develop/ui/compose/performance
95
+ - Android vitals: https://developer.android.com/topic/performance/vitals
96
+ - Capture a system trace: https://developer.android.com/topic/performance/tracing
@@ -0,0 +1,87 @@
1
+ # Background execution and battery
2
+
3
+ Battery is not something an app spends; it is something the app makes the *device* spend by waking it, holding it awake, using the radio, or keeping a sensor on. Both platforms now assume that background work is a privilege they grant, not a thread you own — code written as if a background task runs whenever it wants will be deferred, throttled or killed, and the resulting bug report reads as "sync does not work", not as "battery".
4
+
5
+ ## The rule that covers both platforms
6
+
7
+ **Say what the work needs, let the system choose when.** Declare the constraints (network, charging, idle, deadline), make the work idempotent and resumable, and let the scheduler batch it with other apps' work so the device wakes once instead of five times. Every API below is a variation on that.
8
+
9
+ **Never poll on a timer.** A periodic wake to ask "is there anything new?" is the single most expensive pattern in mobile, and it is almost always replaceable by a push message that tells the app there *is* something new (see [mobile-firebase](../../mobile-firebase/SKILL.md) for FCM and APNs delivery).
10
+
11
+ ## Android
12
+
13
+ | Need | API |
14
+ |---|---|
15
+ | Deferrable work that must survive process death and reboot | `WorkManager` — `OneTimeWorkRequest` / `PeriodicWorkRequest` with `Constraints` |
16
+ | Work the user is actively waiting for, right now | `setExpedited(...)` on a `WorkRequest`, or a foreground service with the right type |
17
+ | Work tied to a user-visible ongoing task (playback, navigation, an upload the user started) | A foreground service with a declared `foregroundServiceType` and a notification |
18
+ | An exact time (an alarm clock, a calendar reminder) | `AlarmManager.setExactAndAllowWhileIdle` — requires the exact-alarm permission on recent versions, and Google Play restricts which apps may declare it |
19
+ | A reaction to a server event | FCM data message, high priority only when it is genuinely time-critical |
20
+
21
+ Rules that break naive code:
22
+
23
+ - **Doze and App Standby.** With the screen off and the device still, network access and alarms are deferred to maintenance windows; rarely used apps land in restrictive standby buckets with tighter job and alarm quotas. Periodic `WorkManager` work has a minimum interval of 15 minutes and is never exact.
24
+ - **Background start restrictions.** An app in the background generally cannot start a foreground service; the allowed cases are enumerated per platform version. Code that "starts a service when the push arrives" is the usual casualty — use expedited work instead.
25
+ - **Foreground service types are enforced.** Recent Android versions require a declared `foregroundServiceType`, require a matching permission, and apply timeouts to some types (`dataSync` and `mediaProcessing` among them). Check the behaviour-changes page for the `targetSdk` you compile against before relying on a long-running service.
26
+ - **Wake locks.** A partial wake lock never released is reported by Play Console → Android vitals as a stuck wake lock, against your installed base. `WorkManager` holds the wake lock for you; taking one manually is almost always a bug.
27
+ - **Batch the radio.** Each transmission wakes the radio and keeps it in a high-power state for seconds afterwards. Batch analytics, logs and telemetry into one flush with a deadline rather than sending per event.
28
+
29
+ Diagnose with `adb shell dumpsys batterystats --charged <package>` (Battery Historian renders it), and read the Excessive Wakeups and Stuck Partial Wake Locks metrics in Android vitals — those are field data from real installs, which no local run reproduces.
30
+
31
+ ## iOS
32
+
33
+ | Need | API |
34
+ |---|---|
35
+ | Short refresh when the system decides the app is likely to be used | `BGAppRefreshTaskRequest` via `BGTaskScheduler` — seconds of runtime |
36
+ | Longer maintenance (database compaction, ML model update, bulk upload) | `BGProcessingTaskRequest`, typically run while charging and idle |
37
+ | A download or upload that must continue after the app is suspended or terminated | `URLSession` with a **background** configuration, which the system continues out of process |
38
+ | Finish a short piece of work when the user leaves the app | `UIApplication.beginBackgroundTask` — roughly half a minute, not a general mechanism |
39
+ | React to a server event | APNs; a background (`content-available`) push wakes the app opportunistically and is rate-limited, a user-visible notification is not |
40
+
41
+ Rules that break naive code:
42
+
43
+ - Every background identifier must be listed in `Info.plist` under `BGTaskSchedulerPermittedIdentifiers`, registered before the app finishes launching, and matched by the right `UIBackgroundModes` capability. A missing registration is a launch-time exception, not a silent no-op.
44
+ - The system decides *whether and when*. There is no guaranteed schedule, and an app the user rarely opens may get no background time at all. Schedule the next request at the end of each run, and treat every run as "do as much as you can, then checkpoint".
45
+ - Always set the task's `expirationHandler` and call `setTaskCompleted(success:)`. A task that neither finishes nor expires cleanly costs the app future background time.
46
+ - Background `URLSession` delivers completion to the app delegate after relaunch: the download handler must work with no UI and no in-memory state.
47
+ - Xcode Organizer and MetricKit report battery and disk-write metrics per release from real devices; `MXAppExitMetric` shows terminations. These are the iOS equivalent of Android vitals.
48
+
49
+ ## Flutter
50
+
51
+ Flutter's own isolate model does not extend the platform's background allowances — the constraints above still apply, and the plugin you choose is a wrapper over `WorkManager` and `BGTaskScheduler`.
52
+
53
+ - Background work runs in a **separate isolate** with its own memory: no access to the UI isolate's state, no plugin registry unless the entry point registers it (`@pragma('vm:entry-point')` on the callback, and a background isolate binary messenger for plugins that need one).
54
+ - Persist everything the background run produces; the UI isolate will not see in-memory results.
55
+ - Verify on both platforms separately. A background plugin that works on Android frequently does nothing on iOS, because Android's 15-minute periodic work has no iOS equivalent.
56
+ - For downloads, prefer a plugin backed by the platform's own background transfer (`URLSession` background configuration / `DownloadManager` or `WorkManager`) over a Dart `HttpClient` call in a background isolate, which stops when the process is suspended.
57
+
58
+ ## React Native and Expo
59
+
60
+ - Expo exposes background work through `expo-task-manager` plus a background task/fetch module; the module name and API have changed across SDK versions, so check the docs for the SDK the project pins before writing code against it.
61
+ - Bare React Native uses Headless JS on Android (a JS task run without a UI, started from a native service or a push) and the `BGTaskScheduler`/background-`URLSession` bridges on iOS, usually through a community package.
62
+ - The JavaScript runtime is not guaranteed to exist in the background. Any handler must boot its own dependencies, must not assume the navigation state or a store is hydrated, and must finish quickly.
63
+ - Both platforms' limits apply unchanged: a JS background handler does not get more runtime than the native API it is wrapping.
64
+
65
+ ## Kotlin Multiplatform
66
+
67
+ Scheduling is a host responsibility: `WorkManager` in `androidMain`, `BGTaskScheduler` in the iOS host app. Put the *work itself* — the sync algorithm, the queue drain, the conflict resolution — in `commonMain` behind a suspend function, and let each host's scheduler call it. That keeps the retry and idempotency logic in one place and tested once ([mobile-offline-sync](../../mobile-offline-sync/SKILL.md) covers the queue design).
68
+
69
+ ## Reviewing background code
70
+
71
+ - [ ] Every periodic job has a justification that a push message cannot satisfy.
72
+ - [ ] Work is idempotent: running it twice, or being killed halfway, leaves correct state.
73
+ - [ ] Constraints are declared (network type, charging, battery not low) instead of checked in code after the wake.
74
+ - [ ] Retries use exponential backoff with a cap and a jitter, not a fixed short interval.
75
+ - [ ] Network calls are batched; analytics and logs are flushed with a deadline, not per event.
76
+ - [ ] Location, Bluetooth scanning and sensors are started for a bounded window and explicitly stopped, at the coarsest accuracy that works.
77
+ - [ ] The app degrades correctly when the system grants no background time at all — the user still gets correct data on next launch.
78
+
79
+ ## Official documentation
80
+
81
+ - Android background work: https://developer.android.com/develop/background-work/background-tasks
82
+ - WorkManager: https://developer.android.com/topic/libraries/architecture/workmanager
83
+ - Android foreground service types: https://developer.android.com/develop/background-work/services/fgs/service-types
84
+ - Optimize for Doze and App Standby: https://developer.android.com/training/monitoring-device-state/doze-standby
85
+ - BGTaskScheduler: https://developer.apple.com/documentation/backgroundtasks/bgtaskscheduler
86
+ - Using background tasks to update your app: https://developer.apple.com/documentation/backgroundtasks/using-background-tasks-to-update-your-app
87
+ - Downloading files in the background: https://developer.apple.com/documentation/foundation/url_loading_system/downloading_files_in_the_background
@@ -0,0 +1,101 @@
1
+ # Performance in Flutter
2
+
3
+ Flutter splits every frame across two threads: the **UI thread** runs `build`, layout and paint (Dart), and the **raster thread** turns the resulting layer tree into GPU commands. A dropped frame belongs to one of them, and the fix is different. DevTools tells you which.
4
+
5
+ Deep guides already in this plugin: [`mobile-flutter/references/performance.md`](../../mobile-flutter/references/performance.md), with [`app-size.md`](../../mobile-flutter/references/performance/app-size.md) and [`concurrency.md`](../../mobile-flutter/references/performance/concurrency.md).
6
+
7
+ ## Measure
8
+
9
+ **Always in profile mode.** Debug builds run the Dart VM in JIT with assertions and unoptimized widget code; numbers from `flutter run` in debug are meaningless.
10
+
11
+ ```bash
12
+ flutter run --profile # on a physical device, never an emulator, for frame data
13
+ flutter build apk --analyze-size # or appbundle / ipa / macos
14
+ flutter build apk --split-debug-info=build/symbols --obfuscate
15
+ ```
16
+
17
+ | Question | Tool |
18
+ |---|---|
19
+ | Which thread drops frames | DevTools → Performance → Frames chart (blue = UI, teal = raster) |
20
+ | What runs inside a slow frame | DevTools → Performance → Timeline events, then the CPU profiler for the Dart stack |
21
+ | Which widgets rebuild, and how often | DevTools → Performance → Track widget builds; `debugProfileBuildsEnabled`, `debugPrintRebuildDirtyWidgets` |
22
+ | What repaints | `debugRepaintRainbowEnabled`, and DevTools' "Highlight repaints" |
23
+ | Memory growth and leaks | DevTools → Memory: take a snapshot after the journey, repeat it, diff the retained set |
24
+ | Startup cost | `flutter run --profile --trace-startup`, which writes `start_up_info.json` with `timeToFirstFrameMicros` and `timeToFrameworkInitMicros` |
25
+ | Size | `--analyze-size` output opened in DevTools → App size |
26
+
27
+ Mark your own expensive spans so they appear on the timeline:
28
+
29
+ ```dart
30
+ import 'dart:developer' as developer;
31
+
32
+ final task = developer.TimelineTask()..start('parseOrders');
33
+ final orders = parseOrders(payload);
34
+ task.finish();
35
+ ```
36
+
37
+ ## Rebuilds: the usual causes
38
+
39
+ - **A `setState`, `notifyListeners` or stream emission high in the tree.** Move the state down to the smallest widget that needs it, or split the widget so only the changing part rebuilds.
40
+ - **`const` missing.** A `const` constructor lets the framework skip the subtree entirely. Enable `prefer_const_constructors` in `analysis_options.yaml`.
41
+ - **Rebuilding a constant child inside an animation.** Pass it through the `child:` parameter of `AnimatedBuilder`, `ValueListenableBuilder` or `StreamBuilder` so it is built once and reused.
42
+ - **A selector that returns a new object every time.** `context.watch<Cart>()` rebuilds on every cart change; `context.select((Cart c) => c.itemCount)` rebuilds only when the count changes. The same applies to `BlocSelector`, Riverpod's `select`, and any `buildWhen`/`listenWhen`.
43
+ - **Equality that never holds.** A state class without `==`/`hashCode` (or a freezed/equatable equivalent) makes every emission look new. A `List` field compares by identity: copy it or use an immutable collection.
44
+ - **Work in `build`.** `build` can be called many times per second. No parsing, no sorting, no `Future` creation, no controller construction inside it.
45
+
46
+ ## Lists
47
+
48
+ - `ListView.builder` / `GridView.builder` / `SliverList` with a builder — never a `ListView(children: [...])` over a long collection, which builds every child.
49
+ - Give `itemExtent` (or `prototypeItem`) when rows have a fixed height: it lets the framework skip measuring and makes scrolling to an offset O(1).
50
+ - Stable `ValueKey`s on items so reorders and inserts do not rebuild everything.
51
+ - Wrap an item in `RepaintBoundary` only when it repaints independently of its neighbours (an animating row in a static list). Applied indiscriminately it costs memory and a layer per row.
52
+ - `AutomaticKeepAlive` keeps offscreen items alive: useful for a video or a form, expensive for a plain row.
53
+
54
+ ## Painting
55
+
56
+ - `Opacity` over a large subtree allocates an offscreen layer. Prefer `AnimatedOpacity` on a leaf, `Color.withValues(alpha: ...)`, or `FadeTransition`.
57
+ - `ClipRRect` with antialiasing and `Clip.antiAliasWithSaveLayer` is the expensive variant; `Clip.hardEdge` is cheap. A rounded container is cheaper as a `BoxDecoration` with `borderRadius` than as a clip.
58
+ - Shadows and blurs (`BackdropFilter`, `ImageFiltered`) scale with the area covered. Constrain them.
59
+ - Decode images at the size they are displayed: `cacheWidth`/`cacheHeight` on `Image.asset`/`Image.network`, or `ResizeImage`. A 4000 px photo in a 100 px avatar costs ~64 MB of decoded pixels.
60
+ - `SizedBox` instead of `Container` when you only need space; `Container` with no decoration still builds a `LimitedBox`/`ConstrainedBox` chain.
61
+
62
+ ## Off the UI thread
63
+
64
+ Anything that takes more than a frame belongs in an isolate. `compute()` covers one-shot work (JSON parsing, image manipulation, crypto); `Isolate.run` is the modern equivalent for a single call; a long-lived worker isolate with `ReceivePort` covers repeated work. Remember that only transferable values cross the boundary, and that plugins that need the platform thread cannot be called from a background isolate unless they use a background isolate binary messenger. See [`concurrency.md`](../../mobile-flutter/references/performance/concurrency.md).
65
+
66
+ ## Startup
67
+
68
+ - Nothing blocking in `main()` before `runApp`. `WidgetsFlutterBinding.ensureInitialized()` plus a single fast await is the ceiling; move plugin initialization, remote config fetches and database opens behind the first frame or into a splash state.
69
+ - Deferred loading (`deferred as`) splits rarely used code on web; on mobile it does not remove the code from the binary, so it is not a size fix.
70
+ - Confirm which renderer the project actually uses (`flutter --version` and the release notes for the pinned channel). Impeller and Skia have different shader-compilation behaviour, and shader jank fixes that apply to one do not apply to the other.
71
+
72
+ ## Regression detection in CI
73
+
74
+ `integration_test` produces a machine-readable timeline summary:
75
+
76
+ ```dart
77
+ // integration_test/scroll_perf_test.dart
78
+ final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
79
+
80
+ testWidgets('feed scroll stays within budget', (tester) async {
81
+ app.main();
82
+ await tester.pumpAndSettle();
83
+
84
+ await binding.watchPerformance(() async {
85
+ await tester.fling(find.byType(ListView), const Offset(0, -500), 3000);
86
+ await tester.pumpAndSettle();
87
+ }, reportKey: 'feed_scroll');
88
+ });
89
+ ```
90
+
91
+ Run it with `flutter drive` (or `flutter test integration_test` with a driver that writes the report) and assert on the emitted summary: `average_frame_build_time_millis`, `worst_frame_build_time_millis`, `missed_frame_build_budget_count` and the raster equivalents. Store the numbers per build and compare against the previous baseline; fail the job when the worst frame or the missed-budget count crosses the threshold you set in §5 of the skill.
92
+
93
+ For size, run `flutter build --analyze-size` in CI, extract the total from the JSON it writes, and compare with the baseline.
94
+
95
+ ## Official documentation
96
+
97
+ - Flutter performance best practices: https://docs.flutter.dev/perf/best-practices
98
+ - Flutter performance profiling: https://docs.flutter.dev/perf/ui-performance
99
+ - DevTools performance view: https://docs.flutter.dev/tools/devtools/performance
100
+ - Measuring app size: https://docs.flutter.dev/perf/app-size
101
+ - Integration test performance profiling: https://docs.flutter.dev/cookbook/testing/integration/profiling
@@ -0,0 +1,82 @@
1
+ # Performance on native iOS
2
+
3
+ iOS performance work is mostly main-thread work. The main thread renders, handles touch and runs SwiftUI/UIKit updates; anything that blocks it becomes a hitch, a hang or a watchdog termination. Instruments explains a single run, MetricKit and Xcode Organizer explain the installed base.
4
+
5
+ Related material already in this plugin: [`mobile-ios/references/performance-release.md`](../../mobile-ios/references/performance-release.md).
6
+
7
+ ## Measure
8
+
9
+ **Release configuration, physical device.** The Simulator has a different GPU path and no thermal or memory pressure; debug builds skip optimization and enable extra checks.
10
+
11
+ | Question | Tool |
12
+ |---|---|
13
+ | Where CPU time goes | Instruments → Time Profiler, with "Record Waiting Threads" to catch blocking |
14
+ | Why a scroll drops frames | Instruments → Animation Hitches (hitch time per frame, scroll and commit phases) |
15
+ | What SwiftUI is doing and why a view updated | Instruments → SwiftUI template; `Self._printChanges()` inside `body` in debug |
16
+ | Main thread blocked | Instruments → Hangs; the Thread Performance Checker in a debug run |
17
+ | Allocations, growth and leaks | Instruments → Allocations (mark generations around the journey) and Leaks; the Memory Graph Debugger for retain cycles |
18
+ | Launch cost | Instruments → App Launch; `DYLD_PRINT_STATISTICS=1` for pre-main dynamic linking time |
19
+ | Field data from real users | MetricKit (`MXMetricManager`), and Xcode Organizer → Launch Time, Hangs, Memory, Disk Writes, Battery |
20
+ | Download size | App Store Connect → App Store → the build's app size report, per device variant |
21
+
22
+ Apple treats the main thread being blocked for more than about 250 ms as a hang; Organizer reports it as a hang rate per hour of use. A scroll hitch is measured as *hitch time ratio* — milliseconds of hitch per second of scrolling.
23
+
24
+ ## Launch
25
+
26
+ Launch splits into pre-main (dynamic linker, runtime setup) and post-main (your `AppDelegate`/`App` initialization up to the first usable frame). Apple's guidance is to finish launch in roughly 400 ms, before the system's launch animation ends.
27
+
28
+ - **Pre-main:** the number of dynamically linked frameworks dominates. Merge small dynamic frameworks into static libraries or use mergeable libraries; remove `+load` methods and heavy static initializers (`__attribute__((constructor))`, non-lazy Swift globals with side effects).
29
+ - **Post-main:** every SDK initialized synchronously in `application(_:didFinishLaunchingWithOptions:)` is launch cost. Defer analytics, remote config, crash-symbol upload and network prefetch to after the first frame. Read persisted state asynchronously; `UserDefaults` is cheap but a Core Data or SwiftData stack opened on the main queue is not.
30
+ - **Measure the right end point.** `os_signpost` a span from `didFinishLaunching` to the moment content is on screen, and query it in a UI test with `XCTOSSignpostMetric`.
31
+
32
+ ```swift
33
+ import XCTest
34
+
35
+ final class LaunchTests: XCTestCase {
36
+ func testLaunchPerformance() {
37
+ measure(metrics: [XCTApplicationLaunchMetric()]) {
38
+ XCUIApplication().launch()
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ `measure(metrics:)` records a baseline in the test bundle and fails the test when a later run regresses past the configured deviation — this is the cheapest regression gate on iOS.
45
+
46
+ ## SwiftUI updates
47
+
48
+ - **Identity drives everything.** A view whose identity changes is torn down and rebuilt; a `ForEach` over indices instead of stable `id`s rebuilds every row on any change. Use `Identifiable` with an id that survives a refresh.
49
+ - **Narrow what `body` observes.** With `@Observable`, SwiftUI tracks the exact properties read inside `body`; reading a whole model object where one property is needed widens the dependency. With `ObservableObject`, every `@Published` change invalidates every observer — split the object instead.
50
+ - `Self._printChanges()` at the top of `body` prints which dependency caused the update. It is the fastest way to answer "why did this redraw".
51
+ - **Lazy containers for long content:** `LazyVStack`/`LazyHStack` inside a `ScrollView`, or `List`. A plain `VStack` in a `ScrollView` builds every row.
52
+ - **`AnyView` and heavy type erasure** defeat structural identity and the diffing fast path; prefer `@ViewBuilder` and generics.
53
+ - **Expensive work in `body`.** `body` may run many times per frame. Format dates, sort and filter outside it, in the model.
54
+
55
+ For UIKit: cell reuse (`dequeueReusableCell`), self-sizing cells with cached heights, `UICollectionViewDiffableDataSource` over `reloadData`, image decoding off the main thread (`UIImage.prepareForDisplay`/`preparingForDisplay`), and avoiding `layoutIfNeeded` inside `layoutSubviews`.
56
+
57
+ ## Memory
58
+
59
+ - Retain cycles are the dominant leak: a closure capturing `self` strongly in a long-lived object, a delegate declared `strong`, a `Timer` or `NotificationCenter` observer never invalidated, a `Task` that outlives its owner. `[weak self]` in escaping closures and the Memory Graph Debugger's cycle detection cover most of it.
60
+ - Images: decode at display size (`UIGraphicsImageRenderer`, `prepareThumbnail(of:)`, or `ImageIO` with `kCGImageSourceThumbnailMaxPixelSize`). Full-resolution decoding into a small view is the standard memory spike.
61
+ - `MXAppExitMetric` from MetricKit tells you how often users' apps were terminated for memory pressure in the field — the only reliable signal that the footprint is too large on the devices you actually ship to.
62
+
63
+ ## Concurrency and the main thread
64
+
65
+ - `@MainActor` isolates UI state; work that is not UI should not be on it. An `actor` or a detached `Task` with an explicit priority keeps parsing and I/O off the main thread.
66
+ - A synchronous call into a `DispatchQueue.sync`, a `NSLock`, or an `await` on an actor already busy on the main thread all produce a hang. The Hangs instrument names the blocking frame.
67
+ - Background `URLSession` configurations continue transfers after suspension; ordinary sessions do not. See [background.md](background.md).
68
+
69
+ ## Regression detection in CI
70
+
71
+ - `XCTMetric` based tests (`XCTApplicationLaunchMetric`, `XCTClockMetric`, `XCTMemoryMetric`, `XCTOSSignpostMetric`) with committed baselines fail the build on regression. Run them on a pinned simulator or device model; baselines are per-device.
72
+ - Track binary size per build from the archive and compare against the previous baseline.
73
+ - Feed MetricKit payloads into your backend so launch, hang and memory metrics have a per-release series. See [mobile-observability](../../mobile-observability/SKILL.md).
74
+
75
+ ## Official documentation
76
+
77
+ - Improving your app's performance: https://developer.apple.com/documentation/xcode/improving-your-app-s-performance
78
+ - Reducing your app's launch time: https://developer.apple.com/documentation/xcode/reducing-your-app-s-launch-time
79
+ - Understanding hangs in your app: https://developer.apple.com/documentation/xcode/understanding-hangs-in-your-app
80
+ - Analyzing responsiveness issues: https://developer.apple.com/documentation/xcode/analyzing-responsiveness-issues-in-your-shipping-app
81
+ - MetricKit: https://developer.apple.com/documentation/metrickit
82
+ - Performance tests with XCTest: https://developer.apple.com/documentation/xctest/performance-tests