검색 결과를 반환하는 API를 검색어가 바뀔 때마다 호출하여 결과를 받아오는 것은 굉장히 비효율적이다. 그렇기 때문에 검색 기능을 제공하는 많은 앱들은 사용자가 검색어 입력이 일정 시간을 기준으로 들어오지 않을 때 API를 호출한다.
아래 화면은 아이폰에서 '29cm' 앱을 실행시킨 후 검색어를 입력했을 때의 상황이다.
Android에서는 Kotlin 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를 만들 수 있다.
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 호출을 진행할 수 있다.