Android Kotlin Flow를 사용하여 자동으로 데이터를 refresh하는 전략

woga·2023년 10월 10일
0

Android 공부

목록 보기
46/49

Making timers lifecycle-aware

타이머의 수명주기를 인식하도록 만들기

아티클 : https://bladecoder.medium.com/strategies-for-automatically-refreshing-data-on-android-using-kotlin-flow-cd23ba7cfbe0

이 아티클은 Andoird 앱 내 데이터를 효율적으로 로드하기 위해 Flow를 사용하기 위한 시리즈 중 세번째 글이다. 전 시리즈 글이 궁금하면 위 링크를 타고 들어가서 확인해주길 바란다!

Simple periodic refresh

UI에 표시되는 데이터 셋이 언제 변경됐는지 판단하기 힘들거나 너무 자주 변경되면 주기적으로 데이터를 다시 로드하게끔 한다.

이 때 가장 간단한 방법 중 하나는 무한 루프에서 emit 후 delay()를 호출하는 Flow를 만드는 것이다

fun tickerFlow(period: Duration): Flow<Unit> = flow {
    while (true) {
        emit(Unit)    // Tick
        delay(period)
    }
}

이는 RxJava에서 최초의 delay 값은 0으로 두고 고정된 emitted value(Unit)을 갖는 Observable.interval()와 동일하다

그리고 아래 예시처럼 map()이나 mapLatest()를 사용해서 타이머의 "tick" 기능을 수행하고 결과를 반환한다.

tickerFlow(REFRESH_INTERVAL)
    .map {
        repository.loadSomeData()
    }

그러나 map()과 mapLatest()를 쓰는 것은 미묘한 동작 차이가 있는데 이를 유의하자.

  • 전체 Flow에서 map()은 단일 코루틴 내에서 순차적으로 실행된다. 즉, delay() 함수가 로드 작업이 완료된 후에만 실행이 된다. 결과적으로 각 로드되는 작업은 이전 로드 작업을 수행하는데 걸린 시간에 고정된 delay 간격을 더한 만큼 지연될 것이다.

  • 메인 코루틴에서 mapLatest()은 하위 코루틴이 tickerFlow를 생성하여 로딩 작업이 동시에 수행되어 메인 코투린을 일시 중단하지 않고 결과를 수집하는 동안 *upstream 값만 수집하게 된다.
    즉, 이전 tick 직후에 delay()가 실행되고 각 로드 작업이 스케줄에 따라 정확하게 시작된다.
    이는 또한 타임아웃 역할을 하기 때문에 interval이 일반적인 로딩 시간 보다 길어야한다. tickerFlow()가 새 값을 emit할 때 시간 내에 완료되지 않은 이전 로딩 작업은 취소되기 때문이다. 이 때 다음 하위 코루틴이 이전 하위 코루틴을 대체하여 다음 로드 작업을 실행한다.


*참고 : upstream

이 간단한 구현은 이전 실행을 고려하지 않고 Flow collection이 시작되거나 다시 시작될 때 항상 새로운 로드를 트리거한다. 이는 로딩 작업이 간단한 피쳐들만 몇 초의 짧은 간격을 두고 실행하기엔 충분할 것이다.

Making refresh smarter in order to leverage caching

그러나 반대로 refresh 되는 간격이 길고 로드 작업 자체가 많은 리소스가 필요한 경우 (ex. api call) 위의 로직은 효율적이지 않다.

일시적으로 숨겨진 화면이 다시 display되고 Flow collection이 다시 시작되면 다음을 수행하고 싶다.

"몇 분 또는 몇 시간 동안 새로운 데이터를 다시 로드하는 불필요한 작업을 피하자"

그러면 위의 tickerFlow()StateFlow를 함께 사용하면 스마트하게 구현할 수 있다.

fun synchronizedTickerFlow(
    period: Duration,
    subscriptionCount: StateFlow<Int>,
    timeSource: TimeSource = ElapsedRealTimeSource
): Flow<Unit> {
    return flow {
        var nextEmissionTimeMark: TimeMark? = null
        flow {
            nextEmissionTimeMark?.let { delay(-it.elapsedNow()) }
            while (true) {
                emit(Unit)
                nextEmissionTimeMark = timeSource.markNow() + period
                delay(period)
            }
        }
            .flowWhileShared(subscriptionCount, SharingStarted.WhileSubscribed())
            .collect(this)
    }
}

이 함수는 수명주기를 인식하여 상위 StateFlow에 구독자가 하나 이상 있는 동안에만 값을 내보낸다.
더욱 스마트한 점은 nextEmissionTime을 기억한다는 것이다. 따라서 구독자가 없는 onPause()에서 onResume() 한 후 해당 시간에 도달할 때까지 기다렸다가 다음 값을 내보내게 된다.

  • 여기서 Duration, TimeSource, TimeMarkkotlin.time 코틀린 표준 라이브러리 버전 1.9.0에 포함된 api를 기반으로 사용된다

  • 함수가 반환되는 main Flow는 내부적으로 a secondary Flow의 output을 수집한다. main Flow는 단순히 상태 (nextEmissionTime)를 캡슐화하고 모든 Flow 연산자의 경우와 마찬가지로 각 컬렉션에 대해 로컬로 만드는 것이다.
    a secondary Flow는 flowWhileShared() 덕분에 상위 라이프사이클(subscriptionCount가 0에 도달하면 즉시 중지)에 따라 시작 및 중지되는 Flow다.

Note: we don’t pass any timeout value to the sharing strategy SharingStarted.WhileSubscribed() because the cost of stopping and restarting the secondary Flow is cheap.
(secondary Flow를 중지하고 다시 시작하는데 드는 리소스가 별로 들지 않아서 시간 초과 값을 전달하지 않음)

  • 그리고 심플한 방법으로 구현한 tickerFlow와 차이점이 있다면 첫 번째 tick이 nextEmissionTimeMark에 도달할 때만 emit 된다는 것이다. TimeMark는 한 시점을 나타내며 nextEmissionTimeMark는 다음 방출이 발생해야 하는 가장 이른 시점이다. (최신 시점)

    • 왜냐면, main flow {} 가 시작되고 nextEmissionTimeMark가 null이라 첫번째 틱이 지연 없이 즉시 방출된다. 그런 다음 each emission 후에 다음 emission 의 future point는 markNow()를 사용하여 TimeSource에서 현재 시점을 갖고와서 파라미터로 받은 period: Duration을 추가하여 계산한다.

    • 그리고 seconday flow에서 delay로 일시 중지되고 다시 시작되면 nextEmissionTimeMark가 null이 아닌 경우, 해당 시점에 도달할 때까지 기다리는 시간(Duration)은 TimeMark에서 elapsedNow()를 호출하여 결과를 negating으로 계산한다. elapsedNow() 는 실제로 TimeMark와 Now 사이의 경과된 시간을 반환하므로 TimeMark가 미래에 있을 경우 음의 값이기 때문이다. Duration이 음수인 delay()는 영향을 미치지 않고 즉시 반환되므로 다음 배출 시간에 이미 도달하여 현재인 경우를 별도로 처리할 필요가 없습니다.

The importance of using the right TimeSource

이 코드가 제대로 작동하려면 monotonic TimeSource를 사용해야 한다. monotonic clock이란 항상 앞으로 이동하며 조정하거나 재설정할 수 없는 clock이다. 그래서 kotlin.time은 이미 TimeSource.Monotonic을 제공하고 있으며, 이는 JVM과 Android의 System.nanoTime()을 기반으로 한다. JVM과 Android에선 이 목적을 이미 제공하고 있다.

JVM에는 화면이 꺼진 후 발생할 수 있는 최대 절전 모두에 들어가면 CPU가 시계를 멈추기 때문에 Android 애플리케이션에 문제를 일으킬 수 있다.
즉, 사용자가 10분 동안 깊은 절전 모드에 들어갔다가 Android 기기의 잠금을 해제하고 애플리케이션으로 돌아가면 데이터 새로 고침이 10분 늦게 트리거 된다.

Android에서 이 케이스에 적한한 시계는 SystemClock.elapsedRealtimeNanos() 이다. 이는 장치가 딥 슬립 모드에서 보낸 시간을 포함하는 나노초 정밀도를 가진 모노톤 시계다.

Kotlin 용 공식 Android Jetpack 라이브러리는 아직 이 시계 기반을 제공하지 않아서 TimeSource 자체적으로 다음 코드를 만들어서 쓰면 된다.

object ElapsedRealTimeSource : AbstractLongTimeSource(DurationUnit.NANOSECONDS) {
    override fun read(): Long = SystemClock.elapsedRealtimeNanos()
    override fun toString(): String = "TimeSource(SystemClock.elapsedRealtimeNanos())"
}

구현된 코드에서 TimeSource가 synchronizedTickerFlow() 파라미터로 전달되므로 Test 코드를 짤 때도 TestTimeSource를 사용하는 등 구현을 쉽게 스왑할 수 있다.

Putting it all together

팩토리와 사용해서 위 함수를 조합해서 사용할 수 있다. example을 같이 보자.

@OptIn(ExperimentalCoroutinesApi::class)
val results: StateFlow<Result> = stateFlow(viewModelScope, Result.Empty) { subscriptionCount ->
    synchronizedTickerFlow(REFRESH_PERIOD, subscriptionCount)
        .mapLatest {
            repository.loadSomeData()
        }
}

다음은 이 코드가 UI 상태 변경에 단계별로 반응하는 방식이다.

  1. UI가 처음 표시되고 resultsStateFlow 수집을 시작하면 synchronizedTickerFlow()는 주기적으로 새 값을 방출하기 시작하여 최신 데이터 로드가 트리거된다. 이 데이터는 StateFlow에 캐시되어 현재 및 미래의 모든 구독자와 공유된다.

  2. UI가 보이지 않게 되고 StateFlow 수집을 중지하면 기본 흐름은 활성 상태로 유지되지만 tiker은 새 값을 내보내지 않으므로 새 데이터가 로드되지 않는다. 리소스 절약 굿

  3. UI가 다시 표시되고 다시 수집을 시작하면 즉시 StateFlow의 캐시된 값을 받게 된다. main flow에서 ticker가 재개된다. 그러나 다음 방출의 계획된 시간이 도달할 때까지 기다린 후 아무것도 방출하지 않는다. 이렇게 하면 데이터가 무조건 교체되는 대신 유효한 한 StateFlow 캐시에 보존된다. 이제 우리는 더 많은 자원을 절약하게 된다.

Advanced use case: sharing the time reference

더 진화된 사례로 "지난 10분의 일정이 다음 10분의 일정을 주기적으로 모두 로드해서 결과가 겹치지 않도록 하고 싶다"를 하고 싶을 수 있다.

해당 기준 시점은 이전 예와 동일하게 synchronizedTickerFlow()를 사용하여 주기적으로 업데이트하고 stateFlow()를 사용하여 캐시 및 공유할 수 있다.

private val timeReferenceFlow: Flow<Instant> = stateFlow(viewModelScope, null) { subscriptionCount ->
    synchronizedTickerFlow(REFRESH_PERIOD, subscriptionCount)
        .map { Instant.now() }
}.filterNotNull()
val results1: StateFlow<Result> = stateFlow(viewModelScope, Result.Empty) { subscriptionCount ->
    timeReferenceFlow
        .flowWhileShared(subscriptionCount, SharingStarted.WhileSubscribed())
        .distinctUntilChanged()
        .map { timeReference: Instant ->
            repository.loadDataForTime(timeReference)
        }
}

더 자세한 내용은 원문을 확인해주길!

Summary

Ticker Flow를 사용하는 것은 Kotlin 애플리케이션에서 사용자에게 제공되는 데이터를 주기적으로 업데이트하는 간단하고 우아한 방법이다. Android에서 효율적인 캐싱을 구현하기 위해 데이터를 노출하는 StateFlow가 더 이상 구독자가 없을 때 이를 중지하고 적절한 monotonic clock에 따라 다음 Tick의 시간을 기억함으로써 타이머를 UI 라이프사이클과 동기화할 수 있다.

That complexity can be hidden behind a few reusable Flow operators. (이러한 복잡성은 재사용 가능한 몇 개의 Flow 연산자 뒤에 감출 수 있다.)

profile
와니와니와니와니 당근당근

0개의 댓글