본문 바로가기

Android Studio

[Android][Kotlin]MVVM,Hilt,Retrofit2,RecyclerView 활용 예시

728x90

 

 

 

** hanbikan 님의 예시를 참고했다.

 

 

Clean Architecture

Hanbikan 님의 클릭 아키텍처 구조를 따라 예시 앱의 구조를 설계했다.

그럼 이렇게 되려나.. 🤔

 

 

 

1. 라이브러리와 인터넷 권한 추가

Retrofit, Gson, okhttp, Hilt, Compose 라이브러리를 추가한다.

manifests에 인터넷 권한 추가와 네트워크 트래픽을 사용하기 위해 usedClearTextTraffic Flag를 활성화한다.

 

<manifest
	<uses-permission android:name="android.permission.INTERNET"/>
	<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
    
	<application
		...
        android:usesCleartextTraffic="true"
		...
	</application>
    
</manifest>

 

 

2. @HiltAndroidApp 어노테이션 추가

Hilt 셋업에서 필수인 @HiltAndroidApp 어노테이션을 추가한 클래스를 생성한다.

해당 어노테이션을 사용하여 컴파일 타임 시 표준 컴포넌트 빌딩에 필요한 클래스들을 초기화한다.

 

@HiltAndroidApp
class MainApplication : Application()

 

 

manifests에 MainApplication 클래스 name을 등록한다.

 

<application
	...
    android:name=".MainApplication"
    	...
</application>

 

 

3. DTO, Entity 클래스 생성

서버에서 받아올 데이터를 정의한 data 클래스를 생성한다.

 

data class UserListResponse(
    val page: Int,
    val per_page: Int,
    val total: Int,
    val total_pages: Int,
    val data: List<User>
)

 

data class User(
    val id: Int,
    val email: String,
    val first_name: String,
    val last_name: String,
    val avatar: String
)

 

 

4. Interface 생성

@GET 어노테이션을 사용해 요청할 API 내용을 적어준다.

그리고 UserListResponse로 리턴 받는다.

 

interface NetworkService {
    @GET("api/users")
    suspend fun getUserList(@Query("page") page: Int): UserListResponse
}

 

 

5. Module install 하기

인터페이스나 외부 라이브러리 클래스 사용을 할 때는 @Module 어노테이션을 사용해 Hilt에게 알려줄 Dependency를 제공할 수 있다.

 

Hilt의 기본 규칙은 모든 Module에 @InstallIn 어노테이션을 사용하여 어떤 component에 install 할 지 반드시 정해야 한다고 한다.

(여기서 component는 module과 injection을 묶어주는 역할을 한다.)

 

Hilt에서 기본으로 제공하는 component 중 하나에 @InstallIn 어노테이션을 사용하여 module들을 install 한다.

 

 

이번 예시에서는 SingletonComponent를 사용하였다. (ApplicationComponent가 SingletonComponent로 이름이 변경되었다.)

 

@Provides 어노테이션은 Retrofit, OkhttpClient, Room과 같은 외부 라이브러리에서 제공되는 클래스로 프로젝트 내에서 소유할 수 없는 경우 또는 빌더 패턴으로 인스턴스를 생성해야 하는 경우에 사용한다.

 

@Provides 어노테이션이 달린 함수들의 반환 타입을 보고 Hilt에서 알아서 해당 함수를 찾아 호출하여 객체를 생성해서 넣어준다.

함수의 매개변수는 해당 항목의 종속 항목을 Hilt에게 알려준다.

Hilt는 해당 유형의 인스턴스를 제공해야할 때마다 함수 본문을 실행한다.

따라서 API를 생성해 줄 각 각의 객체 생성 함수에 @Provides 어노테이션을 붙여준다.

 

@Provides 를 붙여서 Retrofit 생성하는 방법을 Hilt에 알리기!

Hilt는 @Provides 가 달린 provideRetrofit()가
▶ 반환 타입인 Retrofit을 제공함을 알 수 있다.
▶ 매개변수인 GsonConverterFactory, OkHttpClient를 통해 provideOkHttpClient(), provideGsonConverterFactory()에 종속되었음을 알 수 있다.
▶ 메소드의 본문은 Retroit을 제공하는 방법을 알려준다.
▶ 따라서 Hilt는 Retrofit이 필요할 때마다 provideRetrofit()를 실행해야 한다.

 

 

@Scope

별다른 scope를 제공하지 않으면 바인딩을 요청할 때마다 Hilt가 바인딩의 새 인스턴스를 제공한다.

scope를 지정하면 특정 component의 생명 주기 동안 동일한 인스턴스를 제공할 수 있다.

이번 예시에서는 @Singleton 어노테이션을 사용하였다.

 

OkHttp Client, Gson Converter, Retrofit, NetworkService를 Activity, Fragment, ViewModel 등에서 injection 받을 수 있도록 NetworkModule 클래스를 작성한다.

 

NetworkModule.kt

더보기
@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(gsonConverterFactory: GsonConverterFactory, okHttpClient: OkHttpClient): Retrofit {
        return Retrofit.Builder()
                        .baseUrl(BASE_URL)
                        .addConverterFactory(gsonConverterFactory)
                        .client(okHttpClient)
                        .build()
    }

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


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

 

 

6. Repository class 생성

(mapping class 가 없다면 바로 viewModel로 순서가 넘어간다.)

 

Repository class에서는 NetworkService에 데이터를 요청하고 Retrofit 통신으로 받아온 user 데이터를 관리한다.

 

Constructor injection(생성자 삽입) : Component를 Dependency graph에 넣는 것

install된 Component들로부터 Dependency를 받기 위해서는 Hilt에 인스턴스를 제공하는 방법을 알려주어야 한다.

인스턴스 제공 방법을 Hilt에게 알리려면 삽입하려는 클래스에 @Inject 어노테이션을 붙여야 한다.

그리고 어노테이션을 붙이려면 constructor 키워드도 필요하다.

 

@Inject 어노테이션을 붙인 constructor 클래스에 인스턴스를 주입한다.

이렇게 하면 Hilt에서 UserRepository 인스턴스 제공 방법을 알게 된다.

 

아래와 같이 UserRepository constructor 클래스에 @Inject 어노테이션을 붙이고 NetworkService 클래스를 매개변수로 넣었다.

그러면 UserRepository에 NetworkService가 종속 항목으로 있게 된다.

 

// 매개변수인 NetworkService 는 UserRepository 의 종속(dependency) 항목이다.
// 따라서 Hilt 는 NetworkService 를 제공하는 방법도 알고 있어야 한다.
class UserRepository @Inject constructor(
    private val networkService: NetworkService
) {
   // page 에 있는 user 데이터를 가지고 온다.
    suspend fun getUserList(page: Int): List<User> {
        return networkService.getUserList(page).data
    }
}

 

 

7. Domain class 생성

서버나 로컬 DB에서 가져온 데이터를 담은 DTO 클래스와 그 데이터를 원하는 형태로 가공하기 위한 Domain 클래스를 나누어 생성한다.

그리고 두 클래스를 mapping 한다.

이렇게 layer를 나누면 API 변경 등으로 DTO 클래스의 구조나 필드가 변경되더라도 실제 Domain 클래스에는 영향을 미치지 않는다. 필요에 따라 중간 Mapper 로직을 변경하면 된다.

(translator 로직을 실습해 볼 수 있어서 좋았다.)

 

7.1. UserModel data class 생성(domain class)

뷰를 위해 재가공된 데이터 클래스이다.

서버에서 가져온 first_name과 last_name을 name 하나로 병합하여 사용하기 위함이다.

 

 

 

7.2. translator class 생성

User 데이터를 어떻게 변환할지 정의한다.

/**
 * 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)
    }
}

 

 

7.3. UseCase class 생성

Repository로 데이터를 읽어온 뒤, Translator를 통해 앱에서 필요한 데이터로 재가공하여 반환해 준다.

 

class GetUserListUseCase @Inject constructor(
    private val userRepository: UserRepository
) {
    // invoke : 이름 없이 간편하게 호출될 수 있는 함수
    // operator(연산자) 키워드를 통해 invoke 함수를 부를 수 있다.
    suspend operator fun invoke(page: Int): List<UserModel> {
        return userRepository.getUserList(page).toUserModelList()
    }
}

 

 

8. ViewModel

 

View에서 참조하게 될 데이터를 담아둘 viewModel 클래스를 생성한다.

Hilt에서 제공하는 ViewModel을 활성화하기 위해 @HiltViewModel 어노테이션을 사용한다.

그러면 @AndroidEntryPoint 어노테이션이 사용된 MainActivity나 Fragment에서 viewModel 인스턴스를 가져올 수 있다.

이렇게!

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

	private val viewModel: MainViewModel by viewModels()
    
}

 

 

MainViewModel.kt

더보기
@HiltViewModel
class MainViewModel @Inject constructor(private val getUserListUserCase: GetUserListUserCase): 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 = getUserListUserCase(1)
        }
    }
}

 

 

9. RecyclerView

9.1. Compose view

Compose view를 위한 컴포저블을 작성한다. @Composable 어노테이션을 사용하면 setContent()에 사용할 함수를 만들 수 있다.

 

UserItemView.kt

더보기
// @Preview 를 사용해서 UI 미리보기를 하려면 매개변수를 사용하지 않아야 한다.
@Composable
fun UserItemView(userModel: UserModel) {
    Card(
        // modifier : Compose 의 구성 요소들을 꾸미거나 행동을 추가하기 위한 요소들의 모임
        modifier = Modifier
            .fillMaxWidth()
            .height(64.dp)
            .padding(16.dp)
    ) {
        Row (
            modifier = Modifier.fillMaxSize(),
            verticalAlignment = Alignment.CenterVertically
        ) {
            val painter = rememberAsyncImagePainter(
                model = ImageRequest.Builder(LocalContext.current)
                    .data(userModel.avatar)
                    .crossfade(true)
                    .build()
            )
            Image(
                painter = painter,
                contentScale = ContentScale.Crop,
                contentDescription = "Picture of a user"
            )
            Text(
                text = userModel.name,
                modifier = Modifier.padding(start = 8.dp)
            )
        }
    }
}

 

 

9.2. UserAdapter class

DiffUtil은 어댑터에서 아이템에 수정이 생겼을 때, 전체 리스트를 갱신하는 것이 아니라 바뀐 아이템에 대해서만 데이터를 바꿔주기 때문에 RecyclerView.Adpater를 사용했을 때보다 효율적인 데이터 갱신이 이루어진다.

DiffUtil을 활용하기 위해 UserAdapter class에서 ListAdapter를 사용했다.

 

UserAdapter.kt

더보기
// DiffUtil 를 활용하기 위해 ListAdapter 사용
class UserAdapter: ListAdapter<UserModel, RecyclerView.ViewHolder>(UserModelDiffCallback()) {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return UserViewHolder(ComposeView(parent.context))
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val user = getItem(position)
        (holder as UserViewHolder).bind(user)
    }

    // viewHolder 를 사용하면, 한 번 생산하여 저장했던 뷰를 다시 findViewById() 를 통해 뷰를 불러올 필요가 사라진다.
    inner class UserViewHolder(composeView: ComposeView): RecyclerView.ViewHolder(composeView) {
        fun bind(userModel: UserModel) {
            ( itemView as ComposeView ).setContent {
                // MdcTheme 함수는  MDC 테마를 자동으로 읽고 사용자를 대신하여 밝은 테마와 어두운 테마를 위해 Material Theme 로 전달한다.
                MdcTheme {
                    UserItemView(userModel = userModel)
                }
            }
        }
    }
}

// DiffUtil 은 어댑터에서 현재 데이터 리스트와 교체될 데이터 리스트를 비교하여 무엇이 바뀌었는 지 알아내는 클래스이다.
// 이를 통해 아이템에 수정이 생겼을 때, 전체 리스트를 갱신하는 것이 아니라 바뀐 아이템에 대해서만 데이터를 바꿔주기 때문에 효율적인 데이터 갱신이 이루어진다.
private class UserModelDiffCallback: DiffUtil.ItemCallback<UserModel>() {
    override fun areItemsTheSame(oldItem: UserModel, newItem: UserModel): Boolean {
        return oldItem.id == newItem.id
    }

    // areItemsTheSame() 가 먼저 실행이 되고 결과가 true 로 반환되었을 경우에 areContentsTheSame() 이 호출된다.
    // 그렇기 때문에 areItemsTheSame() 에서는 id 처럼 아이템을 식별할 수 있는 유니크 값을 비교하고,
    // areContentsTheSame() 에서는 아이템의 내부 정보가 동일한 지 비교한다.
    override fun areContentsTheSame(oldItem: UserModel, newItem: UserModel): Boolean {
        return oldItem == newItem
    }
}

 

 

10. BindingAdapter

10.1. BindingAdapter 생성

BindingAdapter를 사용하여 뷰에서 처리할 adapter의 역할을 분리시켜 주었다.

 

@BindingAdapter 어노테이션 안의 매개변수는 xml에서 사용한다. 이 중 requireAll의 기본 값은 true인데, 이 때는 반드시 모든 값을 연결해줘야 한다.

 

첫 번째 매개변수는 속성과 연결된 뷰의 유형을 결정한다. 두 번째 매개변수는 지정된 속성의 결합 표현식에서 허용되는 유형을 결정한다.

만약 이름 충돌이 발생하면 Android 프레임 워크에서 제공하는 기본 어댑터보다 우선 적용된다.

 

ViewBindingAdapter.kt

// @BindingAdapter 통해 recyclerView 에 adapter 의 연결과
// ListAdapter 의 submitList 를 통해 데이터를 업데이트 할 수 있다.
@BindingAdapter("adapter", "submitList", requireAll = true)
fun bindRecyclerView(view: RecyclerView, adapter: RecyclerView.Adapter<*>, submitList: List<Any>?) {
    view.adapter = adapter.apply {
        (this as ListAdapter<Any, *>).submitList(submitList?.toMutableList())
    }
}

 

 

10.2. xml에 적용

 

activity_main.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="adapter"
            type="com.example.retrofit2ex.ui.adapter.UserAdapter" />
        <variable
            name="viewModel"
            type="com.example.retrofit2ex.ui.presenter.MainViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".ui.MainActivity">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerview_user_list"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
            adapter="@{adapter}"
            submitList="@{viewModel.userList}"
            />

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

 

11. MainActivity

이제 객체를 주입할 대상인 MainActivity에 @AndroidEntryPoint 어노테이션을 추가하여 Hilt를 사용하고 있는 ViewModel을 주입할 수 있도록 한다. @AndroidEntryPoint 어노테이션은 다음의 Android class에도 추가할 수 있다.

  • Activity
  • Fragment
  • View
  • Service
  • BroadcastReceiver

이 중 한 클래스에 @AndroidEntryPoint 어노테이션을 붙이면 이 클래스에 종속된 다른 클래스에도 어노테이션을 붙여주어야 한다.

예를 들어 MainActivity 내부의 ExampleFragment에서 Hilt를 사용하고 있다면 MainActivity에도 어노테이션을 붙여주어야 한다.

 

MainActivity.kt

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private val viewModel: MainViewModel by viewModels()


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main).also {
            // layoutManager 초기화, viewModel 과 adapter 를 dataBinding 으로 넘기고
            // xml 에서 관련 로직을 처리한 덕분에 뷰의 코드가 간략해졌다.
            it.lifecycleOwner = this
            it.viewModel = viewModel
            it.adapter = UserAdapter()
        }
    }
}

 

 

완성

 

 

 

[참고]

 

  • 참고한 예시

https://rccode.tistory.com/296

 

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

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

rccode.tistory.com

 

  • Hilt

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

 

Hilt를 사용한 종속 항목 삽입  |  Android 개발자  |  Android Developers

Hilt를 사용한 종속 항목 삽입 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Hilt는 프로젝트에서 종속 항목 수동 삽입을 실행하는 상용구를 줄이는 Android용

developer.android.com

 

https://hyperconnect.github.io/2020/07/28/android-dagger-hilt.html

 

Dagger Hilt로 안드로이드 의존성 주입 시작하기

Dagger Hilt에 대해 알아보고 안드로이드 프로젝트에 적용하는 방법을 소개합니다.

hyperconnect.github.io

 

  • Binding adapter

https://developer.android.com/topic/libraries/data-binding/binding-adapters?hl=ko

 

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

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

developer.android.com

 

 

 

 

728x90