Android Debounce Throttling
업데이트:
카테고리: Android
/태그: Debounce, Throttling
검색 엔진을 구현하는데 입력 순간순간 마다 요청을 보내게 되면 난잡하고, 요청이 너무 많이가 서버에서도 좋을 것이 없기에 입력 후 몇초간 추가적인 입력이 없으면 검색이 되도록 로직을 변경하였다.
Dedounce
프로그래밍에서의 Dedounce는 자주발생하는 이벤트를 그룹화하여 필요한 만큼만 호출
대표적인 예시가 검색, 회원가입 필드 유효성 검사이다.
검색의 경우는 입력에 대한 결과를 필터링하여 보여줘야하는데, 매입력마다 api를 호출하게되면 서버측에서도 낭비이기 때문에, 일정시간동안 입력이 없다면 그때 api를 호출하여 결과를 보여준다.
회원가입의 경우도 가입을 시도할 때 모든 필드가 정상적으로 잘 입력이 되어있는지 확인하고 딜레이를 두어 api를 호출한다.
kotlin의 debounce는 flow의 확장함수 형태로 구현이 되어 있다.
fun debounce(): Flow<Int> = flow<Int> {
emit(1)
emit(2)
delay(500L)
emit(3)
emit(4)
delay(200L)
emit(5)
delay(700L)
emit(6)
}.debounce(400L)
fun main() = runBlocking<Unit> {
debounce().collect { log(it.toString()) }
}
// main | 10:22:15:940 - 2
// main | 10:22:16:648 - 5
// main | 10:22:16:984 - 6
400ms 이내 발생하는 이벤트는 debounce하고 있어 위와 같이 로그가 남게된다.
Throttling
일정주기마다 이벤트를 전달하는 기법
debounce는 마지막 이벤트를 기준으로 일정 시간동안 이벤트가 더 없다면, 마지막 이벤트를 보내지만, Throttle은 해당 주기안에서 하나의 이벤트를 보내게 된다.
이때 보내는 이벤트는 ThrottleFrist
, ThrottleLast
가 될 수도 있다.
해당 기법을 사용하는 대표적인 예로는 버튼 중복 클릭 방지 및 활성화 처리
종아요 / 스크랩 버튼을 여러번 클릭하는 경우, 모든 클릭에 대해서 api를 호출한다면 서버에 과부하가 걸리게 된다.
그래서 ThrottleFirst
를 통해서 첫번째 이벤트만 전달되게하면, 기간동안 한번만 이벤트가 처리되기 때문에 부하가 감소한다.
메세지 전송과 같은 경우도 ThrottleLast
를 활용하여 text가 비어있을 때는 메세지 전송을 보내지 못하도록 비활성화 시켜, 불필요한 api 호출을 방지
inner class SearchTextWatcher(private val viewModel: MenuEditViewModel): TextWatcher {
private val debouncePeriod = 500L
private var job: Job? = null
override fun afterTextChanged(p0: Editable?) {}
override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
job?.cancel()
job = MainScope().launch {
delay(debouncePeriod)
viewModel.initIngredNameList()
viewModel.searchIngredientTypeList(p0.toString())
viewModel.initWarningMessage()
binding.ingredNameList.adapter = ListSpinnerAdapter(baseContext, viewModel.menuEditUiState.value.ingredientNameList)
binding.ingredUnitWraning.visibility = View.GONE
}
}
}
TextWatcher 내부에서 Job을 설정하고 새로 입력이 들어왔을 때 기존에 있던 Job을 취소하고 다시 설정한다.
이것을 Flow로 간결하게 표현한다면
// editText를 Flow로 변환
fun EditText.textChangesToFlow(): Flow<CharSequence?> {
// flow 콜백 받기
return callbackFlow {
// text가 변경될 때 따로 설정
val listener = object : TextWatcher {
override fun afterTextChanged(s: Editable?) = Unit
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) =
Unit
override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) {
Log.d(TAG, "textChangesToFlow() / TextWatcher onTextChanged text : $text")
//offer(text) is deprecated
// 값 내보내기
trySend(text)
}
}
// 위에서 지정한 editText에 대해서 listener를 등록함
addTextChangedListener(listener)
// 콜백이 사라질때 실행, 리스너 제거
awaitClose {
Log.d(TAG, "textChangesToFlow() awaitClose 실행")
removeTextChangedListener(listener)
}
}.onStart {
// Rx의 onNext 와 동일
// 콜백이 시작될때 event를 방출
Log.d(TAG, "textChangesToFlow() / onStart 발동")
emit(text)
}
}
private var myCoroutineJob: Job = Job()
private val myCoroutineContext: CoroutineContext
// 실행 위치, 관리 객체 설정
get() = Dispatchers.IO + myCoroutineJob
// Rx의 스케줄러와 비슷
// IO 스레드에서 돌리겠다
GlobalScope.launch(context = myCoroutineContext) {
// editText 가 변경되었을때
val editTextFlow = mySearchViewEditText.textChangesToFlow()
editTextFlow
// 연산자들
// 입력되고 나서 1초 뒤에 받는다
.debounce(1000)
.filter {
it?.length!! > 0
}
// flow로 collect된 값 처리
.onEach {
Log.d(TAG, "flow로 받는다 $it")
// 해당 검색어로 api 호출
searchPhotoApiCall(it.toString())
}
// 별도의 코루틴에서 이벤트를 관리할 수 있기 때문에 launchIn으로 관리
.launchIn(this)
}
override fun onDestroy() {
Log.d(TAG, "PhotoCollectionActivity - onDestroy() called")
myCoroutineContext.cancel()
super.onDestroy()
}
확실히 위의 방법보다는 아래의 방법이 좀더 직관적이고 코드가 간결하다.
job
언제든 취소 가능한 것
부모의 작업이 취소되면 모든 자식의 작업이 취소된다.
자식의 작업이 실패되면 CancellationException
을 발생시킨다.
Coroutine Job
:launch
때 생성되어 특정 블럭의 코드를 실행시키고, 그 블록이 완료되면 끝난다.CompletableJob
: 팩토리 함수로 생성된다