갑자기 그런 생각이 들 때가 있다.

이 작업 며칠 전에도 했는데, 새 화면 만들고 나니까 또 해줘야 하네?

회사에서 열심히 화면을 만들고, 뷰 클릭 처리를 달고, 테스트를 하다보니 뷰가 이중 클릭되어 예기치 못한 크래쉬가 발생했다. 그래서 액티비티로 돌아가 이중 클릭을 방지하는 코드를 넣던 중, ‘며칠 전에 했는데’ 라는 생각이 들었다. 문득 몇 주 전 컨퍼런스에서 KTX라는 키워드와 함께 비슷한 케이스를 언급한 게 생각나서 한 번 사용해 보기로 했다.


Kotlin Extensions (KTX)

코틀린 익스텐션(Kotlin extension, KTX)은 Android Jetpack과 기타 안드로이드 라이브러리에 포함된 코틀린 확장 프로그램 세트이다. 조금 풀어서 말하자면 코틀린 코드를 더 간결하고 직관적으로 쓰도록 도와주는 기능의 모음 이라고 할 수 있겠다. 아래와 같은 여러 코틀린 기능을 활용해 코드를 좀 더 쉽게 쓸 수 있다.

  • 확장 함수
  • 확장 프로그램 속성
  • 람다
  • 이름이 지정된 매개변수
  • 매개변수 기본값
  • 코루틴

이 중 모든 View 에서 사용할 수 있는 확장 함수(Extension functions)를 붙여 줄 생각이다.

KTX를 사용하기 위해서 기존에는 experimental = true 속성을 gradle에 추가해주어야 했다. 하지만 Android Studio 3.5 버전 기준으로 새 프로젝트를 Kotlin 언어로 생성하면 자동으로 Kotlin Extension 을 사용하도록 build.gradle에 추가가 되는 것 같다.

apply plugin: 'kotlin-android-extensions'

...

dependencies {
    ...
    implementation 'androidx.core:core-ktx:1.1.0'
}



기존 처리 방식

기존에는 중복 클릭 방지가 필요했던 View마다 boolean 값을 확인하는 방식으로 처리했었다. 뷰의 OnClickListener에서 클릭을 받는 순간 isClickable을 false로 바꾸고, 300ms 후에 다시 true로 변경하는 과정을 거쳤다.

class SomeActivity : AppCompatActivity() {
    private var isClickable = true

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        view.setOnClickListener {
            if (isClickable) {
                isClickable = false
                doSomething()
                it.postDelayed({
                    isClickable = true
                }, 300)
            }
        }
    }
}


복잡한 코드는 아니지만, 일일이 boolean 값을 생성하고 갱신하는 것이 너무 번거로웠다. 그래서 범용적으로 쓸 수 있는 listener 를 만들고, 그 안에서 모두 처리할 수 있게 만들어보기로 했다.


해결책

  • Listener 생성
  • Listener 연결
  • 호출

Listener 생성

View에서 setOnClickListener 대신 쓸 클래스를 만들어 주었다. ‘중복 클릭을 방지하는 리스너’라서 변수 이름 짓기 너무 힘들었는데 Throttle 이라는 단어를 사용하기로 했다. (예제를 찾아보니 Debounce, SingleClick 등등의 용어도 사용하는 것 같다.)

자꾸 생각하다보니 너무 궁금해져서 ThrottleDebounce 의 작동 원리와 차이점을 찾아보았다. Throttle은 마지막 함수가 호출된 후 일정 시간이 지나기 전에 다시 호출되지 않도록 하는 것, Debounce는 이벤트를 그룹화하여 특정시간이 지난 후 하나의 이벤트만 발생하도록 하는 기술 이라고 한다. 내가 쓰는 방식은 한 번 호출 후 일정 시간 동안 호출을 막는 것이므로 Throttle 에 가까워 이 네이밍 그대로 가기로 했다.

참고 : https://webclub.tistory.com/607

기본 클릭 간격(인터벌)은 이전과 마찬가지로 300ms로 주고, 필요할 때에는 초기화 시에 다른 값을 넣어 사용할 수 있도록 했다.

// OnThrottleClickListener.kt

class OnThrottleClickListener(
    private val clickListener: View.OnClickListener,
    private val interval: Long = 300
) :
    View.OnClickListener {
    
    private var clickable = true
    // clickable 플래그를 이 클래스가 아니라 더 상위 클래스에 두면
    // 여러 뷰에 대한 중복 클릭 방지할 수 있다.

    override fun onClick(v: View?) {
        if (clickable) {
            clickable = false
            v?.run {
                postDelayed({
                    clickable = true
                }, interval)
                clickListener.onClick(v)
            }
        } else {
            Log.d(TAG, "waiting for a while")
        }
    }
}


View.OnClickListener를 상속 받아 클릭 이벤트를 감지한다. 만약 클릭이 발생할 경우 onClick에서 클릭 가능 여부를 판단하고, 인자로 받은 clickListener를 통해 이벤트를 실행한다.

clickable이 true일 때에만 클릭을 실행하는데, 이 플래그가 클릭리스너 내부에 있기 때문에 버튼A와 버튼B를 연속 클릭 했을 때에는 각각의 플래그가 적용되어 A와 B가 둘 다 클릭될 것이다. 때문에 여러 뷰의 중복 클릭을 방지하고 싶다면 플래그를 더 상위 클래스로 옮겨야 한다.


Listener 연결

다음으로, MainActivity 에서 onCreate 시 위에서 만든 ThrottleClickListener를 세팅해주는 함수가 필요하다. KTX를 통해 View에서 쓸 수 있는 함수를 만들어 리스너를 세팅해주었다. (만드는 방법은 특별한 건 없고… 그냥 만드니 동작했다고 한다) 너무 간단해서 KTX 사용 안 한 줄 알고 gradle을 수십 번 확인했다…

(View) -> Unit 람다 식을 파라미터로 받고, 새로운 View.OnClickListener를 만들어서 onClick 에서의 액션으로 지정해주었다. 또한 클릭 간격 조정이 필요할 때를 위해 interval도 세팅할 수 있게 오버로드 해주었다.

// OnThrottleClickListener.kt

fun View.onThrottleClick(action: (v: View) -> Unit) {
    val listener = View.OnClickListener { action(it) }
    setOnClickListener(OnThrottleClickListener(listener))
}

// with interval setting
fun View.onThrottleClick(action: (v: View) -> Unit, interval: Long) {
    val listener = View.OnClickListener { action(it) }
    setOnClickListener(OnThrottleClickListener(listener, interval))
}


호출

이렇게까지 만들어 두었으면 뷰에서 사용하기는 간단하다. 바로 호출.

// MainActivity.kt

button.onThrottleClick {
    Log.d(TAG, "button Clicked : ${++btnCount}")
}


로그 창을 보면 버튼을 광클했음에도 0.3초가 지나기 전에는 숫자 카운트가 되지 않는다.



결론

중복 클릭 막는 문제는 보통 Rx나 Coroutine으로도 많이 처리하는 것 같다. 나는 아직 코루틴은 써보지 않았고, Rx로는 써봤지만 ‘굳이 클릭 처리 하려고 구독하고 해제까지?’라는 생각이 들었다. 보고 쓰고 관리하기에 깔끔하다면 클래식하더라도 단순한 코드가 좋은 것 같다. 그리고 단순한 코드를 만들 수 있게 해준 KTX에 대해 더 공부해봐야 겠다는 생각이 들었다. 음.

닭 잡는 칼은 닭에, 소 잡는 칼은 소에.


References

  • https://medium.com/@masaaki.iwaguchi/android-how-to-prevent-multiple-view-clicks-using-databinding-and-kotlin-extensions-5d5859071b4b
  • https://medium.com/@fornewid/android-ktx-databinding%EC%9C%BC%EB%A1%9C-view-layer-%EC%BD%94%EB%93%9C-%EC%A4%84%EC%9D%B4%EA%B8%B0-%EF%B8%8F-690cd61aec3d