자동 검색 기능이란?
검색어를 입력하고 검색 버튼을 눌러야 검색한 단어에 해당하는 결과를 가져오는 것이 아닌, 검색어를 입력하고 나서 일정한 시간내에 별다른 입력이 주어지지 않으면 검색어를 통해 바로 결과를 호출하는 것을 의미한다.
카톡에서도 최상단에 검색탭을 통해 대화 방을 찾을 때에도 이 방법을 사용하고 있다.
이때 문제는 ㅇ-> 이-> 입-> 입ㄹ-> 입려-> 입력
이런식으로 검색어를 입력하며 검색어가 변경될때마다 API 를 호출해준다면 단위시간동안 너무 많은 API호출이 이뤄질 수 있기 때문에
debounce와 throttle 과 같은 방법을 사용하여 이 문제를 해결해야 한다.
나는 이 문제를 해결하기 위해 flow의 debounce 함수를 적용하였다.
xml 에서는 자동 검색 기능을 구현하기 위해 사용 했던 방법은 다음과 같다.
TextWatcher 를 이용한 확장함수
함수의 이름 그대로 입력한 단어의 변화를 플로우로 방출해주는 함수이다.
fun EditText.textChangesToFlow(): Flow<CharSequence?> {
return callbackFlow {
val listener = object : TextWatcher {
override fun afterTextChanged(p0: Editable?) = Unit
override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) = Unit
override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) {
// 변경된 값을 방출
trySend(text)
}
}
// 리스너 등록
addTextChangedListener(listener)
// 플로우가 종료되면 리스너 제거
awaitClose { removeTextChangedListener(listener) }
}
}
Activity or Fragment(Fragment 의 경우 onViewCreated 내부에서 호출)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setupTabsWithNavigation()
initObserver()
}
private fun initObserver() {
repeatOnStarted {
// 검색어를 입력하는 곳에서 선언
launch {
// 입력에 변화로 방출되는 플로우에 대해서
val editTextFlow = binding.etSearch.textChangesToFlow()
editTextFlow
// debounce 를 걸어주어 지정한 시간동안 변경 사항이 없을 경우 값을 수집하도록 함
.debounce(SEARCH_TIME_DELAY)
// 빈 값 무시
.filter {
it?.isNotEmpty()!!
}
.collect { text ->
text?.let {
// 공백을 제거
val query = it.toString().trim()
// 검색어 갱신
searchViewModel.updateSearchQuery(query)
}
}
}
// 리스트를 출력하는 곳에서 선언
// Adapter 와 ViewHolder 코드는 생략
launch {
viewModel.searchVideos.collectLatest {
videoSearchAdapter.submitData(it)
}
}
}
}
RepeatOnLifecycle API 를 간편하게 사용하기 위한 확장함수
fun LifecycleOwner.repeatOnStarted(block: suspend CoroutineScope.() -> Unit) {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED, block)
}
}
ViewModel
private val _searchQuery: SaveableMutableStateFlow<String?> = savedStateHandle.getMutableStateFlow(KEY_SEARCH_TEXT, null)
val searchQuery = _searchQuery.asStateFlow()
// 검색어 업데이트
fun updateSearchQuery(query: String) {
_searchQuery.value = query
}
val searchVideos: Flow<PagingData<VideoItem>> =
// 검색어가 존재할때만 다음 연산을 수행
searchQuery.filterNotNull()
// 두 Flow(검색어, 정렬기준) 를 하나의 Flow 로 결합, 하나의 pair 로 만들어 방출
.combineTransform(searchSortMode) { query, sortMode -> emit(query to sortMode) }
// 새로운 pair 값이 방출 될때마다 이전의 Flow를 취소하고 새로운 Flow 를 시작
// 검색어, 정렬기준 둘중 하나만 값이 변해도 이전 검색을 취소하고 새로운 검색을 시작
.flatMapLatest { (query, sortMode) ->
getVideoSearchListUseCase(query, sortMode)
.map { pagingData ->
pagingData.map { videoEntity ->
videoEntity.toItem()
}
}
}
// 검색 결과를 viewModel의 생명주기와 연결
// 검색의 결과를 viewModel에 캐싱
// 뷰모델이 파괴되면 검색 결과도 사라지며
// configuration change 가 발생해도 이전 검색 결과를 재사용할 수 있음
.cachedIn(viewModelScope)
debouce를 제거하면 변화되는 입력에 대해 바로 바로 검사를 수행 할 수 있기 때문에 입력에 대한 validation을 수행할때도 유용하여 자주 사용해오고 있었다.
이 화면을 이제 컴포즈로 변경해서 만들어보도록 하겠다.
뷰로 만든 화면을 컴포즈로 Migration 하는게 아닌 같은 기능을 수행하는 똑같은 화면을 Compose 로 만들어보는 것이기 때문에 앞으로 만들 컴포즈로 구성된 화면과 기존의 XML 기반의 화면은 같은 뷰모델을 쓰게끔 만들 것이다.
(MVVM 패턴에서 뷰모델은 뷰에 의존적이지 않기 때문에, 뷰모델은 뷰를 몰라야 한다. 따라서 뷰가 교체 되어도 문제 없이 작동 시키는 것이 목표이다!)
우선 컴포즈와 기존의 뷰는 입력을 받고 이를 텍스트 필드에 반영하는 로직이 아예 다르다.
일단 가장 큰 차이점은 TextWatcher 를 사용하지 못한다.
(따라서 TextWatcher를 이용해서 만들었던 확장함수를 사용할 수 없다!!)
@Composable
fun KakaoMediaSearchApp(
viewModel: SearchComposeViewModel = hiltViewModel()
) {
// 검색어
val searchQuery by viewModel.searchQuery.collectAsState()
// 검색 결과
val videoItems = viewModel.searchVideos.collectAsLazyPagingItems()
...
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
// 현재까지 입력된(변경된) 검색어를 화면에 표시
value = searchQuery ?: "",
singleLine = true,
onValueChange = {
// 변경된 값을 업데이트
val query = it.trim()
viewModel.updateSearchQuery(query)
},
leadingIcon = {
Icon(
Icons.Filled.Search,
contentDescription = "Search Icon"
)
},
placeholder = {
Text(
text = stringResource(R.string.search),
fontWeight = FontWeight.Thin
)
},
)
...
위와 같이 검색어를 입력받고 그것을 화면에 반영, 혹은 뷰모델에 선언한 변수의 값을 변경하는 로직이 아예 달라졌다.
그렇다면 debounce 는 어떻게 설정해줘야 할까?
첫번째 시도에서는 기존에 뷰에서와 같이 화면, 즉 스크린 내에서 debouce 를 걸어 일정한 시간이 지나도 값이 변하지 않으면 updateSearchQuery 함수를 호출하는 것을 구현해보려고 하였지만(searchQuery 변수의 타입이 State<T
> 이므로 snapshotFlow 등의 SideEffect API 를 사용해서 구현)
TextField 의 검색어가 변경되면 어차피 onValueChange가 동작하기에 onValueChange = {} 내부에서 코루틴 스코프를 열어 state 를 Flow 로 변환하고 debounce 를 걸어주는 작업을 하는 것이 좀 무겁다는 생각이 들었다. Side Effect API 의 대한 사용 숙련도도 낮고
또한 API 호출을 위한 검색어의 변화를 필터링하는 작업은 뷰가 아닌 뷰모델에서 해주는 것이 더 적절하다고 판단하였기 때문에 해당 작업을 뷰모델 내부에서 진행하는 것으로 결정했다.
변화된 뷰모델 코드는 다음과 같다
private val _searchQuery: SaveableMutableStateFlow<String> =
savedStateHandle.getMutableStateFlow(
KEY_SEARCH_QUERY, ""
)
val searchQuery = _searchQuery.asStateFlow()
// 뷰에서 해줬던 validation 작업을 viewModel 로 옮김
val debouncedSearchQuery: Flow<String?> = searchQuery
// StateFlow -> Flow
.debounce(SEARCH_TIME_DELAY)
.filter { it.isNotEmpty() }
// Flow 가 되었기 때문에 distinctUntilChanged 를 달아줘 이전의 값에 대해 필터링
.distinctUntilChanged()
fun updateSearchQuery(query: String) {
_searchQuery.value = query
}
val searchVideos: Flow<PagingData<VideoItem>> =
debouncedSearchQuery.filterNotNull()
.combineTransform(searchSortMode) { query, sortMode -> emit(query to sortMode) }
.flatMapLatest { (query, sortMode) ->
getVideoSearchListUseCase(query, sortMode)
.map { pagingData ->
pagingData.map { videoEntity ->
videoEntity.toItem()
}
}
}
.cachedIn(viewModelScope)
searchQuery 의 값이 순간 순간 계속 변경되어도 debouncedSearchQuery 는 이전 처럼 마지막 입력이 입력된 후 일정한 시간이 지난 후에 변경된다. debouncedSearchQuery 의 값이 변경되어야 API 가 호출되기 때문에 뷰모델의 적절한 수정을 통해 요구사항을 충족시킬 수 있었다.
변경된 xml 기반의 코드는 다음과 같다.
private fun initObserver() {
repeatOnStarted {
launch {
// 콜백을 플로우로 변환하는 확장함수를 제외 전부 뷰모델로 이전(간결 간결)
binding.etSearch.textChangesToFlow().collect { text ->
text?.let {
val query = it.toString().trim()
viewModel.updateSearchQuery(query)
}
}
}
}
위의 코드가 적용된 프로젝트는 아래 링크를 통해 확인할 수 있다.
https://github.com/easyhooon/KakaoMediaSearchApp2
추가)
위와 같이 요구사항을 성공적으로 구현한 이후 여러 차례 테스트를 하던 중 버그를 발견하여, 해당 이슈를 해당 하는 과정은 글이 너무 길어져 2편으로 나눠 작성하였다.
참고)