Android

[Android] Hilt + Retrofit + Flow + Coil + 멀티 모듈 구조 프로젝트 - 1 -

참깨빵위에참깨빵_ 2024. 12. 23. 21:09
728x90
반응형

※ 모든 코드는 예시 코드기 때문에 실제로 사용하려면 반드시 리팩토링 후 사용한다

※ 컴포즈 안 쓴다

 

몇 차례에 걸쳐 실제 api를 사용하는 간단한 멀티 모듈 안드 프로젝트를 만드는 과정을 포스팅하려고 한다. 사용할 라이브러리는 제목에 써둔 것들 정도다.

예제 프로젝트기 때문에 최대한 가볍게 구성했다. 절대 귀찮아서 이렇게 만든 게 아니다 어디까지나 이런 식으로 멀티 모듈 형태와 클린 아키텍처로 짤 수 있다는 걸 보여주기 위한 시리즈다.

 

이 포스팅은 아래 포스팅을 바탕으로 진행한다.

 

https://onlyfor-me-blog.tistory.com/1052

 

[Android] 멀티 모듈 프로젝트 구성하고 hilt 적용하기

지금까지 app 모듈 안에 data, domain, presentation 폴더를 만들고 그 안에서 작업해 온 사람도 있을 것이다.그러나 이렇게 하면 삐끗하면 클린 아키텍처를 어길 수 있으니, 실제로 저 이름을 가진 모듈

onlyfor-me-blog.tistory.com

 

만들 앱은 jsonplaceholder에서 제공하는 무료 api 3가지를 호출해서 리사이클러뷰에 표시하는 앱이다.

 

https://jsonplaceholder.typicode.com/

 

JSONPlaceholder - Free Fake REST API

{JSON} Placeholder Free fake and reliable API for testing and prototyping. Powered by JSON Server + LowDB. Serving ~3 billion requests each month.

jsonplaceholder.typicode.com

 

완성하면 아래처럼 작동할 것이다. photos, todos 버튼은 다음 포스팅에서 확인하고 여기선 albums 버튼을 눌렀을 때 데이터를 표시하게만 구현해 본다.

 

 

일단 추가 프로젝트 설정부터 진행한다. libs.versions.toml은 아래와 같다.

 

[versions]
agp = "8.7.3"
kotlin = "1.9.0"
coreKtx = "1.13.1"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
appcompat = "1.7.0"
lifecycleViewmodelKtx = "2.8.5"
loggingInterceptor = "4.9.0"
material = "1.12.0"
activity = "1.9.2"
constraintlayout = "2.1.4"
jetbrainsKotlinJvm = "1.9.0"

hilt = "2.48.1"
dataStore = "1.1.1"
coil = "2.6.0"
coroutines-core = "1.7.3"
okhttp = "4.12.0"
okhttpUrlconnection = "4.9.2"
retrofit = "2.9.0"
room = "2.6.1"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
converter-scalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "retrofit" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }

datastore = { module = "androidx.datastore:datastore-preferences", version.ref = "dataStore" }

kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines-core" }

kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.5.0"}
hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }

coil = { group = "io.coil-kt", name = "coil", version.ref = "coil"}

okhttp-urlconnection = { module = "com.squareup.okhttp3:okhttp-urlconnection", version.ref = "okhttpUrlconnection" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
room-runtime = { group = "androidx.room", name = "room-ktx", version.ref = "room"}
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room"}
room-paging = { group = "androidx.room", name = "room-paging", version.ref = "room"}

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
android-library = { id = "com.android.library", version.ref = "agp" }
jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt"}
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version = "1.8.10"}
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }

 

막상 추가해놓고 사용하지 않은 것들이 있는데 지워도 된다. 내가 이 프로젝트를 고도화하기 위해 미리 추가해 둔 것들이라 괜히 공간만 차지할 것이다.

이제 라이브러리들을 적절하게 모듈 별 app.gradle에 추가한다. 먼저 프로젝트 gradle이다.

 

// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.jetbrains.kotlin.android) apply false
    alias(libs.plugins.android.library) apply false
    alias(libs.plugins.jetbrains.kotlin.jvm) apply false
    alias(libs.plugins.hilt) apply false
    alias(libs.plugins.kotlin.kapt) apply false
    alias(libs.plugins.kotlin.parcelize) apply false
}

 

app 모듈의 app.gradle이다.

 

dependencies {
    implementation(project(":data"))
    implementation(project(":domain"))
    implementation(project(":presentation"))

    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.appcompat)
    implementation(libs.material)
    implementation(libs.androidx.activity)
    implementation(libs.androidx.constraintlayout)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)

    implementation(libs.datastore)

    implementation(libs.kotlinx.serialization.json)

    implementation(libs.hilt)
    kapt(libs.hilt.compiler)

    implementation(libs.retrofit)
    implementation(libs.converter.gson)
    implementation(libs.converter.scalars)

    implementation(libs.okhttp)
    implementation(libs.okhttp.urlconnection)
    implementation(libs.logging.interceptor)

    // for instrumentation tests
    androidTestImplementation("com.google.dagger:hilt-android-testing:2.48.1")
    kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.48.1")

    // for local unit tests
    testImplementation("com.google.dagger:hilt-android-testing:2.48.1")
    kaptTest("com.google.dagger:hilt-android-compiler:2.48.1")
}

 

data 모듈의 app.gradle이다.

 

dependencies {
    implementation(project(":domain"))

    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.appcompat)
    implementation(libs.material)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)

    implementation(libs.hilt)
    kapt(libs.hilt.compiler)

    implementation(libs.datastore)

    implementation(libs.kotlinx.serialization.json)

    implementation(libs.room.runtime)
    implementation(libs.room.paging)
    kapt(libs.room.compiler)

    implementation(libs.retrofit)
    implementation(libs.converter.gson)
    implementation(libs.converter.scalars)

    implementation(libs.okhttp)
    implementation(libs.okhttp.urlconnection)
    implementation(libs.logging.interceptor)
}

 

domain 모듈의 app.gradle이다.

 

dependencies {
    implementation(libs.kotlinx.serialization.json)
    implementation(libs.kotlinx.coroutines.android)
}

 

presentation 모듈의 app.gradle이다.

 

dependencies {
    implementation(project(":domain"))

    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.appcompat)
    implementation(libs.material)
    implementation(libs.androidx.activity)
    implementation(libs.androidx.constraintlayout)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)

    implementation(libs.hilt)
    kapt(libs.hilt.compiler)

    implementation(libs.kotlinx.coroutines.android)

    implementation(libs.coil)

    implementation(libs.androidx.lifecycle.viewmodel.ktx)
}

 

Sync now를 눌러서 변경사항들을 적용한 다음 app 모듈에 di 폴더를 만들고 AppModule을 만든다.

 

import com.example.data.api.ApiService
import com.example.data.mapper.AlbumMapper
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import javax.inject.Singleton

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

    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit = Retrofit.Builder()
        .baseUrl("https://jsonplaceholder.typicode.com/")
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    @Provides
    @Singleton
    fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create(ApiService::class.java)

    @Provides
    @Singleton
    fun provideAlbumMapper(): AlbumMapper = AlbumMapper()

}

 

아직 추가되지 않은 클래스들이 있어서 에러가 발생할 것이다. mapper부터 천천히 추가해 본다.

 

import com.example.data.model.response.AlbumResponse
import com.example.domain.entity.AlbumEntity

class AlbumMapper {
    fun mapToDomain(albumResponse: AlbumResponse): AlbumEntity =
        AlbumEntity(
            id = albumResponse.id,
            userId = albumResponse.userId,
            title = albumResponse.title,
        )
}

 

그리고 AlbumMapper에서 사용하는 AlbumEntity는 아래처럼 작성했다.

 

data class AlbumEntity(
    val id: Int,
    val userId: Int,
    val title: String
)

 

다음은 ApiService다. ApiService는 레트로핏 메서드를 정의하는 인터페이스로 data 모듈의 api 폴더에 만들었다.

 

import com.example.data.model.response.AlbumResponse
import com.example.data.model.response.PhotoResponse
import com.example.data.model.response.TodoResponse
import retrofit2.Response
import retrofit2.http.GET

interface ApiService {
    @GET("albums")
    suspend fun getAlbums(): Response<List<AlbumResponse>>

    @GET("photos")
    suspend fun getPhotos(): Response<List<PhotoResponse>>

    @GET("todos")
    suspend fun getTodos(): Response<List<TodoResponse>>
}

 

AlbumResponse는 아래와 같다. API 응답이 스네이크가 아니기 때문에 @SerializedName을 쓰지 않았다.

실제로는 앱 내 컨벤션을 카멜 또는 스네이크로 맞추거나 서버에서 내려주는 json의 필드명이 앱과 달라 데이터를 못 받는 경우를 막기 위해 @SerializedName을 사용할 수 있지만 여기선 고려하지 않는다.

 

data class AlbumResponse(
    val id: Int,
    val userId: Int,
    val title: String
)

 

이제 domain에 repository 폴더를 만들고 인터페이스를 정의한다. Flow 형태로 값을 사용하기 위해 리턴 타입이 Flow로 설정된 걸 볼 수 있다.

 

import com.example.domain.entity.AlbumEntity
import kotlinx.coroutines.flow.Flow

interface AlbumRepository {
    fun getAlbums(): Flow<Result<List<AlbumEntity>>>
}

 

이 인터페이스의 구현은 data 모듈에 작성한다.

 

import com.example.data.api.ApiService
import com.example.data.mapper.AlbumMapper
import com.example.data.model.response.AlbumResponse
import com.example.data.util.performApiCall
import com.example.domain.entity.AlbumEntity
import com.example.domain.repository.AlbumRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject

class AlbumRepositoryImpl @Inject constructor(
    private val apiService: ApiService,
    private val albumMapper: AlbumMapper,
): AlbumRepository {
    override fun getAlbums(): Flow<Result<List<AlbumEntity>>> = performApiCall(
        funcName = "getAlbums()",
        apiCall = { apiService.getAlbums() }
    ).map { result ->
        result.mapCatching { response: List<AlbumResponse> ->
            response.map(albumMapper::mapToDomain)
        }
    }
}

 

performApiCall()은 data 모듈에 만든 유틸 함수다. 만들다 보니 API 응답 처리부분이 비슷해서 공통 함수로 만들었다.

ApiCaller라는 파일 클래스를 만들고 그 안에 작성하면 되며 Flow에 담긴 값을 사용하기 위해 emit()을 사용한다.

 

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import retrofit2.Response
import java.io.IOException

fun <T> performApiCall(
    funcName: String,
    apiCall: suspend () -> Response<T>
): Flow<Result<T>> = flow {
    try {
        val response = apiCall()
        if (response.isSuccessful) {
            val body = response.body()
            if (body != null) {
                emit(Result.success(body))
            } else {
                emit(Result.failure(ApiException.EmptyResponseException(funcName)))
            }
        } else {
            emit(
                Result.failure(
                    ApiException.ApiErrorException(
                        funcName,
                        "Status Code: ${response.code()}, Error Body: ${response.errorBody()?.string()}"
                    )
                )
            )
        }
    } catch (e: IOException) {
        emit(Result.failure(ApiException.NetworkException("[$funcName] - Network error: ${e.message}")))
    } catch (e: Exception) {
        emit(Result.failure(Exception("[$funcName] - Unexpected error: ${e.message}")))
    }
}

 

ApiException도 ApiCaller와 같은 폴더에 작성했다.

 

sealed class ApiException(message: String) : Exception(message) {
    class NetworkException(message: String) : ApiException(message)
    class EmptyResponseException(funcName: String) : ApiException("[$funcName] - response.body() == null")
    class ApiErrorException(funcName: String, errorBody: String?) : ApiException("[$funcName] - api error : $errorBody")
}

 

그리고 data 모듈에 di 폴더를 만들고 repository들을 bind할 수 있게 해주는 추상 클래스를 만들었다.

 

import com.example.data.repository.AlbumRepositoryImpl
import com.example.data.repository.PhotoRepositoryImpl
import com.example.data.repository.TodoRepositoryImpl
import com.example.domain.repository.AlbumRepository
import com.example.domain.repository.PhotoRepository
import com.example.domain.repository.TodoRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

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

    @Binds
    @Singleton
    abstract fun bindAlbumRepository(impl: AlbumRepositoryImpl): AlbumRepository

    @Binds
    @Singleton
    abstract fun bindPhotoRepository(impl: PhotoRepositoryImpl): PhotoRepository

    @Binds
    @Singleton
    abstract fun bindTodoRepository(impl: TodoRepositoryImpl): TodoRepository
}

 

이렇게 인터페이스를 리턴하도록 설정하면 domain 모듈에선 각 레포지토리 인터페이스를 주입받아 사용할 수 있다.

그리고 domain 모듈에 usecase를 만들 건데, domain 모듈엔 hilt 의존성이 없어서 dagger 어노테이션을 쓸 수 없으니 어노테이션을 사용하지 않는 생성자 주입 형태로 레포지토리를 주입받아 사용한다.

아래는 getAlbums()를 호출하는 usecase 예시다.

 

import com.example.domain.entity.AlbumEntity
import com.example.domain.repository.AlbumRepository
import kotlinx.coroutines.flow.Flow

class GetAlbumsUseCase(
    private val albumRepository: AlbumRepository
) {
    operator fun invoke(): Flow<Result<List<AlbumEntity>>> = albumRepository.getAlbums()
}

 

앞서 작성한 RepositoryModule 덕에 usecase의 invoke()를 호출하면 AlbumRepository의 구현체인 AlbumRepositoryImpl을 호출하는 효과를 볼 수 있다.

그리고 usecase를 제공하는 object class를 만든다. presentation 모듈은 domain 모듈에 있는 usecase를 알 수 없기 때문에 모든 모듈을 알고 있는 app 모듈에 usecase를 제공하는 모듈을 object class로 만들어서 presentation 모듈에서 쓸 수 있게 한다.

 

import com.example.domain.repository.AlbumRepository
import com.example.domain.usecase.GetAlbumsUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

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

    @Provides
    @Singleton
    fun provideGetAlbumsUseCase(albumRepository: AlbumRepository): GetAlbumsUseCase =
        GetAlbumsUseCase(albumRepository)

}

 

이제 presentation 모듈에 뷰모델을 만들어서 AlbumRepositoryImpl로부터 데이터를 받아오는 함수와 이 함수로부터 얻은 값을 담을 StateFlow 프로퍼티를 작성한다.

 

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.domain.entity.AlbumEntity
import com.example.domain.usecase.GetAlbumsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class AlbumViewModel @Inject constructor(
    private val getAlbumsUseCase: GetAlbumsUseCase
): ViewModel() {

    private val _albums = MutableStateFlow<List<AlbumEntity>>(emptyList())
    val albums: StateFlow<List<AlbumEntity>> = _albums.asStateFlow()

    private val _error = MutableStateFlow<String?>(null)
    val error: StateFlow<String?> = _error.asStateFlow()

    fun loadAlbums() = viewModelScope.launch(Dispatchers.IO) {
        getAlbumsUseCase().collect { result ->
            result.fold(
                onSuccess = { albums ->
                    _albums.value = albums
                },
                onFailure = { e ->
                    _error.value = e.message
                }
            )
        }
    }

}

 

usecase 안에 operator fun invoke()를 작성했기 때문에 usecase 이름을 함수처럼 사용할 수 있으며 onSuccess, onFailure을 각각 정의해 통신 성공, 실패 시의 로직을 각각 작성한다. 여기선 예제기 때문에 성공, 실패 시 데이터와 에러 메시지를 StateFlow에 담는 처리만 있다.

뷰모델을 만들었다면 액티비티나 프래그먼트에 뷰모델 프로퍼티를 선언하고 이 프로퍼티를 통해 loadAlbums()를 호출한 다음, 뷰모델의 albums StateFlow를 관찰하면 된다.

로딩 중에 프로그레스 바를 표시하거나 완료 후 토스트, 스낵바를 표시하는 등 자잘한 처리들은 모두 생략한다.

 

import android.os.Bundle
import android.util.Log
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope
import com.example.presentation.R
import com.example.presentation.databinding.ActivityAlbumBinding
import com.example.presentation.viewmodels.AlbumViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch

@AndroidEntryPoint
class AlbumActivity : AppCompatActivity() {

    private val TAG = this::class.simpleName

    private val albumViewModel: AlbumViewModel by viewModels()

    private lateinit var binding: ActivityAlbumBinding
    private lateinit var albumAdapter: AlbumAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityAlbumBinding.inflate(layoutInflater)
        setContentView(binding.root)
        enableEdgeToEdge()
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.album_main)) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }

        initRecyclerView()

        albumViewModel.loadAlbums()

        observeViewModel()
    }

    private fun initRecyclerView() {
        albumAdapter = AlbumAdapter()
        binding.rvAlbum.adapter = albumAdapter

    }

    private fun observeViewModel() {
        lifecycleScope.launch {
            albumViewModel.albums.collect {
                if (it.isNotEmpty()) {
                    albumAdapter.submitList(it)
                }
            }
        }

        lifecycleScope.launch {
            albumViewModel.error.collect {
                Log.e(TAG, "error : $it")
            }
        }
    }

}

 

API 응답이 200이더라도 데이터들이 들어있는 정상적인 리스트를 반드시 보내지 않을 수 있으니 isNotEmpty()로 간단한 예외처리를 했다. 이후엔 어댑터에 API에서 받은 리스트를 넘겨서 리사이클러뷰에 세팅한다.

액티비티의 xml과 어댑터의 구현은 아래와 같다.

 

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/album_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".views.album.AlbumActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rvAlbum"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        tools:listitem="@layout/item_album"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />

</androidx.constraintlayout.widget.ConstraintLayout>
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.domain.entity.AlbumEntity
import com.example.presentation.databinding.ItemAlbumBinding
import com.example.presentation.views.util.MyDiffUtil

class AlbumAdapter: ListAdapter<AlbumEntity, AlbumAdapter.AlbumViewHolder>(
    MyDiffUtil(
        idSelector = { it.id },
        contentComparator = { old, new -> old == new }
    )
) {

    inner class AlbumViewHolder(private val binding: ItemAlbumBinding) :
        RecyclerView.ViewHolder(binding.root) {
        fun bind(item: AlbumEntity) {
            binding.albumData = item
            binding.executePendingBindings()
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AlbumViewHolder {
        return AlbumViewHolder(
            ItemAlbumBinding.inflate(
                LayoutInflater.from(parent.context),
                parent,
                false
            )
        )
    }

    override fun onBindViewHolder(holder: AlbumViewHolder, position: Int) =
        holder.bind(getItem(position))
}

 

리사이클러뷰에서 쓰는 아이템의 xml 이름은 item_album.xml이다

데이터 바인딩으로 전달받은 리스트 안의 데이터들을 바로 뷰에 세팅한다. presentation 모듈은 domain 모듈에 의존하고 있으니 domain 모듈에서 사용하는 엔티티를 가져와 사용할 수 있다.

 

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <variable
            name="albumData"
            type="com.example.domain.entity.AlbumEntity" />
    </data>

    <androidx.cardview.widget.CardView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:cardBackgroundColor="@color/black"
        android:layout_marginHorizontal="20dp"
        android:layout_marginBottom="20dp"
        app:cardCornerRadius="20dp">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:padding="20dp">

            <TextView
                android:id="@+id/tvId"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:textColor="@color/white"
                android:textSize="20dp"
                app:layout_constraintVertical_bias="0"
                android:text="@{`id : ` + String.valueOf(albumData.id)}"
                tools:text="id : 1"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintBottom_toBottomOf="parent" />

            <TextView
                android:id="@+id/tvUserId"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="8dp"
                android:textColor="@color/white"
                android:textSize="20dp"
                android:text="@{`userId : ` + String.valueOf(albumData.userId)}"
                tools:text="userId : 1"
                app:layout_constraintStart_toStartOf="@id/tvId"
                app:layout_constraintTop_toBottomOf="@id/tvId" />

            <TextView
                android:id="@+id/tvTitle"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:textColor="@color/white"
                android:textSize="20dp"
                android:maxLines="1"
                android:ellipsize="end"
                android:layout_marginTop="8dp"
                android:text="@{`title : ` + albumData.title}"
                tools:text="title : non esse culpa molestiae omnis sed optio"
                app:layout_constraintStart_toStartOf="@+id/tvId"
                app:layout_constraintTop_toBottomOf="@+id/tvUserId" />

        </androidx.constraintlayout.widget.ConstraintLayout>

    </androidx.cardview.widget.CardView>

</layout>

 

이제 실행한 후 맨 위의 버튼을 누르면 api에서 데이터를 받아와 리사이클러뷰에 표시할 것이다.

다음 포스팅에선 나머지 소스코드들을 확인하고 작동원리를 좀 더 깊게 확인한다.

반응형