Skip to content

버튼 연속 클릭 문제(다중 터치 : throttleFirst)

p-chanmin edited this page Jan 27, 2024 · 1 revision

버튼을 연속 클릭하면?

앱을 개발하면서 테스트를 진행할 때, 특정 부분에서 빠른 속도로 클릭을 진행하면 이벤트가 여러 번 발생한다는 문제가 있었다.

대표적으로 PhotoPicker를 사용하는 경우, PhotoPicker를 실행시키는 버튼을 연타하면 2~3개 정도의 PhotoPicker가 여러 개 표시되는 문제가 있었고, 일정이 존재하는 캘린더의 부분을 연타하면 Bottom Sheet가 여러 번 표시되면서 Exception으로 앱이 죽는 경우가 발생하였다.

throttleFirst

throttleFirst를 통해서 원하는 기능을 개발할 수 있었다.

image

throttleFirst는 RxJava 라이브러리에서 제공하는 함수 중 하나로 특정 기간 동안에 첫 번째 이벤트를 발생시키고 그 이후에는 일정 기간 동안의 이벤트를 무시하는 데 사용하여 다중 터치 문제를 해결할 수 있다.

하지만 Coroutine Flow를 사용하고 있기 때문에 RxJava 라이브러리에서 제공하는 throttleFirst를 사용하기에는 문제가 있었다.

image

Coroutine Flow를 통해 간단히 구현할 수 있으므로 샘플과 함께 throttleFirst의 연산자 추가는 아직 계획에 없다는 것을 확인할 수 있었고, 직접 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초 사이에 발생한 이벤트는 무시되는 형태이다.

throttleFirst 적용

기존에 적용했던 코드는 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에 확장함수로 clickssetClickEvent를 설정하였다.

이를 통해서 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)
}

RecyclerView에서 throttleFirst 사용은?

위에서 했던 경우는 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를 찾을 수 없는 문제가 발생하였다.

LifecycleOwneritemView가 attach될 때 RecyclerView와 ViewHolder가 모두 뷰 트리에 연결되기 때문에 올바른 LifecycleOwner를 반환할 수 있다.

onBindViewHolder() 가 실행된 이후에 doOnAttach()가 실행되기 때문에 올바른 LifecycleOwner를 반환할 수 없는 것이었다.

Log를 확인해보면 다음과 같다.

image

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를 통하여 원하는 작업을 진행할 수 있게 되었다.

⚽️협업 룰

코딩 컨벤션

📔회고

팀 회고

개인 회고

K004 김근범

K016 박찬민

K032 이해림

J153 차세찬

J156 최다정

👨‍🏫멘토링 회의록

💻개발일지

Android

K004 김근범

K016 박찬민

K032 이해림

J153 차세찬

J156 최다정

💡트러블슈팅

Android

K004 김근범

K016 박찬민

K032 이해림

J153 차세찬

J156 최다정

📋회의록

스크럼 회의

스프린트 회의

밋밋 회의

공통

BackEnd

Android

기획

Clone this wiki locally