@mmerterden/multi-agent-pipeline 20.1.0 → 20.2.1
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/CHANGELOG.md +37 -0
- package/docs/facts.json +6 -6
- package/manifest.json +55 -34
- package/package.json +1 -1
- package/pipeline/multi-agent-refs/features/usage-reporting.md +7 -3
- package/pipeline/schemas/prefs.schema.json +4 -0
- package/pipeline/scripts/usage-register.mjs +2 -0
- package/pipeline/skills/.skill-manifest.json +36 -20
- package/pipeline/skills/.skills-index.json +75 -9
- package/pipeline/skills/shared/README.md +13 -7
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
- package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
- package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
- package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
- package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
- package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
- package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
- package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
- package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
- package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
- package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
- package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
- package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
- package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
- package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
- package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
- package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
- package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
- package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
- package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
- package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
- package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
- package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
- package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
- package/pipeline/skills/skills-index.md +8 -2
|
@@ -9,6 +9,10 @@ Performance optimization patterns for Android apps targeting Jetpack Compose,
|
|
|
9
9
|
R8, Baseline Profiles, and modern profiling tools. Covers startup, runtime,
|
|
10
10
|
memory, and image loading optimization.
|
|
11
11
|
|
|
12
|
+
Detailed code examples live in `references/patterns.md`, organized under the
|
|
13
|
+
same section headings used below. Load the matching section when you implement
|
|
14
|
+
or review that area; the guidance here is enough to decide what to do.
|
|
15
|
+
|
|
12
16
|
## Contents
|
|
13
17
|
|
|
14
18
|
- [Baseline Profiles](#baseline-profiles)
|
|
@@ -27,88 +31,15 @@ memory, and image loading optimization.
|
|
|
27
31
|
## Baseline Profiles
|
|
28
32
|
|
|
29
33
|
Baseline Profiles pre-compile critical code paths at install time, improving
|
|
30
|
-
startup time and reducing jank on first run.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// build.gradle.kts (:app)
|
|
36
|
-
plugins {
|
|
37
|
-
alias(libs.plugins.android.application)
|
|
38
|
-
alias(libs.plugins.baselineprofile)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
dependencies {
|
|
42
|
-
baselineProfile(project(":baselineprofile"))
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
baselineProfile {
|
|
46
|
-
automaticGenerationDuringBuild = true
|
|
47
|
-
}
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
### Generator Module
|
|
51
|
-
|
|
52
|
-
```kotlin
|
|
53
|
-
// :baselineprofile/build.gradle.kts
|
|
54
|
-
plugins {
|
|
55
|
-
alias(libs.plugins.android.test)
|
|
56
|
-
alias(libs.plugins.baselineprofile)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
android {
|
|
60
|
-
namespace = "com.example.baselineprofile"
|
|
61
|
-
targetProjectPath = ":app"
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
baselineProfile {
|
|
65
|
-
useConnectedDevices = true
|
|
66
|
-
}
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
### Profile Generator
|
|
70
|
-
|
|
71
|
-
```kotlin
|
|
72
|
-
@RunWith(AndroidJUnit4::class)
|
|
73
|
-
@LargeTest
|
|
74
|
-
class BaselineProfileGenerator {
|
|
75
|
-
|
|
76
|
-
@get:Rule
|
|
77
|
-
val rule = BaselineProfileRule()
|
|
78
|
-
|
|
79
|
-
@Test
|
|
80
|
-
fun generateBaselineProfile() {
|
|
81
|
-
rule.collect(
|
|
82
|
-
packageName = "com.example.app",
|
|
83
|
-
includeInStartupProfile = true,
|
|
84
|
-
) {
|
|
85
|
-
// Cold start
|
|
86
|
-
pressHome()
|
|
87
|
-
startActivityAndWait()
|
|
88
|
-
|
|
89
|
-
// Critical user journeys
|
|
90
|
-
device.findObject(By.text("Search")).click()
|
|
91
|
-
device.waitForIdle()
|
|
92
|
-
|
|
93
|
-
device.findObject(By.res("search_field")).text = "Istanbul"
|
|
94
|
-
device.waitForIdle()
|
|
95
|
-
|
|
96
|
-
device.findObject(By.res("flight_card")).click()
|
|
97
|
-
device.waitForIdle()
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
### Generate and Apply
|
|
34
|
+
startup time and reducing jank on first run. Apply the `baselineprofile` plugin
|
|
35
|
+
to `:app`, add a `:baselineprofile` test module that targets `:app`, and write a
|
|
36
|
+
`BaselineProfileRule` generator that exercises cold start plus the critical user
|
|
37
|
+
journeys. Run `./gradlew :app:generateBaselineProfile` to produce
|
|
38
|
+
`baseline-prof.txt` and `startup-prof.txt` under `app/src/main/`.
|
|
104
39
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
# Profile is written to app/src/main/baseline-prof.txt
|
|
110
|
-
# Startup profile to app/src/main/startup-prof.txt
|
|
111
|
-
```
|
|
40
|
+
See `references/patterns.md` -> "Baseline Profiles" for the `:app` and
|
|
41
|
+
generator `build.gradle.kts`, the `BaselineProfileGenerator` test, and the
|
|
42
|
+
generate command.
|
|
112
43
|
|
|
113
44
|
## Compose Stability
|
|
114
45
|
|
|
@@ -125,564 +56,117 @@ For this to work, parameters must be **stable** (immutable or observable).
|
|
|
125
56
|
| `class` (non-data) | No | Compose cannot infer stability |
|
|
126
57
|
| Lambda `() -> Unit` | Unstable by default | Captured variables may change |
|
|
127
58
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
val route: String,
|
|
136
|
-
val price: String,
|
|
137
|
-
val status: String,
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
// Mark a class as stable (Compose can track changes via equals)
|
|
141
|
-
@Stable
|
|
142
|
-
data class FilterState(
|
|
143
|
-
val origin: String = "",
|
|
144
|
-
val destination: String = "",
|
|
145
|
-
val date: LocalDate? = null,
|
|
146
|
-
)
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
### Use kotlinx.collections.immutable
|
|
59
|
+
Mark truly immutable models `@Immutable` and observable-but-stable state
|
|
60
|
+
`@Stable`. Replace `List`/`Set`/`Map` parameters with
|
|
61
|
+
`kotlinx.collections.immutable` variants (`ImmutableList`, `persistentListOf()`)
|
|
62
|
+
so list-carrying UI state stays stable. On Kotlin 2.0+ the Compose Compiler
|
|
63
|
+
enables strong skipping by default (unstable params compared by `===`, lambdas
|
|
64
|
+
memoized), reducing the need for manual annotations. Verify results in the
|
|
65
|
+
Compose compiler reports.
|
|
150
66
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// build.gradle.kts
|
|
155
|
-
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8")
|
|
156
|
-
|
|
157
|
-
// Usage
|
|
158
|
-
@Immutable
|
|
159
|
-
data class FlightListUiState(
|
|
160
|
-
val flights: ImmutableList<FlightUiModel> = persistentListOf(),
|
|
161
|
-
val isLoading: Boolean = false,
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
// Convert in ViewModel
|
|
165
|
-
val uiState: StateFlow<FlightListUiState> = repository.getFlights()
|
|
166
|
-
.map { flights ->
|
|
167
|
-
FlightListUiState(
|
|
168
|
-
flights = flights.map(::toUiModel).toImmutableList(),
|
|
169
|
-
)
|
|
170
|
-
}
|
|
171
|
-
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), FlightListUiState())
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
### Strong Skipping Mode (Kotlin 2.0+)
|
|
175
|
-
|
|
176
|
-
With the Compose Compiler plugin for Kotlin 2.0+, strong skipping is enabled
|
|
177
|
-
by default. This makes more composables skippable:
|
|
178
|
-
|
|
179
|
-
- Unstable parameters are compared by instance equality (`===`)
|
|
180
|
-
- Lambdas are memoized by default
|
|
181
|
-
- Less need for manual `@Stable` / `@Immutable` annotations
|
|
182
|
-
|
|
183
|
-
Verify in Compose compiler reports:
|
|
184
|
-
|
|
185
|
-
```properties
|
|
186
|
-
# gradle.properties
|
|
187
|
-
composeCompiler.reportsDestination=build/compose-reports
|
|
188
|
-
composeCompiler.metricsDestination=build/compose-metrics
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
```bash
|
|
192
|
-
./gradlew assembleRelease
|
|
193
|
-
# Check build/compose-reports/app_release-composables.txt for skippability
|
|
194
|
-
```
|
|
67
|
+
See `references/patterns.md` -> "Compose Stability" for the annotation
|
|
68
|
+
examples, the immutable-collections `StateFlow` mapping, and the
|
|
69
|
+
`gradle.properties` report settings.
|
|
195
70
|
|
|
196
71
|
## Recomposition Debugging
|
|
197
72
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
4. Interact with the app and observe which composables recompose
|
|
204
|
-
|
|
205
|
-
High recomposition counts on composables that should not change indicate
|
|
206
|
-
unnecessary invalidation.
|
|
207
|
-
|
|
208
|
-
### Recomposition Highlighter
|
|
209
|
-
|
|
210
|
-
```kotlin
|
|
211
|
-
// Debug utility -- remove before release
|
|
212
|
-
@Composable
|
|
213
|
-
fun RecompositionCounter(label: String) {
|
|
214
|
-
val count = remember { mutableIntStateOf(0) }
|
|
215
|
-
count.intValue++
|
|
216
|
-
SideEffect {
|
|
217
|
-
Log.d("Recomposition", "$label: ${count.intValue}")
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
### Compose Compiler Metrics
|
|
223
|
-
|
|
224
|
-
Generate stability reports to identify unstable composables:
|
|
225
|
-
|
|
226
|
-
```bash
|
|
227
|
-
./gradlew assembleRelease \
|
|
228
|
-
-PcomposeCompiler.reportsDestination=build/compose-reports \
|
|
229
|
-
-PcomposeCompiler.metricsDestination=build/compose-metrics
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
Key files:
|
|
233
|
-
- `*-composables.txt`: Lists all composables with restartable/skippable status
|
|
234
|
-
- `*-classes.txt`: Lists all classes with stability status
|
|
235
|
-
- `*-module.json`: Summary metrics
|
|
236
|
-
|
|
237
|
-
Look for composables marked `restartable` but NOT `skippable` -- these are
|
|
73
|
+
Use Android Studio's Layout Inspector with "Show Recomposition Counts" enabled
|
|
74
|
+
to find composables that recompose more than they should. For targeted logging,
|
|
75
|
+
a small `RecompositionCounter` debug composable prints per-label counts (remove
|
|
76
|
+
before release). For a build-wide view, generate Compose compiler metrics and
|
|
77
|
+
look for composables marked `restartable` but NOT `skippable` -- those are the
|
|
238
78
|
recomposition hotspots.
|
|
239
79
|
|
|
240
|
-
|
|
80
|
+
See `references/patterns.md` -> "Recomposition Debugging" for the Layout
|
|
81
|
+
Inspector steps, the `RecompositionCounter` snippet, the metrics command, and
|
|
82
|
+
the report files to read.
|
|
241
83
|
|
|
242
|
-
|
|
84
|
+
## Lazy Layout Performance
|
|
243
85
|
|
|
244
|
-
Always provide stable
|
|
245
|
-
|
|
86
|
+
Always provide a stable `key` for `LazyColumn`/`LazyRow` items; without it
|
|
87
|
+
Compose cannot diff efficiently and re-renders the whole list on any change.
|
|
246
88
|
|
|
247
89
|
```kotlin
|
|
248
|
-
// CORRECT: stable key from item ID
|
|
249
90
|
LazyColumn {
|
|
250
|
-
items(
|
|
251
|
-
items = flights,
|
|
252
|
-
key = { flight -> flight.id },
|
|
253
|
-
) { flight ->
|
|
91
|
+
items(items = flights, key = { it.id }) { flight ->
|
|
254
92
|
FlightCard(flight = flight)
|
|
255
93
|
}
|
|
256
94
|
}
|
|
257
|
-
|
|
258
|
-
// WRONG: no key -- full list diff on every change
|
|
259
|
-
LazyColumn {
|
|
260
|
-
items(flights) { flight ->
|
|
261
|
-
FlightCard(flight = flight)
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
```
|
|
265
|
-
|
|
266
|
-
### contentType for Heterogeneous Lists
|
|
267
|
-
|
|
268
|
-
```kotlin
|
|
269
|
-
LazyColumn {
|
|
270
|
-
items(
|
|
271
|
-
items = feedItems,
|
|
272
|
-
key = { it.id },
|
|
273
|
-
contentType = { item ->
|
|
274
|
-
when (item) {
|
|
275
|
-
is FeedItem.Flight -> "flight"
|
|
276
|
-
is FeedItem.Hotel -> "hotel"
|
|
277
|
-
is FeedItem.Ad -> "ad"
|
|
278
|
-
}
|
|
279
|
-
},
|
|
280
|
-
) { item ->
|
|
281
|
-
when (item) {
|
|
282
|
-
is FeedItem.Flight -> FlightCard(item)
|
|
283
|
-
is FeedItem.Hotel -> HotelCard(item)
|
|
284
|
-
is FeedItem.Ad -> AdBanner(item)
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
95
|
```
|
|
289
96
|
|
|
290
|
-
`contentType`
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
```kotlin
|
|
296
|
-
// WRONG: formatting on every recomposition
|
|
297
|
-
items(flights, key = { it.id }) { flight ->
|
|
298
|
-
val formatted = SimpleDateFormat("HH:mm", Locale.getDefault())
|
|
299
|
-
.format(Date(flight.departureTime))
|
|
300
|
-
Text(formatted)
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// CORRECT: precompute in ViewModel or UiModel
|
|
304
|
-
data class FlightUiModel(
|
|
305
|
-
val id: String,
|
|
306
|
-
val formattedDeparture: String, // precomputed
|
|
307
|
-
)
|
|
308
|
-
```
|
|
97
|
+
For heterogeneous lists, add `contentType` so Compose reuses layouts of the
|
|
98
|
+
same type (like RecyclerView view types). Never format or allocate inside item
|
|
99
|
+
scope -- precompute display strings in the `UiModel`. Default prefetch is
|
|
100
|
+
usually sufficient.
|
|
309
101
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
LazyColumn(
|
|
314
|
-
state = rememberLazyListState(),
|
|
315
|
-
// Default prefetch is usually sufficient
|
|
316
|
-
// For custom behavior:
|
|
317
|
-
flingBehavior = ScrollableDefaults.flingBehavior(),
|
|
318
|
-
) {
|
|
319
|
-
items(flights, key = { it.id }) { flight ->
|
|
320
|
-
FlightCard(flight = flight)
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
```
|
|
102
|
+
See `references/patterns.md` -> "Lazy Layout Performance" for the keyed vs
|
|
103
|
+
unkeyed contrast, the `contentType` example, the precompute-in-UiModel pattern,
|
|
104
|
+
and prefetch configuration.
|
|
324
105
|
|
|
325
106
|
## R8 Optimization
|
|
326
107
|
|
|
327
108
|
R8 performs code shrinking, obfuscation, and optimization in release builds.
|
|
109
|
+
Enable it with `isMinifyEnabled = true` and `isShrinkResources = true` on the
|
|
110
|
+
`release` build type, using `proguard-android-optimize.txt` plus your keep
|
|
111
|
+
rules. Turn on `android.enableR8.fullMode=true` for more aggressive
|
|
112
|
+
optimization (may require extra keep rules for reflection-based code). Measure
|
|
113
|
+
impact by comparing debug and release APK sizes.
|
|
328
114
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
```kotlin
|
|
332
|
-
android {
|
|
333
|
-
buildTypes {
|
|
334
|
-
release {
|
|
335
|
-
isMinifyEnabled = true
|
|
336
|
-
isShrinkResources = true
|
|
337
|
-
proguardFiles(
|
|
338
|
-
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
339
|
-
"proguard-rules.pro",
|
|
340
|
-
)
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
```
|
|
345
|
-
|
|
346
|
-
### R8 Full Mode
|
|
347
|
-
|
|
348
|
-
R8 full mode enables more aggressive optimizations:
|
|
349
|
-
|
|
350
|
-
```properties
|
|
351
|
-
# gradle.properties
|
|
352
|
-
android.enableR8.fullMode=true
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
Full mode may require additional keep rules for reflection-based code.
|
|
356
|
-
|
|
357
|
-
### Measuring R8 Impact
|
|
358
|
-
|
|
359
|
-
```bash
|
|
360
|
-
# Before R8 (debug APK)
|
|
361
|
-
./gradlew assembleDebug
|
|
362
|
-
ls -la app/build/outputs/apk/debug/app-debug.apk
|
|
363
|
-
|
|
364
|
-
# After R8 (release APK)
|
|
365
|
-
./gradlew assembleRelease
|
|
366
|
-
ls -la app/build/outputs/apk/release/app-release.apk
|
|
367
|
-
|
|
368
|
-
# Compare sizes
|
|
369
|
-
```
|
|
115
|
+
See `references/patterns.md` -> "R8 Optimization" for the `buildTypes` block,
|
|
116
|
+
the full-mode property, and the size-comparison commands.
|
|
370
117
|
|
|
371
118
|
## Memory Leak Detection
|
|
372
119
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
LeakCanary runs automatically in debug builds. It detects:
|
|
381
|
-
- Activity leaks
|
|
382
|
-
- Fragment leaks
|
|
383
|
-
- ViewModel leaks
|
|
384
|
-
- Service leaks
|
|
385
|
-
- Custom watched objects
|
|
386
|
-
|
|
387
|
-
### Common Leak Patterns
|
|
388
|
-
|
|
389
|
-
```kotlin
|
|
390
|
-
// LEAK: Activity reference in singleton
|
|
391
|
-
object Analytics {
|
|
392
|
-
private var context: Context? = null // holds Activity reference
|
|
393
|
-
fun init(context: Context) { this.context = context }
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// FIX: Use application context
|
|
397
|
-
object Analytics {
|
|
398
|
-
private var context: Context? = null
|
|
399
|
-
fun init(context: Context) { this.context = context.applicationContext }
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// LEAK: Coroutine scope outlives lifecycle
|
|
403
|
-
class MyActivity : ComponentActivity() {
|
|
404
|
-
init {
|
|
405
|
-
GlobalScope.launch { // outlives Activity
|
|
406
|
-
// long-running work
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// FIX: Use lifecycleScope
|
|
412
|
-
class MyActivity : ComponentActivity() {
|
|
413
|
-
override fun onCreate(savedInstanceState: Bundle?) {
|
|
414
|
-
super.onCreate(savedInstanceState)
|
|
415
|
-
lifecycleScope.launch {
|
|
416
|
-
// cancelled when Activity is destroyed
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// LEAK: Anonymous inner class holds reference
|
|
422
|
-
class MyViewModel : ViewModel() {
|
|
423
|
-
val callback = object : Callback {
|
|
424
|
-
override fun onResult(data: Data) {
|
|
425
|
-
// this holds implicit reference to MyViewModel
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
```
|
|
430
|
-
|
|
431
|
-
### Manual Object Watching
|
|
120
|
+
Add LeakCanary as `debugImplementation` only; it runs automatically in debug
|
|
121
|
+
builds and reports Activity, Fragment, ViewModel, Service, and custom watched
|
|
122
|
+
leaks. The recurring leak sources are: Activity/Context references held in
|
|
123
|
+
singletons (fix with `applicationContext`), `GlobalScope` coroutines that
|
|
124
|
+
outlive a lifecycle (fix with `lifecycleScope`/`viewModelScope`), and anonymous
|
|
125
|
+
inner classes capturing an implicit outer reference. For custom objects, watch
|
|
126
|
+
them explicitly with `objectWatcher.expectWeaklyReachable(...)`.
|
|
432
127
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
val watcher = LeakCanary.objectWatcher
|
|
436
|
-
watcher.expectWeaklyReachable(myObject, "MyObject should be GC'd")
|
|
437
|
-
```
|
|
128
|
+
See `references/patterns.md` -> "Memory Leak Detection" for the dependency
|
|
129
|
+
line, the leak-vs-fix pairs, and the manual object-watching snippet.
|
|
438
130
|
|
|
439
131
|
## Startup Optimization
|
|
440
132
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
@get:Rule
|
|
448
|
-
val benchmarkRule = MacrobenchmarkRule()
|
|
449
|
-
|
|
450
|
-
@Test
|
|
451
|
-
fun startupCompilation() {
|
|
452
|
-
benchmarkRule.measureRepeated(
|
|
453
|
-
packageName = "com.example.app",
|
|
454
|
-
metrics = listOf(StartupTimingMetric()),
|
|
455
|
-
iterations = 5,
|
|
456
|
-
startupMode = StartupMode.COLD,
|
|
457
|
-
) {
|
|
458
|
-
pressHome()
|
|
459
|
-
startActivityAndWait()
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
@Test
|
|
464
|
-
fun startupWithScrolling() {
|
|
465
|
-
benchmarkRule.measureRepeated(
|
|
466
|
-
packageName = "com.example.app",
|
|
467
|
-
metrics = listOf(
|
|
468
|
-
StartupTimingMetric(),
|
|
469
|
-
FrameTimingMetric(),
|
|
470
|
-
),
|
|
471
|
-
iterations = 5,
|
|
472
|
-
startupMode = StartupMode.COLD,
|
|
473
|
-
) {
|
|
474
|
-
pressHome()
|
|
475
|
-
startActivityAndWait()
|
|
476
|
-
|
|
477
|
-
val list = device.findObject(By.res("flight_list"))
|
|
478
|
-
list.setGestureMargin(device.displayWidth / 5)
|
|
479
|
-
list.fling(Direction.DOWN)
|
|
480
|
-
device.waitForIdle()
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
```
|
|
485
|
-
|
|
486
|
-
### Startup Best Practices
|
|
487
|
-
|
|
488
|
-
```kotlin
|
|
489
|
-
@HiltAndroidApp
|
|
490
|
-
class MyApplication : Application() {
|
|
491
|
-
override fun onCreate() {
|
|
492
|
-
super.onCreate()
|
|
493
|
-
// Only initialize essentials here
|
|
494
|
-
// Defer non-critical init to background
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// Use App Startup library for lazy initialization
|
|
499
|
-
class AnalyticsInitializer : Initializer<Analytics> {
|
|
500
|
-
override fun create(context: Context): Analytics {
|
|
501
|
-
return Analytics.init(context)
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
|
|
505
|
-
}
|
|
506
|
-
```
|
|
507
|
-
|
|
508
|
-
### Defer Heavy Initialization
|
|
509
|
-
|
|
510
|
-
```kotlin
|
|
511
|
-
// WRONG: blocking startup
|
|
512
|
-
class MyApplication : Application() {
|
|
513
|
-
override fun onCreate() {
|
|
514
|
-
super.onCreate()
|
|
515
|
-
Database.initialize(this) // slow
|
|
516
|
-
ImageLoader.setup(this) // slow
|
|
517
|
-
CrashReporting.setup(this) // slow
|
|
518
|
-
}
|
|
519
|
-
}
|
|
133
|
+
Measure cold start with a Macrobenchmark `StartupTimingMetric` (add
|
|
134
|
+
`FrameTimingMetric` to catch scroll jank during startup), running against a
|
|
135
|
+
release build on a real device. Keep `Application.onCreate` minimal: initialize
|
|
136
|
+
only essentials, defer database, image loader, and other heavy setup to a
|
|
137
|
+
background dispatcher (or the App Startup library). Crash reporting can usually
|
|
138
|
+
stay on main.
|
|
520
139
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
super.onCreate()
|
|
525
|
-
ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.Default) {
|
|
526
|
-
Database.initialize(this@MyApplication)
|
|
527
|
-
ImageLoader.setup(this@MyApplication)
|
|
528
|
-
}
|
|
529
|
-
// Crash reporting can stay on main (usually lightweight)
|
|
530
|
-
CrashReporting.setup(this)
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
```
|
|
140
|
+
See `references/patterns.md` -> "Startup Optimization" for the
|
|
141
|
+
`StartupBenchmark` tests, the App Startup `Initializer`, and the
|
|
142
|
+
blocking-vs-deferred `Application.onCreate` contrast.
|
|
534
143
|
|
|
535
144
|
## Image Loading with Coil
|
|
536
145
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
// Composable
|
|
544
|
-
@Composable
|
|
545
|
-
fun FlightImage(imageUrl: String, modifier: Modifier = Modifier) {
|
|
546
|
-
AsyncImage(
|
|
547
|
-
model = ImageRequest.Builder(LocalContext.current)
|
|
548
|
-
.data(imageUrl)
|
|
549
|
-
.crossfade(true)
|
|
550
|
-
.memoryCacheKey(imageUrl)
|
|
551
|
-
.diskCacheKey(imageUrl)
|
|
552
|
-
.build(),
|
|
553
|
-
contentDescription = "Flight image",
|
|
554
|
-
modifier = modifier,
|
|
555
|
-
contentScale = ContentScale.Crop,
|
|
556
|
-
)
|
|
557
|
-
}
|
|
558
|
-
```
|
|
559
|
-
|
|
560
|
-
### Global Image Loader Configuration
|
|
561
|
-
|
|
562
|
-
```kotlin
|
|
563
|
-
@HiltAndroidApp
|
|
564
|
-
class MyApplication : Application(), ImageLoaderFactory {
|
|
565
|
-
|
|
566
|
-
override fun newImageLoader(): ImageLoader =
|
|
567
|
-
ImageLoader.Builder(this)
|
|
568
|
-
.memoryCache {
|
|
569
|
-
MemoryCache.Builder(this)
|
|
570
|
-
.maxSizePercent(0.25) // 25% of app memory
|
|
571
|
-
.build()
|
|
572
|
-
}
|
|
573
|
-
.diskCache {
|
|
574
|
-
DiskCache.Builder()
|
|
575
|
-
.directory(cacheDir.resolve("image_cache"))
|
|
576
|
-
.maxSizePercent(0.02) // 2% of disk
|
|
577
|
-
.build()
|
|
578
|
-
}
|
|
579
|
-
.crossfade(true)
|
|
580
|
-
.respectCacheHeaders(true)
|
|
581
|
-
.build()
|
|
582
|
-
}
|
|
583
|
-
```
|
|
584
|
-
|
|
585
|
-
### Image Size Optimization
|
|
586
|
-
|
|
587
|
-
```kotlin
|
|
588
|
-
// Downscale to view size -- prevents loading full-resolution images
|
|
589
|
-
AsyncImage(
|
|
590
|
-
model = ImageRequest.Builder(LocalContext.current)
|
|
591
|
-
.data(imageUrl)
|
|
592
|
-
.size(Size(200, 200)) // Request specific size
|
|
593
|
-
.scale(Scale.FILL)
|
|
594
|
-
.build(),
|
|
595
|
-
contentDescription = null,
|
|
596
|
-
modifier = Modifier.size(100.dp), // Display size
|
|
597
|
-
)
|
|
598
|
-
```
|
|
599
|
-
|
|
600
|
-
### Placeholder and Error States
|
|
146
|
+
Load images with Coil's `AsyncImage`, enabling `crossfade` and explicit
|
|
147
|
+
cache keys. Configure a global `ImageLoader` via `ImageLoaderFactory` with
|
|
148
|
+
memory and disk cache limits (e.g. 25% of app memory, 2% of disk). Always
|
|
149
|
+
request images at display size with `.size(...)` to avoid decoding
|
|
150
|
+
full-resolution bitmaps, and supply `placeholder`/`error`/`fallback` painters.
|
|
601
151
|
|
|
602
|
-
|
|
603
|
-
AsyncImage
|
|
604
|
-
|
|
605
|
-
contentDescription = "Airline logo",
|
|
606
|
-
placeholder = painterResource(R.drawable.placeholder_airline),
|
|
607
|
-
error = painterResource(R.drawable.error_airline),
|
|
608
|
-
fallback = painterResource(R.drawable.default_airline),
|
|
609
|
-
modifier = Modifier
|
|
610
|
-
.size(48.dp)
|
|
611
|
-
.clip(CircleShape),
|
|
612
|
-
)
|
|
613
|
-
```
|
|
152
|
+
See `references/patterns.md` -> "Image Loading with Coil" for the basic
|
|
153
|
+
`AsyncImage`, the `ImageLoaderFactory` configuration, the size-optimization
|
|
154
|
+
request, and the placeholder/error states.
|
|
614
155
|
|
|
615
156
|
## General Performance Patterns
|
|
616
157
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
Box {
|
|
630
|
-
LazyColumn(state = listState) {
|
|
631
|
-
items(flights, key = { it.id }) { FlightCard(it) }
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
if (showScrollToTop) {
|
|
635
|
-
FloatingActionButton(
|
|
636
|
-
onClick = { /* scroll to top */ },
|
|
637
|
-
modifier = Modifier.align(Alignment.BottomEnd),
|
|
638
|
-
) {
|
|
639
|
-
Icon(Icons.Default.ArrowUpward, contentDescription = "Scroll to top")
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
```
|
|
645
|
-
|
|
646
|
-
### remember for Expensive Objects
|
|
647
|
-
|
|
648
|
-
```kotlin
|
|
649
|
-
// WRONG: recreated every recomposition
|
|
650
|
-
@Composable
|
|
651
|
-
fun DateDisplay(timestamp: Long) {
|
|
652
|
-
val formatter = SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
|
|
653
|
-
Text(formatter.format(Date(timestamp)))
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// CORRECT: remembered across recompositions
|
|
657
|
-
@Composable
|
|
658
|
-
fun DateDisplay(timestamp: Long) {
|
|
659
|
-
val formatter = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) }
|
|
660
|
-
Text(formatter.format(Date(timestamp)))
|
|
661
|
-
}
|
|
662
|
-
```
|
|
663
|
-
|
|
664
|
-
### Debounce for Search
|
|
665
|
-
|
|
666
|
-
```kotlin
|
|
667
|
-
@HiltViewModel
|
|
668
|
-
class SearchViewModel @Inject constructor(
|
|
669
|
-
private val searchUseCase: SearchFlightsUseCase,
|
|
670
|
-
) : ViewModel() {
|
|
671
|
-
|
|
672
|
-
private val searchQuery = MutableStateFlow("")
|
|
673
|
-
|
|
674
|
-
val searchResults: StateFlow<List<Flight>> = searchQuery
|
|
675
|
-
.debounce(300) // Wait 300ms after last keystroke
|
|
676
|
-
.distinctUntilChanged()
|
|
677
|
-
.filter { it.length >= 2 }
|
|
678
|
-
.flatMapLatest { query -> searchUseCase(query) }
|
|
679
|
-
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
|
680
|
-
|
|
681
|
-
fun onQueryChange(query: String) {
|
|
682
|
-
searchQuery.value = query
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
```
|
|
158
|
+
- **`derivedStateOf`** for values computed from state that change less often
|
|
159
|
+
than their inputs (e.g. a scroll-to-top flag derived from
|
|
160
|
+
`firstVisibleItemIndex`), so recomposition triggers only on the derived
|
|
161
|
+
change.
|
|
162
|
+
- **`remember`** expensive objects (formatters, regex) so they survive
|
|
163
|
+
recomposition instead of being reallocated each pass.
|
|
164
|
+
- **Debounce** search input in the ViewModel (`debounce(300)` +
|
|
165
|
+
`distinctUntilChanged()` + `flatMapLatest`) to avoid a request per keystroke.
|
|
166
|
+
|
|
167
|
+
See `references/patterns.md` -> "General Performance Patterns" for the
|
|
168
|
+
`derivedStateOf` list example, the `remember` formatter contrast, and the
|
|
169
|
+
debounced search `StateFlow`.
|
|
686
170
|
|
|
687
171
|
## Do's and Don'ts
|
|
688
172
|
|