안드로이드 앱 개발 공부/자꾸 까먹어서 적어두는 구현방법

[안드로이드 스튜디오] Retrofit + 코루틴 +fragment 리사이클러뷰 kotlin

플래시🦥 2023. 2. 8.
반응형

영화 정보를 가지고 올 수 있는 영화진흥위원회의 open Api를 활용해서 리사이클러뷰를 사용해서 일별 박스오피스 순위코루틴retrofit으로 가져와 보여주려고 한다. 

 

이것을 구현하기 위해 열심히 공부도 하고 정말 많은 검색을 해가면서 구현했지만, 계속된 오류로 며칠 동안 해결하지 못했었다. 내가 구현하려는 api의 형태도 달랐고, 구현하려는 방식도 달라서 오류의 원인을 찾기 힘들었다. 

그래서 이 글이 누군가에게 조금이라도 도움이 되었으면 좋겠다. 

 

 

 

1.build.gradle(Model:app) 에 추가

//retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.google.code.gson:gson:2.9.0'

//코루틴
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'

2.  데이터 클래스

retrofit을 사용해서 json을 가져와 저장할 수 있도록 데이터 클래스를 작성해 주도록 한다. 

api를 url로 호출하면 아래와 같은 형태를 가지고 있다. 

{
  "boxOfficeResult": {
    "boxofficeType": "일별 박스오피스",
    "showRange": "20230207~20230207",
    "dailyBoxOfficeList": [
      {
        "rnum": "1",
        "rank": "1",
        "rankInten": "0",
        "rankOldAndNew": "OLD",
        "movieCd": "20228555",
        "movieNm": "더 퍼스트 슬램덩크",
        "openDt": "2023-01-04",
        "salesAmt": "439298244",
        "salesShare": "33.5",
        "salesInten": "-674321",
        "salesChange": "-0.2",
        "salesAcc": "25132396225",
        "audiCnt": "44100",
        "audiInten": "24",
        "audiChange": "0.1",
        "audiAcc": "2436505",
        "scrnCnt": "955",
        "showCnt": "2986"
      },
      ....
    ]
  }
}

이걸 dataclass 형식으로 표현해 주면 된다. 

 

movieInfo.kt

import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName

data class MOVIES(val boxOfficeResult: BOXOFFICERESULT)
data class BOXOFFICERESULT(
    @SerializedName("boxofficeType") @Expose val boxofficeType: String,
    @SerializedName("showRange") @Expose val showRange: String,
    @SerializedName("dailyBoxOfficeList") @Expose val dailyBoxOfficeList: List<MOVIEINFO>
)
data class MOVIEINFO(
    @SerializedName("rnum") @Expose var rnum: String,
    @SerializedName("rank") @Expose var rank: String,
    @SerializedName("rankInten") @Expose var rankInten: String,
    @SerializedName("rankOldAndNew") @Expose var rankOldAndNew: String,
    @SerializedName("movieCd") @Expose var movieCd: String,
    @SerializedName("movieNm") @Expose var movieNm: String,
    @SerializedName("openDt") @Expose var openDt: String,
    @SerializedName("salesAmt") @Expose var salesAmt: String,
    @SerializedName("salesShare") @Expose var salesShare: String,
    @SerializedName("salesInten") @Expose var salesInten: String,
    @SerializedName("salesChange") @Expose var salesChange: String,
    @SerializedName("salesAcc") var salesAcc: String,
    @SerializedName("audiCnt") @Expose var audiCnt: String,
    @SerializedName("audiInten") @Expose var audiInten: String,
    @SerializedName("audiChange") @Expose var audiChange: String,
    @SerializedName("audiAcc") @Expose var audiAcc: String,
    @SerializedName("scrnCnt") @Expose var scrnCnt: String,
    @SerializedName("showCnt") @Expose var showCnt: String
)

 @SerializedName annotation의 value는 객체를 직렬화 및 역직렬화 할 때 이름으로 사용되고,

object 중 해당 값이 null일 경우, json으로 만들 필드를 자동 생략해 준다.

 

3. 리사이클러뷰 배치 및 구성

fragment_home.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ui.home.HomeFragment">


    <RelativeLayout
        android:id="@+id/layout_pop"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginTop="16dp"
        android:layout_marginEnd="16dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <TextView
            android:id="@+id/text_popularMoview"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/popular_movie"
            android:textAlignment="center"
            android:textSize="20sp" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/popularMoview_recyclerView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            android:layout_below="@id/text_popularMoview"
            android:layout_marginTop="15dp" />

    </RelativeLayout>

    <RelativeLayout
        android:id="@+id/layout_new"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginTop="16dp"
        android:layout_marginEnd="16dp"
        app:layout_constraintTop_toBottomOf="@+id/layout_pop">

        <TextView
            android:id="@+id/text_newMovie"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/new_movie"
            android:textAlignment="center"
            android:textSize="20sp" />


    </RelativeLayout>


    <TextView
        android:id="@+id/textHome"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/layout_new" />
</androidx.constraintlayout.widget.ConstraintLayout>

 

recycler_movie.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">

    <LinearLayout
        android:layout_margin="10dp"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <androidx.cardview.widget.CardView
            android:id="@+id/cardView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <androidx.constraintlayout.widget.ConstraintLayout
                android:elevation="10dp"
                android:layout_width="match_parent"
                android:layout_height="match_parent">

                <ImageView
                    android:id="@+id/imageView"
                    android:layout_width="162dp"
                    android:layout_height="240dp"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toTopOf="parent"
                    app:srcCompat="@drawable/ic_launcher_foreground" />

                <TextView
                    android:id="@+id/name_text"
                    android:layout_width="108dp"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="10dp"
                    android:textSize="18sp"
                    android:ellipsize="end"
                    android:maxLines="1"
                    android:textStyle="bold"
                    android:gravity="center"

                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@+id/imageView" />
            </androidx.constraintlayout.widget.ConstraintLayout>
        </androidx.cardview.widget.CardView>

    </LinearLayout>

</layout>

 

반응형

4. 리사이클러뷰 어댑터

fragment에서 아까 만들어 두었던 dataclass의 movieInfo 형태의 arrayList를 받아 바인딩해 준다. 

 

MyMovieAdapter.kt

class MyMovieAdapter(private var data: ArrayList<MOVIEINFO>) :
    RecyclerView.Adapter<MyMovieAdapter.MyViewHolder>() {

    val TAG = "MyMovieAdapter"

    @SuppressLint("NotifyDataSetChanged")
    fun updateMovies(newMovies: List<MOVIEINFO>) {
        data.clear()
        data.addAll(newMovies)
        notifyDataSetChanged()
    }

    // 생성된 뷰 홀더에 값 지정
    inner class MyViewHolder(val binding: RecyclerMovieBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(currentMovie: MOVIEINFO) {
            binding.nameText.text=currentMovie.movieNm
        }
    }

    // 어떤 xml 으로 뷰 홀더를 생성할지 지정
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        //val binding = RecyclerMovieBinding.inflate(LayoutInflater.from(parent.context),parent,false)
        val inflater = LayoutInflater.from(parent.context)
        val binding = RecyclerMovieBinding.inflate(inflater, parent, false)
        return MyViewHolder(binding)
    }

    // 뷰 홀더에 데이터 바인딩
    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        val movie = data
        if (movie != null) {
            holder.bind(movie.get(position))
        }
    }

    // 뷰 홀더의 개수 리턴
    override fun getItemCount(): Int {
        return data.size
    }


}

 

5. Retrofit

retrofit을 사용해서 json을 가지고올 수 있도록 구성해준다.

해당 api를 사용할 수 있는 주소는 

http://kobis.or.kr/kobisopenapi/webservice/rest/boxoffice/searchDailyBoxOfficeList.json?key=인증키&targetDt=20120101  

이다.

여기서 baseUrl은 http://kobis.or.kr/kobisopenapi/webservice/rest/boxoffice/ 이다. 

searchDailyBoxOfficeList.json? 은 endpoint이고, key=인증키&targetDt=20120101는 query이다. 

endpoint를 가지고 제공하는 여러 요청을 할 수 있고, 쿼리를 가지고 사용자가 원하는 응답을 받을 수 있다. 

 

 

RetrofitClient.kt

 

object RetrofitClient{
    private val baseUrl = " http://www.kobis.or.kr/kobisopenapi/webservice/rest/boxoffice/"

    private fun getRetrofit(): Retrofit = Retrofit.Builder()
            .baseUrl(baseUrl)
            .addConverterFactory(GsonConverterFactory.create())
            .build()

    fun getRetrofitService(): ApiUrlInterface = getRetrofit().create(ApiUrlInterface::class.java)
}

 

apiUrl.kt

class apiUrl{

    companion object{
        const val EndPoint ="searchDailyBoxOfficeList.json?"
        const val API_KEY = BuildConfig.KOFIC_API
    }

}

interface ApiUrlInterface{
    @GET(apiUrl.EndPoint)
    suspend fun getDailyBoxOffice(
        @Query("targetDt") //parameter
        targetDate:String,
        @Query("key")
        key:String = apiUrl.API_KEY
    ): Response<MOVIES>
}

 

6. ViewModel 구성

뷰모델에서는 코루틴을 사용해서 데이터를 저장한다. 

(나는 쿼리로 하루 전 연월일을 넣어주어야 해서 getdate() 함수를 사용했다.)

HomeViewModel.kt

@RequiresApi(Build.VERSION_CODES.O)
class HomeViewModel : ViewModel() {

    val TAG="HomeViewModel"
    private val movieService = RetrofitClient.getRetrofitService()
    var job: Job? = null
    val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
        onError("Exception: ${throwable.localizedMessage}")
    }

    var data = MutableLiveData<List<MOVIEINFO>>()
    val movieLoadError = MutableLiveData<String?>()
    val loading = MutableLiveData<Boolean>()

    fun refresh() {
        getMovieData()
    }

    private fun getMovieData(){
        loading.value=true

        job= CoroutineScope(Dispatchers.IO+exceptionHandler).launch {
            val response = movieService.getDailyBoxOffice(getdate())
            withContext(Dispatchers.Main) {
                if (response.isSuccessful) {
                    data.postValue(response.body()?.boxOfficeResult?.dailyBoxOfficeList)
                    //Log.d(TAG, response.body()?.boxOfficeResult?.dailyBoxOfficeList.toString())
                    movieLoadError.postValue(null)
                    loading.postValue(false)

                } else {
                    onError("Error : ${response.message()}")

                }
            }
        }

        
    }

    @SuppressLint("SimpleDateFormat")
    @RequiresApi(Build.VERSION_CODES.O)
    fun getdate() :String{
        //하루 전 날짜
        val calendar : Calendar = GregorianCalendar()
        val SDF : SimpleDateFormat = SimpleDateFormat("yyyyMMdd")
        calendar.add(Calendar.DATE,-1)
        val res = SDF.format(calendar.time)
        Log.d("getDate()",res )
        return res
    }

    private fun onError(message: String) {
        movieLoadError.postValue(message)
        loading.postValue(false)
    }

    override fun onCleared() {
        super.onCleared()
        job?.cancel()
    }
}

 

7.리사이클러뷰가 존재하는 Fragment

뷰모델의 함수를 호출하면 뷰모델에서 데이터를 요청해 받아오고 그 데이터를 받아 온다. 

받아 온 데이터를 가지고 리사이클러뷰 어댑터에 보내주는 역할을 한다. 

HomeFragment.kt

class HomeFragment : Fragment() {
    val TAG = "HomeFragment"
    private lateinit var homeViewModel: HomeViewModel
    private var _binding: FragmentHomeBinding? = null
    private var myMovieAdpater=MyMovieAdapter(arrayListOf())

    private val binding get() = _binding!!

    @RequiresApi(Build.VERSION_CODES.O)
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentHomeBinding.inflate(inflater, container, false)
        val root: View = binding.root

        homeViewModel = ViewModelProvider(this).get(HomeViewModel::class.java)
        homeViewModel.refresh()

        binding.popularMoviewRecyclerView.apply {
            layoutManager = LinearLayoutManager(context).also {
                it.orientation=LinearLayoutManager.HORIZONTAL
            }
            adapter = myMovieAdpater
        }

        observeViewModel()


        return root
    }

    @RequiresApi(Build.VERSION_CODES.O)
    fun observeViewModel() {
        homeViewModel.data.observe(viewLifecycleOwner, Observer {
            it?.let{
                myMovieAdpater.updateMovies(it)
                myMovieAdpater.notifyDataSetChanged()
            }

        })

        homeViewModel.movieLoadError.observe( viewLifecycleOwner, Observer { isError ->
            Log.d(TAG,"movieLoadError="+isError.toString())
            //Toast.makeText(context,isError,Toast.LENGTH_LONG).show()
        })

        homeViewModel.loading.observe(viewLifecycleOwner, Observer { isLoading ->
            isLoading?.let {
                if(it){
                    Log.d(TAG,"loading="+it.toString())
                    //Toast.makeText(context,it.toString(),Toast.LENGTH_LONG).show()
                }
            }
        })
    }

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

}

 

<결과>

이미지는  api로 이미지 url을 제공해 주지 않아 임시로 고정된 이미지를 사용하고 있다.

728x90
반응형

댓글