프로그래밍/Android

[안드로이드] Kotlin Flow를 이용한 순간검색 (Instant Search)을 구현해보자

Lou Park 2020. 9. 27. 14:45

내가 만든 앱에서 순간검색을 지원하고 싶었던 순간이 많았는데...

원래 되는대로 검색 요청 날리다가 이번에 새로운 방법을 알게되서 적어본다.

Kotlin에서 Flow가 뭘까...공부하다가 나온 예제에서 발견했다.

완성하면 이렇게된다.




전체 프로젝트 Github

https://github.com/lx5475/Kotlin-Flow-Instant-Search



간단하게 치킨집 목록을 검색하는 걸로 시작해보도록하겠다.


1
2
3
4
5
6
7
8
9
10
11
12
13
dependencies {
    // RecyclerView
    implementation "androidx.recyclerview:recyclerview:1.1.0"
    // ViewModel
    def lifecycle_version = "2.2.0"
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
    // LiveData
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
    // Koin
    def koin_version = '2.1.6'
    implementation "org.koin:koin-core:$koin_version"
    implementation "org.koin:koin-android-viewmodel:$koin_version"
}
cs


activity_main.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
 
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_result"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/et_search"/>
 
    <EditText
        android:id="@+id/et_search"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
 
</androidx.constraintlayout.widget.ConstraintLayout>
cs


MainActivity.kt

et_search.onTextChanged는 내가 만든 Extension인데 TextWatcher > onTextChanged 내부에서 구현해주면된다.


EditText에서 문자가 업데이트되는대로 queryChannel에 보내주고있다.

이렇게 해서 검색어를 view에서 ViewModel단으로 옮길 수 있다.

사용할 수 있는 메소드는 offer()와 send() 두가지가 있다.


* 문서에보면 offer는 즉시 element를 채널에 동기적으로 전송한다고 되어있고

send()는 element를 채널로 전송하되, 채널의 버퍼가 가득차거나 존재하지 않는 경우 지연시킨다라고 되어있다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class MainActivity : AppCompatActivity() {
 
    private val viewModel: MainViewModel by viewModel()
    private lateinit var adapter: ChickenAdapter
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
 
        init()
 
        // 검색 결과를 observe하여 Adapter에 등록
        viewModel.searchResult.observe(this, Observer {
            adapter.submitList(it)
        })
 
        et_search.setText(""// 처음에 모든 치킨집 리스트 보여주기 위함
    }
 
    private fun init() {
        adapter = ChickenAdapter()
        // RecyclerView 설정
        rv_result.adapter = adapter
        rv_result.setHasFixedSize(true)
        rv_result.layoutManager = LinearLayoutManager(this)
        // EditText 입력 값에 변화가 있으면 BroadcastChannel로 값 전송
        et_search.onTextChanged { s, start, before, count ->
            val queryText = s.toString()
            // Channel에 queryText 전송, Channel 용량을 침범하지 않았다면 true 아니면 false 리턴
            viewModel.queryChannel.offer(queryText)
        }
    }
}
cs

* send()를 사용하여 비동기적으로 채널에 element를 전송하는 방법.
lifecycleScope 사용을 위해 추가 dependency가 필요하다.

1
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
cs

1
2
3
4
5
6
7
        et_search.onTextChanged { s, start, before, count ->
            lifecycleScope.launch {
                val queryText = s.toString()
                // Channel에 queryText 전송, Channel 용량을 침범하지 않았다면 true 아니면 false 리턴
                viewModel.queryChannel.send(queryText)
            }
        }
cs


MainRepository.kt

Koin에 대한 포스팅은 아니니까 생략하고~

Api를 호출하는 걸 흉내내기 위해서 Repository에 다음과 같이 구현해주었다.

검색어가 들어오면 해당 텍스트가 들어가는 아이템만 뽑아서 반환한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class MainRepository() {
    private val list = arrayListOf(
        "범프리카 인생치킨",
        "더꼬치다",
        "치킨대가",
        "처갓집",
        "오 순살",
        "후라이드참잘하는집",
        "박옥녀",
        "치킨더홈",
        "꾸브라꼬숯불두마리치킨",
        "갓튀긴후라이드",
        "쌀통닭",
        "21세기굽는치킨",
        "림스치킨",
        "치킨신드롬",
        "철인7호",
        "동키치킨",
        "굽네치킨",
        "땅땅치킨",
        "썬더치킨",
        "BBQ",
        "Bhc",
        "장수통닭",
        "동근이숯불두마리치킨",
        "갓튀긴후라이드"
    )
 
    suspend fun search(text: String?): List<String> {
        delay(100// 실제 Api 호출인것처럼 하기위해 딜레이 넣음
        return list.filter { it.contains(text ?: "") }
    }
}
cs


MainViewModel.kt

mapLatest에서 Api 호출 결과를 받는다.

만약에 이전에 검색어를 이용해서 Api 결과를 받아오는 도중 새로운 검색어가 들어온다면,

이전 호출은 취소된다.


catch는 위에 블록까지만 잡아주므로 작성 순서에 유의하자!


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@ExperimentalCoroutinesApi
@FlowPreview
class MainViewModel(
    application: Application,
    repository: MainRepository
) : AndroidViewModel(application) {
 
    private val SEARCH_TIMEOUT = 300L
    /*
    Channel.CONFLATED는 ConflatedChannel를 생성
    ConflatedChannel:
        보내진 element 중에서 하나의 element만 버퍼링하므로서
        Receiver가 항상 최근에 보내진 element를 가져올 수 있도록함
     */
    val queryChannel = BroadcastChannel<String>(Channel.CONFLATED)
 
    val searchResult = queryChannel
        .asFlow() // BroadcastChannel을 hot flow로 바꿈
        // search() 호출 속도를 조절할 수 있음. 
        // 해당 ms동안 새로운 텍스트를 입력하지 않으면 search() 호출
        .debounce(SEARCH_TIMEOUT) 
        .mapLatest { text ->
            // 여기에 실제 Api를 호출하는 코드를 적어주시면 됩니다.
            withContext(Dispatchers.IO) {
                repository.search(text)
            }
        }
        .catch { e: Throwable ->
            // 에러 핸들링은 여기서!
            e.printStackTrace()
        }
        .asLiveData()
}
cs



더 궁금한 것이 있다면 아래 링크를 참조

역시나 포스팅은 귀찮지만 내가 얼마나 모르고 사용했는지 알게되네...


Broadcast Channel Documentation

https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.channels/-broadcast-channel/


Instant Search with Kotlin Coroutines

https://www.hellsoft.se/instant-search-with-kotlin-coroutines/


Kotlin Flow for Android: Getting Started

https://www.raywenderlich.com/9799571-kotlin-flow-for-android-getting-started