검색 기능을 Flow의 debounce와 flatMapLatest를 활용해 구현해보자!

akcineg·5일 전
0

안드로이드 공부

목록 보기
9/9

일반적인 검색 기능은 어떻게 구현할까?

검색 결과를 반환하는 API를 검색어가 바뀔 때마다 호출하여 결과를 받아오는 것은 굉장히 비효율적이다. 그렇기 때문에 검색 기능을 제공하는 많은 앱들은 사용자가 검색어 입력이 일정 시간을 기준으로 들어오지 않을 때 API를 호출한다.

아래 화면은 아이폰에서 '29cm' 앱을 실행시킨 후 검색어를 입력했을 때의 상황이다.

Android에서는 Kotlin Flow의 debounce를 활용하면 효율적으로 검색 기능을 구현할 수 있다.

Flow debounce

fun <T> Flow<T>.debounce(timeoutMillis: Long): Flow<T>

Returns a flow that mirrors the original flow, but filters out values that are followed by the newer values within the given timeout. The latest value is always emitted.

[해석] 일반적인 flow를 반영하지만, 주어진 Timeout 안에서 새로운 값이 나오면 그 이전의 값들은 필터링됩니다. 그리고 Timeout 내에 가장 마지막 값은 항상 emit 됩니다.
즉, Timeout 내에 더 이상 새로운 값이 들어오지 않으면 마지막으로 들어온 값이 emit 됩니다.

예제

flow {
    emit(1)
    delay(90)
    emit(2)
    delay(90)
    emit(3)
    delay(1010)
    emit(4)
    delay(1010)
    emit(5)
}.debounce(1000)

실행 결과

3, 4, 5

1은 90ms 후에 들어온 2에 의해, 2는 90ms 후에 들어온 3에 의해 필터링되어 값이 emit 되지 않았다. 하지만 3, 4, 5는 자신이 들어오고 나서, 새로운 값이 timeout == 1000ms 내에 들어오지 않았기 때문에 값이 emit 될 수 있었다.

활용

val queryResultState = _query
        .debounce(timeoutMillis = 500)
        .map {
        	...
        }
        .stateIn(
        	scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = QueryUiState.Loading
        )

ViewModel 클래스에서 query 변수를 가지고서 debounce를 적용해 정해놓은 timeout 내에 들어오는 최신 값만 emit 하는 새로운 flow를 만들 수 있다.

Flow flatMapLatest

inline fun <T, R> Flow<T>.flatMapLatest(crossinline transform: suspend (value: T) -> Flow<R>): Flow<R> 

Returns a flow that switches to a new flow produced by transform function every time the original flow emits a value. When the original flow emits a new value, the previous flow produced by transform block is cancelled.

[해석] original flow가 값을 emit할 때마다 transform 함수에 의해 생성되는 새로운 flow로 바꿔 리턴한다. orginal flow가 새로운 값을 emit 할 때 transform 함수에 의해 생성되는 flow는 취소된다.

예제

flow {
    emit("a")
    delay(100)
    emit("b")
}.flatMapLatest { value ->
    flow {
        emit(value)
        delay(200)
        emit(value + "_last")
    }
}

결과

a b b_last

orignal flow가 a를 emit할 때, transform 함수에 의해 a를 emit하고 200ms 중단되는 동안에 original flow가 새로운 값인 b를 emit하게 되어서, transform으로 생성되는 flow가 cancel 되었기 때문에 a_last가 emit 되지 않은 것이다.

활용

val queryResultState = _query
	.flatMapLatest<String, QueryUiState> { query ->
        if (query == "") {
            flowOf(QueryUiState.Empty)
        } else {
            flow {
                emit(QueryUiState.Loading)
                queryRepository.getQueryResult(query)
                    .fold(
                        onSuccess = { queryResult ->
                            emit(QueryUiState.Success(QueryResult(queryResult)))
                        },
                        onFailure = { exception ->
                            emit(QueryUiState.Error(exception.message ?: "Unknown error"))
                        }
                    )
            }
        }
    }
    .stateIn(
         scope = viewModelScope,
         started = SharingStarted.WhileSubscribed(5000),
         initialValue = QueryUiState.Loading
    )

flatMapLatest를 활용하면 API 호출 결과를 기다리는 동안에 새로운 query가 다시 들어오게 되면 API를 호출하는 flow가 cancel 되고, 새로운 query에 대해서 API를 다시 호출하기 때문에 효율적으로 API를 호출할 수 있게 된다.

전체 활용

코드

// SearchViewModel

@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
    val queryResultState = _query
        .debounce(timeoutMillis = 500)
        .flatMapLatest<String, QueryUiState> { query ->
        if (query == "") {
            flowOf(QueryUiState.Empty)
        } else {
            flow {
                emit(QueryUiState.Loading)
                queryRepository.getQueryResult(query)
                    .fold(
                        onSuccess = { queryResult ->
                            emit(QueryUiState.Success(QueryResult(queryResult)))
                        },
                        onFailure = { exception ->
                            emit(QueryUiState.Error(exception.message ?: "Unknown error"))
                        }
                    )
            }
        }
    }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = QueryUiState.Loading
        )


// QueryRepositoryImpl

class QueryRepositoryImpl @Inject constructor(
    private val queryRemoteDataSource: QueryRemoteDataSource
) : QueryRepository {
    override suspend fun getQueryResult(query: String): Result<List<String>> {
        delay(1500)
        val responseDto = queryRemoteDataSource.getQueryResponseDto(query)
        return runCatching {
            responseDto.results.juso?.map {
                it.roadAddr
            } ?: emptyList()
        }
    }
}

극적인 결과를 만들기 위해서 검색 결과 리스트를 받아오는 flow에서 검색 결과 데이터를 emit하기 이전에 1500ms의 딜레이를 주었다. 첫 번째로 500ms 시간으로 deboune를 적용하여 0.5초 timeout 내에 검색 쿼리의 변화가 없을 때 API 호출을 시작하도록 하였다. 두 번째로 flatMapLatest를 적용해 1.5초동안 검색 결과를 로드하는 동안 새로운 검색 쿼리에 대한 값이 emit되면 이전의 검색 쿼리에 대한 API 호출을 취소하고 새로운 검색 쿼리에 대해서 API 호출을 진행할 수 있다.

결과

profile
내 가치를 증명하자

0개의 댓글

관련 채용 정보