[Android] 뷰 더블 클릭 방지하기

김병수·2022년 6월 6일
1
post-thumbnail
post-custom-banner

서론

최근 개발 중이던 서비스를 릴리즈 하고 난 이후의 일이다.
강제 종료 로그를 확인해봤더니 IllegalStateException 이라는 이름으로 다양한 종류의 강제 종료 로그가 찍혀있는 것을 확인했다.

처음에는 이 오류들이 어떠한 상황에서 발생하는지 몰랐지만, 곰곰이 생각하다 보니 설마? 하는 생각과 함께 그 원인을 찾게 되었다.
원인은 바로 이 글의 제목과도 같은 더블 클릭 때문이었고, 오늘은 더블 클릭을 방지하는 여러 방법들 중에 한 가지를 소개하고자 한다.


본론

문제가 발생하게 된 배경

위의 스크린샷 이미지는 실제로 오류가 발생했던 앱과는 아무런 연관이 없으며, 예시를 위한 화면임을 미리 말씀드립니다.

위의 스크린샷 이미지를 바탕으로 한 가지 상황을 가정해보자.

사용자가 파란색 네모 박스가 그려져 있는 매수 버튼을 클릭하면, 선택한 수량만큼의 가상화폐를 매수하기 위해 앱은 서버에 매수 API를 호출할 것이다.
그리고 서버에서는 사용자가 요청한 작업에 대한 응답을 앱으로 전달할 것이고, 앱은 서버로부터 받은 응답을 바탕으로 사용자에게 매수 작업에 대한 결과를 보여주게 될 것이다.

위의 스크린샷 이미지에서는 서버로부터 받은 응답을 Toast 메시지의 형태로 보여주고 있으며, 코드는 아래와 같다.

Toast.makeText(requireContext(), "매수주문이 정상 처리되었습니다.", Toast.LENGTH_SHORT).show()

이 코드에서 사용하고 있는 requireContext()는 현재 Fragment와 연결되어 있는 @NonNull Context을 반환하며,
만약 Fragment와 연결되어 있는 Context가 없는 상황에서 requireContext를 호출한다면, IllegalStateException을 유발한다.

여기서 오늘의 문제 IllegalStateException 오류의 원인을 다음과 같이 유추할 수 있었다.

API 응답이 도착하기 전에, API를 호출한 Fragment가 detach 되거나 다른 Fragment가 Top Fragment로 변경되어, requireContext() 함수가 IllegalStateException을 유발한다.

이게 발생 가능한 상황인가??

더블 클릭이 가능하다면 발생 가능하다.

위의 스크린샷 이미지에서 매수 버튼을 누른 직후 바로 하단의 차트 탭을 클릭했을 때를 생각해보자.

  1. 매수 API는 호출되었지만, 이에 대한 응답이 도착하기 전에
  2. 기존의 화면(A)에서 차트 화면(B)으로 전환되었고
  3. 서버로부터 응답이 도착함에 따라, Toast 메시지를 출력하기 위해 A 화면에서 requireContext()를 호출하는 콜백 함수가 실행됨

이러한 경우에 requireContext()IllegalStateException을 유발하게 된다.

해결방법

더블 클릭을 방지하기 위한 방법으로는 여러가지가 있지만, 이 글에서는 Throttle 방식으로 해결하는 방법을 소개하려고 한다.

방법은 아래와 같다.

  1. 클릭 가능 여부를 의미하는 Boolean 타입의 변수(clickable)를 하나 선언한다.
  2. 임의의 클릭 이벤트가 발생했을 때,
    2.1. 클릭이 가능하다면(clickable == true), clickablefalse로 변경하고 300ms 이후에 메인 쓰레드에서 clickable을 true로 변경하는 작업을 Handler에 등록한다.
    2.2. 클릭이 불가능하다면(clickable == false), 어쩔수 없다.

이를 구현한 코드는 아래와 같다.

// 이중 클릭 방지를 위한 함수 Java
public boolean isThrottleClick() {
    if (clickable) {
        clickable = false;
        new Handler(Looper.getMainLooper()).postDelayed(() -> clickable = true, 300L);
        return true;
    } else {
        Log.i("TAG", "waiting for a while");
        return false;
    }
}
// 이중 클릭 방지를 위한 함수 Kotlin
fun isThrottleClick(): Boolean {
    if (clickable) {
        clickable = false
        Handler(Looper.getMainLooper()).postDelayed(() -> clickable = true, 300L)
        return true
    } else {
        Log.i("TAG", "waiting for a while")
        return false
    }
}

위의 코드를 상위 Fragment 또는 Activity에 선언해두고 사용하면 서로 다른 View들에 대하여, 이중 클릭을 방지할 수 있다.

단일 View에 대하여 이중 클릭을 막고 싶은데요?

만약 단일 View에 대하여 이중 클릭을 방지하고자 한다면, 아래와 같이 Listener를 만들고,

class OnThrottleClickListener(
    private val onClickListener: View.OnClickListener,
    private val interval: Long = 300L
) : View.OnClickListener {

    private var clickable = true

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

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

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

}

Activity 또는 Fragment에서 다음과 같이 사용하면 이중 클릭을 방지할 수 있다.

 noDoubleClickView.onThrottleClick {
     // 클릭 이벤트 처리는 여기에
 }

결론

설마 앱을 이렇게 사용하겠어?

라고 생각하면서 개발하면 절대 안된다고 생각하고 있었는데,

설마 이렇게 까지 사용할줄이야..

라고 생각하게 되었다.
이 세상의 모든 개발자들 화이팅.

profile
주니어 개발자
post-custom-banner

2개의 댓글

comment-user-thumbnail
2023년 3월 9일

좋은 글 잘 봤습니다 ! 👏🏻👏🏻 이세상 개발자 화이팅 !!

1개의 답글