일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- Rxjava Observable
- android ar 개발
- 클래스
- rxjava hot observable
- 플러터 설치 2022
- 큐 자바 코드
- rxjava disposable
- 안드로이드 유닛테스트란
- 서비스 vs 쓰레드
- 안드로이드 유닛 테스트
- 안드로이드 os 구조
- ar vr 차이
- 2022 플러터 설치
- 2022 플러터 안드로이드 스튜디오
- 자바 다형성
- 스택 자바 코드
- 안드로이드 라이선스
- 안드로이드 유닛 테스트 예시
- 객체
- 멤버변수
- rxjava cold observable
- 서비스 쓰레드 차이
- 스택 큐 차이
- jvm이란
- jvm 작동 원리
- android retrofit login
- 안드로이드 레트로핏 사용법
- 안드로이드 레트로핏 crud
- ANR이란
- 안드로이드 라이선스 종류
- Today
- Total
나만을 위한 블로그
[Android] Hilt + Retrofit + Flow + Coil + 멀티 모듈 구조 프로젝트 - 2 - 본문
※ 모든 코드는 예시 코드기 때문에 실제로 사용하려면 반드시 리팩토링 후 사용한다
※ 컴포즈 안 쓴다
이전 포스팅에서 이어지는 글이다.
https://onlyfor-me-blog.tistory.com/1121
photos, todos 버튼을 눌렀을 때의 동작들도 마저 구현한다. 먼저 ApiService에 엔드포인트 2개를 추가한다.
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>>
}
응답을 받을 때 사용할 data class들도 정의한다.
data class PhotoResponse(
val albumId: Int,
val id: Int,
val title: String,
val url: String,
val thumbnailUrl: String,
)
data class TodoResponse(
val userId: Int,
val id: Int,
val title: String,
val completed: Boolean
)
그리고 domain 모듈에 Repository 인터페이스들도 추가한다.
import com.example.domain.entity.PhotoEntity
import kotlinx.coroutines.flow.Flow
interface PhotoRepository {
fun getPhotos(): Flow<Result<List<PhotoEntity>>>
}
import com.example.domain.entity.TodoEntity
import kotlinx.coroutines.flow.Flow
interface TodoRepository {
fun getTodos(): Flow<Result<List<TodoEntity>>>
}
domain 모듈의 entity 패키지에 각각 Entity들을 추가한다.
data class PhotoEntity(
val albumId: Int,
val id: Int,
val title: String,
val url: String,
val thumbnailUrl: String,
)
data class TodoEntity(
val userId: Int,
val id: Int,
val title: String,
val completed: Boolean
)
data 모듈에 인터페이스들을 구현하는 클래스를 추가한다.
import com.example.data.api.ApiService
import com.example.data.mapper.PhotoMapper
import com.example.data.model.response.PhotoResponse
import com.example.data.util.performApiCall
import com.example.domain.entity.PhotoEntity
import com.example.domain.repository.PhotoRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class PhotoRepositoryImpl @Inject constructor(
private val apiService: ApiService,
private val photoMapper: PhotoMapper,
): PhotoRepository {
override fun getPhotos(): Flow<Result<List<PhotoEntity>>> = performApiCall(
funcName = "getPhotos()",
apiCall = { apiService.getPhotos() }
).map { result ->
result.mapCatching { response: List<PhotoResponse> ->
response.map(photoMapper::mapToDomain)
}
}
}
import com.example.data.api.ApiService
import com.example.data.mapper.TodoMapper
import com.example.data.model.response.TodoResponse
import com.example.data.util.performApiCall
import com.example.domain.entity.TodoEntity
import com.example.domain.repository.TodoRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class TodoRepositoryImpl @Inject constructor(
private val apiService: ApiService,
private val todoMapper: TodoMapper,
): TodoRepository {
override fun getTodos(): Flow<Result<List<TodoEntity>>> = performApiCall(
funcName = "getTodos()",
apiCall = { apiService.getTodos() }
).map { result ->
result.mapCatching { response: List<TodoResponse> ->
response.map(todoMapper::mapToDomain)
}
}
}
Mapper의 구현은 각각 아래와 같다.
import com.example.data.model.response.PhotoResponse
import com.example.domain.entity.PhotoEntity
class PhotoMapper {
fun mapToDomain(photoResponse: PhotoResponse): PhotoEntity =
PhotoEntity(
photoResponse.albumId,
photoResponse.id,
photoResponse.title,
photoResponse.url,
photoResponse.thumbnailUrl,
)
}
import com.example.data.model.response.TodoResponse
import com.example.domain.entity.TodoEntity
class TodoMapper {
fun mapToDomain(todoResponse: TodoResponse): TodoEntity =
TodoEntity(
userId = todoResponse.userId,
id = todoResponse.id,
title = todoResponse.title,
completed = todoResponse.completed
)
}
그리고 domain 모듈에 usecase들을 만든다.
import com.example.domain.entity.PhotoEntity
import com.example.domain.repository.PhotoRepository
import kotlinx.coroutines.flow.Flow
class GetPhotosUseCase(
private val photoRepository: PhotoRepository
) {
operator fun invoke(): Flow<Result<List<PhotoEntity>>> = photoRepository.getPhotos()
}
import com.example.domain.entity.TodoEntity
import com.example.domain.repository.TodoRepository
import kotlinx.coroutines.flow.Flow
class GetTodosUseCase(
private val todoRepository: TodoRepository
) {
operator fun invoke(): Flow<Result<List<TodoEntity>>> = todoRepository.getTodos()
}
그리고 app 모듈의 DomainModule에 방금 만든 usecase들을 추가한다. 생성자 매개변수로 repository를 받으니 DomainModule에서 usecase 인스턴스 생성 시 repository의 의존성을 전달하게 구현한다.
import com.example.domain.repository.AlbumRepository
import com.example.domain.repository.PhotoRepository
import com.example.domain.repository.TodoRepository
import com.example.domain.usecase.GetAlbumsUseCase
import com.example.domain.usecase.GetPhotosUseCase
import com.example.domain.usecase.GetTodosUseCase
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)
@Provides
@Singleton
fun provideGetPhotosUseCase(photoRepository: PhotoRepository): GetPhotosUseCase =
GetPhotosUseCase(photoRepository)
@Provides
@Singleton
fun provideGetTodosUseCase(todoRepository: TodoRepository): GetTodosUseCase =
GetTodosUseCase(todoRepository)
}
RepositoryModule에도 Repository 인터페이스와 impl 클래스들의 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
}
이제 뷰모델을 만든다. 대단한 로직은 없고 데이터를 가져와서 리사이클러뷰에 표시만 하기 때문에 매우 간결하다.
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.domain.entity.PhotoEntity
import com.example.domain.usecase.GetPhotosUseCase
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 PhotoViewModel @Inject constructor(
private val getPhotosUseCase: GetPhotosUseCase
): ViewModel() {
private val _photos = MutableStateFlow<List<PhotoEntity>>(emptyList())
val photos: StateFlow<List<PhotoEntity>> = _photos.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error = _error.asStateFlow()
fun loadPhotos() = viewModelScope.launch(Dispatchers.IO) {
getPhotosUseCase().collect { result ->
result.fold(
onSuccess = { photos ->
_photos.value = photos
},
onFailure = { e ->
_error.value = e.message
}
)
}
}
}
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.domain.entity.TodoEntity
import com.example.domain.usecase.GetTodosUseCase
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 TodoViewModel @Inject constructor(
private val getTodosUseCase: GetTodosUseCase
): ViewModel() {
private val _todos = MutableStateFlow<List<TodoEntity>>(emptyList())
val todos: StateFlow<List<TodoEntity>> = _todos.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error = _error.asStateFlow()
fun loadTodos() = viewModelScope.launch(Dispatchers.IO) {
getTodosUseCase().collect { result ->
result.fold(
onSuccess = { todos ->
_todos.value = todos
},
onFailure = { e ->
_error.value = e.message
}
)
}
}
}
이제 리사이클러뷰 어댑터를 만들고 XML에서 리사이클러뷰를 선언한 뒤 붙이면 된다.
그리고 이전 포스팅에서 까먹고 안 올린 코드가 있는데 바로 MyDiffUtil이다. presentation 모듈에 util 패키지를 만들고 여기에 추가했다.
import androidx.recyclerview.widget.DiffUtil
class MyDiffUtil<T: Any>(
private val idSelector: (T) -> Any,
private val contentComparator: (T, T) -> Boolean = { old, new -> old == new }
): DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean =
idSelector(oldItem) == idSelector(newItem)
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean =
contentComparator(oldItem, newItem)
}
어댑터에서 동일한 형태로 DiffUtil을 사용하기 때문에 공통화한 것으로 역시 주의깊게 볼 코드는 없다.
사진을 보여줄 어댑터부터 작성한다. 먼저 item_photo.xml의 전체 코드다.
<?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="photoData"
type="com.example.domain.entity.PhotoEntity" />
</data>
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginBottom="20dp"
app:cardBackgroundColor="@color/black"
app:cardCornerRadius="20dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="20dp">
<ImageView
android:id="@+id/ivOriginImage"
android:layout_width="120dp"
android:layout_height="120dp"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:originImageUrl="@{photoData.url}"
tools:src="@drawable/unsplash" />
<ImageView
android:id="@+id/ivThumbnailImage"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_marginStart="12dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="@+id/ivOriginImage"
app:layout_constraintStart_toEndOf="@+id/ivOriginImage"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0"
app:originThumbUrl="@{photoData.thumbnailUrl}"
tools:src="@drawable/unsplash" />
<TextView
android:id="@+id/tvPhotoAlbumId"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@{`albumId : ` + String.valueOf(photoData.albumId)}"
android:textColor="@color/white"
android:textSize="20dp"
app:layout_constraintStart_toStartOf="@+id/ivOriginImage"
app:layout_constraintTop_toBottomOf="@+id/ivOriginImage"
tools:text="albumId : 1" />
<TextView
android:id="@+id/tvPhotoId"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@{`id : ` + String.valueOf(photoData.id)}"
android:textColor="@color/white"
android:textSize="20dp"
app:layout_constraintStart_toStartOf="@+id/tvPhotoAlbumId"
app:layout_constraintTop_toBottomOf="@+id/tvPhotoAlbumId"
tools:text="id : 1" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:maxLines="1"
android:text="title : accusamus beatae ad facilis cum similique qui sunt"
android:textColor="@color/white"
android:textSize="20dp"
app:layout_constraintStart_toStartOf="@+id/tvPhotoAlbumId"
app:layout_constraintTop_toBottomOf="@+id/tvPhotoId"
tools:text="@{`title : ` + photoData.title}" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</layout>
그리고 BindingAdapter의 구현이다.
import android.widget.ImageView
import androidx.databinding.BindingAdapter
import coil.load
@BindingAdapter("originImageUrl")
fun loadImage(view: ImageView, url: String?) {
url?.let {
view.load(it) {
crossfade(true)
placeholder(android.R.drawable.progress_indeterminate_horizontal)
error(android.R.drawable.stat_notify_error)
}
}
}
@BindingAdapter("originThumbUrl")
fun loadThumbImage(view: ImageView, url: String?) {
url?.let {
view.load(it) {
crossfade(true)
placeholder(android.R.drawable.progress_indeterminate_horizontal)
error(android.R.drawable.stat_notify_error)
}
}
}
PhotoAdpater의 구현은 아래와 같다.
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.domain.entity.PhotoEntity
import com.example.presentation.databinding.ItemPhotoBinding
import com.example.presentation.views.util.MyDiffUtil
class PhotoAdapter : ListAdapter<PhotoEntity, PhotoAdapter.PhotoViewHolder>(
MyDiffUtil(
idSelector = { it.id },
contentComparator = { old,new -> old == new }
)
) {
inner class PhotoViewHolder(private val binding: ItemPhotoBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: PhotoEntity) {
binding.photoData = item
binding.executePendingBindings()
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PhotoViewHolder {
return PhotoViewHolder(
ItemPhotoBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: PhotoViewHolder, position: Int) =
holder.bind(getItem(position))
}
PhotoActivity의 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/photo_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".views.photo.PhotoActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvPhoto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:listitem="@layout/item_photo"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</androidx.constraintlayout.widget.ConstraintLayout>
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.ActivityPhotoBinding
import com.example.presentation.viewmodels.PhotoViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@AndroidEntryPoint
class PhotoActivity : AppCompatActivity() {
private val TAG = this::class.simpleName
private val photoViewModel: PhotoViewModel by viewModels()
private lateinit var binding: ActivityPhotoBinding
private lateinit var photoAdapter: PhotoAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityPhotoBinding.inflate(layoutInflater)
setContentView(binding.root)
enableEdgeToEdge()
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.photo_main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
initRecyclerView()
photoViewModel.loadPhotos()
observeViewModel()
}
private fun initRecyclerView() {
photoAdapter = PhotoAdapter()
binding.rvPhoto.adapter = photoAdapter
}
private fun observeViewModel() {
lifecycleScope.launch {
photoViewModel.photos.collect { photos ->
Log.e(TAG, "photos : $photos")
photoAdapter.submitList(photos)
}
}
}
}
마지막으로 todo 리사이클러뷰도 구현한다. 아이템의 이름은 item_todo.xml이다.
<?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="todoData"
type="com.example.domain.entity.TodoEntity" />
</data>
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginBottom="20dp"
app:cardBackgroundColor="@color/black"
app:cardCornerRadius="20dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="20dp">
<CheckBox
android:id="@+id/cbCompleted"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="@{todoData.completed}"
android:clickable="false"
android:focusable="false"
android:focusableInTouchMode="false"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:checked="true" />
<TextView
android:id="@+id/tvTodoUserId"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{`userId : ` + String.valueOf(todoData.userId)}"
android:textColor="@color/white"
android:textSize="20dp"
app:layout_constraintBottom_toBottomOf="@+id/cbCompleted"
app:layout_constraintStart_toEndOf="@+id/cbCompleted"
app:layout_constraintTop_toTopOf="@+id/cbCompleted"
tools:text="userId : 1" />
<TextView
android:id="@+id/tvTodoId"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@{`id : ` + String.valueOf(todoData.id)}"
android:textColor="@color/white"
android:textSize="20dp"
app:layout_constraintStart_toStartOf="@+id/tvTodoUserId"
app:layout_constraintTop_toBottomOf="@+id/tvTodoUserId"
tools:text="id : 1" />
<TextView
android:id="@+id/tvTodoTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@{`title : ` + todoData.title}"
android:textColor="@color/white"
android:textSize="20dp"
app:layout_constraintStart_toStartOf="@+id/tvTodoId"
app:layout_constraintTop_toBottomOf="@+id/tvTodoId"
tools:text="title : delectus aut autem" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</layout>
마찬가지로 어댑터도 구현한다.
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.domain.entity.TodoEntity
import com.example.presentation.databinding.ItemTodoBinding
import com.example.presentation.views.util.MyDiffUtil
class TodoAdapter: ListAdapter<TodoEntity, TodoAdapter.TodoViewHolder>(
MyDiffUtil(
idSelector = { it.id },
contentComparator = { old, new -> old == new }
)
) {
inner class TodoViewHolder(private val binding: ItemTodoBinding): RecyclerView.ViewHolder(binding.root) {
fun bind(item: TodoEntity) {
binding.todoData = item
binding.executePendingBindings()
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TodoViewHolder {
return TodoViewHolder(
ItemTodoBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: TodoViewHolder, position: Int) =
holder.bind(getItem(position))
}
아래는 TodoActivity의 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/todo_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".views.todo.TodoActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvTodo"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:listitem="@layout/item_todo"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</androidx.constraintlayout.widget.ConstraintLayout>
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.ActivityTodoBinding
import com.example.presentation.viewmodels.TodoViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@AndroidEntryPoint
class TodoActivity : AppCompatActivity() {
private val TAG = this::class.simpleName
private val todoViewModel: TodoViewModel by viewModels()
private lateinit var binding: ActivityTodoBinding
private lateinit var todoAdapter: TodoAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityTodoBinding.inflate(layoutInflater)
setContentView(binding.root)
enableEdgeToEdge()
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.todo_main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
initRecyclerView()
todoViewModel.loadTodos()
observeViewModel()
}
private fun initRecyclerView() {
todoAdapter = TodoAdapter()
binding.rvTodo.adapter = todoAdapter
}
private fun observeViewModel() {
lifecycleScope.launch {
todoViewModel.todos.collect { todos ->
Log.e(TAG, "## todos : $todos")
todoAdapter.submitList(todos)
}
}
}
}
이제 실행하면 이전 포스팅의 움짤대로 버튼을 누를 때마다 데이터를 가져와서 리사이클러뷰에 표시할 것이다.
프로젝트는 완성됐는데 이 코드들은 어떤 순서로 동작하는가? 유저가 album 버튼을 눌러서 화면 이동 후 데이터를 표시하는 흐름을 바탕으로 확인해 본다.
큰 흐름은 Presentation -> Domain -> Data -> Domain -> Presentation 형태의 순환형 구조다.
- album 버튼을 눌러 AlbumActivity로 이동하게 되면 AlbumViewModel.getAlbums()가 호출된다
- AlbumViewModel.getAlbums()는 GetAlbumsUseCase를 호출하기 때문에 domain 모듈의 GetAlbumUseCase로 이동한다
- GetAlbumsUseCase는 AlbumRepository.getAlbums()를 호출한다. 이 때 AlbumRepository를 생성자 주입받으며 AlbumRepository는 hilt의 영향으로 구현체인 AlbumRepositoryImpl의 형태로 주입된다
- AlbumRepositoryImpl.getAlbums()가 호출되고 performApiCall() 함수를 통해 ApiService.getAlbums()를 호출한다. 이로 인해 레트로핏을 통한 API 호출이 시작된다. 호출 후 받는 응답의 타입은 Response<List<AlbumResponse>>다
여기까지는 유저가 버튼을 누른 후 API 요청을 하기까지의 과정이고, 아래부턴 API 응답을 받은 후 응답이 뷰에 표시되기까지의 과정이다. 실패 케이스와 성공 케이스가 크게 다르지 않으니 성공 케이스만 확인한다.
- performApiCall()을 통한 API 호출 결과로 Flow<Result<List<AlbumResponse>>> 객체가 리턴된다. performApiCall()은 레트로핏 호출 결과를 Flow<Result<T>>로 변환하는 함수다
- AlbumRepository.getAlbums()에서 performApiCall()로 Flow<Result<List<AlbumResponse>>> 객체가 생성되면 map 안에서 AlbumMapper를 통해 AlbumResponse에 담긴 값들을 AlbumEntity로 변환한다. mapCatching은 Result 타입 중 success 데이터를 리턴하고 예외가 발생하면 Result.failure로 처리한다
- 결국 AlbumRepositoryImpl에선 Flow<Result<List<AlbumResponse>>> 타입의 객체를 리턴하고 이를 usecase에서 사용한다. GetAlbumUseCase.invoke()에선 별 처리를 하지 않아서 이 객체는 그대로 뷰모델로 전달된다
- AlbumViewModel.loadAlbums()로 돌아와서 collect를 통해 Flow<Result<List<AlbumEntity>>> 객체를 얻는다. 이 때 collect {} 안에 있는 result 리시버 변수의 타입은 Result<List<AlbumEntity>>임을 참고한다
- 데이터를 가져오는 데 성공했으니 onSuccess가 호출되어 _albums(MutableStateFlow)에 List<AlbumEntity>가 담긴다
- _albums의 값이 바뀌었으니 StateFlow 타입인 albums의 값도 바뀌고, AlbumActivity의 observeViewModel()에 있는 lifecycleScope.launch {} 중 albums를 collect하는 코드가 작동한다
- isNotEmpty()를 통해 리스트가 비었는지 체크한 후 값이 들어 있다면 AlbumAdapter.submitList()를 통해 어댑터로 리스트를 보낸다
- AlbumAdpater의 DiffUtil에 의해 비교 처리를 거친 후 뷰홀더 생성, 뷰홀더 바인드 등의 처리가 이뤄지고 데이터 바인딩을 통해 item_album.xml에 데이터들이 자리잡는다
- getAlbums()로부터 가져온 데이터들의 위치 선정이 끝나서 리사이클러뷰가 완성되면 유저에게 표시된다
이 데이터 흐름을 그림으로 표현하면 아래와 같다.
그 외에 말할 것이라면
- domain 모듈에 인터페이스 형태로 추상화를 정의하고 data 모듈에서 impl 클래스를 구현하고, data 모듈에 Mapper를 둬서 데이터와 비즈니스 로직을 분리해 의존성 역전 원칙을 지키려 시도했다
- impl 클래스는 필요하다면 API 호출, DB 작업을 모킹해서 테스트할 수 있고, Mapper와 api 호출이 분리돼 있어 계층별 독립적인 테스트가 가능하다
안드로이드에서 클린 아키텍처를 따르려면 어떤 식으로 짜야 하는지에 대해 대략적인 예시 프로젝트를 만들었지만 예시는 어디까지나 예시일 뿐이다. 계속해서 레퍼런스를 찾고 다른 사람들은 어떻게, 왜 그렇게 구현했는지 확인하고 조사하는 과정이 반드시 필요하다.
이 글도 클린 아키텍처를 따르려고 노력했지만 분명 다르거나 부족한 부분이 있다. 예시인 만큼 그 부분은 감안해 주길 희망한다.
'Android' 카테고리의 다른 글
[Android] 안드로이드 스튜디오와 Cursor AI 연동하는 법 (0) | 2025.01.01 |
---|---|
[Android] Hilt + Retrofit + Flow + Coil + 멀티 모듈 구조 프로젝트 - 1 - (0) | 2024.12.23 |
[Android] 내 위치 정보 가져와서 사용하는 법 (with. Hilt) (0) | 2024.12.18 |
[Android] 현재 내 위치 가져오는 법 (0) | 2024.11.13 |
[Android] 초기 데이터 로드 2 : 의문점 해소하기 (0) | 2024.10.13 |