일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- 멤버변수
- 2022 플러터 안드로이드 스튜디오
- jvm 작동 원리
- 서비스 쓰레드 차이
- Rxjava Observable
- 안드로이드 라이선스 종류
- ar vr 차이
- 안드로이드 유닛 테스트 예시
- rxjava cold observable
- 서비스 vs 쓰레드
- 객체
- 스택 큐 차이
- 클래스
- 큐 자바 코드
- android retrofit login
- 안드로이드 os 구조
- android ar 개발
- 2022 플러터 설치
- ANR이란
- 자바 다형성
- 스택 자바 코드
- rxjava disposable
- rxjava hot observable
- jvm이란
- 안드로이드 유닛 테스트
- 안드로이드 레트로핏 crud
- 안드로이드 라이선스
- 안드로이드 유닛테스트란
- 플러터 설치 2022
- 안드로이드 레트로핏 사용법
- Today
- Total
나만을 위한 블로그
[Android] Hilt + Retrofit + Flow + Coil + 멀티 모듈 구조 프로젝트 - 1 - 본문
※ 모든 코드는 예시 코드기 때문에 실제로 사용하려면 반드시 리팩토링 후 사용한다
※ 컴포즈 안 쓴다
몇 차례에 걸쳐 실제 api를 사용하는 간단한 멀티 모듈 안드 프로젝트를 만드는 과정을 포스팅하려고 한다. 사용할 라이브러리는 제목에 써둔 것들 정도다.
예제 프로젝트기 때문에 최대한 가볍게 구성했다. 절대 귀찮아서 이렇게 만든 게 아니다 어디까지나 이런 식으로 멀티 모듈 형태와 클린 아키텍처로 짤 수 있다는 걸 보여주기 위한 시리즈다.
이 포스팅은 아래 포스팅을 바탕으로 진행한다.
https://onlyfor-me-blog.tistory.com/1052
만들 앱은 jsonplaceholder에서 제공하는 무료 api 3가지를 호출해서 리사이클러뷰에 표시하는 앱이다.
https://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에서 데이터를 받아와 리사이클러뷰에 표시할 것이다.
다음 포스팅에선 나머지 소스코드들을 확인하고 작동원리를 좀 더 깊게 확인한다.
'Android' 카테고리의 다른 글
[Android] 내 위치 정보 가져와서 사용하는 법 (with. Hilt) (0) | 2024.12.18 |
---|---|
[Android] 현재 내 위치 가져오는 법 (0) | 2024.11.13 |
[Android] 초기 데이터 로드 2 : 의문점 해소하기 (0) | 2024.10.13 |
[Android] 단위 테스트 시 Stub, Mock, Fake, Spy 선택 기준 (0) | 2024.10.11 |
[Android] 경쟁 상태(Race Condition)란? (0) | 2024.09.29 |