Android Multi Module Clean Architecture with Hilt, Ktor Client (1)
프로젝트 개요
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 "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutine_android_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>>>
// PagingData를 사용하기 위한 Generic Function
fun <T> getSearchResult(query: String): Flow<T>
}
getSearchResult 함수를 보시면 Generic Function 으로 했습니다. 그 이유는, Domain Module은 Kotlin pure library로 모듈을 생성하게 되는데, 이 부분에서 Android Framework에 있는 Jetpack Paging이라는 라이브러리를 사용하지 못하게 됩니다. 따라서, PagingData를 받기 위해 Generic Function을 사용했습니다.
Use Case
class GetSearchResultUseCase @Inject constructor(
private val unsplashRepository: UnsplashRepository
) {
fun <T> execute(query: String) = unsplashRepository.getSearchResult<T>(query)
}
class GetSearchResultOfPageUseCase @Inject constructor(
private val unsplashRepository: UnsplashRepository
) {
fun execute(query: String, page: Int) = unsplashRepository.getSearchResultOfPage(query, page)
}
실제 사용자가 하는 일련의 행동들을 나타냅니다.
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
@Singleton
class KtorUnsplashRepositoryImpl @Inject constructor(
private val unsplashService: UnsplashService,
private val responseToPhotoList: (Result<UnsplashResponse>) -> Result<List<UnsplashPhoto>>
) : UnsplashRepository {
override fun getSearchResultOfPage(
query: String,
page: Int
): Flow<Result<List<UnsplashPhoto>>> = flow {
val response = unsplashService.searchPhotos(query, page)
emit(responseToPhotoList(response))
}
override fun <T> getSearchResult(query: String): Flow<T> =
Pager(
config = PagingConfig(
pageSize = 20,
maxSize = 100,
enablePlaceholders = false
),
pagingSourceFactory = { KtorUnsplashPagingSource(unsplashService, query) }
).flow as Flow<T>
}
class KtorUnsplashPagingSource 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)
object DataModule {
@Singleton
@Provides
fun provideKtorHttpClient(): HttpClient {
return HttpClient(OkHttp) {
install(JsonFeature) {
GsonSerializer()
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.ALL
}
}
}
@Singleton
@Provides
fun provideUnsplashService(
retrofit: Retrofit,
okHttpClient: HttpClient
): UnsplashService {
return KtorUnsplashService(okHttpClient)
}
@Singleton
@Provides
fun provideUnsplashRepository(
unsplashService: UnsplashService
): UnsplashRepository {
return KtorUnsplashRepositoryImpl(unsplashService, ResponseMapper::responseToPhotoList)
}
}
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()
galleryViewModel.searchPageResult.observe(viewLifecycleOwner) {
데이터 처리 관련 로직 ex) 어댑터 데이터 추가, 상태 관리...
}
galleryViewModel.searchResult.observe(viewLifecycleOwner) {
데이터 처리 관련 로직 ex) 어댑터 데이터 추가, 상태 관리...
}
}
ViewModel의 데이터를 처리하는 부분입니다.
ViewModel
@HiltViewModel
class GalleryViewModel @Inject constructor(
getSearchResultUseCase: GetSearchResultUseCase,
getSearchResultOfPageUseCase: GetSearchResultOfPageUseCase
) : ViewModel() {
val searchPageResult = getSearchResultOfPageUseCase.execute(DEFAULT_QUERY, FIRST_PAGE)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
.filterIsInstance<Result.Success<List<UnsplashPhoto>>>()
.map { it.data }
.asLiveData()
val searchResult = getSearchResultUseCase
.execute<PagingData<UnsplashPhoto>>(DEFAULT_QUERY)
.cachedIn(viewModelScope)
.asLiveData()
companion object {
const val DEFAULT_QUERY = "cats"
const val FIRST_PAGE = 1
}
}
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
Author And Source
이 문제에 관하여(Android Multi Module Clean Architecture with Hilt, Ktor Client (1)), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@ams770/Android-Multi-Module-Clean-Architecture-with-Hilt-Ktor-Client-1저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)