본문 바로가기

TIL

[TIL] kotlin Youtube Data API 3 - [ 3 ] 단일 Retrofit으로 여러 EndPoint호출하기

Youtube Data API에서 다른 EndPoint를 사용해서 각각의 API를 호출해보자.

  • 이전 게시글에서 Videos EndPoint를 사용해서 Trending / Top 10 List를 받아오는 코드를 작성해 보았는데 Video EndPoint는 API 호출 비용1 인 대신 채널 정보를 가져올 수 없다. 이전에 작성한 코드에 이어서 Search EndPoint를 통해 Category 별 Video와 Channel을 Video EndPoint와 SearchEndPoint 각각의 EndPoint로 호출해보는 예제를 추가해보자.
  • HomeFragment 의 경우 코드내 주석으로 자세한 설명 대체, 이전글에서 다룬 부분은 별도로 기록되지 않음

 

이전의 코드는 아래 게시글에서 작성.

 

[TIL] kotlin Youtube Data API 3 - [ 2 ] videos:List 받아와 Trending 10 Thumbnails 만들기

Youtube Data API 3 videos:List 받아와 Trending 10 Thumbnails 만들기 [ 2 ] API Key 생성 및 retrofit은 이전에 작성했던 youtube Data API 3 기본설정 [ 1 ] 참고 [TIL] Youtube Data API 3 기본 설정 [ 1 ] -1- Youtube Data API 3 videos:Li

junes-daily.tistory.com

 

Category별 Video와 Category에 해당하는 Channel Rcv 추가 / 구현 화면

 

VideoInterface.kt

interface VideoInterface {

    @GET("videos")
    suspend fun searchYoutube(
        @Query("key") key: String = Constants.API_KEY,
        @Query("part") part: String,
        @Query("videoCategoryId") videoCategoryId: String,
        @Query("chart") chart: String,
        @Query("hl") hl: String,
        @Query("maxResults") maxResults: Int,
        @Query("regionCode") regionCode: String,
    ): Response<SearchResponse>
    
    // 새로 추가된 코드
    @GET("search")
    suspend fun searchChannel(
        @Query("key") key: String = Constants.API_KEY,
        @Query("part") part: String,
        @Query("maxResults") maxResults: Int,
        @Query("q") q: String,
        @Query("regionCode") regionCode: String,
        @Query("type") type: String,
    ): Response<ChannelResponse>
}
  • API Service Interface로 생성해둔 VideoInterface 내부에 채널 데이터를 받아올 수 있도록 search EndPoint를 호출하는 searchChannel 함수를 생성해준다.

 

 

RetrofitInstance.kt

object RetrofitInstance {
    private val okHttpClient: OkHttpClient by lazy {
        val interceptor = HttpLoggingInterceptor()

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

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

    private val searchRetrofit: Retrofit by lazy {

        val gson = GsonBuilder().setLenient().create()

        Retrofit.Builder()
            .baseUrl("https://youtube.googleapis.com/youtube/v3/")
            .addConverterFactory(GsonConverterFactory.create(gson))
            .client(okHttpClient)
            .build()
    }

    val api: VideoInterface by lazy {
        searchRetrofit.create(VideoInterface::class.java)
    }
}
  • Retrofit Instance는 baseUrl이 새로 추가된 search 메서드가 기존의 videos 메서드와 동일하기 때문에 변경사항이 없다.

 

VideoRepository.kt

interface VideoRepository {
    suspend fun search(
        key: String,
        part: String,
        videoCategoryId: String,
        chart: String,
        hl: String,
        maxResults: Int,
        regionCode: String,
    ): Response<SearchResponse>

    suspend fun searchChannel(
        key: String,
        part: String,
        maxResults: Int,
        q: String,
        regionCode: String,
        type: String,
    ): Response<ChannelResponse>
}

VideoRepositoryImpl.kt

class VideoRepositoryImpl : VideoRepository {
    override suspend fun search(
        key: String,
        part: String,
        videoCategoryId: String,
        chart: String,
        hl: String,
        maxResults: Int,
        regionCode: String,
    ): Response<SearchResponse> {
        return api.searchYoutube(key, part, videoCategoryId, chart, hl, maxResults, regionCode)
    }

    override suspend fun searchChannel(
        key: String,
        part: String,
        maxResults: Int,
        q: String,
        regionCode: String,
        type: String,
    ): Response<ChannelResponse> {
        return api.searchChannel(key, part, maxResults, q, regionCode, type)
    }
}
  • Repository는 API Service Interface 의 데이터 액세스 방법을 지정하며 데이터 소스를 추상화해줘야 하므로 VideoInterface와 마찬가지로 searchChannel 함수를 작성해준다.

 

SearchViewModel.kt

fun searchYoutube(videoCategoryId: String) =
        viewModelScope.launch(Dispatchers.IO) {
            try {
                val response: Response<SearchResponse> = searchRepository.search(
                    Constants.API_KEY,
                    "snippet",
                    videoCategoryId,
                    "mostPopular",
                    "ko-KR",
                    10,
                    "KR"
                )
                if (response.isSuccessful) {
                    response.body()?.let { body ->
                        _searchResult.postValue(body)
                    }
                } else {
                    val videosErrorBody: ResponseBody? = response.errorBody()
                }
            } catch (e: Exception) {
            }
        }
  • Fragment에서 Spinner를 통해 지정할 예정인 category ID 값을 파라미터로 받아 Category별 인기 동영상(Youtube API 파라미터 값 - mostPopular)로 지정 후 호출이 정상적으로 이루어질 경우 LiveData에 postValue를 통해 호출받은 데이터 저장하도록 한다.

 

fun searchChannels(CategoryId: String) = viewModelScope.launch(Dispatchers.IO) {
        try {
            val channelResponse: Response<ChannelResponse> = searchRepository.searchChannel(
                Constants.API_KEY,
                "snippet",
                15,
                CategoryId,
                "KR",
                "channel"
            )
            if (channelResponse.isSuccessful) {
                channelResponse.body()?.let { body ->
                    _channelSearchResult.postValue(body)
                }
            } else {
                val channelsErrorBody: ResponseBody? = channelResponse.errorBody()
            }
        } catch (e: Exception) {

        }
    }
  • Channel List 또한 Fragment의 Spinner를 통해 CategoryId값을 지정하여서 해당 CategoryId 별로 채널 리스트가 다르게 나오도록 API 호출 매서드 작성

Category Video Rcv목록 Infinity Scroll 을 위한 다음 페이지 API 호출 함수 정의

    fun searchYoutubeNextPage(videoCategoryId: String, pageToken: String) =
        viewModelScope.launch(Dispatchers.IO) {
            try {
                val response: Response<SearchResponse> = searchRepository.search(
                    Constants.API_KEY,
                    "snippet",
                    videoCategoryId,
                    "mostPopular",
                    "ko-KR",
                    10,
                    "KR",
                    pageToken
                )
                if (response.isSuccessful) {
                    response.body()?.let { body ->
                    // 기존 아이템
                        val currentVideoList = _searchResult.value?.items
                        
                        // 새로 추가되는 아이템
                        currentVideoList?.let {
                            currentVideoList.addAll(body.items)

                            _searchResult.postValue(
                                SearchResponse(
                                    etag = body.etag,
                                    items = currentVideoList,
                                    kind = body.kind,
                                    nextPageToken = body.nextPageToken,
                                    pageInfo = body.pageInfo
                                )
                            )
                        }
                    }
                } else {
                    val videosNextPageErrorBody: ResponseBody? = response.errorBody()
                }
            } catch (e: Exception) {
            }
        }
  • Category Rcv는 _searchResult LiveData를 Observe 해서 해당 데이터에 변화가 생길 시 Rcv에 아이템을 표시해줘야 하는데 추가로 데이터를 받을 시 _searchResult에 데이터를 추가하려면 따로 List를 생성해서 해당 List에 기존 데이터를 담고 추가 된 데이터를 같은 형식으로 담아줘야 하기 때문에 items 는 기존에 데이터에 새로운 body에서 추가된 데이터를 addAll로 더해주고 나머지 etag, kind 값은 기존 데이터가 유지될 필요가 없어서 body 값으로 변경해주고 변경된 값으로 _searchResult에 postValue로 값을 저장한다.

Category, Channel 각각의 Rcv Adapter 생성해주기

class HomeCategoryRcvViewAdapter() :
    ListAdapter<Item,HomeCategoryRcvHolder>(DifferCallback.differCallback) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeCategoryRcvHolder {
        return HomeCategoryRcvHolder(
            HomeRcvItemCategoryBinding.inflate(LayoutInflater.from(parent.context),parent, false)
        )
    }

    override fun onBindViewHolder(holder: HomeCategoryRcvHolder, position: Int) {
        val category = currentList[position]
        val snippet = category.snippet
        val thumbnails = snippet.thumbnails

        holder.apply {
            holder.testThumbnails.load(thumbnails.medium.url)
            holder.testItemID.text = snippet.title

        }
    }
}

class HomeCategoryRcvHolder(binding: HomeRcvItemCategoryBinding) :
    RecyclerView.ViewHolder(binding.root) {
    val testThumbnails : ImageView = binding.homeRcvImgCategoryVideoThumbnail
    val testItemID:TextView = binding.homeRcvTvCategoryVideoTitle
}

 

class HomeChannelRcvAdapter() :
    ListAdapter<ChItem, HomeChannelRcvHolder>(DifferCallback.channelDifferCallback) {


    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeChannelRcvHolder {
        return HomeChannelRcvHolder(
            HomeRcvItemChannelBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        )
    }

    override fun onBindViewHolder(holder: HomeChannelRcvHolder, position: Int) {
        val category = currentList[position]
        val snippet = category.snippet
        val thumbnails = snippet.thumbnails

        holder.apply {
            channelThumbnails.load(thumbnails.medium.url)
            channelTitle.text = snippet.title
        }
    }
}

class HomeChannelRcvHolder(binding: HomeRcvItemChannelBinding) :
    RecyclerView.ViewHolder(binding.root) {
    val channelThumbnails: ImageView = binding.homeRcvImgChannelThumbnail
    val channelTitle: TextView = binding.homeRcvTvChannelTitle
}

 

CategoryId.kt

object CategoryId {

    val categoryMap = mapOf(
        "영화" to "1",
        "차량" to "2",
        "음악" to "10",
        "반려동물,동물" to "15",
        "스포츠" to "17",
        "Short Movies" to "18",
        "여행" to "19",
        "게임" to "20",
        "Vlog" to "22",
        "코미디" to "23",
        "앤터테이먼트" to "24",
        "뉴스" to "25",
        "스타일" to "26",
    )
}
  • Category에 선택된 값을 Query 값으로 Channel 검색을 해야할 때 사용해야 하는데 음악 카테고리인 10을 선택했을 때 채널명 검색이 음악으로 검색이 될 수 있도록 하기 위해 Youtube API 호출 프로퍼티인 CategoryId의 값을 Map형태로 저장

 

 

HomeFragment.kt

class HomeFragment : Fragment() {
    private var _binding: HomeFragmentBinding? = null
    private val binding get() = _binding!!

    private lateinit var searchViewModel: SearchViewModel
    private lateinit var homeCategoryRcvViewAdapter: HomeCategoryRcvViewAdapter
    private lateinit var homeTrendingRcvViewAdapter: HomeTrendingRcvAdapter
    private lateinit var homeChannelRcvAdapter: HomeChannelRcvAdapter

    private var isUserScrolling = false

    // Auto Scroll / Handler 객체 생성
    private var scrollHandler = Handler(Looper.getMainLooper())
    private val scrollRunnable = object : Runnable {
        override fun run() {
            autoScroll()
            scrollHandler.postDelayed(this, 5000)
        }
    }

    // CategoryQuery 를 Fragment 전역에서 사용할 수 있도록 지정
    private var categoryQuery: String? = null

    // PageToken 을 Fragment 전역에서 사용할 수 있도록 지정
    private var _pageToken: String? = null
    private val pageToken get() = _pageToken!!

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

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        searchViewModel = (activity as MainActivity).searchViewModel

        setupRecyclerView()
        searchCategory()
        searchViewModel.searchYoutube("1", "")
        searchViewModel.searchChannels("tv")
        searchViewModel.searchTrending()


        searchViewModel.trendingResult.observe(viewLifecycleOwner) { response ->
            val result: List<Item> = response.items

            homeTrendingRcvViewAdapter.submitList(result)
        }

        searchViewModel.searchResult.observe(viewLifecycleOwner) { response ->
            val result: List<Item> = response.items
            // Rcv 마지막 Position에 Scroll이 위치할 경우 다음 페이지의 Item 을 받아오기 위해 _pageToken에 nextPage 값 할당.
            _pageToken = response.nextPageToken

            homeCategoryRcvViewAdapter.submitList(result)
            // 현재 화면에 그려지는 데이터셋에서는 Rcv가 자동으로 새로고침이 되지 않기 때문에 Data를 새로고침 해준다.
            homeCategoryRcvViewAdapter.notifyDataSetChanged()
        }

        searchViewModel.channelResult.observe(viewLifecycleOwner) { response ->
            val result: List<ChItem> = response.chItems

            homeChannelRcvAdapter.submitList(result)
        }

        // Auto Scroll 적용
        scrollHandler.postDelayed(scrollRunnable, 5000)
    }

    private fun searchCategory() {

        // Spinner 를 통해 변경되는 CategoryQuery 값을 받아 API 호출
        binding.homeSpnCategorySelect.setOnSpinnerItemSelectedListener<String> { _, _, _, query ->
            searchViewModel.searchYoutube(CategoryId.categoryMap[query] ?: "1", "")
            searchViewModel.searchChannels(query)

            categoryQuery = query
        }
    }


 private fun setupRecyclerView() {
        // Trending Rcv가 스크롤 될 시 item 중앙에 Scroll이 위치하도록 SnapHelper 사용
        val trendingSnapHelper = LinearSnapHelper()
        trendingSnapHelper.attachToRecyclerView(binding.homeRcvTrendingList)
        
        binding.homeRcvCategoryList.apply {
            setHasFixedSize(true)
            layoutManager =
                LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
            adapter = homeCategoryRcvViewAdapter
            
            // Category Rcv Infinity Scroll 적용
            addOnScrollListener(categoryRcvListener)
        }

        binding.homeRcvTrendingList.apply {
            setHasFixedSize(true)
            layoutManager =
                LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
            adapter = homeTrendingRcvViewAdapter
        }

        binding.homeRcvChannelList.apply {
            setHasFixedSize(true)
            layoutManager =
                LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
            adapter = homeChannelRcvAdapter
        }
    }
    
    // Category Rcv Infinity Scroll 함수 정의
    private val categoryRcvListener = object : RecyclerView.OnScrollListener() {
        override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
            super.onScrollStateChanged(recyclerView, newState)
            when (newState) {
                RecyclerView.SCROLL_STATE_DRAGGING -> {
                    isUserScrolling = true
                }

                RecyclerView.SCROLL_STATE_IDLE -> {
                    if (isUserScrolling) {
                        val layoutManager = recyclerView.layoutManager as LinearLayoutManager
                        val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition()
                        val itemCount = recyclerView.adapter?.itemCount ?: 0

                        if (lastVisibleItemPosition == itemCount - 1) {
                            searchViewModel.searchYoutubeNextPage(
                                CategoryId.categoryMap[categoryQuery] ?: "1", pageToken
                            )
                        }
                    }
                    isUserScrolling = false
                }
            }
        }
    }

    // Trending Rcv의 Scroll이 자동으로 순환하는 함수 정의
    private fun autoScroll() {
        val layoutManager = binding.homeRcvTrendingList.layoutManager as LinearLayoutManager
        val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition()
        val itemCount = homeTrendingRcvViewAdapter.itemCount

        if (lastVisibleItemPosition < itemCount - 1) {
            binding.homeRcvTrendingList.smoothScrollToPosition(lastVisibleItemPosition + 1)
        } else {
            binding.homeRcvTrendingList.scrollToPosition(0)
        }
    }

    override fun onPause() {
        super.onPause()
        // Spinner를 펼친 상태에서 다른 Fragment로 이동 시 Spinner가 닫히도록 함.
        binding.homeSpnCategorySelect.dismiss()
    }

    override fun onDestroyView() {
        super.onDestroyView()
        // Auto Scroll Handler 제거
        scrollHandler.removeCallbacksAndMessages(null)
        _binding = null
    }
}

 

 


 

[오늘 복습한 내용]

1. ??

 

 


[오류,에러 등등]

1. RecyclerView 아이템 추가를 했는데 View에 적용이 되지 않는 오류

 

문제 코드

        searchViewModel.searchResult.observe(viewLifecycleOwner) { response ->
            val result: List<Item> = response.items
            _pageToken = response.nextPageToken

            binding.testButton.setOnClickListener {
                searchViewModel.searchYoutubeNextPage("17",pageToken)
                Log.d("xxxx", "pageTokey: $pageToken")

                homeCategoryRcvViewAdapter.submitList(result)
                homeCategoryRcvViewAdapter.notifyDataSetChanged()
            }

            homeCategoryRcvViewAdapter.submitList(result)
            
            Log.d("xxxx", "home frag submitList? : ${result.size}")

        }

 

해결 방법 = notifyDataSetChanged()를 코드 맨 아래로 이동.

 

해결 코드

        searchViewModel.searchResult.observe(viewLifecycleOwner) { response ->
            val result: List<Item> = response.items
            _pageToken = response.nextPageToken

            binding.testButton.setOnClickListener {
                searchViewModel.searchYoutubeNextPage("17",pageToken)
                Log.d("xxxx", "pageTokey: $pageToken")

                homeCategoryRcvViewAdapter.submitList(result)

            }

            homeCategoryRcvViewAdapter.submitList(result)
            homeCategoryRcvViewAdapter.notifyDataSetChanged()

            Log.d("xxxx", "home frag submitList? : ${result.size}")

        }

 


[느낀 점]

1. 어렵다

 

2. MVVM이 되는건지... 안되는건지... 

 

3. 여러 코드를 접하면 다양한 의문을 갖게되어서 좋은 것 같다.

 

 


[Reference]

 

// Infinity Scroll

https://tral-lalala.tistory.com/108