[Android] Kotlin Flow를 활용해보자

: ) YOUNG·2023년 5월 22일
1

안드로이드

목록 보기
12/17
post-thumbnail

순서

Retrofit을 통한 통신 순서는

Compose View(Screen Event) -> ViewModel -> Repository -> Api interface

Response 결과를 통해 데이터를 반환 받는 순서는 그냥 반대로 하면된다

Api interface -> Repository -> ViewModel -> Compose View(Screen)



API의 Response를 처리할 수 있는 상태 값들을 모듈화

먼저 네트워크의 결과값을 상태에 따라 처리할 수 있도록 모듈로 빼준다.

NetworkResult.kt


sealed class NetworkResult<T>(var data: Any? = null, val message: String? = null) {
    data class Success<T> constructor(val value: T) : NetworkResult<T>(value)
    class Error<T> @JvmOverloads constructor(
        var code: Int? = null,
        var msg: String? = null,
        var exception: Throwable? = null
    ) : NetworkResult<T>(code, msg)

    class Loading<T> : NetworkResult<T>()
} // End of TestNetworkResult sealed class

sealed class로 성공, 로딩, 실패를 구분할 수 있는 클래스를 분리해놓고 나중에 API의 Response 결과에 따라서 상태를 구분할 수 있도록 해줌



Repositry와 Screen사이의 역할을 하는 ViewModel에서 데이터 처리


Screen에서 버튼을 통해서 이벤트를 주고 통신을 한다고 가정해보자.
이벤트가 일어났을 때, ViewModel에 통신을 할 수 있는 함수를 하나 만들고 해당 함수를 실행시켜주면 된다.

ViewModel.kt


    // ================================= getNavNFC =================================
    private val _postNFCIdResponseSharedFlow = MutableSharedFlow<NetworkResult<NFC>>()
    var postNFCIdResponseSharedFlow = _postNFCIdResponseSharedFlow
        private set

    fun postNFCId(nfcId: Int) {
        viewModelScope.launch {
            nfcRepo.postNFCId(nfcId).onStart {
                _postNFCIdResponseSharedFlow.emit(NetworkResult.Loading())
            }.catch {
                _postNFCIdResponseSharedFlow.emit(
                    NetworkResult.Error(
                        null, it.message, it.cause
                    )
                )
            }.collectLatest { result ->
                when {
                    result.isSuccessful -> {
                        _postNFCIdResponseSharedFlow.emit(
                            NetworkResult.Success(result.body()!!)
                        )
                    }

                    result.errorBody() != null -> {
                        _postNFCIdResponseSharedFlow.emit(
                            NetworkResult.Error(result.code(), result.message())
                        )
                    }
                }
            }
        }
    } // End of getNavNFC

MutableSharedFlow를 통해서 네트워크 상태값을 관리하게 되는데 이 Flow의 상태값을 직접 emit()으로 지정해 주어 상태를 변하게 할 수 있다.

_postNFCIdResponseSharedFlowemit()으로 상태값을 변경하는데, 여기서 Flow와 LiveData를 통한 Retrofit 통신처리의 차이점이 많이 드러난다.

emit()을 짧막하게 설명하자면 LiveData에서 setValuepostValue가 있는데, emit()postValue의 역할을 한다고 보면된다.

LiveData로 Retrofit 통신을 할 때는 ViewModel 부분에서 ViewModelScope를 통해서 nfcRepo.postNFCId(nfcId)를 실행하게 된다. 이전 jetpack에서는 Repository의 Api interface를 호출하는게 전부였지만 Flow는 방식이 조금 달라진다.


Flow를 사용하면 여러가지 함수를 사용해서 Flow의 상태에 따라 어떤 상태값으로 지정할 지 커스텀이 훨씬 쉬워진다는 장단점이있다.

이런 부분에서 봤을 때, Flow를 한번 사용하게 되면 굳이(?) LiveData를 써야되나 라는 생각이 드는데 구글에서는 UI를 업데이트 하는 부분에서나 통신부를 분리해서 LiveData와 Flow를 같이 쓰는 것을 권장한다고는 하는데, LiveData와 Flow 둘다 Kotlin 코드이긴 하나 LiveData는 사실상 안드로이드에서 밖에 쓰이지 않기 때문에 Flow가 Kotlin 언어를 사용하여 Android개발을 하는데는 더 적합하지 않을까 라고 조심스럽게 생각해본다.


순서는 간단하게 함수가 호출되서 Repository의 Api 함수를 호출하게 되면 viewModelScope.launch를 통해서 Coroutine으로 감싸서 비동기처리를 시작하게 된다.

onStart 에서는 가장 먼저 시작하면서 지정할 상태값을 설정하면 되는데 기본적으로 통신을 하는 동안 시간이 얼마나 걸릴지 모르니 당연히 처음 상태는 Loading()상태로 지정해서 SharedFlow로 상태값을 변환해준다.

.catch 부분에서는 에러가 발생했을 때 상태를 처리하는데 sealed class로 만들어놓은 상태에서 Error 타입으로 지정하여 catch에서 잡힌 에러 메세지를 매개변수로 넣어주면 된다. 이렇게 되면 SharedFlow의 상태값은 Error 상태로 지정되게 된다.

collectLatest 부분에서는 에러가 생기지 않고 정상동작을 하는 부분이다.
result를 결과값으로 사용하는데,nfcRepo.postNFCId(nfcId) 에서 나온 결과값에 따라서 처리한다.

만약 결과값인 result가 isSuccessful이면 SharedFlow를 Success상태로 emit하고,
errorBody()가 null값이 아닐 경우에는 SharedFlow를 Error 상태로 emit한다.



API를 호출하는 함수 구현체가 있는 Repository


Repositry.kt


    // ==================================== postNFCData ====================================
    suspend fun postNFCId(nfcId: Int): Flow<Response<NFC>> = flow {
        val requestBodyJson = JsonObject().apply {
            addProperty("id", nfcId)
        }

        emit(nfcApi.postNFCId(requestBodyJson))
    }.flowOn(Dispatchers.IO)


API를 호출하는 함수 구현체가 있는 Repository

Screen.kt


@Composable
fun NFCStartScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    navController: NavController,
    nfcViewModel: NFCViewModel = hiltViewModel(LocalContext.current as ComponentActivity),
    nfcResponseViewModel: NFCResponseViewModel = hiltViewModel()
) {

	// ... 일단 생략
    // .. 아래에서 설명하면서 같이 보여줌

    val nfcState = nfcViewModel.nfcState.collectAsState()
    val nfcScreenState = remember { nfcState }

    LaunchedEffect(key1 = nfcScreenState.value) {
        if (nfcScreenState.value == "1") {
            nfcResponseViewModel.postNFCId(nfcScreenState.value.toInt())
        } else if (nfcScreenState.value == "SECOND") {
            navController.navigate(route = Screen.Test.route)
        }
    }

    NFCStartContent(nfcData = nfcScreenState.value)
} // End of NFCStartScreen

위의 Screen 예시는 NFC를 태그했을 때 데이터가 넘어오면 NFC에 저장되어 있는 값을 서버로 보내는 Screen이다.

기존의 Jetpack에서 Retofit을 사용한 예시는 Fragment에서 LiveData의 Observer를 통해서 상태를 관리했겠지만, 이번에는 Flow를 활용하고, observer를 직접적으로 사용할 수 없기 때문에, postNFCIdResponseSharedFlowState 의 값을 collectAsState()를 통해서 상태 값이 변하게되면 내부의 코드가 value에 따라서 동작하도록 설계되었다.



ViewModel.kt


@HiltViewModel
class NFCViewModel @Inject constructor(
) : ViewModel() {
    private val _nfcState = MutableStateFlow("")
    val nfcState = _nfcState.asStateFlow()

    private val _getNFCData = mutableStateOf<NFC?>(null)
    val getNFCData = _getNFCData

    fun setNFCData(newNFCData: NFC) {
        _getNFCData.value = newNFCData
    } // End of setNFCData
    
    fun setNFCState(newNFCState: String) {
        _nfcState.value = newNFCState
    } // End of setNFCState

    private val _sharedNFCStateFlow = MutableSharedFlow<String>()
    val sharedNFCStateFlow = _sharedNFCStateFlow.asSharedFlow()

    fun setNFCSharedFlow(newSharedNFCState: String) {
        viewModelScope.launch {
            _sharedNFCStateFlow.emit(newSharedNFCState)
        }
    } // End of setNFCSharedFlow
}  // End of NFCViewModel class
 

ViewModel에서는 viewModelScope를 사용해서 비동기처리를 통해
Repository에 있는 메소드를 실행한다.


Repository.kt


    // ====================== postNFCData ==================
    suspend fun postNFCId(nfcId: Int): Flow<Response<NFC>> = flow {
        val requestBodyJson = JsonObject().apply {
            addProperty("id", nfcId)
        }

        emit(nfcApi.postNFCId(requestBodyJson))
    }.flowOn(Dispatchers.IO)

위의 함수는 viewModel에서 넘겨받는 매개변수 nfcId를 JsonBody타입으로 "id"를 key값으로 nfc의 데이터를 담아서 보내게 된다.

이전의 LiveData를 사용했을 때는 postValue()를 사용해서 ViewModel의 LiveData가 바라보도록 했지만, 현재는 Repository에서는 Flow를 통한 비동기 처리로 진행을 하여 emit()을 통해 실행하도록 구현했다.

.flowOn()을 통해서 쓰레드 종류를 설정할 수 있다.

이제 이 함수를 return값인 Flow<Response<NFC>>가 다시 ViewModel로 돌아간다.



API Response를 가지고 다시 돌아가기

ViewModel.kt


    // ================================= getNavNFC =================================
    private val _postNFCIdResponseSharedFlow = MutableSharedFlow<NetworkResult<NFC>>()
    var postNFCIdResponseSharedFlow = _postNFCIdResponseSharedFlow
        private set

    fun postNFCId(nfcId: Int) {
        viewModelScope.launch {
            nfcRepo.postNFCId(nfcId).onStart {
                _postNFCIdResponseSharedFlow.emit(NetworkResult.Loading())
            }.catch {
                _postNFCIdResponseSharedFlow.emit(
                    NetworkResult.Error(
                        null, it.message, it.cause
                    )
                )
            }.collectLatest { 
            result ->${result.body()}")

                when {
                    result.isSuccessful -> {
                        _postNFCIdResponseSharedFlow.emit(
                            NetworkResult.Success(result.body()!!)
                        )
                    }

                    result.errorBody() != null -> {
                        _postNFCIdResponseSharedFlow.emit(
                            NetworkResult.Error(result.code(), result.message())
                        )
                    }
                }
            }
        }
    } // End of getNavNFC

위에서 설명한 ViewModel부분이 여기로 다시 돌아온다.

Repository의 API호출 구현체와 결괏값의 상태를 따라서 ViewModel에서 구현할 수 있다.

해당 결과값을 Screen으로 보내줄 때는 다시 SharedFlow에 담아서 Screen이 ViewModel의 데이터를 참조할 수 있도록 구현하면 된다.

여기서는 _postNFCIdResponseSharedFlow 상태를 emit()하여 해당 SharedFlow를 가지고 Screen에서 특정 상황이나 결과값에 따라 View를 구성할 것 이다.



ViewModel의 결괏값을 가지고 Screen 구성하기


Screen.kt


    val postNFCIdResponseSharedFlowState =
        nfcResponseViewModel.postNFCIdResponseSharedFlow.collectAsState(null)

    LaunchedEffect(key1 = postNFCIdResponseSharedFlowState.value) {
        when (postNFCIdResponseSharedFlowState.value) {
            is NetworkResult.Success -> {
                val data = postNFCIdResponseSharedFlowState.value!!.data
                
                if (postNFCIdResponseSharedFlowState.value!!.data != null) {
                    nfcViewModel.setNFCData(data as NFC)
                    navController.navigate(route = Screen.Main.route) {
                        popUpTo(Screen.NFCStart.route) {
                            inclusive = true
                        }
                    }

                }
            }

            is NetworkResult.Loading -> {
                // 로딩에서 보이게 될 화면 작성
            }

            is NetworkResult.Error -> {
                // 에러시 보이게 될 화면이나 이벤트 코드 작성
                // 에러 메세지나 코드에 따라서 다르게 동작할 수 있음
            }
            else -> {
                // null값이 들어가기 때문에 Sealed Class이지만 else문이 필요함 
                // Nullable이 아니었다면, 없었음
            }
        }
    }

아까 위에서 생략된 Screen파트가 나온다.

ViewModel의 SharedFlow를 .collectAsState()를 만들어 이 값을 변수로 빼놓고

이 값이 변할때 마다 내부가 동작하도록 해서 Flow의 상태에 따라서 Screen이 알맞게 동작하도록 구현하기만 하면 된다.


Screen에서 사용하기 위한 ViewModel 참조값의 타입이 현재 <NetworkResult<NFC>>으로 되어있기 때문에
.value에는 NetworkResult의 Sealed cLass가 담겨있고 내부인 .data에는 NetworkResult의 상태값에 담긴 NFC 데이터가 들어가 있을 것이다.


현재 위의 예시에서는 Success일 때만 구현체가 만들어져 있고, Error나 Loading에 대한 구현체가 특별히 없다.

만약 로딩을 하는 부분에서 프로그레스바가 보이길 원한다면 is NetworkResult.Loading -> {
내부에 프로그레스바가 동작하도록 구현하면 될 것이고

에러시 특정 메세지나 화면이 보이길 원한다면 마찬가지로 is NetworkResult.Error ->{ 내부에 본인이 원하는 에러처리 View를 구현하면 된다.


따라서 우리는

                val data = postNFCIdResponseSharedFlowState.value!!.data

위 부분을 통해서 NFC객체의 data를 꺼내서 사용하기만 하면된다.



후기

이전 까지는 LiveData를 통해서 서버와의 통신코드를 구현했었는데,

Flow를 사용해보니까 LiveData보다는 Flow가 .catch, .retry등의 여러가지 제공하는 함수가 많다보니 더 사용하기도 좋고, 성능면에서도 좋은 것 같다는 생각이든다.

물론 장단점이 있겠지만, Flow자료를 찾아보면서 LiveData가 Deprecated된다는 얘기가 종종 보이던데...
좀 무섭긴 하지만,, Flow를 써보니 LiveData는 없어도 되는 부분이긴 한 것 같다.

Kotlin 코드이긴 하지만 사실상 안드로이드에서만 사용되는 부분이다 보니 없는게 더 낫지 않나 싶다는 생각이들었다...

0개의 댓글