(포스팅에 사용된 예제는 Github를 통해서도 확인할 수 있습니다)
이전 시간에 작성한 앱에 기능을 추가해보자.
우리가 사용할 API는 카카오 이미지 검색 API 이다.
https://developers.kakao.com/docs/latest/ko/daum-search/dev-guide#search-image
먼저 RestAPI 통신을 위한 인터페이스를 작성하자.
interface Api {
@GET("v2/search/image")
suspend fun getData(
@Query("sort" ) sort : String = "recency",
@Query("size" ) size : Int = 10,
@Query("query") query : String,
@Query("page" ) page : Int
) : Response<DaumSearchResponse>
}
Coroutine을 사용한 비동기 처리를 위해 suspend fun 으로 작성하고 결과값으로는 Response를 반환한다.
이어서 Response로 받을 data class를 작성하자.
data class DaumSearchResponse(
@SerializedName("meta" ) val meta : DaumSearchMeta,
@SerializedName("documents") val documents : List<DaumSearchDocument>
)
data class DaumSearchMeta(
@SerializedName("total_count" ) val totalCount : Int,
@SerializedName("pageable_count") val pageableCount : Int,
@SerializedName("is_end" ) val isEnd : Boolean,
)
data class DaumSearchDocument(
//비디오 검색 결과
@SerializedName("thumbnail") val thumbnail : String? = null,
@SerializedName("url" ) val url : String? = null,
@SerializedName("author" ) val author : String? = null,
//이미지 검색 결과
@SerializedName("thumbnail_url" ) val thumbnailUrl : String? = null,
@SerializedName("image_url" ) val imageUrl : String? = null,
@SerializedName("display_sitename") val displaySiteName : String? = null,
@SerializedName("doc_url" ) val docUrl : String? = null,
//공통
@SerializedName("datetime" ) val datetime : String = "",
) {
//author 필드가 null이 아니면 비디오 검색 결과
fun isVideo() : Boolean {
return author != null
}
fun getSearchItem() : SearchItem {
return if (isVideo()) {
SearchItem(thumbnail!!,author!!,datetime)
}
else {
SearchItem(thumbnailUrl!!,displaySiteName!!,datetime)
}
}
}
data class SearchItem(
val thumbnail : String,
val author : String,
val dateTime : String
)
이후에 비디오 검색 결과의 썸네일 정보도 함께 사용할 예정이라 미리 구현해두었다.
이제 통신 코드를 작성해보자.
통신은 Retrofit2를 사용하여 구현하였으며 먼저 Hilt를 통한 DI을 적용하지 않았을 경우의 코드이다.
class Repository {
private val api : API
private val baseUrl = "https://dapi.kakao.com/"
private val kakaoAK = "YOUR_ACCESS_KEY"
init {
val client = OkHttpClient.Builder()
.connectTimeout(1, TimeUnit.MINUTES)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.addInterceptor { chain ->
val requestBuilder = chain.request().newBuilder()
//카카오 RestAPI 키를 공통 헤더로써 호출에 적용
requestBuilder.header("Authorization", "KakaoAK $kakaoAK")
chain.proceed(requestBuilder.build())
}
.build()
api = Retrofit.Builder()
.client(client)
.baseUrl(baseUrl)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(API::class.java)
}
private fun<T> returnResult(response: Response<T>) : Result<T> {
return try {
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
Result.Success(body)
}
else {
Result.Error(Exception("body is null"))
}
}
else {
Result.Error(Exception("${response.code()} : ${response.errorBody()?.string()}"))
}
} catch (e : Exception) {
Result.Error(e)
}
}
suspend fun getData(query: String, page: Int = 1) : Result<DaumSearchResponse> {
return returnResult(api.getData(query = query, page = page))
}
}
init 을 살펴보면
API 인터페이스를 객체화를 위해 Retrofit.Builder()를 사용하여 Retrofit 객체를 생성하고
Retrofit 객체화를 위해 OkHttpClient.Builder()를 사용하여 OkHttpClient 객체를 생성하고있다.
지금은 Repository라는 통신 클래스 하나만을 사용하고 있지만
추후에 Retrofit을 사용하는 통신 클래스가 늘어나게 된다면
클래스마다 API 객체와 Retrofit 객체, OkHttpClient 객체를 생성하는 코드를 작성해야하는 상황이 생길것이다.
이런 상황을 해결하기 위해 사용하는것이 Hilt이다.
Hilt에 대한 개념적인 설명은 앞서 작성한 포스팅(https://eitu97.tistory.com/83) 을 확인해주길 바란다.
Hilt를 프로젝트에서 사용하기 위한 세팅을 해보자.
먼저 프로젝트 수준의 gradle에 플러그인을 추가한다.
plugins {
...
id 'com.google.dagger.hilt.android' version '2.44' apply false
}
그 후 앱 수준의 gradle에 다음과 같이 작성한다.
...
plugins {
id 'kotlin-kapt'
id 'com.google.dagger.hilt.android'
}
android {
...
}
dependencies {
implementation "com.google.dagger:hilt-android:2.44"
kapt "com.google.dagger:hilt-compiler:2.44"
}
// Allow references to generated code
kapt {
correctErrorTypes true
}
다음은 Application을 작성한다.
@HiltAndroidApp
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
}
}
Hilt를 사용하는 앱은 반드시 @HiltAndroidApp Annotation을 갖는 Application을 포함해야한다.
여기까지 진행했으면 이제 프로젝트에서 Hilt를 사용할 준비가 끝났다.
이제 실제로 Hilt를 사용하여 코드를 작성해보자.
먼저 앞서 작성한 통신 코드를 Hilt를 사용하여 모듈화 할것이다.
(Hilt는 기본적으로 외부 라이브러리와 인터페이스는 DI 할 수 없기 때문에
Retrofit과 같은 외부 라이브러리를 DI 하려는 경우 반드시 모듈화가 필요하다)
@dagger.Module
@InstallIn(SingletonComponent::class)
class ApiModule {
@Provides
fun provideBaseUrl() = "https://dapi.kakao.com/"
@Provides
fun provideKakaoAK() = "YOUR_ACCESS_KEY"
@Singleton
@Provides
fun provideOkHttpClient() = OkHttpClient.Builder()
.connectTimeout(1, TimeUnit.MINUTES)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.addInterceptor { chain ->
val requestBuilder = chain.request().newBuilder()
//카카오 RestAPI 키를 공통 헤더로써 호출에 적용
requestBuilder.header("Authorization", "KakaoAK ${provideKakaoAK()}")
chain.proceed(requestBuilder.build())
}
.build()
@Singleton
@Provides
fun provideRetrofit(client: OkHttpClient) : Retrofit {
return Retrofit.Builder()
.client(client)
.baseUrl(provideBaseUrl())
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Singleton
@Provides
fun provideAPI(retrofit: Retrofit) : Api {
return retrofit.create(Api::class.java)
}
@Singleton
@Provides
fun provideRepository(api : Api) : Repository {
return Repository(api)
}
}
@Provides Annotation을 통해 Hilt에게 객체들을 어떻게 생성하여 의존성을 주입할지에 대해 알려주고 있는 것이다.
이를 통해 Hilt를 사용하여 외부 라이브러리를 DI 할 수 있게 되었다.
이제 API를 주입받아 사용하는 Repository 클래스를 살펴보자.
//@Inject Annotation을 통해 api는 Hilt를 통해 DI된 객체라는것을 알려준다
class Repository @Inject constructor(private val api : Api) {
private fun<T> returnResult(response: Response<T>) : Result<T> {
return try {
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
Result.Success(body)
}
else {
Result.Error(Exception("body is null"))
}
}
else {
Result.Error(Exception("${response.code()} : ${response.errorBody()?.string()}"))
}
} catch (e : Exception) {
Result.Error(e)
}
}
suspend fun getData(query: String, page: Int) : Result<DaumSearchResponse> {
return returnResult(api.getData(query = query, page = page))
}
}
sealed class Result<out T> {
data class Success<out T>(val data : T) : Result<T>()
data class Error(val exception : Exception) : Result<Nothing>()
}
앞서 작성하였던 코드와 다른점이 있다면
API를 객체화 하는 모든 단계를 Hilt를 통한 DI로 대체되어 사용하기 때문에 코드가 굉장히 간결해졌고
만약 같은 API를 사용하는 통신 클래스를 추가로 작성하게 되었을 때
API를 객체화 하는 과정에서 발생하는 유지보수를 모듈에서 전부 해결할 수 있다는것이다.
이제 ViewModel에서 Repository를 주입받아 사용하는 코드를 작성해보자.
//ViewModel에서 Hilt를 사용하는 경우 @HiltViewModel Annotation을 반드시 붙여야한다
@HiltViewModel
class MyViewModel @Inject constructor(private val repository: Repository) : ViewModel() {
val query = MutableLiveData("")
val searchResult = MutableLiveData<List<DaumSearchDocument>>(emptyList())
fun getData() {
//TODO - query를 검색어로 이미지 검색을 실행
val queryString : String? = query.value
if (queryString != null) {
viewModelScope.launch {
val response = withContext(Dispatchers.IO) {
repository.getData(queryString, 1)
}
when(response) {
is Result.Success -> {
searchResult.value = response.data.documents
}
is Result.Error -> {
response.exception.printStackTrace()
}
else -> {
}
}
}
}
}
}
Repository 객체를 주입받아 통신을 실행할 수 있도록 하였다.
viewModelScope에서 suspend fun인 getData()를 IO Dispatcher에서 호출하여
Result를 반환받는다.
Result는 Success와 Error로 구분되는 sealed class로 작성되었으며
Success는 Response.body()를 포함하고 있고 Error는 Exception 을 포함하고있다.
Success인 경우 MutableLiveData로 선언한 searchResult의 value를 갱신해준다.
이로써 통신을 위한 준비가 모두 끝났다.
아래에 MainActivity와 RecyclerView.Adapter와 xml 코드를 첨부해둘테니 어떻게 사용하였는지 확인하기바란다.
//Activity나 Fragment의 경우 Hilt를 사용할 때 @AndroidEntryPoint Annotation을 포함해야한다.
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
...
private val adapterSearch = AdapterSearch()
override fun onCreate(savedInstanceState: Bundle?) {
...
binding.searchResult.apply {
layoutManager = LinearLayoutManager(context)
adapter = adapterSearch
}
myViewModel.searchResult.observe(this) {
adapterSearch.resetList(it)
}
}
}
class AdapterSearch : RecyclerView.Adapter<AdapterSearch.Holder>() {
val list = ArrayList<DaumSearchDocument>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
val inflater = LayoutInflater.from(parent.context)
val binding = ItemRecyclerSearchBinding.inflate(inflater, parent, false)
return Holder(binding)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
holder.setContent(list[position])
}
override fun getItemCount(): Int {
return list.size
}
@SuppressLint("NotifyDataSetChanged")
fun resetList(list: List<DaumSearchDocument>) {
this.list.apply {
clear()
addAll(list)
}
notifyDataSetChanged()
}
inner class Holder(private val binding: ItemRecyclerSearchBinding) : RecyclerView.ViewHolder(binding.root) {
init {
binding.root.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
}
fun setContent(item: DaumSearchDocument) {
val item = item.getSearchItem()
binding.item = item
Glide.with(binding.root).load(item.thumbnail).into(binding.image)
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="item"
type="com.eitu.viewmodelexample.SearchItem" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/image"
android:layout_width="100dp"
android:layout_height="0dp"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:contentDescription="Image" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="0dp"
android:layout_height="match_parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toRightOf="@id/image"
app:layout_constraintBottom_toBottomOf="parent">
<TextView
android:id="@+id/author"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toTopOf="@id/dateTime"
android:text="@{item.author}"/>
<TextView
android:id="@+id/dateTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/author"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:text="@{item.dateTime}"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
참고하면 좋은 문서
Android Hilt
https://developer.android.com/training/dependency-injection/hilt-android?hl=ko
'Android(Kotlin)' 카테고리의 다른 글
CoordinatorLayout(xml) + LazyColumn(Compose) (0) | 2023.09.07 |
---|---|
자주쓰는 라이브러리 implementation 정리 (그때 그때 추가 예정) (0) | 2023.08.11 |
심기일전 코틀린! 04. Hilt (Dependency Injection) (0) | 2023.07.26 |
심기일전 코틀린! - 앱을 만들면서 AAC 이해하기 (ViewModel, DataBinding, LiveData) (0) | 2023.07.26 |
심기일전 코틀린! - 03. AAC (Android Architecture Components) (0) | 2023.07.20 |