-
Notifications
You must be signed in to change notification settings - Fork 0
버튼 연속 클릭 문제(다중 터치 : throttleFirst)
앱을 개발하면서 테스트를 진행할 때, 특정 부분에서 빠른 속도로 클릭을 진행하면 이벤트가 여러 번 발생한다는 문제가 있었다.
대표적으로 PhotoPicker를 사용하는 경우, PhotoPicker를 실행시키는 버튼을 연타하면 2~3개 정도의 PhotoPicker가 여러 개 표시되는 문제가 있었고, 일정이 존재하는 캘린더의 부분을 연타하면 Bottom Sheet가 여러 번 표시되면서 Exception으로 앱이 죽는 경우가 발생하였다.
throttleFirst
를 통해서 원하는 기능을 개발할 수 있었다.
throttleFirst는 RxJava 라이브러리에서 제공하는 함수 중 하나로 특정 기간 동안에 첫 번째 이벤트를 발생시키고 그 이후에는 일정 기간 동안의 이벤트를 무시하는 데 사용하여 다중 터치 문제를 해결할 수 있다.
하지만 Coroutine Flow를 사용하고 있기 때문에 RxJava 라이브러리에서 제공하는 throttleFirst를 사용하기에는 문제가 있었다.
Coroutine Flow를 통해 간단히 구현할 수 있으므로 샘플과 함께 throttleFirst의 연산자 추가는 아직 계획에 없다는 것을 확인할 수 있었고, 직접 throttleFirst를 구현하여야 했다.
const val THROTTLE_DURATION = 1000L
fun <T> Flow<T>.throttleFirst(windowDuration: Long): Flow<T> = flow {
var lastEmissionTime = 0L
collect { upstream ->
val currentTime = System.currentTimeMillis()
if (currentTime - lastEmissionTime > windowDuration) {
lastEmissionTime = currentTime
emit(upstream)
}
}
}
최소 발행과 이후 발행에서lastEmissionTime
을 갱신해 주면서 currentTime
과의 차이를 계산하여 처음 설정한
windowDuration
보다 컸을 경우만 emit
하도록 구현하였다.
즉, THROTTLE_DURATION
으로 1000L
을 설정하였다면 최초 발행 이후, 1초 사이에 발생한 이벤트는 무시되는 형태이다.
기존에 적용했던 코드는 View에 setOnClickListener
를 통해 이벤트를 설정하여서 해당 작업을 수행하도록 하였다.
하지만 throttleFirst를 적용하기 위해서는 그 부분을 수정할 필요가 있었다.
fun View.clicks(): Flow<Unit> = callbackFlow {
setOnClickListener {
this.trySend(Unit)
}
awaitClose { setOnClickListener(null) }
}
fun View.setClickEvent(
uiScope: CoroutineScope,
windowDuration: Long = THROTTLE_DURATION,
onClick: () -> Unit,
) {
clicks()
.throttleFirst(windowDuration)
.onEach { onClick.invoke() }
.launchIn(uiScope)
}
View에 확장함수로 clicks
와 setClickEvent
를 설정하였다.
이를 통해서 setOnClickListener
를 대신하여 setClickEvent
를 사용하여 이벤트를 등록하였다.
callbackFlow
를 통해 클릭하면 Unit
이 보내지고 throttleFirst
를 통해서 최초 발행된 이벤트만 emit
되게 된다.
그렇기 때문에 onEach
에서 setClickEvent
를 등록한 onClick
이벤트가 발생하여 throttleFirst
를 통해 클릭 이벤트를 구현할 수 있었다.
비슷한 방법으로 MaterialToolbar
에서 menu를 클릭했을 경우도 처리할 throttleFirst
를 통해 구현할 수 있었다.
fun MaterialToolbar.menuClicks(): Flow<Int> = callbackFlow {
setOnMenuItemClickListener { menuItem ->
this.trySend(menuItem.itemId)
true
}
awaitClose { setOnMenuItemClickListener(null) }
}
fun MaterialToolbar.setMenuClickEvent(
uiScope: CoroutineScope,
windowDuration: Long = THROTTLE_DURATION,
onMenuClick: (menuId: Int) -> Unit,
) {
menuClicks()
.throttleFirst(windowDuration)
.onEach { onMenuClick.invoke(it) }
.launchIn(uiScope)
}
위에서 했던 경우는 Activity나 Fragment에서 바로 적용할 수 있는 방법이었다.
하지만 ViewModel에서 발생한 이벤트에 대해서는 어떻게 처리할 수 있을까?
ViewModel에 해당하는 ClickListener 인터페이스를 적용하여 Adapter에 넘겨주는 방식으로 ViewHolder에서 발생한 이벤트를 ViewModel에서 처리할 수 있도록 처리하였는데, 이 과정에서 throttleFirst를 어디에 적용하여야할지 고민을 많이 했다.
특히 RecyclerView 사용 시, ViewHolder에서 이벤트가 발생했을 때 어떻게 처리할지 어려움에 부딪혔다.
ViewHolder에서 throttleFirst를 사용하기 위해서 관리할 수 있는 lifecycleScope가 필요한데, 어떤 lifecycleScope를 사용해야할지, 어떻게 사용해야하는지에 대한 문제가 발생했다.
ViewHolder에서 Lifecycler이 필요했기 때문에 findViewTreeLifecycleOwner
를 사용해 보기로 하였다.
findViewTreeLifecycleOwner
는 View의 계층 구조에서 뷰가 속한 LifecycleOwner
를 찾아 반환하는데, bind하는 과정에서 findViewTreeLifecycleOwner
를 통해 뷰가 속한 LifecycleOwner
를 통해 throttleFirst를 적용하고자 하였다.
하지만 bind할 때 findViewTreeLifecycleOwner
를 통해 LifecycleOwner
를 찾을 수 없는 문제가 발생하였다.
LifecycleOwner
는 itemView
가 attach될 때 RecyclerView와 ViewHolder가 모두 뷰 트리에 연결되기 때문에 올바른 LifecycleOwner
를 반환할 수 있다.
onBindViewHolder()
가 실행된 이후에 doOnAttach()
가 실행되기 때문에 올바른 LifecycleOwner
를 반환할 수 없는 것이었다.
Log를 확인해보면 다음과 같다.
bind를 doOnAttach()
에서 해줄 수 있도록 해보았지만, 이는 RecyclerView가 재사용되면서 순서가 바뀌고 원하는 동작을 제대로 해내지 못하는 오류가 발생하였다.
결국 기존에 onBindViewHolder()
를 진행하면서 bind를 진행하고, throttleFirst를 구현하도록 하기 위해서 ViewModel에 SharedFlow를 생성하여 이벤트를 관리하는 방식으로 변경하였다.
이를 통해 viewModelScope에서 사용하여 자동으로 관리할 수 있게끔 할 수 있었다.
fun <T> SharedFlow<T>.setClickEvent(
uiScope: CoroutineScope,
windowDuration: Long = THROTTLE_DURATION,
onClick: suspend (value: T) -> Unit
) {
throttleFirst(windowDuration)
.onEach { onClick.invoke(it) }
.launchIn(uiScope)
}
다음은 팔로우 목록에서 팔로우하는 경우의 예시이다.
// FollowViewHolder.kt
class FollowViewHolder private constructor(
private val binding: ItemFollowBinding,
private val actionType: FollowActionType,
private val userClickListener: OnUserClickListener,
private val eventId: Int?
) : RecyclerView.ViewHolder(binding.root) {
fun bind(
user: UserStatus
) {
...
text = context.getString(R.string.follow_title_follow)
setOnClickListener {
userClickListener.onFollowClick(user)
}
...
}
...
}
// FollowViewModel.kt
@HiltViewModel
class FollowViewModel @Inject constructor(
private val userRepository: UserRepository,
private val followRepository: FollowRepository,
private val eventStoryRepository: EventStoryRepository
) : ViewModel(), OnUserClickListener {
...
private val _followClickEvent = MutableSharedFlow<UserStatus>()
...
init {
setFollowRequestFlow()
...
}
...
override fun onFollowClick(user: UserStatus) {
viewModelScope.launch {
_followClickEvent.emit(user)
}
}
...
private fun setFollowRequestFlow() {
_followClickEvent.setClickEvent(viewModelScope, THROTTLE_DURATION) { user ->
_showPlaceholder.update { true }
followRepository.follow(user.id)
.catch {
emitExceptionEvent(it, R.string.follow_follow_fail)
_showPlaceholder.update { false }
}.collectLatest {
updateFollowing(FollowActionType.FOLLOW)
updateFollower(FollowActionType.FOLLOW)
updateSearchedUser(FollowActionType.FOLLOW)
_showPlaceholder.update { false }
}
}
}
ViewHolder에서는 onFollowClick
의 이벤트를 발생시켜 ViewModel의 SharedFlow에 user정보를 emit한다.
ViewModel은 이 SharedFlow에 대한 setClickEvent에 대한 내용을 init할 때 실행했기 때문에 SharedFlow에 user정보가 emit되었을 때 throttleFirst를 통하여 원하는 작업을 진행할 수 있게 되었다.
- Week1 - Day01
- Week1 - Day02
- Week1 - Day03
- Week1 - Day04
- Week2 - Day01
- Week2 - Day02
- Week2 - Day03
- Week2 - Day04
- Week3 - Day01
- Week3 - Day02
- Week3 - Day03
- Week3 - Day04
- Week4 - Day01
- Week4 - Day02
- Week4 - Day03
- Week4 - Day04
- Week4 - Day05
- Week5 - Day01
- Week5 - Day02
- Week5 - Day03
- Week5 - Day04
- Week6 - Day01
- Week6 - Day02