간단하게 Github 사용자 이름을 입력하고 검색하면 결과물을 표시해주는 예제입니다
Clean Architecture + MVVM 구조에 대해서 자세히 모르시는 분들은 전 글을 참고하시거나 구글링을 권장드립니다
2021.12.16 - [Android Studio/Etc] - Android Clean Architecture를 지향한 MVVM 패키지 구조
추가로 Android Clean Architecture의 개념이 이해가 어느 정도 필요하기 때문에 약간의 구글링을 권장드립니다
이 예제에서는 Retrofit과 Coroutine을 이용하여 Api를 호출할 때 안전하게 예외처리를 해줍니다
SafeApiCall에 대해 참고한 자료는 아래 링크에 있습니다
https://dev.to/eagskunst/making-safe-api-calls-with-retrofit-and-coroutines-1121
전체 코드
이 글에서 소개한 전체 예시 코드
https://github.com/ParkSangSun1/Clean_Architecture_Sample
clean architecture의 base가 되는 구조 예시
https://github.com/ParkSangSun1/Quick_Setup
전체적 정리
의존성 관계는 App(presentation) -> Data -> Domain입니다 main입니다
전체적인 흐름은 아래 그림과 같습니다, 중간중간 어? 이게 어떻게 연결되지 하시는 부분이 있을 겁니다. 그 부분은 의존성 주입을 이용했기 때문에 Module로 주입을 받아 사용할 수가 있습니다.
의존성 주입에 대해서 잘 모르신다면 미리 구글링을 추천드립니다.. (모르시면 아래 부분을 이해하기 힘듭니다)
모듈 추가
기본으로 있는 app을 두고 domain, data 모듈을 추가해야 합니다.
모듈을 추가하는 방법 : 왼쪽 상단 File -> Project Structure -> Modules -> + 아이콘 -> Templates는 Android Library로 체크 후 각각 모듈의 이름 -> Finish (예시에서는 domain, data 모두 Android Library Template을 사용했습니다)
각각 추가했다면 buildSrc 파일도 추가해줍니다
buildSrc 파일 추가하는 방법 : 왼쪽 상단 프로젝트 구조 보기 단위를 Project로 변경 -> 프로젝트 상위 파일에서 마우스 우클릭 -> New -> Directory(buildSrc) -> 만든 파일 안에 New -> File(이름과 확장자는 build.gradle.kts로 해야 합니다) -> Sync Now 클릭 후 기다리기 -> buildSrc 우클릭 -> New -> Directory -> src\main\java 선택 -> java 파일 안에 Dependency.kt 파일 만들기
build.gradle.kts
Dependency.kt
Gradle
build.gradle(Project)
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:7.0.2"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.30"
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.38.1'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
build.gradle(Module:app)
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}
android {
compileSdk 31
defaultConfig {
applicationId "com.pss.clean_architecture_sample"
minSdk 21
targetSdk 31
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
dataBinding true
}
}
dependencies {
implementation project(":data")
implementation project(':domain')
implementation (KTX.CORE)
implementation (AndroidX.APP_COMPAT)
implementation (Google.MATERIAL)
implementation (AndroidX.CONSTRAINT_LAYOUT)
implementation 'androidx.appcompat:appcompat:1.4.0'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
// dagger hilt
implementation (DaggerHilt.DAGGER_HILT)
kapt (DaggerHilt.DAGGER_HILT_COMPILER)
implementation (DaggerHilt.DAGGER_HILT_VIEW_MODEL)
kapt (DaggerHilt.DAGGER_HILT_ANDROIDX_COMPILER)
// ViewModel
implementation (AndroidX.LIFECYCLE_VIEW_MODEL)
// LiveData
implementation (AndroidX.LIFECYCLE_LIVEDATA)
// Retrofit
implementation (Retrofit.RETROFIT)
implementation (Retrofit.CONVERTER_GSON)
implementation (Retrofit.CONVERTER_JAXB)
//okHttp
implementation (OkHttp.OKHTTP)
implementation (OkHttp.LOGGING_INTERCEPTOR)
//coroutines
implementation (Coroutines.COROUTINES)
//by viewModel
implementation (AndroidX.ACTIVITY)
implementation (AndroidX.FRAGMENT)
//nav component
implementation (NavComponent.NAVIGATION_FRAGMENT)
implementation (NavComponent.NAVIGATION_UI)
implementation (NavComponent.NAVIGATION_DYNAMIC_FEATURES_FRAGMENT)
androidTestImplementation (NavComponent.NAVIGATION_TESTING)
implementation (NavComponent.NAVIGATION_COMPOSE)
//datastore
implementation (AndroidX.DATASTORE)
}
build.gradle(Module:domain)
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}
android {
compileSdk 31
defaultConfig {
minSdk 21
targetSdk 31
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
implementation (KTX.CORE)
implementation (AndroidX.APP_COMPAT)
implementation (Google.MATERIAL)
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
// Retrofit
implementation (Retrofit.RETROFIT)
implementation (Retrofit.CONVERTER_GSON)
implementation (Retrofit.CONVERTER_JAXB)
//okHttp
implementation (OkHttp.OKHTTP)
implementation (OkHttp.LOGGING_INTERCEPTOR)
// dager hilt
implementation (DaggerHilt.DAGGER_HILT)
kapt (DaggerHilt.DAGGER_HILT_COMPILER)
implementation (DaggerHilt.DAGGER_HILT_VIEW_MODEL)
kapt (DaggerHilt.DAGGER_HILT_ANDROIDX_COMPILER)
}
build.gradle(Module:data)
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}
android {
compileSdk 31
defaultConfig {
minSdk 21
targetSdk 31
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
implementation project(':domain')
implementation (KTX.CORE)
implementation (AndroidX.APP_COMPAT)
implementation (Google.MATERIAL)
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
// Retrofit
implementation (Retrofit.RETROFIT)
implementation (Retrofit.CONVERTER_GSON)
implementation (Retrofit.CONVERTER_JAXB)
//okHttp
implementation (OkHttp.OKHTTP)
implementation (OkHttp.LOGGING_INTERCEPTOR)
//coroutines
implementation (Coroutines.COROUTINES)
// dager hilt
implementation (DaggerHilt.DAGGER_HILT)
kapt (DaggerHilt.DAGGER_HILT_COMPILER)
implementation (DaggerHilt.DAGGER_HILT_VIEW_MODEL)
kapt (DaggerHilt.DAGGER_HILT_ANDROIDX_COMPILER)
}
App 모듈
이곳은 Clean Architecture에서 Presentation역할을 하는 곳입니다
크게 아래와 같이 나뉩니다
base - BaseActivity 등 base가 되는 파일들이 위치합니다
di - di 관련 module 파일 등이 위치합니다, 이 예제에서는 dagger hilt를 사용했습니다
view - activity와 fragment 등 view에 관련된 파일들이 위치합니다
viewmodel - viewModel 파일들이 위치합니다
widget - extension(코틀린 확장 함수), utils 등의 파일이 위치합니다
base
BaseActivity.kt
package com.pss.clean_architecture_sample.base
import android.os.Bundle
import android.widget.Toast
import androidx.annotation.LayoutRes
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
//BaseActivity.kt
abstract class BaseActivity<T : ViewDataBinding>(@LayoutRes private val layoutResId: Int) :
AppCompatActivity() {
protected lateinit var binding: T
private var waitTime = 0L
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, layoutResId)
init()
}
abstract fun init()
override fun onBackPressed() {
if (System.currentTimeMillis() - waitTime >= 1500) {
waitTime = System.currentTimeMillis()
Toast.makeText(this, "뒤로가기 버튼을 한번 더 누르면 종료됩니다.", Toast.LENGTH_SHORT).show()
} else finish()
}
protected fun shortShowToast(msg: String) =
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
protected fun longShowToast(msg: String) =
Toast.makeText(this, msg, Toast.LENGTH_LONG).show()
}
Activity의 반복적인 코드를 줄이고 중복되는 코드를 줄여 편하게 사용하기 위한 파일
BaseFragment.kt
package com.pss.clean_architecture_sample.base
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.LayoutRes
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.fragment.app.Fragment
abstract class BaseFragment<B : ViewDataBinding>(
@LayoutRes val layoutId: Int
) : Fragment() {
lateinit var binding: B
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = DataBindingUtil.inflate(inflater, layoutId, container, false)
init()
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = this
}
abstract fun init()
protected fun shortShowToast(msg: String) =
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
protected fun longShowToast(msg: String) =
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
}
Fragment의 반복적인 코드를 줄이고 중복되는 코드를 줄여 편하게 사용하기 위한 파일
BaseViewModel.kt
package com.pss.clean_architecture_sample.base
import android.view.View
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.pss.clean_architecture_sample.widget.utils.ScreenState
import com.pss.clean_architecture_sample.widget.utils.SingleLiveEvent
import com.pss.domain.utils.ErrorType
import com.pss.domain.utils.RemoteErrorEmitter
abstract class BaseViewModel : ViewModel(), RemoteErrorEmitter {
val mutableProgress = MutableLiveData<Int>(View.GONE)
val mutableScreenState = SingleLiveEvent<ScreenState>()
val mutableErrorMessage = SingleLiveEvent<String>()
val mutableSuccessMessage = MutableLiveData<String>()
val mutableErrorType = SingleLiveEvent<ErrorType>()
override fun onError(errorType: ErrorType) {
mutableErrorType.postValue(errorType)
}
override fun onError(msg: String) {
mutableErrorMessage.postValue(msg)
}
}
ViewModel의 반복적인 코드를 줄이고 중복되는 코드를 줄여 편하게 사용하기 위한 파일
di
App.kt
package com.pss.clean_architecture_sample.di
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class App : Application(){
companion object {
private lateinit var application: App
fun getInstance() : App = application
}
override fun onCreate(){
super.onCreate()
application = this
}
}
Hilt를 시작하기 위해 @HiltAndroidApp 어노테이션이 달린 Application()을 상속받는 파일, AndroidManifest.xml에서 name에 App을 적용해준다
DataSourcelmplModule.kt
package com.pss.clean_architecture_sample.di
import com.pss.data.remote.api.GithubApi
import com.pss.data.repository.remote.datasource.GithubDataSource
import com.pss.data.repository.remote.datasourceImpl.GithubDataSourceImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class DataSourceImplModule {
@Provides
@Singleton
fun provideMainDataSource(
githubApi: GithubApi
) : GithubDataSource {
return GithubDataSourceImpl(
githubApi
)
}
}
Hilt로 DataSourcelmpl관련 의존성 주입을 해주기 위한 파일
NetworkModule.kt
package com.pss.clean_architecture_sample.di
import com.pss.clean_architecture_sample.widget.utils.Utils.BASE_URL
import com.pss.data.remote.api.GithubApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.readTimeout(10, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.addInterceptor(getLoggingInterceptor())
.build()
}
@Singleton
@Provides
fun provideRetrofitInstance(
okHttpClient: OkHttpClient,
gsonConverterFactory: GsonConverterFactory
): Retrofit {
return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.client(provideHttpClient())
.addConverterFactory(gsonConverterFactory)
.build()
}
@Provides
@Singleton
fun provideConverterFactory(): GsonConverterFactory {
return GsonConverterFactory.create()
}
@Provides
@Singleton
fun provideGithubApiService(retrofit: Retrofit): GithubApi {
return retrofit.create(GithubApi::class.java)
}
private fun getLoggingInterceptor(): HttpLoggingInterceptor =
HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }
}
Hilt로 Retrofit관련 의존성 주입을 해주기 위한 파일
RepositoryModule.kt
package com.pss.clean_architecture_sample.di
import com.pss.data.repository.GithubRepositoryImpl
import com.pss.data.repository.remote.datasourceImpl.GithubDataSourceImpl
import com.pss.domain.repository.GithubRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class RepositoryModule {
@Provides
@Singleton
fun provideMainRepository(
githubDataSourceImpl: GithubDataSourceImpl
): GithubRepository {
return GithubRepositoryImpl(
githubDataSourceImpl
)
}
}
Hilt로 Repository관련 의존성 주입을 해주기 위한 파일
UseCaseModule.kt
package com.pss.clean_architecture_sample.di
import com.pss.domain.repository.GithubRepository
import com.pss.domain.usecase.GetUserRepoUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class UseCaseModule {
@Provides
@Singleton
fun provideGetUserRepoUseCase(repository: GithubRepository) = GetUserRepoUseCase(repository)
}
Hilt로 UseCase관련 의존성 주입을 해주기 위한 파일
view
MainActivity.kt
package com.pss.clean_architecture_sample.view
import android.util.Log
import android.view.View
import androidx.activity.viewModels
import com.pss.clean_architecture_sample.R
import com.pss.clean_architecture_sample.base.BaseActivity
import com.pss.clean_architecture_sample.databinding.ActivityMainBinding
import com.pss.clean_architecture_sample.viewmode.MainViewModel
import com.pss.clean_architecture_sample.widget.utils.ScreenState
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : BaseActivity<ActivityMainBinding>(R.layout.activity_main) {
private val mainViewModel by viewModels<MainViewModel>()
override fun init() {
binding.activity= this
observeViewModel()
}
fun clickSearchBtn(view: View){
mainViewModel.getUserRepo(binding.githubNameEditTxt.text.toString())
}
private fun observeViewModel(){
mainViewModel.mutableScreenState.observe(this,{
Log.d("로그","$it")
when(it){
ScreenState.RENDER -> shortShowToast("성공!")
ScreenState.ERROR -> shortShowToast("에러 발생!!")
else -> shortShowToast("알수없는 에러 발생!!")
}
})
mainViewModel.eventUserRepo.observe(this,{
it.map { item ->
binding.responseTxt.text = item.url
}
})
}
}
사용자가 검색을 누르는 순간 viewModel을 호출, observe로 api호출 결과(성공 or 에러)를 관찰
viewmodel
MainViewModel.kt
package com.pss.clean_architecture_sample.viewmode
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import com.pss.clean_architecture_sample.base.BaseViewModel
import com.pss.clean_architecture_sample.widget.utils.ScreenState
import com.pss.clean_architecture_sample.widget.utils.SingleLiveEvent
import com.pss.domain.model.GithubResponse
import com.pss.domain.usecase.GetUserRepoUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
private val getUserRepoUseCase: GetUserRepoUseCase
) : BaseViewModel() {
val eventUserRepo: LiveData<List<GithubResponse>> get() = _eventUserRepo
private val _eventUserRepo = SingleLiveEvent<List<GithubResponse>>()
fun getUserRepo(owner: String) = viewModelScope.launch {
val response = getUserRepoUseCase.execute(this@MainViewModel, owner)
if(response == null) mutableScreenState.postValue(ScreenState.ERROR) else {
mutableScreenState.postValue(ScreenState.RENDER)
_eventUserRepo.postValue(response!!)
}
}
}
UseCase를 Hilt를 이용해 주입받아 사용
widget
ActivityExtension.kt
package com.pss.clean_architecture_sample.widget.extension
import android.content.Context
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
//Activity Intent
fun AppCompatActivity.startActivityWithFinish(context: Context, activity: Class<*>) {
startActivity(Intent(context, activity).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP))
this.finish()
}
//Vertical RecyclerView
fun RecyclerView.showVertical(context: Context){
this.layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
}
//Horizontal RecyclerView
fun RecyclerView.showHorizontal(context: Context){
this.layoutManager =
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
}
코틀린 확장 함수 파일
ScreenState.kt
package com.pss.clean_architecture_sample.widget.utils
enum class ScreenState {
RENDER,
LOADING,
ERROR
}
API 호출 상태 enum class
SingleLiveEvent.kt
package com.pss.clean_architecture_sample.widget.utils
import android.util.Log
import androidx.annotation.MainThread
import androidx.annotation.Nullable
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import java.util.concurrent.atomic.AtomicBoolean;
class SingleLiveEvent<T> : MutableLiveData<T>() {
companion object {
private const val TAG = "SingleLiveEvent"
}
val mPending: AtomicBoolean = AtomicBoolean(false)
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
if (hasActiveObservers()) {
Log.w(TAG,"Multiple observers registered but only one will be notified of changes.")
}
// Observe the internal MutableLiveData
super.observe(owner, Observer { t ->
if (mPending.compareAndSet(true, false)) {
observer.onChanged(t)
}
})
}
@MainThread
override fun setValue(@Nullable t: T?) {
mPending.set(true)
super.setValue(t)
}
/**
* Used for cases where T is Void, to make calls cleaner.
*/
@MainThread
fun call() {
value = null
}
}
live data를 observe로 관찰할 때 불필요한 호출을 줄이기 위한 파일 (자세한 건 검색해보면 나옵니다)
Utils.kt
package com.pss.clean_architecture_sample.widget.utils
object Utils {
const val BASE_URL = "https://api.github.com"
}
자주 쓰는 함수나 변수 파일
domain 모듈
이곳은 Clean Architecture에서 Domain역할을 하는 곳입니다
크게 아래와 같이 나뉩니다
model - Response data class가 위치합니다
repository - data repository의 실제 구현체가 이곳에 위치합니다(interface)
usecase - 각각의 기능별로 세분화된 usecase가 위치합니다
utils - 자주 쓰는 함수나 변수 등이 위치합니다
model
GithubResponse.kt
package com.pss.domain.model
import com.google.gson.annotations.SerializedName
data class GithubResponse(
@SerializedName("name")
val name: String,
@SerializedName("id")
val id: String,
@SerializedName("created_at")
val date: String,
@SerializedName("html_url")
val url: String
)
Github api를 호출 후 받는 결괏값 data class
repository
GithubRepository.kt
package com.pss.domain.repository
import com.pss.domain.utils.RemoteErrorEmitter
import com.pss.domain.model.GithubResponse
interface GithubRepository {
suspend fun getGithub(remoteErrorEmitter: RemoteErrorEmitter, owner : String) : List<GithubResponse>?
}
data 모듈의 GithubRepositoryImpl가 상속받습니다
usecase
GetUserRepoUseCase.kt
package com.pss.domain.usecase
import com.pss.domain.utils.RemoteErrorEmitter
import com.pss.domain.repository.GithubRepository
import javax.inject.Inject
class GetUserRepoUseCase @Inject constructor(
private val githubRepository: GithubRepository
) {
suspend fun execute(remoteErrorEmitter: RemoteErrorEmitter, owner : String) = githubRepository.getGithub(remoteErrorEmitter, owner)
}
repository를 hilt를 이용해 주입받아 하나의 하나의 세부적인 기능을 선언합니다
utils
ErrorType.kt
package com.pss.domain.utils
enum class ErrorType {
NETWORK,
TIMEOUT,
SESSION_EXPIRED,
UNKNOWN
}
api 호출 에러 타입 enum class
RemoteErrorEmitter.kt
package com.pss.domain.utils
interface RemoteErrorEmitter {
fun onError(msg: String)
fun onError(errorType: ErrorType)
}
에러 타입과 메시지를 받기 위한 interface
data 모듈
이곳은 Clean Architecture에서 Data역할을 하는 곳입니다
크게 아래와 같이 나뉩니다
db - local db에 관련된 room 등의 파일이 위치합니다
mapper - data모듈의 response data class를 domain모듈의 response data class로 바꿔주는 파일이 위치합니다
remote - api, response model 등의 파일이 위치합니다
repository - datasource와 domain의 repository의 Implement 파일이 위치합니다
utils - base 등 자주 쓰는 함수나 변수 등의 파일이 위치합니다
db
예시 코드 작성 X
mapper
Mapper.kt
package com.pss.data.mapper
import com.pss.data.remote.model.GithubResponse
object Mapper {
fun mapperGithub(response: List<GithubResponse>?) : List<com.pss.domain.model.GithubResponse>? {
return if (response != null){
response.toDomain()
} else null
}
fun List<GithubResponse>.toDomain() : List<com.pss.domain.model.GithubResponse> {
return this.map {
com.pss.domain.model.GithubResponse(
it.name,
it.id,
it.date,
it.url
)
}
}
}
domain layer는 data layer를 모르기 때문에 data에서 domain layer의 response data class로 자료형을 변환해 보내줘야 합니다
remote
GithubApi.kt
package com.pss.data.remote.api
import com.pss.data.remote.model.GithubResponse
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path
interface GithubApi {
@GET("users/{owner}/repos")
suspend fun getRepos(@Path("owner") owner: String) : Response<List<GithubResponse>>
}
retrofit을 사용할 때 필요한 인터페이스이다. @GET 어노테이션을 사용하고 반환형은 Response <List <GithubResponse>>입니다(Github repo 검색 api는 List로 받지 않으면 오류가 납니다)
GithubResponse.kt
package com.pss.data.remote.model
import com.google.gson.annotations.SerializedName
data class GithubResponse(
@SerializedName("name")
val name: String,
@SerializedName("id")
val id: String,
@SerializedName("created_at")
val date: String,
@SerializedName("html_url")
val url: String
)
위에 GithubApi에서 반환형으로 사용되는 response data class입니다
repository
GithubDataSource.kt
package com.pss.data.repository.remote.datasource
import com.pss.data.remote.model.GithubResponse
import com.pss.domain.utils.RemoteErrorEmitter
interface GithubDataSource {
suspend fun getGithub(remoteErrorEmitter: RemoteErrorEmitter, owner : String) : List<GithubResponse>?
}
GithubDataSourceImpl의 실질적 구현체입니다. 최종적으로 보면 이곳에서 api 등을 호출하고 다시 view로 보여준다고 보면 됩니다
GithubDataSourceImpl.kt
package com.pss.data.repository.remote.datasourceImpl
import com.pss.data.remote.api.GithubApi
import com.pss.data.remote.model.GithubResponse
import com.pss.data.utils.base.BaseRepository
import com.pss.domain.utils.RemoteErrorEmitter
import com.pss.data.repository.remote.datasource.GithubDataSource
import javax.inject.Inject
class GithubDataSourceImpl @Inject constructor(
private val githubApi: GithubApi
) : BaseRepository(), GithubDataSource {
override suspend fun getGithub(remoteErrorEmitter: RemoteErrorEmitter, owner : String): List<GithubResponse>? {
return safeApiCall(remoteErrorEmitter){githubApi.getRepos(owner).body()}
}
}
GithubDataSource 상속받아 사용합니다
GithubRepositoryImpl.kt
package com.pss.data.repository
import com.pss.data.mapper.Mapper
import com.pss.domain.utils.RemoteErrorEmitter
import com.pss.data.repository.remote.datasource.GithubDataSource
import com.pss.domain.model.GithubResponse
import com.pss.domain.repository.GithubRepository
import javax.inject.Inject
class GithubRepositoryImpl @Inject constructor(
private val githubDataSource: GithubDataSource
) : GithubRepository {
override suspend fun getGithub(
remoteErrorEmitter: RemoteErrorEmitter,
owner: String
): List<GithubResponse>? {
return Mapper.mapperGithub(githubDataSource.getGithub(remoteErrorEmitter, owner))
}
}
Domain layer의 GithubRepository 상속받아 사용합니다
utils
BaseRepository.kt
package com.pss.data.utils.base
import android.util.Log
import com.pss.domain.utils.ErrorType
import com.pss.domain.utils.RemoteErrorEmitter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.ResponseBody
import org.json.JSONObject
import retrofit2.HttpException
import java.io.IOException
import java.net.SocketTimeoutException
abstract class BaseRepository {
companion object {
private const val TAG = "BaseRemoteRepository"
private const val MESSAGE_KEY = "message"
private const val ERROR_KEY = "error"
}
/**
* Function that executes the given function on Dispatchers.IO context and switch to Dispatchers.Main context when an error occurs
* @param callFunction is the function that is returning the wanted object. It must be a suspend function. Eg:
* override suspend fun loginUser(body: LoginUserBody, emitter: RemoteErrorEmitter): LoginUserResponse? = safeApiCall( { authApi.loginUser(body)} , emitter)
* @param emitter is the interface that handles the error messages. The error messages must be displayed on the MainThread, or else they would throw an Exception.
*/
suspend inline fun <T> safeApiCall(emitter: RemoteErrorEmitter, crossinline callFunction: suspend () -> T): T? {
return try{
val myObject = withContext(Dispatchers.IO){ callFunction.invoke() }
myObject
}catch (e: Exception){
withContext(Dispatchers.Main){
e.printStackTrace()
Log.e("BaseRemoteRepo", "Call error: ${e.localizedMessage}", e.cause)
when(e){
is HttpException -> {
if(e.code() == 401) emitter.onError(ErrorType.SESSION_EXPIRED)
else {
val body = e.response()?.errorBody()
emitter.onError(getErrorMessage(body))
}
}
is SocketTimeoutException -> emitter.onError(ErrorType.TIMEOUT)
is IOException -> emitter.onError(ErrorType.NETWORK)
else -> emitter.onError(ErrorType.UNKNOWN)
}
}
null
}
}
/**
* Function that executes the given function in whichever thread is given. Be aware, this is not friendly with Dispatchers.IO,
* since [RemoteErrorEmitter] is intended to display messages to the user about error from the server/DB.
* @param callFunction is the function that is returning the wanted object. Eg:
* override suspend fun loginUser(body: LoginUserBody, emitter: RemoteErrorEmitter): LoginUserResponse? = safeApiCall( { authApi.loginUser(body)} , emitter)
* @param emitter is the interface that handles the error messages. The error messages must be displayed on the MainThread, or else they would throw an Exception.
*/
inline fun <T> safeApiCallNoContext(emitter: RemoteErrorEmitter, callFunction: () -> T): T? {
return try{
val myObject = callFunction.invoke()
myObject
}catch (e: Exception){
e.printStackTrace()
Log.e("BaseRemoteRepo", "Call error: ${e.localizedMessage}", e.cause)
when(e){
is HttpException -> {
if(e.code() == 401) emitter.onError(ErrorType.SESSION_EXPIRED)
else {
val body = e.response()?.errorBody()
emitter.onError(getErrorMessage(body))
}
}
is SocketTimeoutException -> emitter.onError(ErrorType.TIMEOUT)
is IOException -> emitter.onError(ErrorType.NETWORK)
else -> emitter.onError(ErrorType.UNKNOWN)
}
null
}
}
fun getErrorMessage(responseBody: ResponseBody?): String {
return try {
val jsonObject = JSONObject(responseBody!!.string())
when {
jsonObject.has(MESSAGE_KEY) -> jsonObject.getString(MESSAGE_KEY)
jsonObject.has(ERROR_KEY) -> jsonObject.getString(ERROR_KEY)
else -> "Something wrong happened"
}
} catch (e: Exception) {
"Something wrong happened"
}
}
}
safeApiCall을 사용하기 위한 abstract class입니다, Repository에서 상속받아 사용합니다
궁금한 점이나 틀린 부분은 댓글로 작성 부탁드립니다!
추가로
안드로이드 개발에 대한 질문과 정보 등을 공유 및 소통하는 채팅방입니다
'Android' 카테고리의 다른 글
[Android] Library를 만들고 JitPack으로 배포해보자! (0) | 2021.12.30 |
---|---|
[Android] 쉽고 간편한 Dagger Hilt를 사용해 보자! (0) | 2021.12.29 |
Android Clean Architecture를 지향한 MVVM 패키지 구조 (0) | 2021.12.16 |
Android MVVM 패키지 구조 (0) | 2021.12.16 |
Android Studio에서 Pytorch mobile을 사용하며 (4) | 2021.12.06 |