8.8 KiB
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 |
|
|
|
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.