Android Multi Module Clean Architecture with Hilt, Ktor Client (1)

이태훈·2021년 7월 24일

프로젝트 개요

Clean Architecture 기반의 간단한 프로젝트를 만들어보겠습니다.
Hilt를 이용한 Dependency Injection, Ktor Client를 이용한 Http 통신을 하겠습니다.
https://unsplash.com 의 api를 사용하겠습니다.

본 포스팅에서는 아래와 같은 내용을 다루도록 하겠습니다.
1. Clean Architecutre 적용
2. Hilt를 이용한 DI 적용

Clean Architecutre 적용

1
Clean Architecutre 라고 하면 가장 먼저 떠오르는 원 이미지입니다.
위의 구조를 우리는 Presentation Layer에 MVVM 패턴을 적용시켜 다음과 같은 구조를 띄게 하겠습니다.
2

따라서, Presentation Module, Domain Module, Data Module 총 세 가지 모듈을 프로젝트에 적용하겠습니다.

Domain Module (Kotlin pure library)

Add Dependency

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutine_core_version"
implementation "androidx.paging:paging-common:${paging_version}"
implementation group: 'javax.inject', name: 'javax.inject', version: '1'

먼저, Domain Module부터 보겠습니다.
Domain Module에서 생성해야 할 것들은 크게 세 가지가 있습니다.

Repository Interface

Data Layer에서 사용할 Repository의 Abstraction 입니다.
왜, 이런 Abstraction이 필요하냐 함은 SOLID의 원칙 중 하나인 DIP(Dependency Inversion Principle)을 충족시키기 위함입니다. 이와 관련한 내용은 좋은 자료가 많으니 더 자세히 다루지는 않겠습니다.

코드를 보겠습니다.

interface UnsplashRepository {
    fun getSearchResultOfPage(query: String, page: Int): Flow<Result<List<UnsplashPhoto>>>

    fun getSearchResult(query: String): Flow<PagingData<UnsplashPhoto>
}

PagingData가 Android 종속적이지만, domain module에서 androidx paging common 라이브러리를 주입해주었기 때문에 사용이 가능합니다.

Use Case

@Singleton
class GetSearchResultUseCase @Inject constructor(
    private val unsplashRepository: UnsplashRepository
) {

    operator fun invoke(query: String) = unsplashRepository.getSearchResult(query)
}

실제 사용자가 하는 일련의 행동들을 나타냅니다.
JSR-330의 Inject Annotation을 이용하여 Repository를 주입시켜 줍니다.
여기에 대한 내용은 이 포스팅을 참고해주시기 바랍니다.

Entity

data class UnsplashPhoto(
    val id: String,
    val description: String?,
    val urls: UnsplashPhotoUrls,
    val user: UnsplashUser
) {

    data class UnsplashPhotoUrls(
        val raw: String,
        val full: String,
        val regular: String,
        val small: String,
        val thumb: String,
    )

    data class UnsplashUser(
        val name: String,
        val username: String
    )
}
sealed class Result<T> {

    class Success<T>(val data: T, val code: Int) : Result<T>()

    class Loading<T> : Result<T>()

    class ApiError<T>(val message: String, val code: Int) : Result<T>()

    class NetworkError<T>(val throwable: Throwable) : Result<T>()

    class NullResult<T> : Result<T>()
}

상태 관리를 위한 Result 클래스와 사용할 데이터 객체를 정의해줍니다.

Data Module (Android Library)

Add Dependency

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutine_core_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutine_android_version"

implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"

implementation "com.google.code.gson:gson:$gson_version"

implementation "androidx.paging:paging-runtime-ktx:$paging_version"

데이터 모듈에서 생성해야 할 것들은 크게 다음과 같습니다.

Repository Implementations

class KtorUnsplashRepositoryImpl @Inject constructor(
    private val unsplashService: UnsplashService
) : UnsplashRepository {

    override fun getSearchResultOfPage(
        query: String,
        page: Int
    ): Flow<Result<List<UnsplashPhoto>>> = flow {
        val response = unsplashService.searchPhotos(query, page)
        emit(ResponseMapper.responseToPhotoList(response))
    }

    override fun getSearchResult(query: String) =
        Pager(
            config = PagingConfig(
                pageSize = 20,
                maxSize = 100,
                enablePlaceholders = false
            ),
            pagingSourceFactory = { UnsplashPhotoPagingSource(unsplashService, query) }
        ).flow
}
class UnsplashPagingSource constructor(
    private val unsplashService: UnsplashService,
    private val query: String
) : PagingSource<Int, UnsplashPhoto>() {
    override fun getRefreshKey(state: PagingState<Int, UnsplashPhoto>): Int? {
        return state.anchorPosition
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UnsplashPhoto> {
        val position = params.key?: 1
        val response = unsplashService.searchPhotos(query, position, params.loadSize)

        return try {
            response as Result.Success
            LoadResult.Page(
                data = response.data.results,
                prevKey = if (position == 1) null else position - 1,
                nextKey = if (position == response.data.totalPages) null else position + 1
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }
}

Entity

data class UnsplashResponse(
    val results: List<UnsplashPhoto>,
    @SerializedName("total_pages")
    val totalPages: Int
)

Gson 라이브러리의 직렬화 규칙을 사용해줍니다.

Mapper

object ResponseMapper {

    fun responseToPhotoList(response: Result<UnsplashResponse>): Result<List<UnsplashPhoto>> {
        return when(response) {
            is Result.Success -> Result.Success(response.data.results, response.code)
            is Result.ApiError -> Result.ApiError(response.message, response.code)
            is Result.NetworkError -> Result.NetworkError(response.throwable)
            is Result.NullResult -> Result.NullResult()
            is Result.Loading -> Result.Loading()
        }
    }
}

Data Source로 부터 받은 데이터를 Domain Layer의 데이터 객체로 맵핑해줍니다.

Module (optional Hilt)

@Module
@InstallIn(SingletonComponent::class)
object DataModule {

    @Singleton
    @Provides
    fun provideKtorHttpClient(): HttpClient {
        return HttpClient(OkHttp) {
			// 2.0 이상
			install(ContentNegotiation) {
				gson()
			}
			// 2.0 이하
            install(JsonFeature) {
                GsonSerializer()
            }
            install(Logging) {
                logger = Logger.DEFAULT
                level = LogLevel.ALL
            }
        }
    }
}

@Module
@InstallIn(SingletonComponent::class)
interface RepositoryModule {

    @Binds
    @Singleton
    fun bindUnsplashService(ktorUnsplashService: KtorUnsplashService): UnsplashService

    @Binds
    @Singleton
    fun bindUnsplashRepository(unsplashRepositoryImpl: KtorUnsplashRepositoryImpl): UnsplashRepository
}

Ktor Client를 생성하는 내용은 추후에 다루도록 하겠습니다.

Data Sources

interface UnsplashService {

    suspend fun searchPhotos(query: String, page: Int, perPage: Int): Result<UnsplashResponse>
}

Ktor Client를 이용한 Http 통신만을 사용할 예정이기 때문에 Remote Data Source 만 생성합니다.

Presentation Layer

View

@AndroidEntryPoint
class GalleryFragment : Fragment() {
    private val galleryViewModel: GalleryViewModel by viewModels()
        
    lifecycleScope.launch {
        galleryViewModel.searchResult.flowWithLifecycle(lifecycle).collect {

        }
    }
}

ViewModel의 데이터를 처리하는 부분입니다.

ViewModel

@HiltViewModel
class GalleryViewModel @Inject constructor(
	getSearchResult: GetSearchResultUseCase,
) : ViewModel() {

	val searchResult = getSearchResult(DEFAULT_QUERY)
		.cachedIn(viewModelScope)

	companion object {
		const val DEFAULT_QUERY = "cats"
	}
}

Domain Layer의 Use Case를 이용해 원하는 데이터를 받는 코드입니다.

End

다음 포스팅에서는 Ktor Client를 생성하여 프로젝트에 적용하겠습니다.

코드 : https://github.com/TaehoonLeee/multi-module-clean-architecture


References

1: https://antonioleiva.com/clean-architecture-android/

2: https://tech.olx.com/clean-architecture-and-mvvm-on-ios-c9d167d9f5b3

profile
https://www.linkedin.com/in/%ED%83%9C%ED%9B%88-%EC%9D%B4-7b9563237

4개의 댓글

comment-user-thumbnail
2021년 7월 24일

설명이 친절해서 보기 좋네요ㅋㅋㅋ
좋은 글 감사합니다~

답글 달기
comment-user-thumbnail
2021년 7월 28일

오 정말 잘 만드신 것 같아요 bb 멋집니다.

답글 달기
comment-user-thumbnail
2021년 8월 4일

정말 깔끔하게 정리가 잘 돼 있네요 잘 보고 갑니다 ㅎㅎ

답글 달기
comment-user-thumbnail
2023년 4월 20일

좋은 글 감사합니다~

답글 달기