본문 바로가기

Android

🔍 Data Binding with Retrofit (Kakao 웹문서 검색 API)

# Databinding

앱의 데이터 소스와 레이아웃의 구성요소를 결합할 수 있게 해주는 Android JetPack의 라이브러리 중 하나.

Class에서 view들을 정의해서 사용하지 않아도 되고, Data를 View에 연결시켜 두면 Data가 변할 때 따로 세팅해주지 않아도 변경되게 할 수 있다.

다음 검색 api를 이용하여 웹문서를 검색하는 방법으로 실습을 진행해보자!
https://developers.kakao.com/docs/latest/ko/daum-search/dev-guide

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

1. build.gradle 설정

데이터 바인딩 설정은 build.gradle 파일 내 buildFeatrues에서 설정해준다.
dependencies에 lifecycle과 retrofit 라이브러리를 추가해준다.

buildFeatures {  
  dataBinding true  
}

dependencies {
    def lifecycle_version = "2.2.0"
    // https://github.com/square/retrofit  
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'  
    implementation 'com.squareup.retrofit2:retrofit-mock:2.9.0'  
    implementation 'com.google.code.gson:gson:2.8.6'  
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'  

    //lifecycle  
    implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'  
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"  
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"  
    kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version"
}

 

2. fragment_search.xml

기존의 레이아웃과 달리 루트태그를 으로 설정하여 전체 레이아웃파일을 감싸준다.
태그는 하나의 레이아웃만 자식뷰로 가질 수 있으며, 두 개 이상의 레이아웃을 자식뷰로 선언할 경우 다음과 같은 오류가 발생한다.

data binding error **msg:Only one layout element** and one data
element are allowed.

fragment_search.xml 파일에 검색어를 입력할 EditText, 검색버튼으로 사용할 ImageView, 검색 결과를 받아올 RecyclerView를 추가해주었다.

<layout xmlns:android="http://schemas.android.com/apk/res/android"  
  xmlns:tools="http://schemas.android.com/tools"  
  xmlns:app="http://schemas.android.com/apk/res-auto">  

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

 <ImageView  android:id="@+id/button_friends"  
  android:layout_width="wrap_content"  
  android:layout_height="wrap_content"  
  android:layout_marginTop="16dp"  
  android:layout_marginEnd="28dp"  
  android:layout_marginRight="28dp"  
  android:background="@drawable/ic_people_selected"  
  app:layout_constraintEnd_toEndOf="parent"  
  app:layout_constraintTop_toTopOf="parent" />  

 <EditText  android:id="@+id/edittext_search"  
  android:layout_width="0dp"  
  android:layout_height="wrap_content"  
  android:hint="search"  
  app:layout_constraintBottom_toBottomOf="parent"  
  app:layout_constraintEnd_toEndOf="parent"  
  app:layout_constraintHorizontal_bias="0.512"  
  app:layout_constraintStart_toStartOf="parent"  
  app:layout_constraintTop_toTopOf="parent"  
  app:layout_constraintVertical_bias="0.16"  
  app:layout_constraintWidth_percent="0.9" />  

 <ImageView  android:id="@+id/button_search"  
  android:layout_width="0dp"  
  android:layout_height="0dp"  
  android:background="@drawable/ic_search_selected"  
  app:layout_constraintDimensionRatio="1:1"  
  app:layout_constraintTop_toTopOf="@id/edittext_search"  
  app:layout_constraintBottom_toBottomOf="@id/edittext_search"  
  app:layout_constraintEnd_toEndOf="@id/edittext_search"/>  

 <androidx.recyclerview.widget.RecyclerView  
  android:id="@+id/recyclerview_search"  
  android:layout_width="match_parent"  
  android:layout_height="wrap_content"  
  android:layout_marginTop="20dp"  
  app:layout_constraintEnd_toEndOf="parent"  
  app:layout_constraintStart_toStartOf="parent"  
  app:layout_constraintTop_toBottomOf="@id/edittext_search"  
  tools:listitem="@layout/item_search_result"  
  app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"  
  android:orientation="vertical"  
  android:paddingHorizontal="16dp"/>  

 </androidx.constraintlayout.widget.ConstraintLayout>
 </layout>

 

2. SearchFragment.kt 세팅

프래그먼트에서 바인딩을 사용하는 방법은 액티비티에서 사용하는 방법과 약간 다르다.

class SearchFragment : Fragment() {  
    private lateinit var binding: FragmentSearchBinding 

    override fun onCreateView(  
        inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?  ): View? {  
        binding = FragmentSearchBinding.inflate(inflater, container, false)  
        return binding.root  
    }

     // binding은 2가지 방법으로 사용할 수 있다.
     // 1. binding = FragmentSearchBinding.inflate(inflater, container, false)  
     // 2. binding = DataBindingUtil.inflate(inflater, R.layout.fragment_search, container, false)

Activity에서 DataBinding 사용하는 방법

  1. binding = ActivityMainBinding.inflate(LayoutInflater.from(this))
  2. binding = DataBindingUtil.setContentView(this, R.layout.activity_main)

 

3. Data Class 만들기

Daum 웹문서의 검색요청 결과(Response)는 다음과 같은 형식으로 받아오게 된다.

다음과 같은 형식으로 Response Data Class를 만들어준다.
(Documents데이터 클래스에 붙어있는 어노테이션들은 Room 라이브러리를 이용하여 로컬에 데이터를 저장하기 위해서 쓴 것이니 무시해도 된다. id 변수도 안써도 됨.)

data class SearchResponse(  
    val meta: Meta,  
    val documents: MutableList<Documents>  
) {  
    data class Meta(  
        val total_count: Int,  
         val pageable_count: Int,  
         val is_end: Boolean  
    )  
    @Entity(tableName = "search_table")  
    data class Documents(  
        @PrimaryKey(autoGenerate = true)  
        var id: Int? = null,  
        @ColumnInfo(name = "datetime")  
        val datetime: String,  
        @ColumnInfo(name = "contents")  
        val contents: String,  
        @ColumnInfo(name = "title")  
        val title: String,  
        @ColumnInfo(name = "url")  
        val url: String  
    )  
}

 

4. item_search.xml

RecyclerView에 들어갈 item을 만들어 준다. itme_search 또한 태그로 레이아웃 파일 전체를 감싸준다. 이후 태그를 사용하여 그안에 태그로 변수를 선언해준다.
Response로 넘어온 데이터 중에서 Documets의 Title, Contents, DateTime만 아이템으로 띄워줄 예정이기 때문에 다음과 같이 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="data"
              type="com.example.recyclerviewdatabinding.search.SearchResponse.Documents" />  
     </data>  
     <androidx.constraintlayout.widget.ConstraintLayout 
         android:id="@+id/constraintlayout_item"  
          android:layout_width="match_parent"  
          android:layout_height="wrap_content"  
          android:background="@drawable/box_search_result"  
          android:paddingBottom="20dp">  

         <TextView  android:id="@+id/textview_title"  
              android:layout_width="0dp"  
              android:layout_height="wrap_content"  
              android:textSize="25sp"  
              android:text="@{data.title}"  
              app:layout_constraintTop_toTopOf="parent"  
              app:layout_constraintStart_toStartOf="parent"  
              app:layout_constraintEnd_toEndOf="parent"  
              tools:text="Title"/>  

        <TextView  android:id="@+id/textview_contents"  
              android:layout_width="match_parent"  
              android:layout_height="0dp"  
              android:textSize="18sp"  
              android:text="@{data.contents}"  
              android:layout_marginTop="10dp"  
              app:layout_constraintTop_toBottomOf="@id/textview_title"  
              app:layout_constraintStart_toStartOf="@id/textview_title"  
              tools:text="Contents"/>  

       <TextView  
              android:id="@+id/textview_datetime"  
              android:layout_width="match_parent"  
              android:layout_height="0dp"  
              android:layout_marginTop="10dp"  
              android:textSize="13sp"  
              android:text="@{data.datetime}"  
              android:inputType="datetime"  
              app:layout_constraintTop_toBottomOf="@id/textview_contents"  
              app:layout_constraintStart_toStartOf="@id/textview_contents"  
              tools:text="Date Time"/>  
     </androidx.constraintlayout.widget.ConstraintLayout>  
</layout>

 

5. API 인터페이스 구현

interface RetrofitInterface {  
    @GET("/v2/search/web")  
    fun searchInterface(@Header("Authorization") token: String, @Query("query") title: String): Call<SearchResponse>  
}

 

6. Retrofit Service생성

object RetrofitClient {  
    private val BASE_URL = "https://dapi.kakao.com"  

    private fun getInstance(): Retrofit {  
        return Retrofit.Builder()  
            .baseUrl(BASE_URL)  
            .addConverterFactory(GsonConverterFactory.create())  
            .build()  
    }  

    val retrofitInterface = getInstance().create(RetrofitInterface::class.java)  
}

 

7. ViewModel 생성

아래의 코드에서는 Room라이브러리를 이용하기 위하여 AndroidViewModel로 만들어주었지만 그냥 파라미터가 없는 ViewModel()로 만들어주어도 된다.

AndroidViewModel은 Application을 상속받기 때문에 Memory Leak이 발생할 수 있다.
때문에 context작업이 필요한 경우가 아니라면 ViewModel() 사용을 권장한다.

ViewModel 안에 LiveData가 있는 모습이다. 객체의 값이 변경될 경우에는 MutableLiveData로 선언해준다.

class SearchViewModel(application: Application) : AndroidViewModel(application) {  

private val _docsList = MutableLiveData<ArrayList<SearchResponse.Documents>>()  
val docsList: LiveData<ArrayList<SearchResponse.Documents>>  
    get() = _docsList  

  fun changeDocsList(docsList: ArrayList<SearchResponse.Documents>){  
    _docsList.value = docsList  
  }  

  fun searchDocs(searchWord: String) {  
    var searchList = mutableListOf<SearchResponse.Documents>()  
    val token = "KakaoAK bccddec2477515123ee06ae249c39f95"  
    val call: Call<SearchResponse> = RetrofitClient.
    retrofitInterface.searchInterface(  
        token,  
        searchWord  
    )  

    call.enqueue(object: Callback<SearchResponse>{  
        override fun onResponse(  
            call: Call<SearchResponse>,  
            response: Response<SearchResponse>  
        ) {  
            response.takeIf { it.isSuccessful }  
                  ?.body()  
                  ?.let {  
                      searchList = it.documents  
                      changeDocsList(searchList as ArrayList<SearchResponse.Documents>)  
                  } ?: Log.d("error: ", "${response.errorBody()}")  
        }  

        override fun onFailure(call: Call<SearchResponse>, t: Throwable) {  
            Log.d("error: ", t.toString())  
        }  
    })  
  }  
}

 

8. RecyclerView Adapter 생성

아이템 클릭시 문서 url로 이동하도록 설정하기 위해 context를 파라미터로 넣어주었다.
ViewHolder를 inner class로 생성해주고 그 안에서 position값을 갖고 있는 onBind함수를 만들어준다.
data binding 라이브러리는 모듈 패키지에 BR이라는 클래스를 생성한다.
이 클래스에는 데이터 바인딩에 사용된 Resource의 Id가 포함되어 있다.
때문에 간편하게 데이터들을 리소스와 바인딩 시켜줄 수 있다.

class SearchAdapter(private val context: Context): RecyclerView.Adapter<SearchAdapter.SearchViewHolder>() {  
    var datas = mutableListOf<SearchResponse.Documents>()  

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchViewHolder {  
        val binding = ItemSearchResultBinding.inflate(LayoutInflater.from(parent.context), parent, false)  
        return SearchViewHolder(binding)  
    }  

    override fun onBindViewHolder(holder: SearchViewHolder, position: Int) {  
        holder.onBind(position)  

        holder.binding.constraintlayoutItem.setOnClickListener{  
            val openUrl = Intent(Intent.ACTION_VIEW, Uri.parse(datas[position].url))  
            ContextCompat.startActivity(context,openUrl, null)  
        }  
    }  

    override fun getItemCount() = datas.size  

       inner class SearchViewHolder(var binding: ItemSearchResultBinding): RecyclerView.ViewHolder(binding.root){  
        fun onBind(position: Int) {  
            binding.setVariable(BR.data, datas[position])  
        }  
    }  
}

 

9. Search_Fragment에 ViewModel과 Adapter를 연결

private lateinit var viewModel: SearchViewModel

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {  
    super.onViewCreated(view, savedInstanceState)  
    binding.lifecycleOwner = viewLifecycleOwner  
    viewModel = ViewModelProvider(requireActivity()).get(SearchViewModel::class.java)  
    initRecyclerView(view)  
    searchDocs()  
}

 

ViewModel을 선언하는 방법은 사용목적에 따라 다양하다.
초기화 코드에 아무것도 필요하지 않으면 아래와 같이 간단하게 사용할 수 있다.

//NewInstanceFactory 사용법 (안드로이드에서 기본적으로제공해주는 Factory Class)
private lateinit var viewModel: SearchViewModel
viewModel = ViewModelProvider(this, ViewModelProvider.NewInstanceFactory()).get(SearchViewModel::class.java)

//androidx.fragment:fragment-ktx 사용시
private val viewModel: SearchViewModel by viewModels() 

 

파라미터가 있는 ViewModel객체의 인스턴스를 만들고 싶다면 ViewModelProvider.Factory를 구현하면 된다.

class HasParamViewModelFactory(private val param: String) : ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        return if (modelClass.isAssignableFrom(HasParamViewModel::class.java)) {
            HasParamViewModel(param) as T
        } else {
            throw IllegalArgumentException()
          }
    }
}

 

사용방법

class MainActivity : AppCompatActivity() {
    private lateinit var hasParamViewModel: HasParamViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val sampleParam = "Ready Story"

        hasParamViewModel = ViewModelProvider(this, HasParamViewModelFactory(sampleParam))
            .get(HasParamViewModel::class.java)
    }
}

 

Adapter를 연결

fun initRecyclerView(view: View) {  
    adapter = SearchAdapter(view.context)  
    binding.recyclerviewSearch.adapter = adapter  
    binding.recyclerviewSearch.addItemDecoration(VerticalItemDecoration(10))   
}

 

검색 버튼을 눌렀을 때 ViewModel의 _docsList의 value를 변경시켜준다.
view model의 docsList가 변경되면 adapter의 data를 변경시켜준다.

fun searchDocs() {  
    binding.buttonSearch.setOnClickListener {  
      viewModel.searchDocs(binding.edittextSearch.text.toString())  
      viewModel.docsList.observe(viewLifecycleOwner, Observer {  
      adapter.datas = it  
      adapter.notifyDataSetChanged()  
      })  
    }  
}