[Android] Sealed Class 를 Retrofit 통신(with Hilt, flow)

WonDDak·2022년 9월 24일
0

Sealed Class?

  • sealed 라는 말에서 알수 있듯이 봉인된 class라고 할수 있다.
  • abstarac class 의 일종으로 상속받는 child Class로 type을 제한할수 있다.
  • child class들로 type이 제한되므로 when 구문에서 큰 힘을 보여준다.

여기까지만 보면 enum과 다른게 없어 보이지만 큰 차이는 child의 param을 다르게 설정 할수 있으므로 유용하다.
아래 코드를 살펴보자.

sealed class ApiResult<out T> {
    //로딩시 (최초값으로 사용하기)
    object Loading : ApiResult<Nothing>() // 상태값이 바뀌지 않는 서브 클래스의 경우 object 를 사용하는 것을 권장

    // 성공적으로 수신할 경우 body 데이터를 반환
    data class Success<out T>(val data: T) : ApiResult<T>()

    // 오류 메시지가 포함된 응답을 성공적으로 수신한 경우
    data class Error(val code: Int, val message: String?) : ApiResult<Nothing>()

    //예외 발생시
    data class Exception(val e: Throwable) : ApiResult<Nothing>()
}

이런식으로 ApitResult라는 SealdClass의 안에 Loading,Success,Error,Exception 이라는 4가지 child를 구현해보았다.

Sealed Class를 사용할때는 서브클래스들은 반드시 같은 파일내에 선언해주어야한다.
Sealed Class는 자식으로 Sealed Class를 자식으로 받는것이 가능하다.

다음과 같이 ApiResult라는 SealedClass안에 Fail이라는 Sealed Class를 만들었다.

sealed class ApiResult<out T> {
    //로딩시 (최초값으로 사용하기)
    object Loading : ApiResult<Nothing>() // 상태값이 바뀌지 않는 서브 클래스의 경우 object 를 사용하는 것을 권장

    // 성공적으로 수신할 경우 body 데이터를 반환
    data class Success<out T>(val data: T) : ApiResult<T>()
    
    sealed class Fail : ApiResult<Nothing>() {
        data class Error(val  code: Int,val message: String?) : Fail()
        data class Exception(val e:Throwable) : Fail()
    }
}

Retrofit에서 사용해보자

이제 Retrofit 통신을 하기 위해 interface와 Client를 구성하자.
Hilt를 사용하여 다음과 같이 구성하여 ApiModule을 만들었다,

@Module
@InstallIn(SingletonComponent::class)
class ApiModule {

    @Provides
    fun provideBaseUrl() = "https://jsonplaceholder.typicode.com/"


    @Singleton
    @Provides
    fun provideOkHttpClient(): OkHttpClient {
        val connectionTimeOut = (1000 * 30).toLong()
        val readTimeOut = (1000 * 5).toLong()

        val interceptor = HttpLoggingInterceptor()

        HttpLoggingInterceptor(object : HttpLoggingInterceptor.Logger {
            override fun log(message: String) {
                if (!message.startsWith("{") && !message.startsWith("[")) {
                    Timber.tag("OkHttp").d(message)
                    return
                }
                try {
                    // Timber 와 Gson setPrettyPrinting 를 이용해 json 을 보기 편하게 표시해준다.
                    Timber.tag("OkHttp").d(GsonBuilder().setPrettyPrinting().create().toJson(
                        JsonParser().parse(message)))
                } catch (m: JsonSyntaxException) {
                    Timber.tag("OkHttp").d(message)
                }
            }
        })

        interceptor.level = HttpLoggingInterceptor.Level.NONE

        if (BuildConfig.DEBUG) {
            interceptor.level = HttpLoggingInterceptor.Level.BODY
        }

        return OkHttpClient.Builder()
            .readTimeout(readTimeOut, TimeUnit.MILLISECONDS)
            .connectTimeout(connectionTimeOut, TimeUnit.MILLISECONDS)
            .addInterceptor(interceptor)
            .build()
    }

    @Singleton
    @Provides
    fun provideRetrofit(okHttpClient: OkHttpClient, baseUrl: String): Retrofit {
        return Retrofit.Builder()
            .client(okHttpClient)
            .baseUrl(baseUrl)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    @Singleton
    @Provides
    fun providePostsService(retrofit: Retrofit): ApiInterface {
        return retrofit.create(ApiInterface::class.java)
    }

}

그리고 다음과 같이 interface를 만들었다.

interface ApiInterface {

    @GET("posts")
    suspend fun getAllPosts(
        @Query("userId") userId: Int,
    ): List<PostItem>

    @GET("photos")
    suspend fun getAllPhotos(
        @Query("albumId") albumId: Int,
    ): List<PhotoItem>

}

여기서 이상함을 느낀사람이 있을것이다.
대부분 interface에서 Response<> 형태로 받아와서 successful일때 분기처리하여 넘겨주는 것이 일반적이다.
하지만 flow를 이용하여 분기를 처리할 예정이므로 위와같이 처리를 해주고 Repository를 만들었다.

class PostRepository @Inject constructor(
    private val service: ApiInterface,
) {


    fun getAllPost(userId:Int): Flow<ApiResult<List<PostItem>>> =
        handleFlowApi {
           service.getAllPosts(userId)
        }

    suspend fun getAllPhotos(albumId: Int): Flow<ApiResult<List<PhotoItem>>> =
        handleFlowApi {
            service.getAllPhotos(albumId)
        }
}

handleFlowApi 넘어온값을 핸들링해주는 함수이다. 같은 형식으로 넘어가기때문에 편하게 사용하기 위해 사용했다.


fun <T : Any> handleFlowApi(
    execute: suspend () -> T,
): Flow<ApiResult<T>> = flow {
    emit(ApiResult.Loading) //값 갱신전 로딩을 emit
    delay(1000) // (1초대기)
    try {
        emit(ApiResult.Success(execute())) // execute 성공시 해당값을 Success에 담아서 반환
    } catch (e: HttpException) {
        emit(ApiResult.Error(code = e.code(), message = e.message())) // 네트워크 오류시 code와 메세지를 반환
    } catch (e: Exception) {
        emit(ApiResult.Exception(e = e)) // 예외 발생시 해당 에러를 반환
    }
}

이렇게 구성하면 Repository에서 viewmodel로 ApiResult<>형태로 값을 넘겨줄것이다.

@HiltViewModel
class PostsViewModel
@Inject constructor(
    private val postRepository: PostRepository,
) : ViewModel() {

    val userId: StateFlow<Int> get() = _userId
    private var _userId = MutableStateFlow<Int>(1)

    val albumId: StateFlow<Int> get() = _albumId
    private var _albumId = MutableStateFlow<Int>(1)

    @OptIn(ExperimentalCoroutinesApi::class)
    val postList: StateFlow<ApiResult<List<PostItem>>> =
        userId.flatMapLatest { id -> //마지막 데이터 반환
            postRepository.getAllPost(id) // post 요청
        }.flowOn(Dispatchers.IO)
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(3000L),
                initialValue = ApiResult.Loading
            )


    @OptIn(ExperimentalCoroutinesApi::class)
    val photoList: StateFlow<ApiResult<List<PhotoItem>>> =
        albumId.flatMapLatest { id ->
            postRepository.getAllPhotos(id)
        }.flowOn(Dispatchers.IO)
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(3000L),
                initialValue = ApiResult.Loading
            )

    fun insertUserId(id: Int) {
        _userId.value = id
    }

    fun insertPhotoUserId(id: Int) {
        _albumId.value = id
    }

}

이와같이 viewmodel이 구성되어있다.
넘어온 list를 ApiResult 값에 따라 분기처리 해주면 될것이다.

databinding을 사용중이라 다음과 같이 BindingAdpater를 만들어 사용했다. 그중 일부를 보여드리면

object ApiResultBinding {

    @BindingAdapter("bindProgressShow")
    @JvmStatic
    fun View.bindShowProgress(
        data: StateFlow<ApiResult<List<Any>>>,
    ) {
        data.value.let {result ->
            this.visibility = View.GONE

            result.onLoading {
                this.visibility = View.VISIBLE
            }

        }
    }

    @Suppress("UNCHECKED_CAST")
    @BindingAdapter("bindItems")
    @JvmStatic
    fun RecyclerView.bindItems(
        data: StateFlow<ApiResult<List<Any>>>,
    ) {
        data.value.let { result ->
            result.onSuccess {
                when (val mAdapter = this.adapter) {
                    is PhotoAdapter -> {
                        (mAdapter).insertList(it as List<PhotoItem>)
                    }
                    is PostAdapter -> {
                        mAdapter.insertList(it as List<PostItem>)
                    }
                    else -> {

                    }
                }
            }
        }
    }
}

Loading일때 Progress 처리
success일때 data 처리 역할을 해준다.

추가로 onSuccess같은 함수는 편하게 쓰기위해 inline함수로 구현하여 사용했다.

// inline function .. 반복 개체생성이 안됨
// reified : 인라인(inline) 함수와 reified 키워드를 함께 사용하면 T type에 대해서 런타임에 접근할 수 있게 해줌.
inline fun <reified T : Any> ApiResult<T>.onLoading(action: () -> Unit) {
    if (this is ApiResult.Loading) action()
}

inline fun <reified T : Any> ApiResult<T>.onSuccess(action: (data: T) -> Unit) {
    if (this is ApiResult.Success) action(data)
}

inline fun <reified T : Any> ApiResult<T>.onError(action: (code: Int, message: String?) -> Unit) {
    if (this is ApiResult.Error) action(code, message)
}

inline fun <reified T : Any> ApiResult<T>.onException(action: (e: Throwable) -> Unit) {
    if (this is ApiResult.Exception) action(e)
}

이상입니다.

profile
안녕하세요. 원딱입니다.

0개의 댓글