LiveData를 사용해 UI 로직 실행 알림을 받을 때의 문제점

토마스·2023년 8월 29일
0

내 생각

목록 보기
1/1

서론

우리는 안드로이드 앱 구현 시 UI 상태를 유지하는 AAC ViewModel(이하 ViewModel)을 사용합니다. ViewModel이 비즈니스 로직 실행 결과에 따라 달라질 수 있는 UI 상태를 관리하도록 함으로써 ViewModel Owner(Activity나 Fragment)로부터 비즈니스 로직에 대한 관심사를 분리할 수 있습니다. 그리고 ViewModel Owner는 ViewModel이 소유한 LiveData에 관찰자를 등록하여 UI 상태에 맞는 화면을 보여주는 UI 로직을 실행합니다.
위의 글이 이해가 되지 않는다면 상태 홀더 및 UI 상태를 설명하는 안드로이드 공식문서를 참고해주세요.

LiveData를 사용해 UI 로직 실행 알림을 받을 때의 문제점을 알기 위해서는 UI 로직의 종류를 알아야 합니다.

UI 로직의 종류

UI 로직의 종류를 사용자의 입장에서 나눠보면 크게 두 가지로 나눌 수 있습니다.

  • 여러 번 실행하면 사용자 입장에서 구분할 수 있는 UI 로직
  • 여러 번 실행해도 사용자 입장에서 구분할 수 없는 UI 로직

여러 번 실행하면 사용자 입장에서 구분할 수 있는 UI 로직

이러한 로직의 예는 아래와 같습니다.

  • 토스트 메세지를 보여주는 작업
  • 스낵바를 띄우는 작업
  • 다이얼로그를 띄우는 작업
  • 알림을 띄우는 작업

위의 로직들이 두 번 실행되는 것은 한 번 실행되는 것과는 다르게 인식됩니다. 이러한 로직은 특정 UI 이벤트 발생 시에만 실행되어야 합니다.
예를 들어 프로필 이름 수정이 실패한다면 이름 변경에 실패했다는 토스트 메세지를 띄우기를 바란다고 가정하겠습니다. 이 경우 이름 변경에 실패했다는 토스트 메세지 띄우기는 프로필 이름 수정 실패 이벤트가 발생했을 때에만 실행되어야 합니다.

여러 번 실행해도 사용자 입장에서 구분할 수 없는 UI 로직

대표적으로 뷰를 그리는 작업이 있습니다. 뷰를 그리는 로직을 한 번 실행하든 두 번 실행하든 그려진 뷰가 같으면 사용자는 같다고 인식합니다. 이러한 로직은 특정 UI 이벤트가 발생했을 때 뿐만 아니라 더 나은 사용자 경험을 위해 실행될 수 있습니다.
예를 들어 프로필 화면을 그리는 로직은 프로필 정보 가져오기 성공 이벤트가 발생했을 때 뿐만 아니라 구성 변경 시 화면이 유지되는 것처럼 보이기 위해 다시 실행될 수 있습니다.

UI 로직을 구분하지 않고 LiveData를 사용할 시 문제되는 이유

우리는 ViewModel Owner 코드에서 LiveData의 데이터가 변경될 때마다 실행될 UI 로직을 정의합니다.(데이터바인딩을 사용하면 코드를 줄일 수 있습니다.) 여러 번 실행해도 사용자 입장에서 구분할 수 없는 UI 로직이 LiveData 관찰자의 onChanged 메서드가 호출될 때마다 실행되는 것은 문제가 없습니다. 하지만 여러 번 실행하면 사용자 입장에서 구분할 수 있는 UI 로직이 onChanged 메서드가 호출될 때마다 실행되는 것은 문제가 있을 수 있습니다. 이유는 onChanged 메서드가 특정 UI 이벤트가 발생하지 않았을 때에도 호출될 수 있기 때문입니다. 구성 변경 시 ViewModel Owner가 재생성되고 LiveData의 관찰자가 다시 등록됩니다. 이 때 LiveData의 데이터가 존재한다면 onChanged 메서드가 호출됩니다. 여러 번 실행하면 사용자 입장에서 구분할 수 있는 UI 로직은 특정 UI 이벤트 발생 시에만 실행되어야 하는데, LiveData를 사용하면 특정 UI 이벤트가 발생하지 않았을 때에도 UI 로직이 실행될 수 있으므로 추가적인 작업이 필요합니다.

해결방법

1. UI 로직을 실행 후 UI 상태를 이전 상태로 복구함

class ProfileActivity : AppCompatActivity() {

    private val binding: ActivityProfileBinding by lazy {
        ActivityProfileBinding.inflate(layoutInflater)
    }
    private val viewModel: ProfileViewModel by viewModels { ProfileViewModelFactory }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        binding.viewModel = viewModel // 데이터 바인딩으로 UI 상태에 맞게 뷰를 변경

        binding.updateButton.setOnClickListener {
            viewModel.updateName(binding.newName.text.toString())
        }

        binding.refreshButton.setOnClickListener {
            viewModel.fetchProfile()
        }

        viewModel.isNameUpdateFailed.observe(this) {
        	if (it) {
                Toast.makeText(this, "이름을 변경할 수 없습니다.", Toast.LENGTH_SHORT).show()
                viewModel.resetIsNameUpdateFailed()
            }
        }
    }
}

위의 코드처럼 여러 번 실행하면 사용자 입장에서 구분할 수 있는 UI 로직을 실행하고 나면 관련 상태를 초기화하는 메서드를 실행하도록 합니다. 그러면 위의 문제를 해결할 수 있습니다. 하지만 이렇게 구현한다면 비즈니스 로직과 상관없는 메서드를 추가적으로 만들어야 합니다. 또한 코드 작성자가 상태를 초기화하는 메서드 호출을 누락할 가능성도 있습니다.

2. 이벤트의 처리 여부 정보를 가지고 있는 클래스를 이용함

open class Event<out T>(private val content: T) {

    var hasBeenHandled = false
        private set

    fun getContentIfNotHandled(): T? = if (hasBeenHandled) {
        null
    } else {
        hasBeenHandled = true
        content
    }

    fun peekContent(): T = content
}

위의 클래스는 이벤트가 처리되었다는 정보를 소유하고 있습니다. Event 클래스를 이용해 프로필 화면을 구현해보겠습니다.

class ProfileActivity : AppCompatActivity() {

    private val binding: ActivityProfileBinding by lazy {
        ActivityProfileBinding.inflate(layoutInflater)
    }
    private val viewModel: ProfileViewModel by viewModels { ProfileViewModelFactory }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        binding.viewModel = viewModel // 데이터 바인딩으로 UI 상태에 맞게 뷰를 변경

        binding.updateButton.setOnClickListener {
            viewModel.updateName(binding.newName.text.toString())
        }

        binding.refreshButton.setOnClickListener {
            viewModel.fetchProfile()
        }

        viewModel.uiEvent.observe(this) {
            val content = it.getContentIfNotHandled() ?: return@observe
            when (content) {
                ProfileUiEvent.NAME_UPDATE_FAIL ->
                    Toast.makeText(this, "이름을 변경할 수 없습니다.", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

class ProfileViewModel(private val profileRepository: ProfileRepository) : ViewModel() {

    private val _profile = MutableLiveData<ProfileUiState>(ProfileUiState.Loading)
    val profile: LiveData<ProfileUiState> = _profile

    private val _uiEvent = MutableLiveData<Event<ProfileUiEvent>>()
    val uiEvent: LiveData<Event<ProfileUiEvent>> = _uiEvent

    init {
        fetchProfile()
    }

    fun fetchProfile() {
        viewModelScope.launch {
            _profile.value = ProfileUiState.Loading
            profileRepository.getProfile()
                .onSuccess {
                    _profile.value =
                        ProfileUiState.Success(
                            name = it.name,
                            age = it.age.toString(),
                        )
                }
                .onFailure {
                    _profile.value = ProfileUiState.Error
                }
        }
    }

    fun updateName(newName: String) {
        viewModelScope.launch {
        	_profile.value = ProfileUiState.Loading
            profileRepository.updateName(newName)
                .onSuccess {
                    fetchProfile()
                }
                .onFailure {
                    _uiEvent.value = Event(ProfileUiEvent.NAME_UPDATE_FAIL)
                }
        }
    }
}

sealed interface ProfileUiState {
    data class Success(
        val name: String,
        val age: String,
    ) : ProfileUiState

    object Loading : ProfileUiState
    object Error : ProfileUiState
}

enum class ProfileUiEvent {
    NAME_UPDATE_FAIL,
}

이벤트의 처리 여부 정보를 가지고 있는 클래스를 이용함으로써 ViewModel은 UiEvent만 업데이트하고 ViewModel Owner는 UI 로직 실행 후 이벤트 처리 여부를 신경쓰지 않아도 됩니다.

개인적인 생각

상태 홀더 및 UI 상태를 설명하는 안드로이드 공식문서에 따르면 ViewModel을 상태 홀더라고 소개합니다. 그리고 UI에 대한 설명은 아래와 같이 합니다.

사용자가 보는 항목이 UI라면 UI 상태는 앱에서 사용자가 봐야 한다고 지정하는 항목입니다. 동전의 양면과 마찬가지로 UI는 UI 상태를 시각적으로 나타냅니다. UI 상태가 변경되면 변경사항이 즉시 UI에 반영됩니다.

위의 설명을 보고 처음엔 이름 변경 실패 이벤트 발생을 알리기 위해 isNameUpdateFailed처럼 상태를 나타내는 네이밍을 했습니다. isNameUpdateFailed는 이름만 보면 이름 변경 실패 이벤트 발생 여부를 나타냅니다. 이벤트 발생 여부는 처리 여부와 상관 없기 때문에 이벤트가 처리되었어도 isNameUpdateFailed라는 속성의 값이 true인 것이 더 어울린다고 생각합니다. 따라서 저는 이벤트 발생을 알리는 속성의 이름을 uiEvent라고 짓는 것이 좋다고 생각합니다.

저는 ViewModel을 상태 및 이벤트 홀더라고 생각하고 싶습니다. ViewModel이 UiState 뿐만 아니라 UiEvent 속성을 노출함으로써 UI 이벤트 발생 시 UI 상태 변경을 알리거나 UI 이벤트 발생을 알리거나 혹은 둘 다 할 수 있습니다.
ViewModel이 노출하는 속성이 늘어났기 때문에 ViewModel이 제공하는 기능이 늘어난 것처럼 보입니다. 하지만 기능이 늘어난 게 아니라 UI 이벤트 발생을 알리는 일을 한다는 것을 더 명확하게 드러낸 것 뿐이라고 생각합니다. 또한 ViewModel Owner는 ViewModel에게 비즈니스 로직을 위임하고 ViewModel의 속성을 관찰한다는 일관성을 지키기 때문에 기존 패턴을 해치지 않는다고 생각합니다.

세 줄 요약

  1. 여러 번 실행하면 사용자 입장에서 구분되는 UI 로직은 특정 UI 이벤트 발생 시에만 실행되어야 함.
  2. LiveData를 사용하면 특정 UI 이벤트가 발생하지 않았을 때, 구성 변경 시에도 알림 받고 UI 로직이 실행될 수 있음.
  3. EventWrapper 클래스를 사용해서 특정 UI 이벤트 발생 시에만 UI 로직을 실행하게 할 수 있음.

잘못된 정보나 이해하기 힘든 내용이 있다면 알려주시면 감사하겠습니다.

참고자료

profile
안드로이드 개발자 지망생

0개의 댓글