본문 바로가기
Android

Clean Architecture + MVVM + Coroutine + Hilt + Retrofit을 이용하여 안전하게 Github API를 호출해 보자!

by 안솝우화 2021. 12. 27.
반응형

간단하게 Github 사용자 이름을 입력하고 검색하면 결과물을 표시해주는 예제입니다

 

Clean Architecture + MVVM 구조에 대해서 자세히 모르시는 분들은 전 글을 참고하시거나 구글링을 권장드립니다

2021.12.16 - [Android Studio/Etc] - Android Clean Architecture를 지향한 MVVM 패키지 구조

 

Android Clean Architecture를 지향한 MVVM 패키지 구조

안녕하세요 오늘은 안드로이드에서 Clean Architecture를 지향한 MVVM 패키지 구조 예시를 보여드리려고 합니다 그냥 MVVM 디자인 패턴을 적용한 패키지 구조는 이전글을 참고하시면 될것 같습니다 2021

asuhdevstory.tistory.com

추가로 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

 

GitHub - ParkSangSun1/Clean_Architecture_Sample: CleanArchitecture+MVVM+Coroutine+Hilt+SafeApiCall

CleanArchitecture+MVVM+Coroutine+Hilt+SafeApiCall. Contribute to ParkSangSun1/Clean_Architecture_Sample development by creating an account on GitHub.

github.com

 

clean architecture의 base가 되는 구조 예시

https://github.com/ParkSangSun1/Quick_Setup

 

GitHub - ParkSangSun1/Quick_Setup: 프로젝트를 빠르게 SETUP하기 위한 Repository

프로젝트를 빠르게 SETUP하기 위한 Repository. Contribute to ParkSangSun1/Quick_Setup development by creating an account on GitHub.

github.com

 

 

전체적 정리

의존성 관계는 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에서 상속받아 사용합니다

 

 

 

궁금한 점이나 틀린 부분은 댓글로 작성 부탁드립니다!

 

추가로

안드로이드 개발에 대한 질문과 정보 등을 공유 및 소통하는 채팅방입니다

https://open.kakao.com/o/gG5PueVd

반응형