[TIL] Android 앱 개발 심화 : 이미지 검색 어플 구현하기 2

지혜·2024년 1월 26일

Android_TIL

목록 보기
47/70

✏240125 목요일 TIL(Today I learned) 오늘 배운 것

📖Retrofit을 활용한 RecyclerView 구현

  • 안 써본 ListAdapter를 사용해보려고 도전했으나,, 시간 문제와 굳이 데이터 추가와 삭제가 필요한 과제가 아니라고 생각해서 그냥 RecyclerView.Adapter를 사용했다.
  • 여기까지 구현한 코드 전문은 깃허브 Android_ch3_MyImgSearch의 onlySearch 브런치 참조

[ImageDTO.kt]

import com.google.gson.annotations.SerializedName

//내가 사용할 데이터들. 필요한 데이터만 가지고 왔다.
data class KakaoImageData (
    @SerializedName("thumbnail_url")
    val thumbnailUrl: String,
    @SerializedName("display_sitename")
    val siteName: String,
    val datetime: String
)

//kakaoAPI에서 documents는 응답결과를 반환해준다.
data class ImageResponse(
    val documents: List<KakaoImageData>
)
  • [트러블슈팅] 별거 아니라면 별거 아닌 트러블 슈팅인데, datetime을 dateTime으로 적었다가 들어오는 데이터값이 전부 null 이었던 적이 있다. @SerializedName을 사용한 변수를 고치면서 순간적으로 같이 고쳤나본데, datetime은 SerializedName을 안걸어줬기 때문에 꼭 카카오에서 제공하는 변수명을 사용해야 데이터가 제대로 들어온다. 값을 제대로 datetime으로 맞춰주고 해결했다.ㅎ

[SearchAdapter.kt]

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.myimgsearch.databinding.LayoutItemBinding
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter

class SearchAdapter(val kakaoItemData : MutableList<KakaoImageData>) : RecyclerView.Adapter<SearchAdapter.Holder>() {
    inner class Holder(binding : LayoutItemBinding) : RecyclerView.ViewHolder(binding.root) {
        val ivItemImg = binding.ivItemImg
        val tvSiteName = binding.tvSiteName
        val tvdateTime = binding.tvDateTime

        fun bind(data : KakaoImageData) {
            Glide.with(itemView.context).load(data.thumbnailUrl).into(ivItemImg)
            tvSiteName.text = data.siteName
            tvdateTime.text = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").format(OffsetDateTime.parse(data.datetime))
        }

    }

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

    override fun getItemCount(): Int = kakaoItemData.size

    override fun onBindViewHolder(holder: Holder, position: Int) {
        val item = kakaoItemData[position]
        holder.bind(item)
    }

}
  • [트러블슈팅] 수준별 수업 때 튜터님 코드를 많이 참고하고 있었는데, Glide를 통해 이미지를 바인딩하고 있었다. 검색해보니까 이미지 로딩 프레임워크로 빠르고 유능해서 많이 사용한다고 하길래 나도 적용해 보기로 했다.
    => Glide를 사용하기 위해서는 build.gradle(module)에 의존성을 추가해줘야한다.

      implementation ("com.github.bumptech.glide:glide:4.12.0")
       annotationProcessor ("com.github.bumptech.glide:compiler:4.12.0")

    의존성을 추가해 준 후, Gradle을 임포트하면, .load()를 사용하여 이미지 정보를 가지고 있는 개개체를 파라미터로 받을 수 있고, into()를 사용하여 이미지를 넣을 파라미터를 설정할 수 있다.
    +이외에도 load()가 반환한 이미지처리에 로딩중,에러,null일 때 보여줄 수 있는 이미지를 설정할 수 있다.([Android | Kotlin] Glide 참조)

  • [트러블슈팅] 날짜와 시간이 "yyyy-MM-dd HH:mm:ss” 포멧으로 나오는 것이 요구 사항 이였다. 날짜 포맷을 검색해서 맞춰보려고 했는데, LocalDateTime을 활용하는 방법이 있길래 적용했다가 오류를 맞고 결국 AI의 도움을 받아서 공식을 완성했다.
    => 찾아보니까 LocalDateTime은 시간대 정보를 포함하지 않고 OffsetDateTime는 포함하는 차이가 있다고 하는데, 카카오에서 제공하는 datetime의 형식이 시간대정보를 포함하고 있기 때문에 OffsetDateTime을 사용하는게 맞는 것 같다.

[KakaoApi.kt] & [RetrofitInstance.kt]

import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Query

interface KakaoApi {
    @GET("v2/search/image")
    fun getImgData(
        @Header("Authorization") apiKey: String = "KakaoAK ${BuildConfig.KAKAOAPI}",
        @Query("query") query: String
    ) : Call<ImageResponse>

}
  • kakao 이미지 검색하기에서 Header의 Authorization와, 쿼리파라미터에서 query가 필수사용이길래 2개를 걸어줬다. Header의 경우는 여기서 처리하지않고, RetrofitInstance에서 .addHeader로 걸어주는 방법이 있던데, 내가 사용하는 RetrofitInstance에서는 적용하기가 어지러워서 여기서 처리하도록 만들었다 ... .... .

  • : Call<ImageResponse>ImageDTO에 있는 ImageResponse 데이터 클래스를 참조하도록 작성한다. 참고로 비동기식 요청을 처리할 때에는 Call, 동기식 요청을 처리할 때에는 Response를 사용한다.

  • [트러블슈팅] @Header에서 apikey값을 그냥 @Header("Authorization") apiKey: String = BuildConfig.KAKAOAPI 라고만 적었더니, AccessDeniedError","message":"cannot find Authorization : KakaoAK header" 오류가 발생했다.
    => 알고보니까 카카오API에서는 KakaoAK ${REST_API_KEY}라고 저 KakaoAK (한칸띄어쓰기필수)를 포함하는 값만을 키값으로 인식한다고 했다. 맞는 모양으로 고쳐줬다..

import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit

object RetrofitInstance {

    private const val BASE_URL = "https://dapi.kakao.com"

    private fun createOkHttpClient(): OkHttpClient {
        val interceptor = HttpLoggingInterceptor()

        if (BuildConfig.DEBUG)
            interceptor.level = HttpLoggingInterceptor.Level.BODY
        else
            interceptor.level = HttpLoggingInterceptor.Level.NONE

        return OkHttpClient.Builder()
            .connectTimeout(20, TimeUnit.SECONDS)
            .readTimeout(20, TimeUnit.SECONDS)
            .writeTimeout(20, TimeUnit.SECONDS)
            .addNetworkInterceptor(interceptor)
            .build()
    }

    private val retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .addConverterFactory(GsonConverterFactory.create())
        .client(createOkHttpClient())
        .build()

    val api : KakaoApi = retrofit.create(KakaoApi::class.java)
}
  • 얘네는.. 파일명을 짓는데 고민을 했다. 본강의랑 수준별강의때 이름이 너무 달라서.. 근데 Api는 해당 플랫폼의 영향을 많이 받으니까 KakaoApi로 짓고, RetrofitInstance는 맨날 모양이 똑같다고 해서 RetrofitInstance라고 지었다.. 나중에.. 재활용하려고..ㅎㅎ

[SearchFragment.kt]

import android.content.Context
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.GridLayoutManager
import com.example.myimgsearch.databinding.FragmentSearchBinding
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response


class SearchFragment : Fragment() {

    private var _binding: FragmentSearchBinding? = null
    private val binding get() = _binding!!

    private lateinit var adapter: SearchAdapter
    private val dataList = mutableListOf<KakaoImageData>()

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

        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        adapter = SearchAdapter(dataList)
        binding.rvImgData.adapter = adapter
        binding.rvImgData.layoutManager = GridLayoutManager(context, 2)

        binding.btnSearch.setOnClickListener {
            val searchWord = binding.etSearch.text.toString()
            getSearchImg(searchWord)
            hideKeyboard()
        }
    }

    private fun getSearchImg(searchWord : String) {
       RetrofitInstance.api.getImgData(query = searchWord).enqueue(object : Callback<ImageResponse>{
           override fun onResponse(call: Call<ImageResponse>, response: Response<ImageResponse>) {
               val body = response.body()
               
               //dataList.addAll(body.documents)를 null체크한 모습
               body?.let { //it:ImageResponse
               dataList.addAll(it.documents) 
               }
               adapter.notifyDataSetChanged()
               Log.d("api검사", "$dataList")
           }

           override fun onFailure(call: Call<ImageResponse>, t: Throwable) {
               Log.d("api검사","네트워크오류/데이터변환오류.")
           }

       })
    }

    private fun hideKeyboard() {
        val inputMethodManager = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
        inputMethodManager.hideSoftInputFromWindow(view?.windowToken,0)
    }


    override fun onDestroy() {
        super.onDestroy()
        _binding = null
    }


}
  • GridLayoutManager(context, 2) : 요구하는 레이아웃모양이 그리드모양이라 처음 사용해봤다. 딱히 사용법이 렵지는 않아서 뚝딱 걸어줬다. context 옆에 원하는 열 수 만큼 숫자를 걸어주면 된다.

  • [트러블슈팅] getSearchImg()메서드는 만드는 과정 자체가 트러블 슈팅이었다. 저번에 강의를 보고 공부할 때 레트로핏 응답과 요청을 분리해서 생각해버려서.. 하나의 메서드로 만드는 것까지 하루를 꼬박 투자했다... ... .

    => [비동기식요청] RetrofitInstance에 있는 api는 KakaoApi를 참조하기 때문에, api를 통해 getImgData()를 가져온다. 이때! Header에 해당하는 apiKey는 직접 기본값을 입력해줬기 때문에, query 값에 대해서만 query=searchWord로 설정해준다. 만약 키값을 프래그먼트에서 직접 적용하고 싶다면 여기서도 "KakaoAK ${BuildConfig.KAKAOAPI}"라고 원하는 모양을 정확히 입력해줄 수 있도록 한다. (->getImgData("KakaoAK ${BuildConfig.KAKAOAPI}", searchWord))
    그리고 여기서는 Call의 enqueue()를 사용하여 비동기식 요청을 한다. 콜백을 사용하여 백그라운드에서 실행되고, 정상적으로 응답이 오면 onResponse가 오류가 생기면 onFaliure가 호출된다. 해당 메서드안에 응답객체를 사용한 처리를 작성하면 된다.
    +검색결과를 최대 80개까지 표시하도록하는 요구가 있었는데, 카카오 이미지 검색 restAPI의 기본사이즈 값이 80이여서 80개가 제대로 나오는지 확인만 했다. 잘 나와서 확인로그는 삭제했다.

    =>[응답객체 사용하기] onResponse에 response 객체를 사용하여 HTTP응답을 통한 정보에 접근한다. 원래는 onResponse안에서 한번 더 확인하기 위해 if (response.isSuccessful) {}를 사용하지만, 지금은 사용하지 않았다. 여기서 response.body()는 요청사항 그대로 KakaoApi에서 선언한 getImgData의 반환 값인 ImageResponse이다. 나는 가져온 값들을 dataList에 addAll로 담아주었다. 이렇게 데이터가 담기면, 리사이클러뷰가 바로 알아차릴 수 있도록! add해주자마자 그 밑에 adapter.notifyDataSetChanged()를 통해서 바로! 변경사항을 전달해주어야한다.

  • 검색 버튼을 누르면 키보드는 숨김 처리 되도록 구현하는 것이 조건이여서 hideKeyboard()를 만들었다. 검색해보니까 여러 방법들이 나왔는데, 키보드를 숨기는 조건은 여러가지지만, 대체로 똑같은 모양을 사용하고 있어서 뚝딱 걸었다.


✏오늘의 느낀점과 다음에 할 일

  • 레트로핏.. 예시를 찾으면 뷰모델 적용한 예시도 많고, 요청한 후에 응답을 돌아온 객체를 활용하는 방법도 가지가지고, 애초에 요청한 값들이 천차만별이니까 원하는 모양을 만들어 내기가 너무 힘들었다. 위에서 언급했던 것 처럼 난 요청과 응답을 분리해야한다는 이상한 강박?ㅎㅋ 이 있었는데, 본강의자료와, 수준별수업자료와, 온갖 검색을 통해 겨우 두개를 하나로 합칠 수 있었다.
  • 이번에는 의외로 이름짓는게 너무 힘들었다. 가뜩이나 영어도 잘 못하는데... 아직 명확한 개념도 알아가는 단계다 보니까 얘가 뭘 하는 애라고 정확히 말해주는 단어를 찾는데 벅찼다. 이럴 땐 최대한 보편적인 것을 찾아서 따라하려고 하지만 그것조차 쉬운일은 아닌 것 같다.ㅎ
  • 좋아요 기능과 검색어를 저장하는 Shared Preferences 기능을 빼고, 최대한 짜잘한 것들을 레트로핏과 함께 처리했는데, 어쨌든 검색하면 검색한 값이 뜬다는 것이 아주 신기하다.

  • 내일부터는 주말인데.. 월요일에는 뷰모델 수업을 들을 것이기 때문에.. 나머지 좋아요와 Shared Preferences를 주말에 처리하는 것이 좋을지.. 그냥 뒀다가 뷰모델과 함께 적용하는 것이 나을지... 잘 모르겠다. 뷰모델이란게 정확히 뭔지 모르니까.. 일단 토요일은 일정때문에 안되고, 일요일날 되는곳 까지 도전해보는 것을 목표로 해보겠다..

✏참고한 블로그

profile
파이팅!

0개의 댓글