안드로이드 Listener Interface 구현 과정에 대한 이해

이동현·2020년 9월 13일
5

도입

안녕하세요. 이동현입니다.
이번에 다루어 볼 내용은 Callback과 Listener입니다.

안드로이드에서는 앱 내의 뷰(Button, EditText등)들과 사용자가 상호작용을 하기 위해 Callback 함수를 정말 많이 사용하고 구현합니다.
입문에서부터 '버튼 클릭시 이벤트 처리'와 같은 매우 간단한 Callback Pattern을 구현하게 되는 것을 그 근거로 들 수 있습니다.

이번에 직접 Callback을 구현해 볼 일이 있었습니다. 매우 중요한 개념이므로 정리가 필요하다 느껴져 포스팅을 시작하기로 했습니다.

실제 Android Framework에서는 이러한 구조를 어떻게 코드로 나타냈는지 Button의 Click Event처리 과정을 통해 살펴보겠습니다.
또한, RecyclerView와 Activity간의 상호작용을 목적으로 한 Callback Pattern을 직접 구현해보겠습니다.

Button의 Click Event 처리

흔히 사용하는 Button의 Click 이벤트를 처리하기 위해서는, 이를 처리하기 위한 함수와 객체가 필요합니다.

  1. 사용자와 상호작용을 하게 될 Button 객체
  2. Button의 Click Event를 감지하는 Listener 객체
  3. Event 감지 이후, 로직의 처리등을 위해 실행되는 Callback 함수

1. 사용자와 상호작용을 하게 될 Button 객체

Button을 안드로이드 앱 UI로서 시각적으로 보이게 하기 위해서는 layout resource에 다음과 같은 xml을 작성 해야 합니다.

<Button
    android:id="@+id/test_button"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="버튼"/>

Kotlin Extension에서는 자체 제공하는 Synthetic Binding을 통해, 특별한 변수의 선언 없이 Button 객체를 사용할 수 있도록 도와줍니다.

이후 Activity를 관리하는 코드에서 Button 객체를 생성해 줄 수 있습니다.

val testButton = findViewById<Button>(R.id.test_button)

이를 통해 Button 객체를 만들고 사용할 수 있습니다.

2. Button의 Click Event를 감지하는 Listener 객체

화면상에 보이는 View들은 대부분 사용자와 상호작용을 하도록 되어 있습니다.
가장 많이 활용되는 클릭 이벤트 역시 마찬가지입니다.

그렇기 때문에, Button 클래스는 대부분의 View들이 사용하고 있는 기능이 모여있는 View Class의 멤버들을 사용하기 위해 View Class를 상속받습니다.

이러한 Click Event Listener를 구현하도록 도와주는 Interface 역시 View Class에 존재합니다.

/**
 * Interface definition for a callback to be invoked when a view is clicked.
 */
public interface OnClickListener {
    /**
     * Called when a view has been clicked.
     *
     * @param v The view that was clicked.
     */
    void onClick(View v);
}

@ View Class의 OnClickListener Interface


주석을 통해 몇 가지 정보를 알 수 있습니다.
위 코드는 View가 클릭 되었을 경우 호출되는 콜백에 대한 인터페이스를 정의한 것이며,
호출되는 함수는 내부의 'onClick()' 함수가 됩니다.

위의 주석에 따르면, onClick() 함수는 결국 View가 클릭 되었을 경우 불려진다고 합니다. View가 클릭 되었을 경우 onClick() 함수를 호출하는 코드 역시 View 클래스 내부에 존재합니다.

public boolean performClick() {
    ...
    final ListenerInfo li = mListenerInfo;
    if (li != null && li.mOnClickListener != null) {
        li.mOnClickListener.onClick(this);
        ...
    } else {
        ...
    }
    ...
}

@ View Class의 performClick()


코드를 살펴보면, performClick() 함수에서는 listener정보를 관리하는 ListenerInfo 정적 객체의 정보를 가져옵니다. 등록된 OnClickListener가 있을 시 그 내부의 onClick 함수를 실행시켜 주는 방식을 취함을 알 수 있습니다.

추가적으로 performClcik() 함수는 Button의 클릭과 동등한 역할을 하는 함수라고 보면 됩니다.
(TMI. 이 함수를 이용하면 클릭 이벤트를 강제로 발생시킬 수도 있습니다.)

정리하면,
버튼을 클릭하면 내부 로직을 거쳐 performClick() 함수가 실행된다고 봐도 괜찮으며, 만약 OnClickListener가 구현되어 있다면 그 내부의 onClick()을 실행하는 과정을 통해 클릭 이벤트에 대한 처리가 이루어집니다.

코드 플로우를 보아도 아시겠지만, 동작을 처리하기 위해서는 OnClickListener에 대한 구현을 해주어야 합니다. 물론 이 자체가 인터페이스이기 때문에, 사용을 하기 위해서는 이에 대한 구현체를 만들어주는 것이 당연하다고 보여집니다.

View Class에서 봐야할 마지막 함수는 이러한 OnClickListener를 set하도록 도와주는 setOnClickListener() 함수입니다.

public void setOnClickListener(@Nullable OnClickListener l) {
    ...
    getListenerInfo().mOnClickListener = l;
}

@View Class의 setOnClickListener()


인자값으로 OnClickListener 값을 받습니다. 그리고, 위에서 언급한 ListenerInfo에 그 값을 지정해둡니다. 즉, 클릭 이벤트가 발생할 시, setOnClickListener에서 등록해두었던 OnClickLister를 호출하여 사용하는 것입니다. 또한, OnClickListener에 대한 구현은 이 함수 내부에서 진행해도 괜찮다는 판단을 할 수 있습니다. 그리고, 그러한 구현이 결국 Listenr 객체를 만드는 행위가 됩니다.

3. Event 감지 이후, 로직의 처리등을 위해 실행되는 Callback 함수

위에 언급한 코드 중 Interface에 존재하는 onClick() 추상메서드가 구체적인 Callback을 담당하는 역할을 할 수 있습니다.

Button에서 클릭 이벤트를 처리할 때는 setOnClickListener 함수를 통해 OnClickListener를 달아주는 것이 우선입니다.

testButton.setOnClickListener(object: View.OnClickListener {
    override fun onClick(v: View?) {
        Toast.makeText(this, "버튼이 클릭되었습니다.", Toast.LENGTH_SHORT).show()
    }
})

OnClickListener Interface에 대한 구현체가 인자값으로 들어갔으며, 그 내부에 onClick() 메서드를 구현했습니다.

앞으로 Button을 클릭하면, View 내부의 performClick() 메서드에서 등록된 Listener의 onClick()을 실행 할 것이며, 그 대상은 testButton에 달아준 Listener의 onClick()이 되는 것입니다.

참고로 위의 코드는 아래와 같은 람다식으로도 표현이 가능합니다.

testButton.setOnClickListener { 
    Toast.makeText(this, "버튼이 클릭되었습니다.", Toast.LENGTH_SHORT).show() 
}

정리

전체적인 플로우가 어떻게 되는지 정리하고 직접 Listener를 만들어 보도록 하겠습니다.
구분을 위해 View 클래스에서 하는 행동을 파란색, Activity에서 하는 행동을 빨간색으로 표시하겠습니다.

준비과정

  1. OnClickListener Interface를 정의하고, 그 내부에 Callback으로 처리 할 추상 메서드 onClick()을 선언해둡니다.
  2. Listener를 달아주는 setOnClcikListener() 함수를 선언합니다. 이 함수는 OnClickListener의 구현체를 매개값으로 받아, 그 객체의 내부 함수를 사용할 수 있도록 도와줍니다.
  3. 받은 OnClickListener 객체를 변수로 저장해두고, 동작을 원하는 지점에 onClick()을 사용합니다.
  4. 클릭 이벤트를 처리할 Button에 setOnClickListener() 함수를 사용하여 Listener를 달아줍니다. (인터페이스 구현 작업)
  5. 이 때, Callback으로 받을 onClick() 함수에 대한 구현까지 완료합니다.

실행과정

  1. Button을 클릭합니다.
  2. 클릭 이벤트를 감지하고, Listener로 미리 받아두었던 객체의 onClick() 메서드를 실행합니다. (위의 performClick()의 역할)

직접 Callback과 Listener를 구현해보자

Button의 Click Event 처리 로직을 그대로 따라가면, Callback과 Listener를 직접 구현 해 볼 수 있을 것 같습니다.

아래는 이해를 돕기 위한 예제입니다.

❗ 다음과 같은 상황을 가정해봅니다.
CreateUserActivity내부에 값을 입력받는 EditText 2개와 값을 선택 가능한 RecyclerView 1개가 존재합니다.
EditText에는 각각 '이름'과 '아이디'를 입력받습니다.
RecycerView에는 '나이'를 선택할 수 있도록 1~100 까지의 숫자를 띄워줍니다.

'나이'를 선택하자마자 회원가입이 진행되고, 새로운 MainActivity로 이동하여 가입한 회원의 정보를 TextView로 띄워주는 프로그램을 만들어 볼 생각입니다.



여기서의 핵심은 '나이'를 선택하자마자 발생하는 이벤트 입니다.
'나이' 데이터는 RecyclerView에 나열되어 있으며, Activity 단에서는 접근이 불가능한 정보가 됩니다. (Activity에서 등록된 Adapter의 내부 값을 가져올 수 없기 때문입니다.)
또한, RecyclerView 내의 항목을 클릭했을 때, 어떠한 참조도 없는 Activity에 대한 화면 전환을 요구한다는 점도 눈여겨 볼 만 합니다.

이러한 경우에, Listener와 Callback을 이용하면 좋겠다는 생각이 듭니다.
CreateUserActivity에서 Activity내의 변수나 컨텍스트등을 사용할 수 있게끔 Listener를 달아주고, callback 메서드까지 구현해줍니다.
이후, RecyclerView Adapter 내부에서 '나이'데이터를 클릭했을 경우 받아온 Listener객체의 callback 메서드를 실행해주면 됩니다.

즉, 앞서 버튼을 예로 든 상황을 고려했을 때,
CreateUserActivity가 MainActivity(Button이 존재하는)역할을,
Adapter Class가 View Class 역할을 하도록 구현하면 됩니다.

진행과정을 코드로 보겠습니다.

1. Listener Interface 정의 및 내부 추상 메서드 선언

'나이' 값을 선택하는 순간을 포착하고, 그 순간 로직을 처리하기를 기대합니다.

이를 요구하는 Listener를 아래와 같이 Interface로 제작 가능합니다.

interface AgeSelectedListener {
    fun onAgeSelected(age: Int)
}

내부에는, 감지가 된 순간 실행될 로직을 담을 onAgeSeleted(@param) 추상메서드를 담았습니다. 이 때, CreateUserActivity에는 선택된 '나이'값이 전달 되어야 하므로 age를 인자값으로 받을 수 있도록 하였습니다.

본 Interface는 Adapter class 내부에 선언해도 상관없으며, 마찬가지로 외부에 선언해도 괜찮습니다.

2. Listener의 구현체를 매개값으로 받는 부분 만들기

View 클래스의 setOnClickListener(OnClickListener l) 을 담당하는 부분입니다.

본 메서드를 직접 만들어, Adapter 전체에서 사용 할 listener 객체를 받아올 수 있지만,
굳이 메서드를 만들지 않고, 생성자를 통해 listener 객체를 받아오도록 하겠습니다.

선언한 클래스에 생성자를 아래와 같이 받아옵니다.

class AgeAdapter(private val listener: AgeSelectedListener) : 
RecyclerView.Adapter<AgeAdapter.AgeViewHolder>() {
    override fun onCreateViewHolder(
    ...
    

Adapter Class를 만들 때 Listener 객체를 넘겨주어 사용할 수 있습니다.

3. 받은 Listener를 변수로 저장해두고, 원하는 지점에 Callback 함수 호출

여기서는 생성자로 넘어온 Listener를 굳이 새로운 변수로 저장하여 사용 할 필요가 없습니다.
생성자에서 정의한 listener 변수를 이용하면 되기 때문입니다.

listener 변수를 이용하여 내부의 onAgeSelected(age: Int)를 호출하여 사용하면 됩니다.

각 '나이'항목의 뷰를 클릭하는 경우에 동작하기를 원하므로,
만들어준 ViewHolder의 각 itemView를 클릭하는 경우 동작하도록 구현하겠습니다.

override fun getItemCount(): Int = 100

override fun onBindViewHolder(holder: AgeViewHolder, position: Int) {
    val age = position + 1	// 나이는 1~100 (position이 0~99인 관계로)
    
    // xml상에서 나이를 보여주는 TextView의 변수명을 textView로 정했음
    holder.textView.text = age.toString() 
    
    holder.itemView.setOnClickListener {
        listener.onAgeSelected(age)
    }
}

핵심은 onAgeSelected(age) 를 사용한 부분입니다.
그 외 코드에 대한 설명은 주석 설명으로 대체합니다. 아래에 제공하는 코드를 보시면 이해가 되실겁니다.

4. CreateUserActivity에서 Listener 달아주기 (인터페이스 구현)

아직 CreateUserActivity와 Adatper간 연결이 이루어지지 않았습니다.
이는 AgeAdapter 클래스의 생성자에 정의된 AgeSelectedListener 객체를 구현하는 과정으로 이루어 질 것입니다.

아래와 같이 CreateUserActivity 자체를 구현 클래스로 설정할 수 있습니다.

class CreateUserActivity : AppCompatActivity(), AgeSelectedListener {
...
    override fun onAgeSelected(age: Int) {...}
}

이러한 방법을 택했을 경우, 클래스 자체를 생성자의 값으로 넘겨주면 되므로, 아래와 같이 adapter를 달아줄 수 있습니다.

recycler.adapter = AgeAdapter(this)

혹은, 람다식을 사용 할 경우, AgeAdapter를 생성함과 동시에 익명 객체를 만들어주는 방식으로 구현이 가능합니다. 아래와 같은 작업을 통해 이루어집니다.

recycler.adapter = AgeAdapter(object: AgeSelectedListener {
    override fun onAgeSelected(age: Int) {...}
})

두 번째 방법을 사용했을 경우, CreateUserActivity에서 AgeSelectedListener Interface를 직접 구현 할 필요가 없어집니다.

5. Callback 함수의 구현

onAgeSelected(age: Int) 함수의 내부를 구현합니다.

EditText에 입력된 사용자의 '이름'과 '아이디' 값,
RecyclerView에서 선택되어 age라는 값으로 넘어온 '나이' 를
Intent를 사용하여 MainActivity로 넘겨줍니다.

override fun onAgeSelected(age: Int) {
    val intent = Intent(this, MainActivity::class.java).apply {
        putExtra("USER_NAME", input_name.text.toString())
        putExtra("USER_ID", input_id.text.toString())
        putExtra("USER_AGE", age.toString())
    }
    startActivity(intent)
}

실행 결과

CreateUserActivity에서 '아이디'와 '이름'을 입력하고, '나이'를 클릭하면, 아래의 MainActivity로 정보를 전달하여 화면에 띄워주는 과정입니다.

전체 코드

strings.xml

<resources>
    <string name="app_name">CallbackExample</string>
    <string name="result">안녕하세요 name회원님!!\n아이디는 id입니다.\n나이는 age입니다.\n환영합니다!!</string>
</resources>




activity_create_user.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="24dp"
    android:orientation="vertical"
    tools:context=".CreateUserActivity">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="아이디" />

    <EditText
        android:id="@+id/input_id"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="아이디를 입력해주세요" />

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="이름" />

    <EditText
        android:id="@+id/input_name"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="이름을 입력해주세요" />

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="나이 (클릭시 회원가입이 바로 진행됩니다.)" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp" />

</LinearLayout>




activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="24dp"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/result"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textSize="22sp"
        android:text="@string/result"
        android:lineSpacingMultiplier="1.5"/>

</LinearLayout>




CreateUserActivity

class CreateUserActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_create_user)

        recycler.layoutManager = LinearLayoutManager(this)
        recycler.adapter = AgeAdapter(object: AgeSelectedListener {
            override fun onAgeSelected(age: Int) {
                val intent = Intent(this@CreateUserActivity, MainActivity::class.java).apply {
                    putExtra("USER_NAME", input_name.text.toString())
                    putExtra("USER_ID", input_id.text.toString())
                    putExtra("USER_AGE", age.toString())
                }

                startActivity(intent)
            }
        })
    }
}





MainActivity

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val name = intent.getStringExtra("USER_NAME")!!
        val id = intent.getStringExtra("USER_ID")!!
        val age = intent.getStringExtra("USER_AGE")!!

        result.text = resources.getString(R.string.result)
            .replace("name", name)
            .replace("id", id)
            .replace("age", age)
    }
}





AgeAdapter / AgeSelectedInterface

class AgeAdapter(private val listener: AgeSelectedListener) : RecyclerView.Adapter<AgeAdapter.AgeViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AgeViewHolder {
        return AgeViewHolder(LayoutInflater.from(parent.context).inflate(android.R.layout.simple_list_item_1, parent, false))
    }

    override fun getItemCount(): Int = 100

    override fun onBindViewHolder(holder: AgeViewHolder, position: Int) {
        val age = position + 1
        holder.textView.text = age.toString()
        holder.itemView.setOnClickListener {
            listener.onAgeSelected(age)
        }
    }

    inner class AgeViewHolder(view: View): RecyclerView.ViewHolder(view) {
        val textView = view.findViewById<TextView>(android.R.id.text1)
    }
}

interface AgeSelectedListener {
    fun onAgeSelected(age: Int)
}
profile
영차영차

0개의 댓글