Files
2nd/10_Wiki/Topics/Coding/Android_Compose_Effect_Patterns.md
T
2026-05-10 22:08:15 +09:00

8.8 KiB

id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
id title category status source_trust_level verification_status created_at updated_at tags tech_stack applied_in aliases
android-compose-effect-patterns Compose Effects — LaunchedEffect / DisposableEffect / etc Coding draft B conceptual 2026-05-09 2026-05-09
android
compose
vibe-coding
language applicable_to
Kotlin
Android
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번)

@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)

@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)

@Composable
fun Screen(state: ScreenState) {
    SideEffect {
        // 매 successful recompose 시
        analytics.track("Screen viewed", state.toMap())
    }
}

→ 거의 X. Specific 외부 sync 만.

rememberCoroutineScope

@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

LaunchedEffect(userId, page) {
    // userId 또는 page 변경 = re-launch
    items = api.fetchItems(userId, page)
}

함정: key 가 Unit

LaunchedEffect(Unit) {
    api.fetchUser(userId)  // ❌ userId 변경 시 안 다시
}

// 의도가 "1번만" = OK. 그렇지 않으면 위험.

derivedStateOf

val items by remember { mutableStateOf(longList) }

val hasMore by remember {
    derivedStateOf { items.size > 100 }
}

// items 변경 시 만 재계산.
// hasMore 가 변경 안 = 의존 composable 재실행 X.

→ Computed value 의 memoize.

snapshotFlow (state → flow)

LaunchedEffect(Unit) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .distinctUntilChanged()
        .collect { idx ->
            log("scrolled to $idx")
        }
}

→ Compose state 가 Flow.

produceState

@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

var count by remember { mutableStateOf(0) }
// → process death 시 잃음.

var count by rememberSaveable { mutableStateOf(0) }
// → bundle 에 저장. Process death 후 복구.

→ Critical state = saveable.

Custom saver

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

@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

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+)

LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
    log("resumed")
}

LifecycleStartEffect(Unit) {
    log("started")
    onStopOrDispose {
        log("stopped")
    }
}

→ Activity / Fragment lifecycle awareness.

Pull to refresh

@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

@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 폭발 함정

@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

// 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)

// 같은 effect API, 다른 platform.
// → iOS / Android / Desktop / Web 가 같은 코드.

→ Compose Multiplatform 가 Compose 의 cross-platform.

Test

@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.

🔗 관련 문서