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