[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
---
|
||||
id: android-compose-effect-patterns
|
||||
title: Compose Effects — LaunchedEffect / DisposableEffect / etc
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [android, compose, vibe-coding]
|
||||
tech_stack: { language: "Kotlin", applicable_to: ["Android"] }
|
||||
applied_in: []
|
||||
aliases: [LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope, snapshotFlow, derivedStateOf]
|
||||
---
|
||||
|
||||
# Compose Effects
|
||||
|
||||
> Compose 의 lifecycle / side-effect 다루기. **LaunchedEffect (suspend), DisposableEffect (cleanup), SideEffect (every recompose), rememberCoroutineScope**. 잘못 = recomposition 폭발 / leak.
|
||||
|
||||
## 📖 핵심 개념
|
||||
- Composable 가 pure (no side effect).
|
||||
- Side effect = effect API.
|
||||
- Key 가 effect re-run trigger.
|
||||
- Cleanup = onDispose.
|
||||
|
||||
## 💻 코드 패턴
|
||||
|
||||
### LaunchedEffect (suspend, 1번)
|
||||
```kotlin
|
||||
@Composable
|
||||
fun UserScreen(userId: String) {
|
||||
var user by remember { mutableStateOf<User?>(null) }
|
||||
|
||||
LaunchedEffect(userId) { // userId 변경 = 다시 실행
|
||||
user = api.fetchUser(userId)
|
||||
}
|
||||
|
||||
Text(user?.name ?: "Loading...")
|
||||
}
|
||||
```
|
||||
|
||||
→ Coroutine. Composable 가 떠나면 자동 cancel.
|
||||
|
||||
### DisposableEffect (cleanup)
|
||||
```kotlin
|
||||
@Composable
|
||||
fun OrientationListener() {
|
||||
val context = LocalContext.current
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val listener = object : OrientationEventListener(context) {
|
||||
override fun onOrientationChanged(orientation: Int) { ... }
|
||||
}
|
||||
listener.enable()
|
||||
|
||||
onDispose {
|
||||
listener.disable()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ 매 외부 resource 가 cleanup.
|
||||
|
||||
### SideEffect (매 recompose)
|
||||
```kotlin
|
||||
@Composable
|
||||
fun Screen(state: ScreenState) {
|
||||
SideEffect {
|
||||
// 매 successful recompose 시
|
||||
analytics.track("Screen viewed", state.toMap())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ 거의 X. Specific 외부 sync 만.
|
||||
|
||||
### rememberCoroutineScope
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ButtonScreen() {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Button(onClick = {
|
||||
scope.launch {
|
||||
delay(1000)
|
||||
doWork()
|
||||
}
|
||||
}) { Text("Click") }
|
||||
}
|
||||
```
|
||||
|
||||
→ Event handler 안에서 coroutine.
|
||||
|
||||
### LaunchedEffect vs rememberCoroutineScope
|
||||
```
|
||||
LaunchedEffect:
|
||||
- Composable 안 (key 의존)
|
||||
- Composable 떠나면 cancel
|
||||
- Initial / dependency 변경 시
|
||||
|
||||
rememberCoroutineScope:
|
||||
- Event handler (onClick)
|
||||
- Composable 떠나면 cancel
|
||||
- 사용자 action 시
|
||||
```
|
||||
|
||||
### Multiple keys
|
||||
```kotlin
|
||||
LaunchedEffect(userId, page) {
|
||||
// userId 또는 page 변경 = re-launch
|
||||
items = api.fetchItems(userId, page)
|
||||
}
|
||||
```
|
||||
|
||||
### 함정: key 가 Unit
|
||||
```kotlin
|
||||
LaunchedEffect(Unit) {
|
||||
api.fetchUser(userId) // ❌ userId 변경 시 안 다시
|
||||
}
|
||||
|
||||
// 의도가 "1번만" = OK. 그렇지 않으면 위험.
|
||||
```
|
||||
|
||||
### derivedStateOf
|
||||
```kotlin
|
||||
val items by remember { mutableStateOf(longList) }
|
||||
|
||||
val hasMore by remember {
|
||||
derivedStateOf { items.size > 100 }
|
||||
}
|
||||
|
||||
// items 변경 시 만 재계산.
|
||||
// hasMore 가 변경 안 = 의존 composable 재실행 X.
|
||||
```
|
||||
|
||||
→ Computed value 의 memoize.
|
||||
|
||||
### snapshotFlow (state → flow)
|
||||
```kotlin
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex }
|
||||
.distinctUntilChanged()
|
||||
.collect { idx ->
|
||||
log("scrolled to $idx")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ Compose state 가 Flow.
|
||||
|
||||
### produceState
|
||||
```kotlin
|
||||
@Composable
|
||||
fun loadUser(id: String): State<User?> {
|
||||
return produceState<User?>(initialValue = null, id) {
|
||||
value = api.fetchUser(id)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserCard(id: String) {
|
||||
val user by loadUser(id)
|
||||
Text(user?.name ?: "Loading")
|
||||
}
|
||||
```
|
||||
|
||||
→ Async data → State. Custom hook 식.
|
||||
|
||||
### remember vs rememberSaveable
|
||||
```kotlin
|
||||
var count by remember { mutableStateOf(0) }
|
||||
// → process death 시 잃음.
|
||||
|
||||
var count by rememberSaveable { mutableStateOf(0) }
|
||||
// → bundle 에 저장. Process death 후 복구.
|
||||
```
|
||||
|
||||
→ Critical state = saveable.
|
||||
|
||||
### Custom saver
|
||||
```kotlin
|
||||
val UserSaver = listSaver<User, Any>(
|
||||
save = { listOf(it.id, it.name) },
|
||||
restore = { User(id = it[0] as String, name = it[1] as String) }
|
||||
)
|
||||
|
||||
var user by rememberSaveable(stateSaver = UserSaver) { mutableStateOf(initialUser) }
|
||||
```
|
||||
|
||||
### LaunchedEffect with dependency on changing state
|
||||
```kotlin
|
||||
@Composable
|
||||
fun Screen() {
|
||||
val viewModel: MyViewModel = viewModel()
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
// ❌ Effect 가 state 변경 시 매번 — 의도 X.
|
||||
LaunchedEffect(state) {
|
||||
if (state.shouldShowToast) showToast(...)
|
||||
}
|
||||
|
||||
// ✅ Discrete event 는 channel / flow.
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.events.collect { event ->
|
||||
when (event) {
|
||||
is Event.ShowToast -> showToast(event.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ Event 가 state 와 다름.
|
||||
|
||||
### Channel-based event
|
||||
```kotlin
|
||||
class MyViewModel : ViewModel() {
|
||||
private val _events = Channel<Event>()
|
||||
val events = _events.receiveAsFlow()
|
||||
|
||||
fun login() {
|
||||
viewModelScope.launch {
|
||||
_events.send(Event.ShowToast("Logged in"))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ One-shot event.
|
||||
|
||||
### LifecycleEventEffect (Compose 1.7+)
|
||||
```kotlin
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
log("resumed")
|
||||
}
|
||||
|
||||
LifecycleStartEffect(Unit) {
|
||||
log("started")
|
||||
onStopOrDispose {
|
||||
log("stopped")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ Activity / Fragment lifecycle awareness.
|
||||
|
||||
### Pull to refresh
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ItemList(viewModel: ItemViewModel) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val pullRefreshState = rememberPullRefreshState(
|
||||
refreshing = state.isRefreshing,
|
||||
onRefresh = viewModel::refresh,
|
||||
)
|
||||
|
||||
Box(Modifier.pullRefresh(pullRefreshState)) {
|
||||
LazyColumn { ... }
|
||||
PullRefreshIndicator(state.isRefreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 함정: leak via remember
|
||||
```kotlin
|
||||
@Composable
|
||||
fun Screen() {
|
||||
// ❌ Activity / Context 가 remember
|
||||
val context = LocalContext.current
|
||||
val obj = remember { ContextHelper(context) }
|
||||
// → context 가 변경 시 stale.
|
||||
}
|
||||
|
||||
// ✅ context 가 key
|
||||
val obj = remember(context) { ContextHelper(context) }
|
||||
```
|
||||
|
||||
### Recomposition 폭발 함정
|
||||
```kotlin
|
||||
@Composable
|
||||
fun Screen() {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
// ❌ 매 recompose 시 새 lambda
|
||||
Button(onClick = { viewModel.action() }) { ... }
|
||||
|
||||
// ✅ remember + ref 안정
|
||||
val onAction = remember { { viewModel.action() } }
|
||||
Button(onClick = onAction) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
→ Compose Compiler (Strong skipping mode 1.6.0+) 가 자동 fix.
|
||||
|
||||
### State hoisting + effect
|
||||
```kotlin
|
||||
// Stateless composable
|
||||
@Composable
|
||||
fun MyButton(text: String, onClick: () -> Unit) {
|
||||
Button(onClick = onClick) { Text(text) }
|
||||
}
|
||||
|
||||
// Stateful caller
|
||||
@Composable
|
||||
fun Screen() {
|
||||
var count by remember { mutableStateOf(0) }
|
||||
MyButton(text = "$count", onClick = { count++ })
|
||||
}
|
||||
```
|
||||
|
||||
→ Effect 가 stateful caller — child 는 pure.
|
||||
|
||||
### Compose Multiplatform (KMP)
|
||||
```kotlin
|
||||
// 같은 effect API, 다른 platform.
|
||||
// → iOS / Android / Desktop / Web 가 같은 코드.
|
||||
```
|
||||
|
||||
→ Compose Multiplatform 가 Compose 의 cross-platform.
|
||||
|
||||
### Test
|
||||
```kotlin
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun loads_user() {
|
||||
composeRule.setContent {
|
||||
UserScreen(userId = "1")
|
||||
}
|
||||
composeRule.onNodeWithText("Loading").assertExists()
|
||||
composeRule.onNodeWithText("Alice").assertExists() // wait for effect
|
||||
}
|
||||
```
|
||||
|
||||
### Performance
|
||||
```
|
||||
LaunchedEffect: 매 key 변경 = re-launch.
|
||||
SideEffect: 매 recompose = 호출 (성능 주의).
|
||||
DisposableEffect: re-key 시 dispose + create.
|
||||
|
||||
→ 매 effect 의 cost 인지.
|
||||
```
|
||||
|
||||
### iOS / SwiftUI 비교
|
||||
```
|
||||
iOS:
|
||||
- onAppear / onDisappear
|
||||
- task { }
|
||||
- onChange(of:)
|
||||
|
||||
Compose:
|
||||
- LaunchedEffect
|
||||
- DisposableEffect
|
||||
- key 가 변경 trigger
|
||||
|
||||
→ Compose 가 더 명시적 + flexible.
|
||||
```
|
||||
|
||||
## 🤔 의사결정 기준
|
||||
| 작업 | API |
|
||||
|---|---|
|
||||
| Async load | LaunchedEffect |
|
||||
| Cleanup resource | DisposableEffect |
|
||||
| Every recompose | SideEffect (rare) |
|
||||
| Event handler | rememberCoroutineScope |
|
||||
| Async → state | produceState |
|
||||
| State → flow | snapshotFlow |
|
||||
| Computed | derivedStateOf |
|
||||
| Lifecycle | LifecycleEventEffect |
|
||||
| State 보존 | rememberSaveable |
|
||||
|
||||
## ❌ 안티패턴
|
||||
- **Composable 안 직접 launch**: leak.
|
||||
- **LaunchedEffect(Unit) + dynamic dep**: stale.
|
||||
- **State 가 event**: Channel 사용.
|
||||
- **rememberCoroutineScope 가 LaunchedEffect 대체**: scope 같지만 의미 다름.
|
||||
- **No key**: 매 recompose 다시 실행.
|
||||
- **SideEffect 안 비싼 work**: 성능.
|
||||
- **DisposableEffect cleanup 없음**: leak.
|
||||
|
||||
## 🤖 LLM 활용 힌트
|
||||
- LaunchedEffect 가 가장 자주.
|
||||
- DisposableEffect 가 외부 resource.
|
||||
- Event 는 Channel / Flow (state X).
|
||||
- derivedStateOf 가 computed memoize.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[Android_Compose_State_Hoisting]]
|
||||
- [[Android_Compose_Recomposition_Pitfalls]]
|
||||
- [[React_useEffect_Pitfalls]]
|
||||
Reference in New Issue
Block a user