Hanbit the Developer

[Android] MVVM + Hilt + Retrofit2 + Recycler View 간단한 예시(+ Clean Architecture) 본문

Mobile/Android

[Android] MVVM + Hilt + Retrofit2 + Recycler View 간단한 예시(+ Clean Architecture)

hanbikan 2022. 10. 11. 19:09

서론

가장 인기있는 MVVM 및 Clean Architecture를 Retrofit2, Hilt, Recycler View와 함께 쓴 매우 간단한 repository를 작성하였고, 이에 대한 설명이 이 글의 주요 내용입니다. 위 내용들을 실제 예시 코드를 통한 빠르게 학습하고자 하시는 분들이 타겟입니다.

 

이 프로젝트의 앱은 OpenAPI(https://reqres.in/) 서버와 통신하여 유저 리스트를 READ하고 이를 RecyclerView에 표현하는 내용의 앱입니다.

 

https://github.com/hanbikan/recycler-view-with-mvvm-hilt-retrofit2

 

GitHub - hanbikan/recycler-view-with-mvvm-hilt-retrofit2: An example which contains recycler view using MVVM, Hilt, Retrofit2 ju

An example which contains recycler view using MVVM, Hilt, Retrofit2 just for practice. - GitHub - hanbikan/recycler-view-with-mvvm-hilt-retrofit2: An example which contains recycler view using MVVM...

github.com

 

MVVM  with Recycler View using @BindingAdapter

먼저 MVVM은 안드로이드에서 가장 인기있는 디자인 패턴입니다. 이 디자인 패턴을 사용하는 이유와 간단한 개념은 래퍼런스로 대신하겠습니다.(https://beomy.tistory.com/43)

 

1. MainActivity.kt

먼저 뷰에서의 전체 코드입니다. binding.recyclerView.adapter = UserAdapter() 같은 코드가 없다는 점, layoutManager를 초기화 하지 않은 점이 특이합니다. 이는 뷰모델과 어댑터를 databinding의 <data> 영역으로 넘겼고, xml 내에서 관련 로직을 전부 처리한 덕분입니다.

그럼 이제 RecyclerView에 어댑터를 어떻게 넘겨줬는지, ViewModel에서 데이터가 변경되었을 때 이 값이 어떻게 어댑터로 전달되는지를 중심으로 코드를 더 살펴보겠습니다.

주의 사항: Fragment에서 DataBinding을 사용할 경우, lifecycleOwner에 this(Fragment)가 아닌 viewLifecycleOwner를 전달하여야 합니다. 프래그먼트의 라이프싸이클이 내부 뷰들의 라이프싸이클보다 길기 때문에 메모리 누수 가능성이 있습니다.

 

2. activity_main.xml

빨간색으로 표시한 세 줄의 코드가 핵심입니다. 먼저 layoutManager를 xml 내에서 attributes로 넣어주었습니다. 그리고 아래 두 줄은 @BindingAdapter로 처리하였습니다.

 

https://developer.android.com/topic/libraries/data-binding/binding-adapters

 

결합 어댑터  |  Android 개발자  |  Android Developers

결합 어댑터 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 결합 어댑터는 적절한 프레임워크를 호출하여 값을 설정하는 작업을 담당합니다. 한 가지 예로

developer.android.com

 

3. ViewBindingAdapters.kt

@BindingAdapter("adapter", "submitList", requireAll = true)
fun bindRecyclerView(view: RecyclerView, adapter: RecyclerView.Adapter<*>, submitList: List<Any>?) {
    view.adapter = adapter.apply {
        stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY
        (this as ListAdapter<Any, *>).submitList(submitList?.toMutableList())
    }
}

위 코드를 통해 RecyclerView에 Adapter를 연결시키면서 동시에 ListAdapter의 submitList를 통해 데이터를 업데이트 할 수 있습니다.

 

추가로 생소할 수도 있는 코드에 대해 간단히 서술하자면 다음과 같습니다.

 - ListAdapter: 기존의 RecyclerView.Adapter를 사용했을 때 notify 함수를 적재적소에 제대로 쓰는 것이 너무 번거롭고 notifyDataSetChanged 함수를 남용하게 되면 퍼포먼스가 좋지 않습니다. 이를 위해 도입된 것이 DiffUtil이며 이것을 활용할 수 있는 클래스가 바로 ListAdapter입니다. 데이터가 변경되었을 때 submitList()를 호출해주면 바뀐 영역만을 업데이트 해주는 편리성을 지닙니다.

 - StateRestorationPolicy: Recycler View의 스크롤을 내린 뒤, item의 디테일 페이지에 진입하였다가 나오게 되면 뷰가 다시 그려지게 되는데 이때 기존 스크롤이 저장되지 않아 불편합니다. Adapter.stateRestorationPolicy를 설정해주면 스크롤 저장이 구현됩니다.

 

이에 따라, 데이터가 변경되었을 때 다음과 같은 순서를 따라 뷰가 업데이트 됩니다.

 - ViewModel의 데이터가 변경됩니다.

 - databinding을 통해 viewModel.userList가 항시 관찰되고 있으며, 이 값이 변경됨에 따라 ViewBindingAdapters.kt의 bindRecyclerView()가 호출됩니다.

 - submitList()로 인해 Recycler View 내 바뀐 영역의 뷰가 업데이트 됩니다.

 

이렇게 세팅해준 뒤 ViewModel에서 값을 변경하게 되면 어떠한 추가적인 처리없이도 RecyclerView가 업데이트 됩니다.

 

Clean Architecture

클린 아키텍처를 지키게 되면 SOLID 원칙을 따르게 되어 로직이 적절히 분배되고 프로젝트의 구조를 파악하기 쉬우며 유지보수하기 매우 쉬워집니다.

이를 적용한 해당 프로젝트의 구조는 아래와 같습니다.

data layer에서 데이터를 받아서 view로 반영하기까지의 과정을 순차적으로, 각 영역으로 분리하여 서술하겠습니다.

Data Layer

 - Entity, DTO: 서버에서 받아오는 데이터를 정의합니다.

 - DataStore: 우리에게 매우 익숙한 Retrofit2 interface가 위치하게 됩니다.

- Repository: data store를 이용하여 유저와 관련된 데이터를 관리합니다.

Domain Layer

 - UseCase: 비즈니스 로직이 정의됩니다.

해당 프로젝트의 경우에는 '서버로부터 UserModel 리스트를 불러온다.'라는 유즈케이스가 존재합니다. GetUserListUseCase는 repository를 사용하여 데이터를 읽은 뒤 앱에서 필요한 데이터로 재가공하여 반환해줍니다.

 - Translator: Entity를 Model로 변환하는 로직을 담고 있는 영역입니다.

Entity와 Model의 필요성은, 서버로부터 가져온 데이터(Entity)와 클라이언트에서 실질적으로 쓰게 될 데이터(Model)이 다른 경우가 많기 때문에 발생합니다. 예를 들어 서버에 정의된 User entity 필드에 "name" 있는데, 실제 앱에는 아이템에 체크박스가 있어서 isChecked 같은 필드가 추가로 필요한 경우, translatorUserEntity를, "name"과 "isChecked"를 동시에 가지고 있는 UserModel변환하는 책임을 지게 됩니다.

// UserTranslator.kt
/**
 * Translates [User] entity to [UserModel] to use in views.
 */
object UserTranslator {
    fun List<User>.toUserModelList() = map {
        UserModel(it.id, it.email, it.first_name + " " + it.last_name, it.avatar)
    }
}

 

 - Model: 뷰를 위해 재가공된 데이터 클래스입니다.

이 프로젝트에서는 translator 로직을 설명하기 위해, 서버 entity에서의 first_name과 last_name을 model에서는 name 하나로 병합하여 쓴다는 차이점을 의도적으로 두었습니다.

간단히 말해서, 왼쪽에서의 User(Entity)가 Translator를 통해 UserModel로 변환된다는 것입니다.

 

 

Presentation Layer(UI)

 - Presenter(= ViewModel): View에서 참조하게 될 데이터를 hold&update하는 영역입니다.

 - View: ViewModel을 databinding을 통해 관찰하여 뷰를 업데이트 해줍니다.

 

 

다시 간단히 시퀸스를 요약하자면...

 - Data Layer: DataStore가 Entity, DTO를 참조해서 데이터를 가져옵니다. Repository에서 DataStore를 사용하여 데이터를 제공해줍니다.

 - Domain Layer: UseCase가 Repository를 사용하여 데이터를 가져온 뒤, Translator를 통해 Model로 변환하여 이를 제공합니다.

 - Presentation Layer(UI): ViewModel에서 UseCase를 사용해서 Model을 가져오면, databinding을 통해 자동으로 View를 업데이트 합니다.

 

이전에 작성한 코드를 보면 다시 이해해야 해서 코드가 쌓일수록 생산성이 매우 떨어지곤 했는데, 해당 아키텍처를 도입한 뒤에는 생산성이 크게 증가하였고 코드가 쌓여도 생산성이 유지가 되는 것을 몸소 느낄 수 있었습니다.

 

Hilt

Hilt는 Dependency Injection 라이브러리입니다. 프로젝트를 하면서 작성한 클래스들은 서로 의존 관계를 맺게 됩니다. 예를 들어 위에서 다루었던 UserUseCase는 서버로부터 유저 리스트를 가져오기 위해 ReqresService를 참조하게 됩니다.

 

이때 UserUseCase가 ReqresService를 참조하기 위해선 아래와 같은 코드가 필요합니다. 관련 인스턴스를 생성해준 뒤 UserUseCase의 생성자로 dependencyinject 해주어야 합니다.

val httpLoggingInterceptor = HttpLoggingInterceptor().apply {
    level = HttpLoggingInterceptor.Level.BODY
}
val okHttpClient = OkHttpClient.Builder()
    .addNetworkInterceptor(httpLoggingInterceptor)
    .build()
val gsonConverterFactory = GsonConverterFactory.create()
val retrofit = Retrofit.Builder()
    .baseUrl(BASE_URL)
    .client(okHttpClient)
    .addConverterFactory(gsonConverterFactory)
    .build()
val reqresService = retrofit.create(ReqresService::class.java)
val userUseCase = UserUseCase(reqresService)
// Use userUseCase

여기서 Android 공식 문서에 의하면 Hilt는 ...

프로젝트의 모든 Android 클래스에 컨테이너를 제공하고 수명 주기를 자동으로 관리함으로써 애플리케이션에서 DI를 사용하는 표준 방법을 제공합니다.

여기서 컨테이너라고 하는 것은 '가방'이라고 생각하면 쉽습니다. DI 대상(이번 예시에서는 ReqresService의 인스턴스)을 넣어둔 가방을 제공하여, 어디서든 그 내용물을 쉽게 꺼내서 쓸 수가 있게 됩니다.

 

 

이제 코드를 살펴보겠습니다. 아래 코드를 작성함으로써 HttpLoggingInterceptor, OkHttpClient, GsonConverterFactory, Retrofit, ReqresService를 Activity, Fragment, ViewModel 등에서 쉽게 injection 받을 수 있습니다.

*provideAbc() 형태로 함수를 작성하면 Abc 클래스의 인스턴스를 받을 수 있습니다.

@Module
@InstallIn(SingletonComponent::class)
class NetworkModule {
    @Provides
    @Singleton
    fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor {
        return HttpLoggingInterceptor().apply {
            level = HttpLoggingInterceptor.Level.BODY
        }
    }

    @Provides
    @Singleton
    fun provideOkHttpClient(httpLoggingInterceptor: HttpLoggingInterceptor): OkHttpClient {
        return OkHttpClient.Builder()
            .addNetworkInterceptor(httpLoggingInterceptor)
            .build()
    }

    @Provides
    @Singleton
    fun provideGsonConverterFactory(): GsonConverterFactory {
        return GsonConverterFactory.create()
    }

    @Provides
    @Singleton
    fun provideRetrofit(
        okHttpClient: OkHttpClient,
        gsonConverterFactory: GsonConverterFactory
    ): Retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .client(okHttpClient)
        .addConverterFactory(gsonConverterFactory)
        .build()

    @Provides
    @Singleton
    fun provideReqresService(
        retrofit: Retrofit
    ): ReqresService {
        return retrofit.create(ReqresService::class.java)
    }

    companion object {
        private const val BASE_URL = "https://reqres.in"
    }
}

 

이에 따라 UserUseCase에서 생성자로 ReqresService를 받아서 곧바로 사용할 수 있습니다.(@Inject)

class UserUseCase @Inject constructor(
    private val reqresService: ReqresService
){
    suspend fun getUserList(page: Int): List<UserModel> {
        val data = reqresService.getUserList(page).body()?.data
        return data?.toUserModelList() ?: listOf()
    }
}

 

ViewModel에서는 UserUseCase를 받아서 사용하게 됩니다.

@HiltViewModel
class MainViewModel @Inject internal constructor(
    private val userUseCase: UserUseCase
): ViewModel() {
    private val _userList = MutableStateFlow<List<UserModel>>(listOf())
    val userList: StateFlow<List<UserModel>> = _userList

    init {
        readUserList()
    }

    fun readUserList() {
        viewModelScope.launch {
            _userList.value = userUseCase.getUserList(1)
        }
    }
}

 

 

그런데 provideUserUseCase() 함수를 작성하지 않았는데 @Inject를 통해 인젝션이 가능하다는 점이 특이합니다. 먼저 ViewModel부터 ReqresService까지의 의존성은 다음과 같습니다.

MainViewModel(
    UserUseCase(
        ReqresService() // <- provideReqresService()를 통해 인젝션 가능
    )
)

결론부터 말하자면, UserUseCase의 생성자에 있는 인자들(reqresService)이 모두 Hilt에 의해 제공되므로 UserUseCase 또한 인젝션할 수 있는 것입니다.

 

 

물론 @Provides를 통해 제공되는 인스턴스를 @Inject로 곧바로 받아볼 수 있는 것은 아닙니다.

우선 아래와 같이 Application을 상속하는 클래스(기존 프로젝트에 해당 클래스가 없다면 위의 코드를 그대로 작성하시면 됩니다.)에 @HiltAndroidApp을 붙여야 합니다.

@HiltAndroidApp
class MainApplication : Application()

 

또한 Hilt를 참조하게 되는 Android 클래스에 @AndroidEntryPoint를 붙여야 합니다.

물론 아래 MainActivity에서 Hilt를 직접 사용하지 않지만, 해당 액티비티에서 쓰이는 ViewModel이 Hilt를 사용하기 때문에 필요합니다. 같은 방식으로, 만약 MainActivity 내부의 FragmentA라고 하는 프래그먼트가 Hilt를 사용하고 있다면 FragmentA 뿐만 아니라, MainActivity에도 해당 어노테이션을 붙여야만 합니다.

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
    	    // ...
        }
    }
}

 

 

ViewModel에서는 @HiltViewModel을 붙여야 합니다.

@HiltViewModel
class MainViewModel @Inject internal constructor(
    private val userUseCase: UserUseCase
): ViewModel() {
    private val _userList = MutableStateFlow<List<UserModel>>(listOf())
    val userList: StateFlow<List<UserModel>> = _userList

    // ...
    
}

MainViewModel에서 Hilt를 사용하기 때문에 그 상위에 있는 MainActivity, 또 최상위에 있는 Application에 Hilt Annotation을 붙이게 된 것입니다.

 

 

마무리

특히 Hilt는 공식 문서를 통해 자세히 들여다볼 것을 권장합니다.

 

https://developer.android.com/training/dependency-injection?hl=ko 

 

Android의 종속 항목 삽입  |  Android 개발자  |  Android Developers

Android의 종속 항목 삽입 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 종속 항목 삽입(DI)은 프로그래밍에 널리 사용되는 기법으로, Android 개발에 적합합니

developer.android.com