본문 바로가기
Development/Android

[Android] Clean Architecture With Pokedex (feat. Jetpack Compose, Hilt) - 3

by du.it.ddu 2022. 7. 10.
반응형

이번 포스팅은 Domain 레이어와 Presentation 레이어를 연결해주는 Data 레이어를 구현한다.
즉, data 모듈을 구현하게 된다.

실제 앱을 구현할 때, 데이터를 획득하는 방법은 크게 두 가지이다.
Rest api등과 같은 Remote 데이터, 로컬 데이터베이스와 같은 Local 데이터다.
이 예제에서는 Remote 데이터만을 다룰 것이다.

data모듈은 Domain 레이어의 Repository interface를 구현하게 된다.
구현 과정에서 API를 호출하여 네트워크 데이터를 획득하고 Domain 레이어의 Entity로 변환하는 작업이 필요하다.
우선 Retrofit을 활용하여 API 부터 구현한다.

interface PokemonApi {
    @GET("pokemon")
    suspend fun getPokemonList(
        @Query("limit") limit: Int,
        @Query("offset") offset: Int
    ): PokemonListResponse

    @GET("pokemon/{name}")
    suspend fun getPokemonDetail(
        @Path("name") name: String
    ): PokemonDetailResponse
}

data class PokemonListResponse(
    @field:Json(name = "results")
    val results: List<PokemonListItemResponse>
)

data class PokemonListItemResponse(
    @field:Json(name = "name")
    val name: String,

    @field:Json(name = "url")
    val url: String
) {
    val id: Int
        get() = url.split("/").dropLast(1).last().toInt()
}

data class PokemonDetailResponse(
    @field:Json(name = "id")
    val id: Int,

    @field:Json(name = "name")
    val name: String,

    @field:Json(name = "height")
    val height: Int,

    @field:Json(name = "weight")
    val weight: Int,

    @field:Json(name = "stats")
    val stats: List<PokemonStatResponse>,

    @field:Json(name = "types")
    val types: List<PokemonTypeResponse>
)

data class PokemonTypeResponse(
    @field:Json(name = "type")
    val type: PokemonTypeNameResponse,
)

data class PokemonTypeNameResponse(
    @field:Json(name = "name")
    val name: String
)

data class PokemonStatResponse(
    @field:Json(name = "base_stat")
    val baseStat: Int,

    @field:Json(name = "stat")
    val stat: PokemonStatNameResponse,
)

data class PokemonStatNameResponse(
    @field:Json(name = "name")
    val statName: String
)

API와 API 응답을 정의했다.
여기서 컨버터는 Moshi를 활용했다. Moshi를 활용한 이유는 Kotlin과 궁합이 좋아서이다.
이제 API 응답을 Domain에서 정의한 Entity로 변환하는 클래스를 만든다.

class PokemonMapper {

    fun fromResponse(
        response: PokemonDetailResponse
    ): PokemonDetailEntity = PokemonDetailEntity(
        response.id,
        response.name,
        response.id.toPokemonImageUrl(),
        response.weight,
        response.height,
        response.stats.map { PokemonStatEntity(it.stat.statName, it.baseStat) },
        response.types.map { it.findEntity() }
    )

    fun fromResponse(
        response: PokemonListResponse
    ): List<PokemonEntity> = response.results.map { fromResponse(it) }

    fun fromResponse(
        response: PokemonListItemResponse
    ) : PokemonEntity = PokemonEntity(
        response.id,
        response.name,
        response.id.toPokemonImageUrl()
    )

    private fun Int.toPokemonImageUrl(): String =
        POKEMON_IMAGE_BASE_URL + String.format("%03d.png", this)

    private fun PokemonTypeResponse.findEntity(): PokemonTypeEntity =
        PokemonTypeEntity.values().find { it.type == type.name } ?: PokemonTypeEntity.NORMAL

    companion object {
        private const val POKEMON_IMAGE_BASE_URL = "https://assets.pokemon.com/assets/cms2/img/pokedex/detail/"
    }
}

클래스가 아니라 Object여도 되며, 확장함수로 구현해도 된다.
취향껏 하면 된다.

포켓몬 목록은 페이징 처리가 필요하므로, 아래와 같이 PagingSource를 구현한다.

class PokemonPagingSource(
    private val pokemonApi: PokemonApi,
    private val pokemonMapper: PokemonMapper,
    private val limit: Int
) : PagingSource<Int, PokemonEntity>() {
    override fun getRefreshKey(state: PagingState<Int, PokemonEntity>): Int? =
        state.anchorPosition

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, PokemonEntity> =
        try {
            val nextPage = params.key ?: 0
            val data = pokemonApi.getPokemonList(limit, nextPage).let {
                pokemonMapper.fromResponse(it)
            }

            LoadResult.Page(
                data = data,
                prevKey = if (nextPage == 0) null else nextPage - limit,
                nextKey = if (data.isEmpty()) null else nextPage + limit
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
}

 

이제 Repository interface를 구현할 준비가 됐다.
아래와 같이 Repository를 구현한다.

class RemotePokemonRepositoryImpl @Inject constructor(
    private val pokemonApi: PokemonApi,
    private val pokemonMapper: PokemonMapper
) : PokemonRepository {
    override suspend fun getPokemonDetail(
        name: String
    ): PokemonDetailEntity =
        pokemonMapper.fromResponse(pokemonApi.getPokemonDetail(name))

    override fun getPokemonPagingSource(
        limit: Int,
    ) = Pager(
        config = PagingConfig(50)
    ) {
        PokemonPagingSource(pokemonApi, pokemonMapper, limit)
    }.flow
}

Repository의 구현도 간단하다.
로컬 데이터베이스 등의 처리가 추가되면 코드가 다소 길어지겠으나,
잘 분리되어 있다면 큰 문제는 없다.

Data 레이어의 구현도 어렵지 않았다.
Domain 레이어에서 정의된 Entity와 Repository에 따라 구현만 해주면 아주 쉽게 구현할 수 있었다.
이제 다음 포스팅에서 presentation 레이어를 구현할 것이다.

data 모듈의 자세한 구현은 아래 Github에서 참고하면 된다.
https://github.com/DuItDDu/Android_CleanArchitecture_Pokedex/tree/master/data

반응형