Architecture: Handling UI events — MAD Skills[번역]

DwEnn
11 min readMay 7, 2022

--

📝 NOTE : 이 글은 영상의 스크립트를 번역한 것이며 오역이 있을 수 있습니다. 😅

안녕하세요. Architecture MAD Skils 3번째 에피소드 입니다. 🤗
데이터와 UI Layer 에 대해 배웠고 이제 UI events 를 어떻게 다룰지 살펴볼 시간입니다. 이번 에피소드에서는 Jetpack Compose 코드를 보여드릴 예정입니다. 하지만 View 방식에서도 마찬가지로 UI Layer 에서 UI events 를 처리하는 동일한 원칙이 적용됩니다.
이제, User 와 ViewModel 두 유형의 이벤트를 살펴보겠습니다.

User Events

사용자가 앱과 상호작용할 때 User Events 가 생성됩니다.(예: 화면의 버튼을 클릭) ViewModel Events 는 ViewModel 에서 UI 가 수행하기를 원하는 action 입니다. 이전 에피소드와 같이 User Events 를 다루는 것으로 시작하겠습니다. Jetpack Compose jet news sample 을 이용하여 몇 가지 사용 사례를 보여드리겠습니다.

앞서 언급한 User Events 는 사용자가 앱과 상호작용하는 것에 의해 생성됩니다. 예를 들어, 화면에 표시할 수 있는 게시물이 없을 때 우리는 사용자가 수동으로 콘텐츠를 로드할 수 있는 버튼을 보여줍니다. 이 코드는 HomScreens.kt 에서 찾을 수 있습니다.

HomScreenWithList()

우리는 Composable Button 에서 이용 가능한 onClick 파라미터에 값을 전달합니다. 참고로, View 시스템에서 이벤트에 반응하기 위해 Button 의 setOnClickListener 를 사용할 수 있습니다. 아시는대로 compose 는 해당 이벤트를 처리하는 방법에 대해 정의된 composable 에 도달할 때 까지 UI 계층에 이벤트를 전파하는 방법으로 함수를 사용합니다.

HomeRoute()

콘텐츠 새로고침은 비즈니스 로직이므로 ViewModel에게 동작을 위임합니다. UI 계층에 올라가면 HomeRoute 파일에 있는 HomeRoute composable 함수에 도달합니다. 이곳에는 HomeViewModel 이라는 ViewModel 에 stateful composable 을 가지고 있습니다. onRefreshPosts lambda에서는 homViewModel.refreshPosts() 를 호출하여 비즈니스로직을 트리거합니다.

HomeViewModel class

HomeViewModel 클래스를 보면 필요할 때 UI 가 호출할 수 있도록 비즈니스 로직을 처리하는 함수를 어떻게 노출하고 있는지 확인할 수 있습니다. UserEvents 는 앱에서 매우 흔한 내용입니다. 새로운 개념이 아니며 이미 여러분은 오랫동안 이 작업을 수행했을 것입니다.
이제, ViewModel 이벤트를 살펴보겠습니다.

ViewModel Events

ViewModel Evetns는 ViewModel class 에서 시작되어 UI 가 수행할 작업을 뜻합니다. 예를 들어, 사용자에게 인터넷 연결이 없거나 요청이 실패했음을 알립니다. 이러한 동작들 중 일부는 화면에 메시지를 보여주는 것과 같은 일시적인 UI 업데이트를 생성하지만, 그것들 또한 UI State 의 일부로 모델링 되어야 합니다. 이러한 이벤트들은 항상 UI State 를 업데이트 해야합니다. 그것이 우리가 UI와 통신하는 방법이기 때문입니다. 😁
다음은 요청이 실패했을 때 화면에 오류 메시지를 표시하기 위해 UI 와 통신하는 방법을 알아보겠습니다.

HomViewModel 클래스로 돌아가서, HomeUiState 가 HomeScreen 의 UI State 를 어떻게 모델링하지 살펴보겠습니다.

HomeUiState는 HomeViewModel 내에서 관측가능한 데이터 홀더 타입으로 StateFlow 에 정의되어 노출되고 있습니다. 다른 대안으로 LiveData 또는 compose state api 인 mutableStateOf 를 사용 가능합니다.
모든 ViewModel EventsUI State 의 일부로 모델링되어야 하므로, 문자열 리소스 ID를 나타내는 errorMessages: List<Int> 를 HomeUiState 에 추가합니다. 사용자에게 순차적으로 보여줄 여러 메시지를 대기열에 넣고 꺼내올 수 있도록 List 를 선택하였습니다. 참고로, ViewModel 은 UI 구현과는 무관합니다. 이러한 오류 메시지는 Toast, Snackbar, Dialog 등에 표시될 수 있습니다. 모든 UI 에서 사용하기에 보편적인 이름입니다.

JetNews의 디자이너는 SnackBar 에서 이 메시지들을 보여주기로 결정했습니다. 또한 사용자가 원할 경우 사용자가 다시 시도할 수 있도록 허용하는 방법을 보여줍니다.
코드를 입력하기 전에, 어떻게 동작하는지 보겠습니다.

요청이 실패하면, ViewModel이 새 errorMessage 로 UI State 를 업데이트합니다. 그러면 UI 는 새로운 UI State 에 반응하여 화면에 메시지를 표시합니다. 사용자가 메시지와 상호작용하거나 시간이 초과되어서 메시지가 사라지면, UI 는 ViewModel에게 통지하여 UI State 에서 메시지를 제거합니다. compose 의 scaffoldState 는 Snackbar를 실행하는 메소드를 가지는snackbarHostState 라는 속성을 가지고 있습니다. 우리는 Snackbar를 실행하기 위해 이 방법을 사용할 것입니다.

우리가 살펴봐야할 코드는 새로운 데이터가 요청되었을 때 호출되는 ViewModel의 refreshPosts() 함수입니다. 요청이 실패했을 경우에 대해 관심이 있지만 잠시 성공적인 케이스를 살펴보겠습니다.
함수를 호출할 때 UI State 를 업데이트 하여 데이터가 로드되고 있음을 UI에 나타냅니다.(_uiState.update(isLoading = true)) _uiState 는 현재 UI State 를 의미하는 것이기 때문에 복사본을 만들고 isLoading 을 업데이트하고 있습니다. 그리고 repository 에서 getPostsFeed() suspend fun 을 실행하기 위해 새 코루틴 을 실행합니다. 결과가 성공하면 repository가 반환한 데이터로 postsFeed 를 업데이트합니다. 또한 데이터가 이미 로드되었기 때문에, loading flow 를 false 로 설정합니다.

그럼 이제, 더 흥미로운 케이스인 요청이 실패하고 repository가 오류를 반환하는 경우를 살펴보겠습니다. 이 경우에, errorMessages queue 를 업데이트 해야 합니다. 먼저, 현재 errorMessages queue 에 로드 오류 메시지의 문자열 리소스 ID 를 추가합니다. 그런 다음 새 메시지와 isLoading 플래그를 포함하는 copy 객체로 UI State 를 업데이트 합니다.

UI 에서 Snackbar 를 보여주기 위해서는 HomScreenWithList composoble 에서 이 errorMesages 를 처리해야합니다. HomScreenWithList 함수는 scaffoldState 의 snackbarHostState 에 대한 접근권한을 가지고 있습니다.

HomeScreenWithList 함수에는 UiStatescafooldState 외에도 두 개의 파라미터가 존재하는 것을 볼 수 있습니다. onRefreshPosts는 사용자가 재시도 버튼을 눌렀을 때, onErrorDismiss 는 에러 메시지가 dismiss 되었을 때 알리는 용도로 사용됩니다. 위 composable 함수의 내부에서는 화면에 표시할 오류 메시지가 있는지 확인합니다.

if(uiState.errorMessages.isNotEmpty()) {
val errorMessageText = stringResource(uiState.errorMessage[0])
val retryMessageText = stringResource(id = R.string.retry)

만약 표시할 오류 메시지가 존재한다면, 실제 오류 메시지 텍스트와 재시도 메시지에 대한 스트링 리소스를 획득합니다. Snackbar 를 표시하려면 snackbarHostState 의 showSanckbar() 메소드를 호출해야 합니다. showSnackbar() 는 suspend function 이기 때문에 coroutine context 내에서 호출되어야 합니다.

LaunchedEffect(errorMessageText, retryMessageText, scaffoldState) {
val snackbarResult = scaffoldState.snackbarHostState.showSnackbar(
message = errorMessageText,
actionLabel = retryMessageText
)
if (snackbarResult == SnackbarResult.ActionPerformed) {
onRefreshPostsState()
}
onErrorDismissState(errorMessage.id)
}
}

LaunchedEffect 는 정확이 이러한 목적을 위해 설계된 composable 입니다. 이 메소드를 호출하면서 errorMessage, retryMessage, scaffoldState 를 전달합니다. LanchedEffect 에서 키로 사용되며, 만약 값이 변경 되다면 coroutine 은 취소되어 다시 시작되는 것으로 UI 에 항상 최신 정보가 표시되도록 합니다. 이러한 효과를 통해 우리는 suspend showSnackbar() 메소드를 호출 할 수 있게 됩니다. 이 메소드에 errorMessage 와 retryMessage 를 전달하여 호출하면 Snackbar 가 화면에 표시됩니다. 다른 모든 suspend fuction 들과 마찬가지로 사용자가 재시도하거나 일정 시간 후 Snackbar 가 해제될 때, snackbarResult 를 사용할 수 있을 때까지 coroutine 을 일시 중단합니다.
우리는 snackbarResult 를 보고 사용자가 재시도 버튼을 눌렀는지 여부를 확인할 수 있습니다. 만약 result 가 ActionPerformed 라면, 사용자가 재시도 버튼을 누른 것이기 때문에 onRefreshPostState() 를 호출합니다.
이 시점에 우리는 메시지가 더 이상 화면에 나타나지 않는다는 것을 알고 있습니다. 따라서 onErrorDismissState(errorMessage.id) 를 호출하여 오류가 해제되었음을 통지합니다.

이렇게 우리가 해야할 일이 끝이 났습니다. 이제 ViewModel 이 errorMessages 리스트를 추가할 때 마다 Snackbar 가 표시됩니다.
상위 UI 계층 구조에서는 ViewModel 과 상호작용하는 HomeRoute composable 이 존재하여 오류가 사용자에게 표시되었다는 onErrorDismiss lambda 가 존재합니다. onErrorDismiss lambda 에 전달하는 메소드에서는 어떻게 오류 메시지 목록에서 메시지를 제거하고 새로운 값으로 UI State 를 업데이트하는지 아래와 같이 정의되어 있습니다.

사용자가 메시지 또는 메시지 타임아웃과 상호작용하는 것은 ViewModel 이 인지해야 하는 상태 변화입니다. 그렇기 때문에 ViewModel 에 상태 변화가 발생할 때 UI가 통신을 해야 하는 이유입니다. 하지만 이 확인작업은 User action 에 의해 영향을 받는 ViewModel Events 에만 필요합니다.

이번 에피소드는 여기 까지 입니다. 다양한 유형의 UI Events 와 이를 코드로 처리하는 모법 사례를 배우셨기를 바랍니다. 🤗

--

--