[안드로이드 클린아키텍쳐 제주 정보 가져오기 앱] 1 - data, domain layer

이영준·2022년 11월 6일
0

아직까지 클린아키텍쳐에 대해 심층적으로 다룰만큼의 지식은 없기에, 일단은 안드로이드에서 사용되는 클린아키텍쳐 구조 사진을 가져왔다.

UseCase는 각각의 데이터를 가져오는 것 과 같은 '기능'들을 뜻하는데, 사용자가 이를 요청하여 다시 app 계층으로 되돌려준다.

Repository는 domain계층에서 형성되고 구체적인 implementaion은 data 계층에서 행해진다.

UseCase 명세

UseCase : View Tour List, Update Tours List,
View Food restaurants, Update Food restaurants

초기설정 및 api key 추가

이미지 프로세싱을 위한 라이브러리인 glide를 기존의 room, coroutine, livedata, retrofit에 더해 추가하였다.

implementation 'com.github.bumptech.glide:glide:4.14.2'
    kapt 'com.github.bumptech.glide:compiler:4.14.2'

https://github.com/bumptech/glide

현재까지 dependency

dependencies {
    //dagger
    implementation 'com.google.dagger:dagger:2.40.5'
    kapt 'com.google.dagger:dagger-compiler:2.44'

    //retrofit
    def retrofit_version = "2.9.0"
    implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
    implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
    implementation "com.squareup.okhttp3:okhttp:5.0.0-alpha.2"
    implementation(platform("com.squareup.okhttp3:okhttp-bom:4.10.0"))
    implementation("com.squareup.okhttp3:okhttp")
    implementation("com.squareup.okhttp3:logging-interceptor")

    //lifecycle
    def lifecycle_version = "2.6.0-alpha03"
    // ViewModel
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
    // LiveData
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
    //state module
    implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version"
    //annotation processor
    implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
    // roomDB
    def room_version = "2.4.3"
    implementation "androidx.room:room-ktx:$room_version"
    implementation "androidx.room:room-runtime:$room_version"
    kapt "androidx.room:room-compiler:$room_version"

    //navigation
    def nav_version = "2.5.3"
    implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
    implementation "androidx.navigation:navigation-ui-ktx:$nav_version"

    //coroutines
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"

    //glide
    implementation 'com.github.bumptech.glide:glide:4.14.2'
    kapt 'com.github.bumptech.glide:compiler:4.14.2'

    implementation 'androidx.core:core-ktx:1.9.0'
    implementation 'androidx.appcompat:appcompat:1.5.1'
    implementation 'com.google.android.material:material:1.7.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

https://www.themoviedb.org 위 영화정보 제공 사이트에서 api 키를 받아와 gradle 파일에 추가해주었다. (수정)-> 제주 구름톤에 대비하여 제주도에 대한 여러가지 정보를 제공하는 오픈소스 api키를 받아볼 수 있었다.

출처 : https://www.visitjeju.net/kr/visitjejuapi
개인 실습용으로 api키를 요청했더니 다음날 메일로 키를 받아볼 수 있었다.

android {
    namespace 'com.example.tmdbclient'
    compileSdk 32

    defaultConfig {
        applicationId "com.example.tmdbclient"
        minSdk 21
        targetSdk 32
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

        buildConfigField "String","API_KEY","\"내 API 키\""
    }

buildConfigField "String","API_KEY","\"내 API 키""

data layer 1 - JSON 직렬화 (gson) : data/model

https://api.visitjeju.net/vsjApi/contents/searchList?apiKey='내API'&locale=kr&category=c4

사이트에서 정보를 받아오는 방법은 https://api.visitjeju.net의 base url에 /vsjApi/contents/searchList를 get문에 포함시키고,
쿼리에 apiKey(필수), locale(한,영,중 등의 언어 선택 : 필수), currentPage(몇번째 페이지인지 : 선택), category(c1은 관광지, c4는 음식 등등 : 선택)를 적어 주면 된다. 클린 아키텍쳐를 특별히 적용하지 않은 상태에서 retrofit으로 이를 받아오기 위해 Service인터페이스와 retrofitInstance를 만든 것은 아래와 같다.

JejuService.kt

interface JejuService {

    @GET("/vsjApi/contents/searchList")
    suspend fun getFoodList(
        @Query("apiKey") apiKey: String,
        @Query("locale") locale: String? = "kr",
        @Query("category") category: String? = "c4"
        ) : Response<FoodList>


    @GET("/vsjApi/contents/searchList")
    suspend fun getTourList(
        @Query("apiKey") apiKey: String,
        @Query("locale") locale: String? = "kr",
        @Query("category") category: String? = "c1"
    ) : Response<TourList>
}

RetrofitInstance

class RetrofitInstance {
    companion object{
        val BASE_URL = "https://api.visitjeju.net"
        val interceptor = HttpLoggingInterceptor().apply {
            this.level = HttpLoggingInterceptor.Level.BODY//얼만큼 확인할 지
        }
        val client = OkHttpClient.Builder().apply {
            this.addInterceptor(interceptor)
        }.build()

        fun getRetrofitInstance() : Retrofit{
            return Retrofit.Builder()
                .baseUrl(BASE_URL)
                .client(client)
                .addConverterFactory(GsonConverterFactory.create(GsonBuilder().create()))
                .build()
        }
    }
}

하지만 우리는 클린아키텍쳐 형식을 따르기로 했으므로, 일단 이를 남겨두고 모델부터 만들어보자. 기존에 gson을 통해 데이터 모델을 만드는 방법 대로 json to kotlin플러그인을 사용하여 모델을 만들었다.

@Entity(tableName = "jeju_foods")
data class Food(
    @PrimaryKey
    @SerializedName("contentsid")
    val contentsid: String,
    @SerializedName("address")
    val address: String,
    @SerializedName("alltag")
    val alltag: String,
    @SerializedName("introduction")
    val introduction: String,
    @SerializedName("latitude")
    val latitude: Double,
    @SerializedName("longitude")
    val longitude: Double,
    @SerializedName("phoneno")
    val phoneno: String,
    @SerializedName("repPhoto")
    val repPhoto: RepPhoto,
    @SerializedName("title")
    val title: String
)
data class FoodList(
    @SerializedName("currentPage")
    val currentPage: Int,
    @SerializedName("items")
    val items: List<Food>,
    @SerializedName("pageCount")
    val pageCount: Int,
    @SerializedName("pageSize")
    val pageSize: Int,
    @SerializedName("result")
    val result: String,
    @SerializedName("resultCount")
    val resultCount: Int,
    @SerializedName("resultMessage")
    val resultMessage: String,
    @SerializedName("totalCount")
    val totalCount: Int
)

FoodList에는 Food뿐만이 아닌 api가 잘 받아져왔는지에 대한 정보와 페이지 번호도 포함한다. 지금보니 딱히 필요가 없는 것이 많아서 몇개는 삭제해도 될듯? tourList역시 비슷하게 구성하였다.

Food에는 id, 주소, 태그, 설명, 위도경도, 번호, 사진경로, 제목의 필수 요소 말고는 모두 지워줬다. 또한 roomDB로 이를 관리할 것이기에 @Wntity 애너테이션과 함께 테이블명을 지정해줬다.

RoomDB 구성 : data/db

roomDB도 이전 포스팅에 따라서 데이터베이스 스키마를 정의하기 위한 Database파일과, 데이터를 담거나 빼는 로직을 담은 Dao의 두가지 파일을 만들었다.

JejuDatabase

@Database(entities = [Tour::class, Food::class], version = 1,
exportSchema = false)


abstract class JejuDatabase() : RoomDatabase(){
    abstract fun foodDao() : FoodDao
    abstract fun tourDao() : TourDao
}

FoodDao

@Dao
interface FoodDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun saveFood(foods : List<Food>)

    @Query("DELETE FROM jeju_foods")
    suspend fun deleteAllFoods()

    @Query("SELECT * FROM jeju_foods")
    suspend fun getFoods() : List<Food>

}

Domain Layer : domain/usecase, domain/repository

우리가 정의한 유즈케이스를 정의해준다. 여기서 다른 곳에서 만든 로직에 의존하여 이를 끌어와 쓸 것이다.

FoodRepository

interface FoodRepository {
    suspend fun getFood(): List<Food>?
    suspend fun updateFood(): List<Food>?
}

GetFoodUseCase

class GetFoodUseCase(private val foodRepository: FoodRepository) {
    suspend fun execute(): List<Food>? = foodRepository.getFood()
}

data layer 2 - repositoryImpl : data/repository/datasourceImpl

data layer - repository에서는 datasource와 datasourceImpl을 만들어주며, datasource에는 api관련 로직, room관련 로직, 캐시메모리 관련 로직의 인터페이스를 적어주고 이를 구체화하는 것을 impl에서 담당한다.

FoodCacheDataSource

interface FoodCacheDataSource {
    suspend fun getFoodFromCache():List<Food>
    suspend fun saveFoodToCache(foods:List<Food>)
}

FoodCacheDataSourceImpl

class FoodCacheDatasourceImpl : FoodCacheDataSource {
    private var FoodList = ArrayList<Food>()
    override suspend fun getFoodFromCache(): List<Food> {
        return FoodList
    }

    override suspend fun saveFoodToCache(Foods: List<Food>) {
        FoodList.clear()
        FoodList = ArrayList(Foods)
    }
}

같은 방법으로 Local, Remote DataSource와 Impl도 작성해준다.

FoodLocalDatasource

interface FoodLocalDatasource {
    suspend fun getFoodFromDB():List<Food>
    suspend fun saveFoodToDB(Food : List<Food>)
    suspend fun clearAll()
}

FoodLocalDatasourceImpl

class FoodLocalDatasourceImpl(private val FoodDao: FoodDao) : FoodLocalDatasource {
    override suspend fun getFoodFromDB(): List<Food> {
        return  FoodDao.getFoods()
    }

    override suspend fun saveFoodToDB(Foods:List<Food>) {
        CoroutineScope(Dispatchers.IO).launch {
            FoodDao.saveFood(Foods)
        }
    }

    override suspend fun clearAll() {
        FoodDao.deleteAllFoods()
    }
}

FoodRemoteDatasource

interface FoodRemoteDatasource {
    suspend fun getFood(): Response<FoodList>
}

FoodRemoteDataSourceImpl

class FoodRemoteDatasourceImpl(
    private val jejuService: JejuService,
    private val apiKey: String
) : FoodRemoteDatasource {
    override suspend fun getFood(): Response<FoodList> {
        return jejuService.getFoodList(apiKey)
    }
}

마지막으로 FoodRepositoryImpl에서 그동안 쓴 dataSource들을 가져와 domain의 FoodRepository 로직을 완성시킨다.

class FoodRepositoryImpl(
    private val FoodRemoteDatasource: FoodRemoteDatasource,
    private val FoodLocalDatasource: FoodLocalDatasource,
    private val FoodCacheDataSource: FoodCacheDataSource
) : FoodRepository {
    override suspend fun getFood(): List<Food>? {
        return getFoodsFromCache()
    }

    override suspend fun updateFood(): List<Food>? {
        val newListOfFood = getFoodsFromAPI()
        FoodLocalDatasource.clearAll()
        FoodLocalDatasource.saveFoodToDB(newListOfFood)
        FoodCacheDataSource.saveFoodToCache(newListOfFood)
        return newListOfFood
    }

    suspend fun getFoodsFromAPI(): List<Food> {
        lateinit var FoodList: List<Food>
        try {
            val response = FoodRemoteDatasource.getFood()
            val body = response.body()
            if (body != null) {
                FoodList = body.items
            }
        } catch (exception: java.lang.Exception) {
            Log.i("MYTAG", exception.message.toString())
        }

        return FoodList
    }


    suspend fun getFoodsFromDB(): List<Food> {

        lateinit var FoodList: List<Food>
        try {
            FoodList = FoodLocalDatasource.getFoodFromDB()
        } catch (exception: java.lang.Exception) {
            Log.i("MYTAG", exception.message.toString())
        }

        if (FoodList.size > 0) {
            return FoodList
        } else {
            FoodList = getFoodsFromAPI()
            FoodLocalDatasource.saveFoodToDB(FoodList)
            return FoodList
        }
    }


    suspend fun getFoodsFromCache() : List<Food>{

        lateinit var foodList: List<Food>
        try {
            foodList = FoodCacheDataSource.getFoodFromCache()
        } catch (exception: java.lang.Exception) {
            Log.i("MYTAG", exception.message.toString())
        }

        if (foodList.size > 0) {
            return foodList
        } else {
            foodList = getFoodsFromDB()
            FoodCacheDataSource.saveFoodToCache(foodList)
            return foodList
        }
    }
}

roomDB에 data class를 필드에 넣으려면?

빌드 중에 오류가 나서 보았더니

@Entity(tableName = "jeju_foods")
data class Food(
    @PrimaryKey
    @SerializedName("contentsid")
    val contentsid: String,
    @SerializedName("address")
    val address: String,
    @SerializedName("alltag")
    val alltag: String,
    @SerializedName("introduction")
    val introduction: String,
    @SerializedName("latitude")
    val latitude: Double,
    @SerializedName("longitude")
    val longitude: Double,
    @SerializedName("phoneno")
    val phoneno: String,
    @SerializedName("repPhoto")
    val repPhoto: RepPhoto,
    @SerializedName("title")
    val title: String
)

room DB에서는 지정된 형(int,double, string 등) 이외에 다른 형은 넣으려고 하면 오류가 난다. 이럴 때는 @Embedded 태그로 그 데이터 클래스 안에 속한 값들을 풀어서 필드에 넣어주면 된다.

https://developer.android.com/training/data-storage/room/relationships?hl=ko

profile
컴퓨터와 교육 그사이 어딘가

0개의 댓글