reis-mobile 0.7.3 → 0.9.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +39 -0
- package/README.md +100 -26
- package/agents/android-architect.md +3 -3
- package/agents/android-performance-engineer.md +1 -1
- package/agents/flutter-architect.md +3 -3
- package/agents/flutter-performance-engineer.md +1 -1
- package/agents/ios-architect.md +3 -3
- package/agents/ios-performance-engineer.md +1 -1
- package/agents/kmp-architect.md +3 -3
- package/agents/kmp-performance-engineer.md +1 -1
- package/agents/lead-mobile.md +3 -0
- package/agents/mobile-release-engineer.md +3 -3
- package/agents/mobile-staff-engineer.md +1 -1
- package/agents/rn-architect.md +3 -3
- package/agents/rn-performance-engineer.md +1 -1
- package/bin/reis-mobile.mjs +123 -1
- package/commands/consult.md +95 -0
- package/commands/debate.md +15 -11
- package/commands/reis-mobile.md +7 -2
- package/commands/review.md +2 -0
- package/core/config/project.mjs +1 -1
- package/core/diagnostics/doctor.mjs +23 -1
- package/core/providers/catalog.mjs +168 -0
- package/core/providers/consult.mjs +220 -0
- package/core/providers/providers.mjs +156 -0
- package/core/router/intents.mjs +15 -0
- package/package.json +1 -1
- package/skills/mobile-accessibility/SKILL.md +3 -0
- package/skills/mobile-accessibility/references/localization.md +77 -0
- package/skills/mobile-observability/SKILL.md +137 -0
- package/skills/mobile-observability/references/android.md +105 -0
- package/skills/mobile-observability/references/flutter.md +86 -0
- package/skills/mobile-observability/references/ios.md +88 -0
- package/skills/mobile-observability/references/kotlin-multiplatform.md +76 -0
- package/skills/mobile-observability/references/react-native.md +62 -0
- package/skills/mobile-offline-sync/SKILL.md +132 -0
- package/skills/mobile-offline-sync/references/android.md +123 -0
- package/skills/mobile-offline-sync/references/flutter.md +103 -0
- package/skills/mobile-offline-sync/references/ios.md +103 -0
- package/skills/mobile-offline-sync/references/kotlin-multiplatform.md +114 -0
- package/skills/mobile-offline-sync/references/react-native.md +108 -0
- package/skills/mobile-performance/SKILL.md +88 -0
- package/skills/mobile-performance/references/android.md +96 -0
- package/skills/mobile-performance/references/background.md +87 -0
- package/skills/mobile-performance/references/flutter.md +101 -0
- package/skills/mobile-performance/references/ios.md +82 -0
- package/skills/mobile-performance/references/kotlin-multiplatform.md +62 -0
- package/skills/mobile-performance/references/react-native.md +85 -0
- package/skills/mobile-release/SKILL.md +2 -1
- package/skills/mobile-release/references/billing.md +92 -0
- package/skills/mobile-security/SKILL.md +3 -0
- package/skills/mobile-security/references/auth.md +92 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Offline sync on native Android
|
|
2
|
+
|
|
3
|
+
Android ships every piece this needs: Room for the store and the outbox, WorkManager for a drain that survives process death and reboot, and `ConnectivityManager` for the trigger. The mistakes are almost always in how they are combined, not in the APIs themselves.
|
|
4
|
+
|
|
5
|
+
## The store and the outbox
|
|
6
|
+
|
|
7
|
+
Room, with the sync state on the entity and a separate outbox table:
|
|
8
|
+
|
|
9
|
+
```kotlin
|
|
10
|
+
@Entity(tableName = "orders")
|
|
11
|
+
data class OrderEntity(
|
|
12
|
+
@PrimaryKey val id: String, // client-generated UUID
|
|
13
|
+
val total: Long,
|
|
14
|
+
val syncStatus: SyncStatus, // SYNCED, PENDING, FAILED
|
|
15
|
+
val serverVersion: String?, // ETag or version from the last successful fetch
|
|
16
|
+
val updatedAt: Long,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
@Entity(tableName = "outbox")
|
|
20
|
+
data class OutboxEntry(
|
|
21
|
+
@PrimaryKey val id: String, // operation UUID = idempotency key
|
|
22
|
+
val entityType: String,
|
|
23
|
+
val entityId: String,
|
|
24
|
+
val operation: String, // CREATE | UPDATE | DELETE
|
|
25
|
+
val payloadJson: String, // changed fields only
|
|
26
|
+
val attempts: Int = 0,
|
|
27
|
+
val nextAttemptAt: Long,
|
|
28
|
+
val lastError: String? = null,
|
|
29
|
+
val createdAt: Long,
|
|
30
|
+
)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Write the optimistic change and the outbox entry inside one `@Transaction` DAO method. Expose reads as `Flow<List<OrderEntity>>` so the UI updates when either the optimistic write or the sync lands.
|
|
34
|
+
|
|
35
|
+
For paged data backed by a server, Paging 3's `RemoteMediator` is the supported pattern: the `PagingSource` reads Room, the mediator fills Room from the network, and the UI never sees a network state machine.
|
|
36
|
+
|
|
37
|
+
## The drain
|
|
38
|
+
|
|
39
|
+
`WorkManager`, as **unique** work, with backoff delegated to the platform:
|
|
40
|
+
|
|
41
|
+
```kotlin
|
|
42
|
+
val request = OneTimeWorkRequestBuilder<SyncWorker>()
|
|
43
|
+
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
|
|
44
|
+
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
|
|
45
|
+
.build()
|
|
46
|
+
|
|
47
|
+
WorkManager.getInstance(context).enqueueUniqueWork(
|
|
48
|
+
"sync-outbox",
|
|
49
|
+
ExistingWorkPolicy.KEEP, // a burst of reconnect events must not queue five drains
|
|
50
|
+
request,
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Inside the worker:
|
|
55
|
+
|
|
56
|
+
- Process entries **in order per entity**; parallelize only across unrelated entities.
|
|
57
|
+
- Return `Result.retry()` for transient failures — WorkManager applies the backoff policy and re-runs after process death or reboot. Returning `Result.failure()` discards the work.
|
|
58
|
+
- Handle a permanently rejected entry by dead-lettering it in the database and **continuing**, not by failing the whole worker.
|
|
59
|
+
- Enqueue the drain with `ExistingWorkPolicy.KEEP` (or `APPEND_OR_REPLACE` when ordering across enqueues matters) so reconnect bursts do not start concurrent drains.
|
|
60
|
+
- `setExpedited` only when the user is waiting; an ordinary background drain does not qualify and the quota is limited.
|
|
61
|
+
|
|
62
|
+
Constraints and foreground-service rules are in [background.md](../../mobile-performance/references/background.md). A periodic sync has a 15-minute floor and is never exact.
|
|
63
|
+
|
|
64
|
+
## Idempotency over Retrofit/OkHttp
|
|
65
|
+
|
|
66
|
+
Put the entry's id on the request as the idempotency key, and make sure **OkHttp's own retries do not hide a failure you need to record**:
|
|
67
|
+
|
|
68
|
+
```kotlin
|
|
69
|
+
@POST("orders")
|
|
70
|
+
suspend fun createOrder(
|
|
71
|
+
@Header("Idempotency-Key") key: String,
|
|
72
|
+
@Body body: OrderPayload,
|
|
73
|
+
): Response<OrderDto>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`retryOnConnectionFailure` (on by default) retries connection-level failures transparently. That is safe *only* because the key makes the call idempotent; without server-side key handling it is a duplicate-creation mechanism. An `Interceptor` that retries on 5xx compounds the problem — keep retry scheduling in WorkManager, where it survives the process, rather than in an interceptor, where it does not.
|
|
77
|
+
|
|
78
|
+
## Conflict detection
|
|
79
|
+
|
|
80
|
+
Send the version you based the edit on and let the server reject:
|
|
81
|
+
|
|
82
|
+
```kotlin
|
|
83
|
+
@PUT("orders/{id}")
|
|
84
|
+
suspend fun updateOrder(
|
|
85
|
+
@Path("id") id: String,
|
|
86
|
+
@Header("If-Match") version: String,
|
|
87
|
+
@Header("Idempotency-Key") key: String,
|
|
88
|
+
@Body body: Map<String, Any?>, // changed fields only
|
|
89
|
+
): Response<OrderDto>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A `412 Precondition Failed` is the conflict signal; apply the strategy chosen per entity in the skill's §6. If the API has no ETag or version field, record it as a finding — conflicts are undetectable and the design is last-write-wins by accident.
|
|
93
|
+
|
|
94
|
+
## Connectivity as a trigger
|
|
95
|
+
|
|
96
|
+
```kotlin
|
|
97
|
+
connectivityManager.registerNetworkCallback(
|
|
98
|
+
NetworkRequest.Builder()
|
|
99
|
+
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
100
|
+
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) // not just "an interface"
|
|
101
|
+
.build(),
|
|
102
|
+
callback,
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`NET_CAPABILITY_VALIDATED` excludes most captive portals, which a plain `NET_CAPABILITY_INTERNET` check does not. Even so, treat it as a hint: enqueue the WorkManager request and let its `NetworkType.CONNECTED` constraint be the real gate. Also drain on `ON_START` of the process lifecycle and on explicit user refresh.
|
|
107
|
+
|
|
108
|
+
## Testing
|
|
109
|
+
|
|
110
|
+
- **Room** has an in-memory builder (`Room.inMemoryDatabaseBuilder`), so ordering, collapsing, dead-lettering and transaction atomicity are JVM unit tests.
|
|
111
|
+
- **WorkManager** ships `androidx.work:work-testing`: `WorkManagerTestInitHelper` with a `SynchronousExecutor` plus `TestDriver.setAllConstraintsMet`/`setInitialDelayMet` makes the drain deterministic, including the retry path.
|
|
112
|
+
- **The API** should be faked at the repository boundary, or served by MockWebServer when you need real HTTP semantics — MockWebServer can return a 200 the client never receives (`SocketPolicy.DISCONNECT_AFTER_REQUEST`), which is exactly the lost-response case.
|
|
113
|
+
- **Device conditions:** the emulator's cellular/network delay and speed settings, `adb shell svc wifi disable` / `svc data disable`, and `adb shell cmd connectivity airplane-mode enable` for a scripted offline window.
|
|
114
|
+
- Process death: `adb shell am kill <package>` mid-drain, then relaunch and assert exactly one server-side record.
|
|
115
|
+
|
|
116
|
+
## Official documentation
|
|
117
|
+
|
|
118
|
+
- Offline-first app architecture: https://developer.android.com/topic/architecture/data-layer/offline-first
|
|
119
|
+
- Room: https://developer.android.com/training/data-storage/room
|
|
120
|
+
- WorkManager: https://developer.android.com/topic/libraries/architecture/workmanager
|
|
121
|
+
- Testing WorkManager: https://developer.android.com/topic/libraries/architecture/workmanager/how-to/integration-testing
|
|
122
|
+
- Monitoring connectivity status: https://developer.android.com/training/monitoring-device-state/connectivity-status-type
|
|
123
|
+
- Paging with a network and database: https://developer.android.com/topic/libraries/architecture/paging/v3-network-db
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Offline sync in Flutter
|
|
2
|
+
|
|
3
|
+
Flutter has no built-in persistence, so the first decision is the store. The second is where the sync engine lives: it must outlive any widget, so it belongs in a repository/service registered in the app's DI container — never in a `State` object, and never behind a `ChangeNotifier` that a route disposes.
|
|
4
|
+
|
|
5
|
+
## The local store
|
|
6
|
+
|
|
7
|
+
| Package | Fits when | Notes |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `drift` | You want SQL, migrations and typed reactive queries | `Stream` per query out of the box; the usual default for a real offline store |
|
|
10
|
+
| `sqflite` | You want SQLite with no code generation | You write the SQL, the mapping and the change notifications yourself |
|
|
11
|
+
| `objectbox`, `isar`, `realm` | Object graphs, heavy local querying | Check the package's current maintenance status and platform support before adopting it |
|
|
12
|
+
| `hive` / `shared_preferences` | Key–value settings, a small cache | Not a store for records you query, filter or sync per field |
|
|
13
|
+
| `cloud_firestore` | The backend is Firestore | Offline persistence and conflict handling are built in; see [mobile-firebase](../../mobile-firebase/SKILL.md) |
|
|
14
|
+
|
|
15
|
+
Whatever the choice, model the sync state as columns on the record — `syncStatus`, `serverVersion`, `updatedAt`, `lastError` — and expose reads as `Stream`s so the UI reflects an applied mutation without a refresh call.
|
|
16
|
+
|
|
17
|
+
## The outbox
|
|
18
|
+
|
|
19
|
+
A table, not a list:
|
|
20
|
+
|
|
21
|
+
```dart
|
|
22
|
+
// drift
|
|
23
|
+
class Outbox extends Table {
|
|
24
|
+
TextColumn get id => text()(); // client UUID = idempotency key
|
|
25
|
+
TextColumn get entityType => text()();
|
|
26
|
+
TextColumn get entityId => text()();
|
|
27
|
+
TextColumn get operation => text()(); // create | update | delete
|
|
28
|
+
TextColumn get payload => text()(); // JSON of changed fields only
|
|
29
|
+
IntColumn get attempts => integer().withDefault(const Constant(0))();
|
|
30
|
+
DateTimeColumn get nextAttemptAt => dateTime()();
|
|
31
|
+
TextColumn get lastError => text().nullable()();
|
|
32
|
+
DateTimeColumn get createdAt => dateTime()();
|
|
33
|
+
@override Set<Column> get primaryKey => {id};
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Enqueue the outbox entry and apply the optimistic local change **in one transaction**. If they are separate writes, a crash between them leaves the UI showing an edit that will never be sent, or sends an edit the UI never showed.
|
|
38
|
+
|
|
39
|
+
## Draining
|
|
40
|
+
|
|
41
|
+
```dart
|
|
42
|
+
Future<void> drain() async {
|
|
43
|
+
if (_draining) return; // single-flight: reconnect events arrive in bursts
|
|
44
|
+
_draining = true;
|
|
45
|
+
try {
|
|
46
|
+
for (final entry in await _outbox.dueEntriesInOrder()) {
|
|
47
|
+
try {
|
|
48
|
+
await _api.send(entry, idempotencyKey: entry.id);
|
|
49
|
+
await _db.transaction(() async {
|
|
50
|
+
await _outbox.delete(entry.id);
|
|
51
|
+
await _records.markSynced(entry.entityId);
|
|
52
|
+
});
|
|
53
|
+
} on ApiException catch (e) when (e.isPermanent) {
|
|
54
|
+
await _outbox.deadLetter(entry.id, e.message); // do not block the queue
|
|
55
|
+
} on Exception catch (e) {
|
|
56
|
+
await _outbox.scheduleRetry(entry.id, backoffFor(entry.attempts + 1), e.toString());
|
|
57
|
+
if (e is SocketException) break; // offline again: stop early
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
_draining = false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Points that reviews catch: the `_draining` guard, deleting the entry only after the call returned, doing the delete and the record update in one transaction, and breaking out of the loop when the device is offline instead of failing every remaining entry and burning its attempt count.
|
|
67
|
+
|
|
68
|
+
## Triggers and connectivity
|
|
69
|
+
|
|
70
|
+
- `connectivity_plus` reports the **interface**, not reachability — Wi-Fi with a captive portal reports Wi-Fi. Use its stream as a trigger to try, and treat a successful request as the only proof. `internet_connection_checker_plus` adds an actual reachability probe if you want one, but a real API call is just as good a probe.
|
|
71
|
+
- `WidgetsBindingObserver.didChangeAppLifecycleState` → drain on `resumed`.
|
|
72
|
+
- Background drains go through the `workmanager` plugin (or an equivalent) with the platform constraints from [background.md](../../mobile-performance/references/background.md) — remember the background isolate has no access to your DI container and must build its own, and that iOS gives no guaranteed schedule.
|
|
73
|
+
- Never a `Timer.periodic`.
|
|
74
|
+
|
|
75
|
+
## Retries
|
|
76
|
+
|
|
77
|
+
Dart has no built-in backoff. Either use `package:retry` or compute it yourself, and always add jitter:
|
|
78
|
+
|
|
79
|
+
```dart
|
|
80
|
+
Duration backoffFor(int attempt) {
|
|
81
|
+
final base = Duration(seconds: 1 << attempt.clamp(0, 6)); // 1s … 64s
|
|
82
|
+
final jitter = Random().nextInt(base.inMilliseconds ~/ 2);
|
|
83
|
+
return base + Duration(milliseconds: jitter);
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Retry on `SocketException`, `TimeoutException`, and 5xx/408/429. Do not retry other 4xx — dead-letter them. If the HTTP client is Dio, `dio_smart_retry` or a custom `Interceptor` can hold this, but keep the *scheduling* in the outbox: an interceptor that retries in-process loses the retry when the app is killed.
|
|
88
|
+
|
|
89
|
+
## Testing the failure cases
|
|
90
|
+
|
|
91
|
+
- Fake the API layer, not `http`: a `FakeApi` that can return success-after-committing (case 2 in the skill's §9) and a stale-version conflict (case 4) makes those tests deterministic. Mocking at the socket level cannot express "the server committed but the response was lost".
|
|
92
|
+
- `drift` runs on an in-memory database (`NativeDatabase.memory()`), so outbox behaviour — ordering, collapsing, dead-lettering, transaction atomicity — is a fast unit test with no device.
|
|
93
|
+
- Process-death (case 1) is an integration test: enqueue, close the database, reopen it, drain.
|
|
94
|
+
- Use `fakeAsync` or an injected clock for backoff timing; never `await Future.delayed` in tests.
|
|
95
|
+
- Device-level network throttling: the iOS Network Link Conditioner and the Android emulator's network profile settings.
|
|
96
|
+
|
|
97
|
+
## Official documentation
|
|
98
|
+
|
|
99
|
+
- Flutter persistence cookbook: https://docs.flutter.dev/cookbook/persistence
|
|
100
|
+
- drift: https://drift.simonbinder.eu/
|
|
101
|
+
- connectivity_plus: https://pub.dev/packages/connectivity_plus
|
|
102
|
+
- workmanager: https://pub.dev/packages/workmanager
|
|
103
|
+
- Cloud Firestore offline data: https://firebase.google.com/docs/firestore/manage-data/enable-offline
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Offline sync on native iOS
|
|
2
|
+
|
|
3
|
+
iOS gives you two very different routes: **let the system sync for you** with Core Data + CloudKit, or **own the sync** with a local store, an outbox and `URLSession`. Choosing the first when the backend is not CloudKit, or hand-rolling the second when CloudKit would have done, is the most expensive mistake in this area.
|
|
4
|
+
|
|
5
|
+
## Choose the route first
|
|
6
|
+
|
|
7
|
+
| Route | Fits when |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `NSPersistentCloudKitContainer` (Core Data + CloudKit) | Apple-only product, per-user private data, no custom backend. Apple handles transport, retries, merge policy and multi-device |
|
|
10
|
+
| SwiftData with CloudKit | Same, on a newer API surface; check the deployment target and the feature gaps against Core Data before committing |
|
|
11
|
+
| Local store + your own outbox | There is a REST/GraphQL backend, or Android/web clients share the data. This is the rest of this document |
|
|
12
|
+
| A sync SDK (Firestore, Realm/Atlas Device Sync, PowerSync…) | The backend is that product; offline behaviour comes with it — read its conflict rules rather than inventing your own |
|
|
13
|
+
|
|
14
|
+
## The local store
|
|
15
|
+
|
|
16
|
+
- **Core Data** or **SwiftData** for object graphs with relationships and change notification. Enable `NSPersistentHistoryTrackingKey` when a background context or an extension writes, so the UI context can merge remote-ish changes correctly.
|
|
17
|
+
- **GRDB** or raw SQLite when you want SQL, explicit migrations and value types; `ValueObservation` gives reactive reads.
|
|
18
|
+
- `UserDefaults` is for settings. It is not a store for syncable records, and it is not transactional.
|
|
19
|
+
|
|
20
|
+
Keep sync state on the record: `syncStatus`, `serverVersion` (ETag), `updatedAt`, `lastError`.
|
|
21
|
+
|
|
22
|
+
## The outbox
|
|
23
|
+
|
|
24
|
+
A table/entity, written **in the same context save** as the optimistic change:
|
|
25
|
+
|
|
26
|
+
```swift
|
|
27
|
+
struct OutboxEntry: Codable, Identifiable {
|
|
28
|
+
let id: UUID // operation id = idempotency key
|
|
29
|
+
let entityType: String
|
|
30
|
+
let entityID: String
|
|
31
|
+
let operation: Operation // create / update / delete
|
|
32
|
+
let payload: Data // changed fields only
|
|
33
|
+
var attempts: Int
|
|
34
|
+
var nextAttemptAt: Date
|
|
35
|
+
var lastError: String?
|
|
36
|
+
let createdAt: Date
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
In Core Data, perform the optimistic write and the outbox insert on one background context and save once. Two saves mean a crash can leave one without the other.
|
|
41
|
+
|
|
42
|
+
Use an actor for the drain so concurrent triggers cannot interleave:
|
|
43
|
+
|
|
44
|
+
```swift
|
|
45
|
+
actor SyncEngine {
|
|
46
|
+
private var isDraining = false
|
|
47
|
+
|
|
48
|
+
func drain() async {
|
|
49
|
+
guard !isDraining else { return }
|
|
50
|
+
isDraining = true
|
|
51
|
+
defer { isDraining = false }
|
|
52
|
+
|
|
53
|
+
for entry in await outbox.dueEntriesInOrder() {
|
|
54
|
+
do {
|
|
55
|
+
try await api.send(entry, idempotencyKey: entry.id.uuidString)
|
|
56
|
+
try await store.confirm(entry) // delete entry + mark record synced, one save
|
|
57
|
+
} catch let error as APIError where error.isPermanent {
|
|
58
|
+
try? await outbox.deadLetter(entry, reason: error.message)
|
|
59
|
+
} catch {
|
|
60
|
+
try? await outbox.scheduleRetry(entry, after: backoff(entry.attempts + 1))
|
|
61
|
+
if error.isOffline { break }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## URLSession
|
|
69
|
+
|
|
70
|
+
- **`waitsForConnectivity`** on the session configuration makes a request wait for connectivity instead of failing immediately — useful for a user-initiated send, but it does not replace the outbox: the app can still be terminated while waiting.
|
|
71
|
+
- **A background configuration** (`URLSessionConfiguration.background(withIdentifier:)`) hands the transfer to the system, which continues it while the app is suspended and relaunches the app to deliver completion. Use it for uploads and downloads of files; it is not suited to a chatty JSON queue, and the completion handler must work with no UI state in memory.
|
|
72
|
+
- Set `Idempotency-Key` from the entry id on every mutating request, and `If-Match` with the stored ETag on updates. A `412` is the conflict signal.
|
|
73
|
+
- `URLSession` does not retry application-level failures. Schedule retries from the outbox, not by recursing in the completion handler.
|
|
74
|
+
|
|
75
|
+
## Triggers
|
|
76
|
+
|
|
77
|
+
- **Foreground:** `scenePhase` becoming `.active`, or `UIApplication.didBecomeActiveNotification`.
|
|
78
|
+
- **Connectivity:** `NWPathMonitor` (Network framework). `path.status == .satisfied` means a usable interface, not a reachable server — use it as a hint and let the request be the proof. Check `path.isExpensive` and `path.isConstrained` before draining a large queue on cellular or Low Data Mode.
|
|
79
|
+
- **Background:** `BGAppRefreshTaskRequest` for short opportunistic drains, `BGProcessingTaskRequest` for a large one while charging, and a background `URLSession` for transfers that must complete. There is no guaranteed schedule — see [background.md](../../mobile-performance/references/background.md). Always schedule the next request at the end of a run, set the `expirationHandler`, and call `setTaskCompleted(success:)`.
|
|
80
|
+
- **Never** a `Timer` that polls.
|
|
81
|
+
|
|
82
|
+
## Conflict resolution
|
|
83
|
+
|
|
84
|
+
With your own backend: store the ETag, send `If-Match`, apply the per-entity strategy from the skill's §6 on `412`.
|
|
85
|
+
|
|
86
|
+
With Core Data and CloudKit, the merge policy *is* the strategy, and it is not always the one you want: `NSMergeByPropertyObjectTrumpMergePolicy` keeps local values per property, `...StoreTrumpMergePolicy` keeps the persisted ones, and `NSOverwriteMergePolicy`/`NSRollbackMergePolicy` are whole-object. Choose it explicitly on each context rather than inheriting `NSErrorMergePolicy` and discovering conflicts as save failures in production.
|
|
87
|
+
|
|
88
|
+
## Testing
|
|
89
|
+
|
|
90
|
+
- Put the network behind a protocol and inject a fake in tests; `URLProtocol` subclassing works when you need the real `URLSession` path, including a response that arrives after the server committed.
|
|
91
|
+
- Core Data with an in-memory store (`NSInMemoryStoreType`, or `/dev/null` for a SQLite store) makes outbox ordering, collapsing and transaction atomicity fast unit tests. SwiftData takes an in-memory `ModelConfiguration`.
|
|
92
|
+
- Inject a clock rather than reading `Date()` in backoff and expiry logic, so timing is deterministic.
|
|
93
|
+
- Device conditions: the **Network Link Conditioner** (Settings → Developer on device, or the Additional Tools package on the Mac) for loss and latency profiles, and Xcode's simulated network conditions in the debug gauges.
|
|
94
|
+
- Background task runs can be triggered from the debugger with the documented LLDB call to `_simulateLaunchForTaskWithIdentifier:` — the only practical way to test a `BGTask` path without waiting for the system.
|
|
95
|
+
|
|
96
|
+
## Official documentation
|
|
97
|
+
|
|
98
|
+
- Core Data: https://developer.apple.com/documentation/coredata
|
|
99
|
+
- Mirroring a Core Data store with CloudKit: https://developer.apple.com/documentation/coredata/mirroring_a_core_data_store_with_cloudkit
|
|
100
|
+
- SwiftData: https://developer.apple.com/documentation/swiftdata
|
|
101
|
+
- NWPathMonitor: https://developer.apple.com/documentation/network/nwpathmonitor
|
|
102
|
+
- Downloading files in the background: https://developer.apple.com/documentation/foundation/url_loading_system/downloading_files_in_the_background
|
|
103
|
+
- Background tasks: https://developer.apple.com/documentation/backgroundtasks
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Offline sync in Kotlin Multiplatform
|
|
2
|
+
|
|
3
|
+
Offline sync is the strongest case for KMP: the store schema, the outbox, the ordering rules, the backoff, the idempotency keys and the conflict strategy are pure logic that must behave **identically** on both platforms, and a divergence between an Android and an iOS implementation of the same queue produces data bugs that are almost impossible to reproduce. Put all of it in `commonMain`, and leave only scheduling and connectivity to the targets.
|
|
4
|
+
|
|
5
|
+
## The boundary
|
|
6
|
+
|
|
7
|
+
| Layer | Source set | Why |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| Store schema, DAOs, outbox table, drain algorithm, collapsing, backoff, conflict resolution | `commonMain` | One implementation, one test suite, no divergence |
|
|
10
|
+
| HTTP client and serialization | `commonMain` (Ktor + kotlinx.serialization) | Same headers, same retry classification on both targets |
|
|
11
|
+
| Connectivity observation | `expect`/`actual` or injected interface | `ConnectivityManager` vs `NWPathMonitor` |
|
|
12
|
+
| Scheduling the drain | The host apps | `WorkManager` in `androidMain`/the Android app, `BGTaskScheduler` in the iOS app |
|
|
13
|
+
| A secure place for credentials | `expect`/`actual` | Keystore vs Keychain — see [mobile-security](../../mobile-security/SKILL.md) |
|
|
14
|
+
|
|
15
|
+
Prefer an injected interface over `expect`/`actual` for connectivity: it is easier to fake in `commonTest`, and it keeps the shared code free of platform conditionals.
|
|
16
|
+
|
|
17
|
+
## The store and the outbox
|
|
18
|
+
|
|
19
|
+
**SQLDelight** is the common choice: one `.sq` schema, generated typed queries, and `asFlow()` reactive reads on both targets.
|
|
20
|
+
|
|
21
|
+
```sql
|
|
22
|
+
-- outbox.sq
|
|
23
|
+
CREATE TABLE outbox (
|
|
24
|
+
id TEXT NOT NULL PRIMARY KEY, -- operation UUID = idempotency key
|
|
25
|
+
entityType TEXT NOT NULL,
|
|
26
|
+
entityId TEXT NOT NULL,
|
|
27
|
+
operation TEXT NOT NULL,
|
|
28
|
+
payload TEXT NOT NULL, -- changed fields only
|
|
29
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
nextAttemptAt INTEGER NOT NULL,
|
|
31
|
+
lastError TEXT,
|
|
32
|
+
createdAt INTEGER NOT NULL
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
dueInOrder:
|
|
36
|
+
SELECT * FROM outbox WHERE nextAttemptAt <= :now ORDER BY createdAt ASC;
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Drivers differ per target (`AndroidSqliteDriver`, `NativeSqliteDriver`, and an in-memory JDBC driver for JVM tests) but the queries and the transaction semantics do not. Room's KMP support is an alternative; check the version's target coverage before choosing it.
|
|
40
|
+
|
|
41
|
+
Write the optimistic change and the outbox insert in one `transaction { }`.
|
|
42
|
+
|
|
43
|
+
## The drain in commonMain
|
|
44
|
+
|
|
45
|
+
```kotlin
|
|
46
|
+
class SyncEngine(
|
|
47
|
+
private val outbox: OutboxDao,
|
|
48
|
+
private val api: SyncApi,
|
|
49
|
+
private val clock: Clock, // injected: never Clock.System inside the logic
|
|
50
|
+
private val scope: CoroutineScope,
|
|
51
|
+
) {
|
|
52
|
+
private val mutex = Mutex()
|
|
53
|
+
|
|
54
|
+
suspend fun drain() = mutex.withLock { // single-flight across reconnect bursts
|
|
55
|
+
for (entry in outbox.dueInOrder(clock.now())) {
|
|
56
|
+
when (val result = api.send(entry, idempotencyKey = entry.id)) {
|
|
57
|
+
is Sent -> outbox.confirm(entry) // delete + mark synced, one transaction
|
|
58
|
+
is Permanent -> outbox.deadLetter(entry, result.reason) // never blocks the queue
|
|
59
|
+
is Conflict -> resolve(entry, result.serverVersion)
|
|
60
|
+
is Transient -> {
|
|
61
|
+
outbox.scheduleRetry(entry, backoff(entry.attempts + 1))
|
|
62
|
+
if (result.isOffline) break
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A `Mutex` is the right primitive here: the current Kotlin/Native memory manager allows shared mutable state across threads, so the same engine instance works on both targets without freezing rules.
|
|
71
|
+
|
|
72
|
+
Do not read the clock inside the logic. Inject `Clock` so backoff and expiry are deterministic in `commonTest`, and because a device clock that is hours off must never decide ordering.
|
|
73
|
+
|
|
74
|
+
## Ktor
|
|
75
|
+
|
|
76
|
+
```kotlin
|
|
77
|
+
HttpClient {
|
|
78
|
+
install(ContentNegotiation) { json() }
|
|
79
|
+
install(HttpRequestRetry) {
|
|
80
|
+
retryOnServerErrors(maxRetries = 2)
|
|
81
|
+
exponentialDelay() // in-session only; the durable schedule is the outbox
|
|
82
|
+
}
|
|
83
|
+
install(HttpTimeout) { requestTimeoutMillis = 30_000 }
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`HttpRequestRetry` covers a blip inside one drain. It does not survive process death, so the outbox's `nextAttemptAt` remains the real retry schedule. Send `Idempotency-Key` from the entry id on every mutating request and `If-Match` with the stored version on updates; classify `412` as `Conflict`, other 4xx as `Permanent`, 5xx/408/429 and IO failures as `Transient`.
|
|
88
|
+
|
|
89
|
+
## Scheduling from the hosts
|
|
90
|
+
|
|
91
|
+
Both hosts call the same shared `drain()`:
|
|
92
|
+
|
|
93
|
+
- **Android:** a `CoroutineWorker` enqueued as unique work with a `CONNECTED` constraint and `BackoffPolicy.EXPONENTIAL`; return `Result.retry()` for transient outcomes. See [android.md](android.md).
|
|
94
|
+
- **iOS:** `BGAppRefreshTaskRequest` / `BGProcessingTaskRequest` plus a drain on scene activation; always schedule the next request and call `setTaskCompleted(success:)`. See [ios.md](ios.md).
|
|
95
|
+
- The suspend function is exposed to Swift as a completion-handler function; cancel it from the task's `expirationHandler` so an expired background task does not leave a half-drained queue holding the mutex.
|
|
96
|
+
|
|
97
|
+
Platform limits are unchanged by KMP — [background.md](../../mobile-performance/references/background.md) applies in full.
|
|
98
|
+
|
|
99
|
+
## Testing
|
|
100
|
+
|
|
101
|
+
This is where the shared design pays for itself: every case in the skill's §9 is a `commonTest` against an in-memory driver and a fake `SyncApi`, and it runs on the JVM *and* on the Kotlin/Native iOS test binary, which is the only way to prove the two targets behave the same.
|
|
102
|
+
|
|
103
|
+
- Fake `SyncApi` returning: success, success-after-the-response-was-lost, `Conflict` with a newer server version, `Permanent`, and `Transient`.
|
|
104
|
+
- Injected `Clock` and `kotlinx-coroutines-test` for backoff timing.
|
|
105
|
+
- Process-death is a host-level test; the shared part is covered by "reopen the database and drain again".
|
|
106
|
+
- Run `commonTest` on both targets in CI. A test suite that only runs on JVM leaves the Kotlin/Native behaviour unverified, which defeats the reason for sharing the code.
|
|
107
|
+
|
|
108
|
+
## Official documentation
|
|
109
|
+
|
|
110
|
+
- Kotlin Multiplatform: https://kotlinlang.org/docs/multiplatform.html
|
|
111
|
+
- SQLDelight: https://sqldelight.github.io/sqldelight/
|
|
112
|
+
- Ktor client retry and timeouts: https://ktor.io/docs/client-retry.html
|
|
113
|
+
- kotlinx.serialization: https://kotlinlang.org/docs/serialization.html
|
|
114
|
+
- Kotlin/Native memory management: https://kotlinlang.org/docs/native-memory-manager.html
|
|
@@ -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.
|