2024.01.19 - [Android Studio] - [Android][Kotlin]MVVM,Hilt,Retrofit2,RecyclerView 활용 예시
[Android][Kotlin]MVVM,Hilt,Retrofit2,RecyclerView 활용 예시
** hanbikan 님의 예시를 참고했다. Clean Architecture Hanbikan 님의 클릭 아키텍처 구조를 따라 예시 앱의 구조를 설계했다. 그럼 이렇게 되려나.. 🤔 1. 라이브러리와 인터넷 권한 추가 Retrofit, Gson, okhttp,
nalainthesky.tistory.com
====> 기존 코드에 이어서 진행.
이번엔 Hilt와 Room 을 연습해 보았다.
각 각의 사용자 정보에 별 아이콘을 추가하고 아이콘 클릭에 따라 해당 유저를 즐겨찾기에 추가 또는 삭제하는 흐름을 추가했다.
UI
즐겨찾기 기능을 넣을 별 아이콘을 우측에 추가하였다.
아이콘 클릭 시 별 색상이 바뀌도록 하였다.
UserItemView.kt
@Composable
fun UserItemView(){
var isFavorite by remember { mutableStateOf(false) }
... 기존의 view 코드 ...
Spacer(modifier = Modifier.weight(1f))
FavoriteButton(
isFavorite = isFavorite,
onClick = {
isFavorite = !isFavorite
}
)
}
}
}
@Composable
private fun FavoriteButton(isFavorite: Boolean, onClick: () -> Unit) {
IconButton(
onClick = onClick
) {
Icon(
imageVector =
if( isFavorite ) Icons.Filled.Star
else Icons.Outlined.StarOutline,
contentDescription = "my favorite"
)
}
}
초기 데이터 설정하기
1. Entity 생성
데이터베이스의 테이블을 정의하는 클래스이다.
UserModel class에 @Entity 어노테이션을 달아준다.
기본적으로 Room은 클래스 이름을 테이블 이름으로 사용하는데, 나는 다른 이름을 다르게 하기 위해 tableName 속성을 사용하였다.
Entity의 각 필드는 데이터베이스에서 열로 표시된다.
각 항목을 고유하게 하기 위해 id에 @PrimaryKey 어노테이션을 달아 기본키로 설정해 준다.
@Entity(tableName = "user")
data class UserModel(
@PrimaryKey
val id: Int,
val email: String,
val name: String,
val avatar: String
)
2. Dao(Data Access Objects) 생성
@Dao 어노테이션을 달아준다.
Room에서 편의를 위해 기본적으로 제공해 주는 @Insert, @Update, @Delete 와 같은 편의 메서드가 있다.
@Query 어노테이션을 이용하면 데이터를 쿼리하거나 좀 더 복잡한 삽입, 업데이트, 삭제가 필요할 때, 쿼리 메서드 내부에 직접 쿼리문을 작성해서 사용할 수 있다.
@Insert 어노테이션을 통해 로컬 DB에 사용자 정보를 저장하는 쿼리문을 작성한다.
@Dao
interface UserDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun saveUserToDB(userModel: UserModel)
}
코루틴(Coroutine)
비동기 프로그래밍을 위한 라이브러리.
메서드는 시작점과 종료 지점이 정해져 있고 메인 스레드에서 호출하면 내부 로직이 끝날 때까지 종료되지 않으며 다른 외부 요인에 의해서 중지되지 않는다. 그러나 코루틴은 로직 중간의 어느 지점이든 시작점과 종료 지점이 될 수 있으며 일시중지도 가능하고 다른 코루틴으로 이동할 수 도 있기 때문에 기존의 스레드보다 훨씬 적은 자원을 소비한다.
'Suspend' 키워드로 선언된 메서드는 코루틴에 의해 컨트롤될 수 있는 함수임을 뜻한다.
Suspend 함수는 코루틴이 일시 중단되고 다른 코루틴이 실행될 수 있는 지점을 표시한다.
또한 Dispatchers.IO와 같은 백그라운 스레드에서 Suspend 함수를 호출하여 I/O 작업과 같은 블로킹 작업을 비차단적으로 수행할 수 있다.
suspend 함수는 다른 suspend 함수나 코루틴을 사용하는 함수 내에서 호출할 수 있다.
데이터베이스 작업을 비동기적으로 처리하는 이유
1. 데이터베이스 작업은 주로 I/O 작업에 속하는데, 이러한 작업은 시간이 오래 걸릴 수 있다.
따라서 I/O 작업을 메인 스레드에서 수행하면 앱이 응답하지 않는 것처럼 보일 수 있다.
코루틴의 비동기 작업을 통해 메인 스레드를 차단하지 않고 데이터베이스 작업을 수행할 수 있다.
2. 메인 스레드를 차단하지 않으면서 작업을 하기 때문에 ANR(Application Not Responding) 상태에 빠질 가능성이 낮아지고 안전성이 높아진다.
https://stackoverflow.com/questions/76534896/what-are-the-purpose-of-having-suspend-keyword-on-room-daos-function-besides-en
3. Database 생성
데이터베이스를 보유할 클래스이다.
@Database 어노테이션이 달린 UserDatabase 클래스를 생성한다.
version 번호는 데이터베이스 테이블의 스키마를 변경할 때마다 높여야 한다.
@Database(entities = [UserModel::class], version = 1)
// 추상 클래스와 추상 메소드로 만들고 module 에서 구현한다.
abstract class UserDatabase: RoomDatabase() {
abstract fun getUserDao(): UserDao
}
4. DatabaseModule
DatabaseModule이라는 Module class를 생성한다.
Room과 같은 외부라이브러리에서 제공되는 클래스에는 @Inject 어노테이션을 직접 사용할 수 없기 때문에 Hilt 모듈을 통해 해당 클래스에 대한 의존성을 설정한다.
@Module 어노테이션은 Hilt에 해당 클래스가 모듈임을 알려주고 @InstallIn 어노테이션은 어떤 컴포넌트에 구성되는지를 나타낸다.
@Module
@InstallIn(SingletonComponent::class)
class DatabaseModule {
// @Provides 주석은 의존성을 제공하는 메서드임을 나타낸다.
// @Singleton 주석은 해당 메서드의 싱글톤을 구현한다.
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): UserDatabase =
// Room 데이터베이스를 제공하는 메서드이다.
Room.databaseBuilder(context, UserDatabase::class.java, "user_db")
// 데이터베이스 마이그레이션 실패 시 기존 데이터 손실을 허용한다.
.fallbackToDestructiveMigration()
.build()
@Provides
// UserDatabase의 UserDao 인스턴스를 제공하는 메서드이다.
// UserDatabase는 제공 메서드인 provideDatabase()에서 생성되었다.
fun provideUserDao(database: UserDatabase): UserDao = database.getUserDao()
}
5. Repository
UserRepository class에 UserDao를 전달한다.
UserRepository는 외부 서비스(NetworkService)를 통해 사용자 목록을 가져오고, 데이터베이스 작업을 수행한다.
// UserRepository class 는 NetworkService 와 UserDao 에 대한 의존성을 가진다.
class UserRepository @Inject constructor(
private val networkService: NetworkService,
private val userDao: UserDao
) {
suspend fun getUserList(page: Int): List<User> {
return networkService.getUserList(page).data
}
suspend fun saveUserToDB(userModel: UserModel) = userDao.saveUserToDB(userModel)
}
5. UseCase
UseCase 클래스 또한 Hilt의 의존성을 주입한다.
UseCase 클래스는 Repository를 통해 데이터를 가져온다.
서버에서 데이터를 가져오는지, 로컬 데이터베이스에서 데이터를 조회하는지에 대한 구체적인 구현은 UserRepository 클래스에서 이루어진다. UseCase 클래스는 비즈니스 로직과 데이터 접근의 추상화를 제공한다.
class GetUserListUseCase @Inject constructor(
private val userRepository: UserRepository
) {
... 기존 코드 ...
// DB에 사용자 데이터를 저장한다.
suspend operator fun invoke(userModel: UserModel)
= userRepository.saveUserToDB(userModel)
}
6. ViewModel
MainViewModel 클래스에 GetUserListUseCase를 주입한다.
@HiltViewModel
class MainViewModel @Inject constructor(private val getUserListUseCase: GetUserListUseCase): ViewModel() {
// StateFlow : 현재 상태와 새로운 상태 업데이트를 수집기에 내보내는 관찰 가능한 상태 홀더 흐름이다.
private val _userList = MutableStateFlow<List<UserModel>>(listOf())
val userList: StateFlow<List<UserModel>> = _userList
init {
readUserList()
}
private fun readUserList() {
viewModelScope.launch(Dispatchers.IO) {
// value 를 통해 현재 상태 값을 읽는다.
// invoke 함수를 이용해 GetUserListUseCase 의 함수를 함수 이름 없이 간편하게 불러왔다.
_userList.value = getUserListUseCase(1)
// 초기 데이터 넣기
saveUserToDB(_userList.value)
}
}
private fun saveUserToDB(userData: List<UserModel>) {
for( i in userData.indices) {
viewModelScope.launch {
// 또는 invoke 를 이렇게 써도 된다.
getUserListUseCase.invoke(userData[i])
}
}
}
}
앱을 실행해 보면 DB에 데이터가 잘 들어가 있음을 확인할 수 있다.
즐겨찾기 상태 업데이트
별 아이콘을 클릭하여 해당 user의 즐겨찾기 상태 업데이트
필요한 코드 추가
UserModel.kt
var isFavorite: Boolean = false
UserDao.kt
클릭된 사용자의 id 값을 찾아 isFavorite 상태를 업데이트한다.
@Query("UPDATE user SET isFavorite = :isFavorite WHERE id = :id")
suspend fun updateFavorite(isFavorite: Boolean, id: Int)
UserRepository.kt
suspend fun updateFavorite(isFavorite: Boolean, id: Int) =
userDao.updateFavorite(isFavorite, id)
GetUserListUserCase.kt
suspend operator fun invoke(isFavorite: Boolean, id: Int) =
userRepository.updateFavorite(isFavorite, id)
MainViewModel.kt
// Dispatchers.IO : I/O 작업에 사용된다. 파일 읽기/쓰기, 네트워크 호출과 같은 블로킹되는 작업에 유용하다.
// Dispatchers.IO를 사용하여 백그라운드에서 스레드가 실행되도록 한다.
// 이렇게 하면 메인 스레드를 차단하지 않고 작업을 수행할 수 있어 앱의 응답성이 향상된다.
fun updateFavorite(isFavorite: Boolean, id: Int) {
viewModelScope.launch(Dispatchers.IO) {
getUserListUseCase.invoke(isFavorite, id)
}
}
UI
Jetpack compose
Compose는 UI를 더 모듈화 하고 간단하게 구성할 수 있도록 한다.
또한 기존의 Android UI 라이브러리들과는 다르게 UI 간의 결합을 느슨하게 유지하면서 UI를 업데이트하는 방식을 제공한다. 이는 더 나은 유지보수성과 확장성을 제공한다.
Composable이라는 함수로 UI를 정의한다.
컴포저블은 자체적으로 상태 관리를 하며, 상태에 대한 변화를 받아서 UI를 자동으로 업데이트한다.
그런데 이 Compose에서는 viewModel()를 컴포저블로 지정할 수 없다. 컴포저블이 'viewModelStoreOwner'가 아니기 때문이다.
이는 Compose가 화면 전환과 관련된 라이프 사이클이나 상태를 기반으로 하는 Android 컴포넌트보다는 더 단일 책임 원칙에 따라 구현되기 때문이다.
"Composable에서 클릭 이벤트가 발생한 결과를 viewModel에 어떻게 전달해야 할까?"
Compose에서 viewModel()을 사용하기
app 수준의 gradle에 라이브러리 추가
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1'
UserItemView 클래스에서 viewModel을 불러온다.
UserItemView.kt
@Composable
fun UserItemView(
userModel: UserModel,
mainViewModel: MainViewModel = viewModel()
) {
val id by remember { mutableIntStateOf(userModel.id) }
var isFavorite by remember { mutableStateOf(userModel.isFavorite) }
... 기존 UI 코드 ...
FavoriteButton(
isFavorite = isFavorite,
onClick = {
isFavorite = !isFavorite
mainViewModel.updateFavorite(isFavorite, id)
}
)
}
@Composable
private fun FavoriteButton(isFavorite: Boolean, onClick: () -> Unit) {
IconButton(
onClick = onClick
) {
Icon(
imageVector =
if( isFavorite ) Icons.Filled.Star
else Icons.Outlined.StarOutline,
contentDescription = "my favorite"
)
}
}
아이콘을 클릭했을 때 DB의 isFavorite 상태가 업데이트되는 것을 확인할 수 있다.
앱을 다시 실행했을 때 DB 내역을 UI로 가져오기 위해 몇 가지 코드를 더 추가했다.
아래는 새로 추가한 최종 코드
UserDao.kt
@Dao
interface UserDao {
@Query("SELECT * FROM user")
suspend fun getAllUser(): List<UserModel>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun saveUserToDB(userModel: UserModel)
@Query("UPDATE user SET isFavorite = :isFavorite WHERE id = :id")
suspend fun updateFavorite(isFavorite: Boolean, id: Int)
}
UserRepository.kt
class UserRepository @Inject constructor(
private val networkService: NetworkService,
private val userDao: UserDao
) {
// page 에 있는 user 데이터를 가지고 온다.
suspend fun getUserList(page: Int): List<User> {
return networkService.getUserList(page).data
}
suspend fun getUsers(): List<UserModel> = userDao.getAllUser()
suspend fun saveUserToDB(userModel: UserModel) = userDao.saveUserToDB(userModel)
suspend fun updateFavorite(isFavorite: Boolean, id: Int) = userDao.updateFavorite(isFavorite, id)
}
GetUserListUseCase.kt
class GetUserListUseCase @Inject constructor(
private val userRepository: UserRepository
) {
// invoke : 이름 없이 간편하게 호출될 수 있는 함수
// operator(연산자) 키워드를 통해 invoke 함수를 부를 수 있다.
suspend operator fun invoke(page: Int): List<UserModel> {
return userRepository.getUserList(page).toUserModelList()
}
suspend operator fun invoke(): List<UserModel> = userRepository.getUsers()
suspend operator fun invoke(userModel: UserModel) = userRepository.saveUserToDB(userModel)
suspend operator fun invoke(isFavorite: Boolean, id: Int) = userRepository.updateFavorite(isFavorite, id)
}
MainViewModel.kt
@HiltViewModel
class MainViewModel @Inject constructor(private val getUserListUseCase: GetUserListUseCase): ViewModel() {
// StateFlow : 현재 상태와 새로운 상태 업데이트를 수집기에 내보내는 관찰 가능한 상태 홀더 흐름이다.
private val _userList = MutableStateFlow<List<UserModel>>(listOf())
val userList: StateFlow<List<UserModel>> = _userList
init {
readUserList()
}
private fun readUserList() {
viewModelScope.launch(Dispatchers.IO) {
val localData = getUserListUseCase.invoke()
// DB 데이터가 비어있을 경우에만 초기 데이터를 저장하도록 했다.
if( localData.isEmpty() ) {
// value 를 통해 현재 상태 값을 읽는다.
// invoke 함수를 이용해 GetUserListUseCase 의 함수를 함수 이름 없이 간편하게 불러왔다.
_userList.value = getUserListUseCase(1)
// 초기 데이터 저장
saveUserToDB(_userList.value)
} else {
_userList.value = getUserListUseCase.invoke()
}
}
}
private fun saveUserToDB(aa: List<UserModel>) {
for( i in aa.indices) {
viewModelScope.launch {
getUserListUseCase.invoke(aa[i])
}
}
}
// Dispatchers.IO : I/O 작업에 사용된다. 파일 읽기/쓰기, 네트워크 호출과 같은 블로킹되는 작업에 유용하다.
// (Dispatchers.IO) 를 사용하여 백그라운드에서 스레드가 실행되도록 한다.
// 이렇게 하면 메인 스레드를 차단하지 않고 작업을 수행할 수 있어 앱의 응답성이 향상된다.
fun updateFavorite(isFavorite: Boolean, id: Int) {
viewModelScope.launch(Dispatchers.IO) {
getUserListUseCase.invoke(isFavorite, id)
}
}
}
UserItemView.kt 는 동일
'Android Studio' 카테고리의 다른 글
[Android][앱 배포]업데이트 (0) | 2024.05.24 |
---|---|
[Android][Kotlin]MVVM,Hilt,Retrofit2,RecyclerView 활용 예시 (0) | 2024.01.19 |
[Android][Firebase][해결]Missing or insufficient permissions. (0) | 2022.11.07 |
[Android][Java]editText에 maxLines="1" 적용 (0) | 2022.09.23 |
[Android][Firebase][해결]StorageException (0) | 2022.09.20 |