Grusie 안드로이드 개발 기술 블로그

[Android] Compose + Paging 검색 기능 만들기(Flow - debounce) 본문

안드로이드 개발/라이브러리

[Android] Compose + Paging 검색 기능 만들기(Flow - debounce)

grusie 2024. 3. 12. 21:25
728x90
반응형
SMALL

청년정책 앱을 만들던 도중, 검색 기능이 있어야겠다고 판단하여, 검색 기능을 만들게 되었다.

간단하게도 청년정책 앱의 Api는 검색 Api와, 전체를 불러오는 Api가 같은 url을 사용하는 것이라, 파라미터만 추가해주면 될 것 같았다.

 

참고

https://www.youthcenter.go.kr/opi/openApiPlcy.do

 

오픈 API | 청년정책 < 온통청년

 

www.youthcenter.go.kr

 

요청 파라미터

항목 타입 필수여부 설명
openApiVlak String Y 마이페이지 > OpenAPI관리 에서 발급받은 인증키
display Number Y 출력건수, 기본값 10, 최대 100까지 가능합니다.
pageIndex Number Y 조회할 페이지, 기본값 1
srchPolicyId String   정책 ID : 상세화면 조회 시 필수
query String   정책명, 정책소개 정보검색(<polyBizSjnm>, <polyItcnCn>)
bizTycdSel String   정책유형 : 코드값을 사용합니다. 여러개 선택 시 ,(comma)로 분리하여 전송합니다.1. 일자리 분야(023010)2. 주거 분야(023020)3. 교육 분야(023030)4. 복지.문화 분야(023040)5. 참여.권리 분야(023050)
srchPolyBizSecd String   지역코드 - 시,도 : 코드값을 사용합니다.여러개 선택 시 ,(comma)로 분리하여 전송합니다.[코드 :시,도]003002001 :서울 | 003002002 :부산 | 003002003 :대구 | 003002004 :인천 | 003002005 :광주 | 003002006 :대전 | 003002007 :울산 | 003002008 :경기 | 003002009 :강원 | 003002010 :충북 | 003002011 :충남 | 003002012 :전북 | 003002013 :전남 | 003002014 :경북 | 003002015 :경남 | 003002016 :제주 | 003002017 :세종 |
keyword String   검색 키워드여러개 키워드 입력시 ,(comma)로 분리하여 전송합니다.예) 채용,취직,구직

Service

interface PolicyService {
    @GET("youthPlcyList.do")
    suspend fun getPolicyList(
        @Query("openApiVlak")
        apiKey: String = BuildConfig.POLICY_API_KEY,                //api키
        @Query("display") display: Int = 10,                        //보여줄 페이지 수
        @Query("pageIndex") page: Int = 1,                          //조회할 페이지
        @Query("query") query: String? = null,                      //정책명, 정책소개 정보검색
        @Query("bizTycdSel") policyTypeCode: Int? = null,           //정책 유형
        @Query("srchPolyBizSecd") policyRegionCode: Int? = null,    //지역코드
        @Query("keyword") keyword: String? = null,                  //검색 키워드 예) 채용,취직,구직
        @Query("srchPolicyId") policyId: String? = null             //정책 Id
    ): PolicyList
}

 

필요한 정보들을 미리 Service에 담아두기 (추후 사용 예정)

우선 이번 시간에는 검색에 사용될 "query" 부분만 보도록 한다.

 

PagingSource

/**
 * 검색을 위한 페이징 소스
 **/
class PolicyPagingSource(
    private val policyService: PolicyService,
    private val query: String?,
    private val policyTypeCode: Int?,
    private val policyRegionCode: Int?,
    private val keyword: String?
) : PagingSource<Int, PolicyItem>() {
    override fun getRefreshKey(state: PagingState<Int, PolicyItem>): Int? {
        return null
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, PolicyItem> {
        return try {
            val page = params.key ?: 1

            val policyList = policyService.getPolicyList(
                page = page,
                query = query,
                policyTypeCode = policyTypeCode,
                policyRegionCode = policyRegionCode,
                keyword = keyword
            )
            
            val endOfPaginationReached = policyList.youthPolicy.isNullOrEmpty()

            LoadResult.Page(
                data = policyList.youthPolicy ?: emptyList(),
                prevKey = if(page <= 1) null else page - 1,
                nextKey = if(endOfPaginationReached) null else page + 1
            )
        } catch (exception: Exception){
            LoadResult.Error(exception)
        }
    }

}

 

Paging3 라이브러리를 사용하고 있기에, PagingSource를 생성했다. policyService에 각 값들을 파라미터로 넘겨 데이터를 가져오도록 구현 해둠.

로컬DB에서의 검색은 혼동을 줄 수 있기에, 서버통신으로만 처리하기 위해 RemoteMediator가 아닌 PagingSource를 사용하였음.

 

RemoteSource

interface PolicyRemoteSource {
    ...

    suspend fun getSearchPolicyList(
        query: String?,
        policyTypeCode: Int?,
        policyRegionCode: Int?,
        keyword: String?
    ): Flow<PagingData<PolicyItem>>

    ...
}

검색을 해서 받아온 데이터를 담을 RemoteSource에 함수를 추가한 뒤

 

RemoteSourceImpl

class PolicyRemoteSourceImpl @Inject constructor(
    private val policyService: PolicyService,
    private val policyInfoDB: PolicyInfoDB
) : PolicyRemoteSource {
    private val policyInfoDao = policyInfoDB.policyDao()

...
    override suspend fun getSearchPolicyList(
        query: String?,
        policyTypeCode: Int?,
        policyRegionCode: Int?,
        keyword: String?
    ): Flow<PagingData<PolicyItem>> {
        val pagingSourceFactory = {
            PolicyPagingSource(
                policyService = policyService,
                query = query,
                policyTypeCode = policyTypeCode,
                policyRegionCode = policyRegionCode,
                keyword = keyword
            )
        }
        return Pager(
            config = PagingConfig(pageSize = 10, initialLoadSize = 10),
            pagingSourceFactory = pagingSourceFactory
        ).flow
            .flowOn(Dispatchers.IO)
    }
    ...
}

RemoteSourceImpl에서 구현.

PagingSourceFactoryPagingSource를 넣고, config의 정보만큼 데이터를 가져와서, flow형태로 방출한다.

Repository

interface PolicyRepository {
    ...
    
    suspend fun getSearchPolicyList(
        query: String? = null,
        policyTypeCode: Int? = null,
        policyRegionCode: Int? = null,
        keyword: String? = null
    ): Flow<PagingData<PolicySimple>>
    
    ...
 }

Repository에 검색 함수를 추가

RepositoryImpl

class PolicyRepositoryImpl @Inject constructor(
    private val policyRemoteSource: PolicyRemoteSource,
    private val policyLocalDataSource: PolicyLocalDataSource
) : PolicyRepository {

...

    override suspend fun getSearchPolicyList(
        query: String?,
        policyTypeCode: Int?,
        policyRegionCode: Int?,
        keyword: String?
    ): Flow<PagingData<PolicySimple>> =
        policyRemoteSource.getSearchPolicyList(
            query = query,
            policyTypeCode = policyTypeCode,
            policyRegionCode = policyRegionCode,
            keyword = keyword
        ).map { pagingData ->
            pagingData.map {
                it.toPolicySimple()
            }
        }
...
}

RepositoryImpl에서 구현.

RemoteSource로 부터 검색 결과를 받아와, 원하는 형태로 매핑을 해준 뒤 리턴해준다.

 

UseCase

data class PolicyUseCases(
    val getPolicyListUseCase: GetPolicyListUseCase,
    val getSearchPolicyListUseCase: GetSearchPolicyListUseCase,		//추가
    val getPolicyDetailUseCase: GetPolicyDetailUseCase
)

UseCase에도 추가해준다. 

참고) UseCase를 제공해주는 Provides도 수정해주어야 함

 

UseCaseImpl

class GetSearchPolicyListUseCase(private val repository: PolicyRepository) {
    suspend operator fun invoke(
        query: String? = null,
        policyTypeCode: Int? = null,
        policyRegionCode: Int? = null,
        keyword: String? = null
    ) = repository.getSearchPolicyList(
        query = query,
        policyTypeCode = policyTypeCode,
        policyRegionCode = policyRegionCode,
        keyword = keyword
    )
}

UseCase구현체.

invoke()연산자를 통해 useCase를 구현.

로직은 repository를 호출하는 형태

 

ViewModel

private fun getPolicyList(query: String) {
    viewModelScope.launch {
        try {
            setPolicyUiState(PolicySearchUiState.Loading)
            policyUseCases.getSearchPolicyListUseCase(query = query)
                .cachedIn(viewModelScope)
                .collect { pagingData ->
                    _searchPolicyList.emit(pagingData)
                }
        } catch (e: Exception) {
            setPolicyUiState(PolicySearchUiState.Error(e))
        }
    }
}

대망의 뷰모델.

usecase에서 getSearchPolicyListUseCase를 호출하여, 받아오는 Flow를 collect하여 pagingData를 Flow리스트로 emit해줌.

시작 시와 에러 발생 시, 각각 Loading, Error로 UiState 변경

 

Debounce를 활용한 지연처리

@OptIn(FlowPreview::class)
val debouncedSearchQuery: Flow<String?> = searchQuery
    .debounce(Constant.SEARCH_TIME_DELAY)
    .filter { it.trim().isNotEmpty() }
    // Flow 가 되었기 때문에 distinctUntilChanged 를 달아줘 이전의 값에 대해 필터링
    .distinctUntilChanged()

var currentQuery: String? = null

init {
    viewModelScope.launch {
        debouncedSearchQuery.collect { query ->
            if (!query.isNullOrEmpty()) {
                currentQuery = query
                doSearch()
            }
        }
    }
}

검색 버튼으로도 처리하겠지만, 입력값이 없다면 자동으로 검색을 요청하기 위해 Flow의 debounce를 활용하였음.

이전 값과 같은 값은 처리하지 않도록 하기 위해 distinctUntilChanged() 사용

init에서 collect하여, 비어있지 않으면, 검색을 하는 로직

 

SearchScreen

OutlinedTextField(
    modifier = Modifier
        .fillMaxWidth(),
    value = searchQuery,
    singleLine = true,
    trailingIcon = {
        Icon(imageVector = Icons.Filled.Search, contentDescription = "search_icon")
    },
    onValueChange = { changeSearchQuery(it) },
    keyboardActions = KeyboardActions(onDone = {
        onSearch()
    })
)

TextField에서 값의 변경을 처리하고, 변경된 값은 viewModel_searchQuery를 변경하여, SEARCH_TIME_DELAY초가 지나면 검색을 처리한다.

 

val searchPolicyList = viewModel.searchPolicyList.collectAsLazyPagingItems()

PolicySimpleList(
    policyList = searchPolicyList,
    navController = navController,
    loading = { loading -> viewModel.setPolicyUiState(if (loading) PolicySearchUiState.Loading else PolicySearchUiState.Success) }
)

검색 결과를 받아서, LazyColumn에 뿌려준다.

Paging, Debounce를 활용한 검색

원하는대로 잘 나오는 것을 볼 수 있다.

 

코드

GitHub - Grusie/PolicyInfoApp: Clean Architecture + MVVM + Hilt + Compose를 적용한 청년정책 앱

 

GitHub - Grusie/PolicyInfoApp: Clean Architecture + MVVM + Hilt + Compose를 적용한 청년정책 앱

Clean Architecture + MVVM + Hilt + Compose를 적용한 청년정책 앱 - Grusie/PolicyInfoApp

github.com

후기

useCase를 추가하고 Repository를 추가하고 서버통신의 파라미터를 늘려가면서, 클린 아키텍처에 대한 이해를 더 하였고, 서버통신에 관한 것을 뷰에서 완전 모른다는 것에 더더욱 매력을 느꼈다. 코드 수정하는 것을 간편하게 했던 것 같아 좋았던 것 같다.

하지만 컴포즈를 사용하면서 UiState가 변경 될 때 화면이 다시 그려지면서 이전 화면이 깜빡이던 현상을 고치느라 3일 가까이 쓴 것 같다. 사이드 이펙트를 사용하여 버그를 잡을 수 있었고, 컴포즈에 대해서 다음 번에 한 번 더 정리해야겠다고 생각하였다.

반응형
LIST