본문 바로가기

Compose

[ComposeInternals] 이펙트와 이펙트 핸들러 (Effects and Effect handlers)

Jetpack Compose Internals를 참고하여 작성하였습니다

 

 

사이드 이펙트 (Side effects)

  • 함수의 제어나 범위를 벗어나는 모든 것
  • 함수의 호출자가 기대한 것과 다른 동작을 발생시키고 함수의 동작을 변경할 수 있는 것
  • 함수를 비결정적으로 만들고 개발자가 코드를 추론하기 어렵게 함
  • 테스트 가능성이 줄어들고 결함이 발생할 가능성이 생김
  • 전역 변수 쓰기/읽기, 메모리 캐시 접근, DB 작업, 네트워크 쿼리 수행 등등이 부작용의 예시

 

사이드 이펙트가 없는 순수함수

순수 함수 (pure function)
동일한 입력에 항상 같은 결과를 반환하고 부작용 (side effect)이 없으며, 함수 외부 상태에 의존하지 않고 결정적인 함수

 

예시1) 순수 함수

fun add(a: Int, b: Int) = a + b
  • 주어진 입력값만을 사용하여 결과 계산
  • 같은 입력 값에 대해 항상 같은 결과
  • 결정적

예시2) 부수적인 작업이 추가된 함수

fun add(a: Int, b: Int) =
    calculationCache.get(a, b) ?: (a + b).also { calculationCache.store(a, b, it) }
  • 캐시로부터 읽은 값이 언제 수정되었는지 알 수 없음
  • 다른 스레드에서 동시에 업데이트 가능
  • 같은 입력 값에 다른 결과가 반환될 수 있음

 

 

Compose에서의 사이드 이펙트 (Side effects in Compose)

Side effect는 Composable Lifecycle의 통제와 제약에서 벗어날 수 있다

Composable 함수는 여러 번 recomposition이 가능하기 때문에 Composable 내부에서 직접 effect를 실행하지 않도록 권장

 

예시1) 네트워크에서 상태를 로드하는 함수

리컴포지션이 일어날 때마다 effect가 실행됨

@Composable
fun EventsFeed(networkService: EventsNetworkService) {
    val events = networkService.loadAllEvents() // side effect
    
    LazyColumn {
        items(events) {
            event ‑>Text(text = event.name)
        }
    }
}

 

예시2) 외부 상태를 업데이트하는 side effect

effect 실행을 제어할 수 없음

// 모든 compostion, recomposition에 대해 현재 서랍상태를 TouchHandler에 알리는 함수
@Composable
fun MyScreen(drawerTouchHandler: TouchHandler) {
    val drawerState = rememberDrawerState(DrawerValue.Closed)

    // composition의 side effect
    // 함수 외부에서 전달받은 객체(handler)의 상태를 직접 수정
    drawerTouchHandler.enabled = drawerState.isOpen
    ...
}

 

 

위와 같은 이유로 Compose에서는 생명주기를 인식하는 방식으로 Side effect를 실행할 수 있는 메커니즘(Effect Handler) 제공

 

Effect Handler를 통해 여러 composition 중 작업을 지속하거나 Composable이 composition을 떠날 때 자동으로 취소되는 작업들을 수행할 수 있다

 

 

Compose에 의해 제공되는 Effect  메커니즘

  • 이펙트가 올바른 Composable 생명주기 단계에서 실행된다
  • 일시 중단된 이펙트는 적절한 런타임(코루틴 or CoroutineContext)에서 실행된다
  • 참조를 캡처하는 이펙트는 composition을 떠날 때 참조들을 폐기할 기회를 가진다
  • composition을 떠날 때 진행 중이던 일시 중단 이펙트는 취소된다
  • 실시간 입력에 의존하는 이펙트는 자동으로 폐기/취소되고 매번 변할 시 다시 시작된다

 

 

Effect Handler의 종류

이펙트는 Composable이 composition 내에 있을 시 실행될 수 있다

 

  • 비일시 중단 이펙트 (Non suspended effects)
  • 일시 중단 이펙트 (Suspended effects)

 

비일시 중단 이펙트 (Non suspended effects)

DisposableEffect

composition 생명주기의 사이드 이펙트

  • 폐기가 필요한 비일시 중단 이펙트에 사용
  • composition에 들어갈 때 처음 실행, 이후 키가 변경될 때마다 현재 이펙트 폐기 후 다시 실행, composition 떠날 때 폐기
  • 마지막에 onDispose 콜백 실행 필수
  • 최소 하나의 key 필요
  • 시스템 리소스, 이벤트 리스너 등의 관리에 사용

disposableEffect

@Composable
@NonRestartableComposable
fun DisposableEffect(
    key1: Any?,
    effect: DisposableEffectScope.() -> DisposableEffectResult
) {
    remember(key1) { DisposableEffectImpl(effect) }
}

 

예시) DisposableEffect를 사용하는 코드

@Composable
fun backPressHandler(onBackPressed: () -> Unit, enabled: Boolean = true) {
    val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher

    val backCallback = remember {
        object : OnBackPressedCallback(enabled) {
            override fun handleOnBackPressed() {
                onBackPressed()
            }
        }
    }
    
    // dispatcher가 변할 시 콜백에 연결
    DisposableEffect(dispatcher) {
        dispatcher?.addCallback(backCallback)
        onDispose {
            // 메모리 릭 방지
            backCallback.remove()
        }
    }
}

 

예시) composition 들어갈 시 한 번만 이펙트 실행, 떠날 때 폐기해야 하는 경우

// key에 상수 전달 (true, Unit)
@Composable
fun Ex1() {
    DisposableEffect(true) {
        onDispose {
            ...
        }
    }
}

 

 

SideEffect

composition이 어떤 이유로든 실패하면 폐기

슬롯 테이블에 저장되지 않는 이펙트

  • 폐기가 필요하지 않은 이펙트에 사용
  • 모든 composition, recomposition 후에 실행됨
  • 외부 상태에 업데이트를 게시할 때 사용
  • Compose 상태 시스템이 관리하지 않는 Compose가 아닌 코드(상태)에 동기화할 때 사용

sideEffect

@Composable
@NonRestartableComposable
@ExplicitGroupsComposable
@OptIn(InternalComposeApi::class)
fun SideEffect(
    effect: () -> Unit
) {
    currentComposer.recordSideEffect(effect)
}

 

예시) SideEffect 사용한 코드

@Composable
fun MyScreen(drawerTouchHandler: TouchHandler) {
    val drawerState = rememberDrawerState(DrawerValue.Closed)

    // 모든 composition, recomposition 발생 시 notify
    SideEffect {
        drawerTouchHandler.enabled = drawerState.isOpen
    }
}

 

 

currentRecomposeScope

이펙트 핸들러이기보단 이펙트 자체 (생명주기 관리하지 않고 직접적으로 composition에 영향)

View 시스템의 invalidate는 뷰에 새로운 측정, 레이아웃, 그리기 단계를 강제 실행

Compose에서는 State를 사용해 Smart Recomposition을 사용하도록 권장

 

currentRecomposeScope는 recompose를 요청할 수 있도록 하는 인터페이스

// 해당 scope을 수동으로 무효화(invalidate)하여 재구성을 예약
interface RecomposeScope {
    // composition을 로컬에서 무효화하여 recomposieion 강제
    fun invalidate()
}

 

예시) currentRecomposeScope를 사용하는 예시

interface Presenter {
    // 매개변수로 Composable 함수를 받음
    fun loadUser(after: @Composable () -> Unit): User
}

// State를 사용하지 않음
@Composable
fun MyComposable(presenter: Presenter) {
    val user = presenter.loadUser { currentRecomposeScope.invalidate() }
    
    Text("The loaded User: ${user.name}")
}

// State 사용한 일반적인 방식
var user by remember { mutableStateOf<User?>(null) }

LaunchedEffect(Unit) {
    user = presenter.loadUser()
}

 

 

일시 중단 이펙트 (Suspended effects)

rememberCoroutineScope

composition의 자식으로 생각될 수 있는 Job을 생성하는 데 사용되는 CoroutineScope 생성

  • composition 생명주기에 바인딩된 일시 중단 이펙트 실행에 사용
  • composition 생명주기에 바인딩된 CoroutineScope 생성
  • composition을 떠날 때 취소
  • composition 간에 동일한 scope 반환
  • 사용자 상호작용에 응답하여 작업 시작 시 사용
    • rememberCoroutineScope는 사용자 상호작용에 의해 시작된 작업의 범위 정하는 데 사용
    • LaunchedEffect는 composition에 의해 시작된 작업의 범위 정하는 데 사용
  • composition에 들어올 때 Applier dispatcher에서 이펙트 실행

rememberCoroutineScope

@Composable
inline fun rememberCoroutineScope(
    crossinline getContext: @DisallowComposableCalls () -> CoroutineContext =
        { EmptyCoroutineContext }
): CoroutineScope {
    val composer = currentComposer
    val wrapper = remember {
        CompositionScopedCoroutineScopeCanceller(
            createCompositionCoroutineScope(getContext(), composer)
        )
    }
    return wrapper.coroutineScope
}

 

예시) UI에서의 Throttling 구현 예시

// 입력 변경 시 이전 작업 취소하고 새로운 작업 예약
// 잠재적인 네트워크 요청 사이에 최소한의 지연을 강제
@Composable
fun SearchScreen() {
    val scope = rememberCoroutineScope()
    var currentJob by remember { mutableStateOf<Job?>(null) }
    var items by remember { mutableStateOf<List<String>>(emptyList()) }
    
    Column { 
        Row { 
            TextField(
                value = "Start typing to search",
                onValueChange = { text ->
                    currentJob?.cancel()
                    currentJob = scope.async { 
                        delay(3000L)
                        items = viewModel.search(query = text)
                    }
                }
            )
        }
        Row { ItemsVerticalList(items) }
    }
}

 

 

LaunchedEffect

Composable이 composition에 들어갈 때 Composable의 초기 상태를 로드하는 데 사용

  • composition에 진입 시 이펙트 실행(코루틴 실행) / key 변경 시 이펙트 취소하고 실행 (코루틴 실행)
  • composition 떠날 때 이펙트 취소(코루틴 취소)
  • 여러 recomposition에 걸쳐 작업을 지속하는 데 사용
  • composition에 들어올 때 Applier dispatcher에서 이펙트 실행
  • 적어도 하나의 key 필요
  • 일회성 이벤트나 애니메이션, 외부 API 호출 시 사용

LaunchedEffect

@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) { LaunchedEffectImpl(applyContext, block) }
}

 

예시) LaunchedEffect 사용한 코드

@Composable
fun SpeakerList(eventId: String) {
    var speakers by remember { mutableStateOf<List<Speaker>>(emptyList()) }
    
    // eventId 변경 시 cancel
    LaunchedEffect(eventId) {
        // 일시중단 effect
        speakers = viewModel.loadSpeakers(eventId)
    }
    
    ItemsVerticalList(speakers)
}

 

 

produceState

LaunchedEffect를 기반으로 구축된 문법 설탕(syntax sugar)

  • LaunchedEffect가 State를 채우는 경우에 사용됨
  • LauncedEffect에 의존
  • 외부 데이터 소스로부터 Compose 상태를 생성
  • 비동기 작업의 결과를 상태로 변환할 때 유용
  • key가 필수가 아님

produceState

// key 전달하지 않을 시 내부적으로 Unit을 key로 사용
@Composable
fun <T> produceState(
    initialValue: T,
    producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
    val result = remember { mutableStateOf(initialValue) }
    LaunchedEffect(Unit) {
        ProduceStateScopeImpl(result, coroutineContext).producer()
    }
    return result
}

 

예시) produceState 사용한 코드

// 상태에 대한 기본 값 제공 (initialValue)
@Composable
fun SearchScreen(eventId: String) {
    val uiState = produceState(
        initialValue = emptyList<Speaker>(),
        key1 = eventId
    ) {
        viewModel.loadSpeakers(eventId)
    }
    
    ItemsVerticalList(uiState.value)
}

@Composable
fun <T> produceState(
    initialValue: T,
    key1: Any?,
    producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
    val result = remember { mutableStateOf(initialValue) }
    LaunchedEffect(key1) {
        ProduceStateScopeImpl(result, coroutineContext).producer()
    }
    return result
}

 

 

서드 파티 라이브러리 어댑터 (Third party library adapters)

Compose는 Observable, Flow, LiveData와 같은 라이브러리 사용 시, 이에 대한 어댑터를 제공한다

(라이브러리에 따라 종속성을 추가해야 함)

모든 어댑터는 이펙트 핸들러에 위임하게 된다

Third-party 라이브러리 API를 사용해 Observer를 추가하고 방출된 모든 요소를 ad-hoc MutableState로 매핑하고, 어댑터 함수에 의해 불변의 State로 노출

 

LiveData 사용 시

class MyComposableVM : ViewModel() {
    private val _user = MutableLiveData("Park")
    val user: LiveData<String> = _user
}

@Composable
fun MyComposable() {
    val vm = MyComposableVM()
    val user by vm.user.observeAsState()
}


/* LiveDataAdapter.kt */
// 내부적으로 DisposableEffect에 의존
@Composable
fun <R, T : R> LiveData<T>.observeAsState(initial: R): State<R> {
    val lifecycleOwner = LocalLifecycleOwner.current
    val state = remember {
        @Suppress("UNCHECKED_CAST") /* Initialized values of a LiveData<T> must be a T */
        mutableStateOf(if (isInitialized) value as T else initial)
    }
    DisposableEffect(this, lifecycleOwner) {
        val observer = Observer<T> { state.value = it }
        observe(lifecycleOwner, observer)
        onDispose { removeObserver(observer) }
    }
    return state
}

 

 

Rxjava3 사용 시

class MyComposableVM : ViewModel() {
    val user: Observable<String> = Observable.just("Park")
}

@Composable
fun MyComposable() {
    val vm = MyComposableVM()
    val user by vm.user.subscribeAsState("")
}


/* RxJava3Adapter */
@Composable
fun <R, T : R> Observable<T>.subscribeAsState(initial: R): State<R> =
    asState(initial) { subscribe(it) }
    
@Composable
private inline fun <T, S> S.asState(
    initial: T,
    crossinline subscribe: S.((T) -> Unit) -> Disposable
): State<T> {
    val state = remember { mutableStateOf(initial) }
    DisposableEffect(this) {
        val disposable = subscribe { state.value = it }
        onDispose { disposable.dispose() }
    }
    return state
}

 

 

Coroutines Flow 사용 시 

class MyComposableVM : ViewModel() {
    val user: Flow<String> = flowOf("Park")
}

@Composable
fun MyComposable() {
    val vm = MyComposableVM()
    val user by vm.user.collectAsState("")
}


/* SnapshotFlow.kt */
// Flow는 일시중단 context에서 소비되어야 하기에 LaunchedEffect에 위임하는 produceState 의존
@Composable
fun <T : R, R> Flow<T>.collectAsState(
    initial: R,
    context: CoroutineContext = EmptyCoroutineContext
): State<R> =
    produceState(initial, this, context) {
        if (context == EmptyCoroutineContext) {
            collect { value = it }
        } else withContext(context) { collect { value = it } }
    }