[Android] ViewModel을 활용한 간단한 번호 뽑기 예제 만들기

문승연·2023년 7월 31일
0

Android-MVVM

목록 보기
1/3

최근 안드로이드 네이티브 앱을 개발할 때는 대부분 MVVM 디자인 패턴을 활용하고 있으며 새로운 인력을 보충할때도 MVVM 패턴 활용 여부를 중요하게 보고 있다.

MVVM 패턴에 대해 공부하기 앞서서 먼저 ViewModel이라는 개념을 제대로 알고 넘어갈 필요가 있다. 따라서 이를 이해하기 위해 ViewModel 을 활용한 간단한 번호뽑기 앱을 만들어보기로 했다.

1. ViewModel이란?

앱을 구현하기에 앞서 먼저 ViewModel이 무엇인지에 대해 알아보자.

ViewModelJetpack이라는 Android에서 공식적으로 지원하는 개발자들의 코드 작성을 돕는 라이브러리 묶음이다. Jetpack에 대해서는 다음에 좀 더 알아보기로 하고 이번엔 ViewModel에 대해서만 집중해보자.

ViewModel 클래스는 앱 LifeCycle을 고려하여 UI 관련 데이터를 저장하고 관리하도록 설계된 클래스다. ViewModel 클래스를 사용하면 화면 회전과 같이 구성을 변경할 때에도 데이터를 유지할 수 있다.

왜 ViewModel을 사용해야 하는가?

  • 안드로이드 앱을 개발할 때 화면 회전이 발생할 경우 해당 Activity나 Fragment의 UI 상태가 초기화 되는 것을 확인할 수 있다. 이는 화면 회전과 같은 특정 동작이 발생할 경우 Activity나 Fragment의 생명 주기가 onCreate() 부터 다시 실행되기 때문이다. 이 과정에서 UI 관련 데이터가 사라지기 때문에 처음 화면을 켰을때와 같은 상태로 돌아가는 것이다.

  • 보통은 onSaveInstanceState() 메소드를 활용해서 생명주기 변화를 감지하고 onCreate() 메소드가 발생할때 매개변수로 오는 Bundle 객체에서 저장된 데이터를 다시 가져와 적용할 수 있다. 하지만 이는 소량의 데이터에만 적합하며 대용량 데이터에 사용하기에는 적합하지 않다.

  • 또한 Activity나 Fragment같은 UI 컨트롤러에서 비동기 호출이 자주 발생하는데 ViewModel을 활용하며 이를 관리하기에 용이하다. 만약 UI 구성 변경 후 객체가 다시 생성되는 경우 이미 수행되었던 호출을 다시 호출해야 할 수 있고 이는 리소스 낭비로 이어진다.

따라서 Activity나 Fragment와 같은 UI 컨트롤러 로직에서 뷰와 데이터 소유권을 분리하는 방법이 훨씬 쉽고 효율적이다.

2. 구현하기

먼저 activity_main.xml 레이아웃을 구성한다.
DataBinding을 활용해서 @+id/tv_winner TextView의 text는 ViewModel의 변수 값이 바로바로 보이게 설정한다.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:tools="http://schemas.android.com/tools"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

<!--    xml 내에서 사용할 변수들을 선언 -->
<!--    name: 변수 명-->
<!--    type: 세팅할 프래그먼트, 액티비티 or 모델명-->
    <data>
        <variable
            name="mainViewModel"
            type="com.liam.mvvm.viewmodel.MainViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".view.MainActivity">

        <TextView
            android:id="@+id/tv_input_desc"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:text="참가한 인원 수를 입력해주세요\n(최대 50명)"
            android:textColor="@color/black"
            android:textSize="24sp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.25" />

        <EditText
            android:id="@+id/et_max_count"
            android:layout_width="80dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:gravity="center"
            android:inputType="number"
            android:textSize="22sp"
            app:layout_constraintEnd_toEndOf="@id/tv_input_desc"
            app:layout_constraintStart_toStartOf="@id/tv_input_desc"
            app:layout_constraintTop_toBottomOf="@id/tv_input_desc"
            tools:text="50" />


        <TextView
            android:id="@+id/tv_winner"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{mainViewModel.winner.toString()}"
            android:textSize="30sp"
            android:visibility="gone"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.4" />

        <Button
            android:id="@+id/btn_input"
            android:layout_width="128dp"
            android:layout_height="68dp"
            android:text="입력 완료"
            android:textSize="20sp"
            android:textStyle="bold"
            android:onClick="onClick"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toStartOf="@+id/btn_shoot"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.7" />

        <Button
            android:id="@+id/btn_shoot"
            android:layout_width="128dp"
            android:layout_height="68dp"
            android:text="뽑기"
            android:textSize="20sp"
            android:textStyle="bold"
            android:onClick="onClick"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toEndOf="@+id/btn_input"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.7" />


    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

그 다음 MainViewModel 을 구성한다.
MainViewModel은 총 3개의 LiveData로 구성되어있다.
1. winner
2. maxNumber
3. winnerList

winner는 당첨자의 번호. 뽑힐 수 있는 maxNumber는 최대 번호의 수, winnerList는 중복 당첨을 막기 위한 당첨된 사람들의 리스트이다.

class MainViewModel: ViewModel() {

    // MutableLiveData는 수정이 가능함
    private val _winner = MutableLiveData<Int>()
    private var _maxNumber = MutableLiveData<Int>()
    private var _winnerList = MutableLiveData<MutableList<Int>>()

    // 내부 변수
    private val list = mutableListOf<Int>()

    // LiveData는 외부에서 수정이 불가능하게 설정
    // getter를 사용하여 데이터를 읽는 과정만 수행 가능
    val winner: LiveData<Int>
        get() = _winner
    val maxNumber: LiveData<Int>
        get() = _maxNumber
    val winnerList: LiveData<MutableList<Int>>
        get() = _winnerList

    // LiveData를 수정하는 함수
    fun updateMaxNumber(maxNumber: Int) {
        _maxNumber.value = maxNumber
        _winner.value = 0

        list.clear()
        _winnerList.value = list
    }

    fun updateWinner(winner: Int) {
        _winner.value = winner

        list.add(winner)
        _winnerList.value = list
    }

    init {
        _winner.value = 0
        _maxNumber.value = 0
        _winnerList.value = list
    }
}

MainViewModel 내에서 MutableLiveDataLiveData를 분리해서 구현하였다. 둘의 차이점은 LiveData는 MutableLiveData와 달리 외부에서 수정이 불가능하다는 점이다.
ViewModel의 데이터 값을 변경하기 위해서는 updateWinner()updateMaxNumber()와 같이 메소드를 활용해서만 변경할 수 있게 구현하였다.

또한 List 형태의 변수의 경우 원소가 추가되거나 제거되는 로직만으로는 데이터 변화가 observing되지 않는다. 따라서 MainViewModel 내에 list 변수를 하나 선언하고 해당 list에서 원소의 변화를 적용시킨 후 변화된 list 변수를 _winnerList에 적용하는 방식으로 구현하였다.

이제 마지막으로 MainActivity를 구현한다.

class MainActivity: AppCompatActivity(), View.OnClickListener {

    private lateinit var binding: ActivityMainBinding
    private lateinit var mainViewModel: MainViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // binding 세팅
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)

        // MainActivity에서 사용할 ViewModel을 정의
        mainViewModel = ViewModelProvider(this)[MainViewModel::class.java]

        // LifeCycle 및 xml에 사용할 변수에 모델을 넣어준다.
        binding.lifecycleOwner = this
        binding.mainViewModel = mainViewModel

        mainViewModel.maxNumber.observe(this) {
            // it: Int!
            // mainViewModel 내에서 maxNumber의 값 변화를 감지하여 동작을 수행함.
            if (it <= 0) return@observe
            binding.etMaxCount.visibility = View.GONE
            binding.tvInputDesc.text = "당첨자는 바로!!"
        }
        mainViewModel.winner.observe(this) {
            // it: Int!
            // mainViewModel 내에서 winner의 값변화를 감지하여 동작을 수행함.
            binding.tvWinner.visibility = if (it > 0) View.VISIBLE else View.GONE
        }
        mainViewModel.winnerList.observe(this) {
            // it: MutableList<Int>!
            val maxNumber = mainViewModel.maxNumber.value ?: 0
            if (it.size >= maxNumber && maxNumber > 0) {
                Toast.makeText(this, "모든 참가자가 한번씩 선택되었습니다.", Toast.LENGTH_LONG).show()
                binding.btnShoot.isEnabled = false
            }
        }
    }

    override fun onClick(v: View?) {
        when(v?.id) {
            R.id.btn_input -> {
                val text = binding.etMaxCount.text
                if (text.isEmpty()) {
                    Toast.makeText(this, "최대 인원수를 입력해주세요.", Toast.LENGTH_LONG).show()
                    return
                }
                try {
                    // maxNumber를 사용자로부터 입력을 받아 MainViewModel에서 해당값 업데이트
                    val maxNumber = text.toString().toInt()
                    mainViewModel.updateMaxNumber(maxNumber)
                } catch (e: NumberFormatException) {
                    Toast.makeText(this, "숫자만 입력해주세요.", Toast.LENGTH_LONG).show()
                    return
                }
            }
            R.id.btn_shoot -> {
                val maxNumber = mainViewModel.maxNumber.value
                if (maxNumber == null || maxNumber <= 0) {
                    Toast.makeText(this, "먼저 최대 인원수를 입력해주세요.", Toast.LENGTH_LONG).show()
                    return
                }
                val list = (1..maxNumber).map { it }
                val winnerList = mainViewModel.winnerList.value ?: listOf()
                val leftWinners = list.subtract(winnerList.toSet()).toList()
                if (leftWinners.isEmpty()) {
                    Toast.makeText(this, "모든 참가자가 한번씩 선택되었습니다.", Toast.LENGTH_LONG).show()
                    return
                }
                val winner = leftWinners[Random.nextInt(leftWinners.indices)]
                mainViewModel.updateWinner(winner)
            }
        }
    }
}

뽑힐 수 있는 최대 인원수를 입력 후 btn_input 버튼을 누르면 MainViewModel의 updateMaxNumber(maxNumber: Int) 메소드를 호출해 maxNumber 값이 업데이트 된다.

그러면 MainActivity에서 MainViewModel maxNumber 데이터의 변화를 observing 하는 코드에서 변화를 감지하여 다음 로직이 작동한다.

mainViewModel.maxNumber.observe(this) {
	// it: Int!
	// mainViewModel 내에서 maxNumber의 값 변화를 감지하여 동작을 수행함.
	if (it <= 0) return@observe
	binding.etMaxCount.visibility = View.GONE
	binding.tvInputDesc.text = "당첨자는 바로!!"
}

최대 인원수를 입력했으면 btn_shoot 버튼을 누를때마다 당첨자를 한 명씩 뽑는다. 이때 이전에 뽑혔던 당첨자는 다시 뽑지 않는다. 만약 더 이상 뽑을 수 있는 사람이 없으면 버튼을 비활성화 시킨다.

mainViewModel.winner.observe(this) {
	// it: Int!
	// mainViewModel 내에서 winner의 값변화를 감지하여 동작을 수행함.
	binding.tvWinner.visibility = if (it > 0) View.VISIBLE else View.GONE
}

mainViewModel.winnerList.observe(this) {
	// it: MutableList<Int>!
	val maxNumber = mainViewModel.maxNumber.value ?: 0
	if (it.size >= maxNumber && maxNumber > 0) {
		Toast.makeText(this, "모든 참가자가 한번씩 선택되었습니다.", Toast.LENGTH_LONG).show()
		binding.btnShoot.isEnabled = false
	}
}

레퍼런스)
https://developer.android.com/topic/libraries/architecture/viewmodel?hl=ko

https://velog.io/@cksgodl/DataBinding%EA%B3%BC-LiveData-ViewModel%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%98%EC%97%AC-MVVM-%EB%AA%A8%EB%8D%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

profile
"비몽(Bemong)"이라는 앱을 개발 및 운영 중인 안드로이드 개발자입니다.

1개의 댓글

comment-user-thumbnail
2023년 7월 31일

공감하며 읽었습니다. 좋은 글 감사드립니다.

답글 달기