본문 바로가기

TIL

[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:List 받아와 Hot 10 Thumbnails 만들기 [ 1 ] 0. NewProject 만들어 두고 build.gradle 의존성 추가 및 Manifest 권한 부여 build.gradle android { buildFeatures { viewBinding = true buildConfig = true } } dependen

junes-daily.tistory.com

 

작동화면 미리보기

 

작업 흐름

  • 1편 에서 Data Model 과 API Service Interface 및 Retrofit Instance까지 생성을 해두었으니 UI, Repository 생성부터 시작한다.

[ 1 ] Layout UI 생성

  • HomeFragment에서 Trending Top 10 을 보여주기 위해 Fragment 생성

 

home_fragment.xml

<FrameLayout 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=".HomeFragment">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/white">


        <EditText
            android:id="@+id/search_frag_edit_search"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <ImageView
            android:id="@+id/search_frag_btn_search"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:layout_marginEnd="16dp"
            android:src="@drawable/ic_search"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/home_tv_trending"
            android:layout_width="match_parent"
            android:layout_height="40dp"
            android:layout_marginStart="16dp"
            android:layout_marginTop="56dp"
            android:gravity="center_vertical"
            android:text="Trnding / Top10"
            android:textSize="18sp"
            android:textStyle="bold"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.0"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/home_rcv_trending_list"
            android:layout_width="match_parent"
            android:layout_height="212dp"
            android:orientation="horizontal"
            app:cardCornerRadius="20dp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/home_tv_trending"
            tools:listitem="@layout/home_rcv_item_trending" />

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

 

home_rcv_item_trending.xml

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <androidx.cardview.widget.CardView
        android:id="@+id/home_rcv_trending_list"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:layout_marginStart="16dp"
        android:layout_marginTop="12dp"
        android:layout_marginEnd="16dp"
        app:cardCornerRadius="20dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <FrameLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent">

            <ImageView
                android:id="@+id/home_img_trending"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:scaleType="centerCrop"
                android:src="@drawable/img_home_trending_sample1" />

            <TextView
                android:id="@+id/home_tv_trending_count"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="bottom|end"
                android:layout_marginEnd="8dp"
                android:background="@color/light_grey"
                android:text="1/10"
                android:textColor="@color/white"
                android:textSize="20sp"
                android:textStyle="bold" />
        </FrameLayout>
    </androidx.cardview.widget.CardView>
</androidx.constraintlayout.widget.ConstraintLayout>

 

 

[ 2 ]  Repository Pattern 적용

  • Room Database를 통해 네트워크에서 직접 데이터를 가져오지 않고 caching된 데이터를 사용할 수 있도록 Repository 패턴을 사용한다.
  • 이전 게시글에서 미리 생성해둔 API Service Interface를 기반으로 Repository를 생성해준다.

 

Repository.kt

interface VideoRepository {
    suspend fun search(
        key: String,
        part: String,
        videoCategoryId: String,
        chart: String,
        hl: String,
        maxResults: Int,
        regionCode: String,
    ): Response<SearchResponse>
  • Repository Interface 를 생성한 뒤 앱에 필요한 데이터 액세스 방법을 지정해준다. API key를 통한 네트워크 요청을 위한 데이터 소스를 추상화한다.

 

RepositoryImpl.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)
    }
  • Retrofit Interface를 상속받아 실제 데이터, API Service와 상호작용하는 RepositoryImpl을 생성해준다. 이는 앱의 비즈니스 로직과 데이터 사이의 연결다리 역할을 한다.

 

 

[ 3 ] ViewModel, LiveData / ViewModelProvider 만들기

 

ViewModel.kt

class SearchViewModel(
    private val searchRepository: VideoRepository,
    private val savedStateHandle: SavedStateHandle,
): ViewModel() {

    private val _trendingSearchResult = MutableLiveData<SearchResponse>()
    val trendingResult : LiveData<SearchResponse> get() = _trendingSearchResult

    fun searchTrending() = viewModelScope.launch(Dispatchers.IO) {
        try {
            val trendingResponse:Response<SearchResponse> = searchRepository.search(Constants.API_KEY,"snippet","0","mostPopular","ko-KR",10,"KR")
            if (trendingResponse.isSuccessful){
                trendingResponse.body()?.let { body ->
                    _trendingSearchResult.postValue(body)
                }
            }else {
            // API 요청이 실패한 경우 처리.
                val trendingErrorBody:ResponseBody? = trendingResponse.errorBody()
            }

        }catch (e: Exception){
	// 예외처리
        }
    }

}
  • ViewModel() 을 상속받는 ViewModel Class를 생성해주고, API 호출을 통해 얻게 될 데이터를 저장할 LiveData를 MutableLiveData<SearchResponse>() 의 형태로 정의해준다.
  • viewModelScope를 scope로 하며 network 통신을 위해 Dispatcher.IO로 지정된 Couroutine 함수를 정의해주고 repository를 통해 API를 호출하며 호출이 성공적일 경우 응답 결과 데이터를 LIveData에 저장한다.

Constants.kt

object Constants {
    const val API_KEY = BuildConfig.youtubeVideoApi
}

 

ViewModelProviderFactory.kt

class SearchViewModelProviderFactory (
    private val searchRepository: VideoRepository,
    owner: SavedStateRegistryOwner,
    defaultArgs : Bundle? = null
): AbstractSavedStateViewModelFactory(owner,defaultArgs) {
    override fun <T : ViewModel> create(
        key: String,
        modelClass: Class<T>,
        handle: SavedStateHandle
    ): T {
        if (modelClass.isAssignableFrom(SearchViewModel::class.java)){
            return SearchViewModel(searchRepository,handle) as T
        }
        throw IllegalArgumentException("View Model class not found")
    }
}
  • AbstractSavedStateViewModelFactory 추상 클래스를 상속 받은 ViewModelProviderFactory를 통해 ViewModel 인스턴스가 생성될 수 있도록 하며. 이는 AdnroidX의 Saved State module을 통해 ViewModel 객체의 상태를 저장하고 복원하는 기능을 제공한다.
  • owner, defaultArgs, searchRepository 와 같은 파라미터를 통해 ViewModel 객체에 필요한 의존성을 주입한다.

 

[ 4 ] RecyclerView Adapter, ViewHolder 만들기

class HomeTrendingRcvAdapter() :
    ListAdapter<Item,HomeTrendingRcvViewHolder>(DifferCallback.differCallback){
    
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeTrendingRcvViewHolder {
        return HomeTrendingRcvViewHolder(
            HomeRcvItemTrendingBinding.inflate(LayoutInflater.from(parent.context),parent,false)
        )
    }

    override fun onBindViewHolder(holder: HomeTrendingRcvViewHolder, position: Int) {

        val category = currentList[position]
        val snippet = category.snippet
        val thumbnails = snippet.thumbnails

        holder.apply {
            holder.trendingThumbnails.load(thumbnails.medium.url)
            holder.trendingCount.text = "${position+1}/10"
        }
    }


}


class HomeTrendingRcvViewHolder(binding: HomeRcvItemTrendingBinding) :
    RecyclerView.ViewHolder(binding.root) {
    val trendingThumbnails: ImageView = binding.homeImgTrending
    val trendingCount : TextView = binding.homeTvTrendingCount
}

object DifferCallback{
    val differCallback = object : DiffUtil.ItemCallback<Item>() {
        override fun areContentsTheSame(
            oldItem: Item,
            newItem: Item
        ): Boolean {
            return oldItem == newItem
        }

        override fun areItemsTheSame(
            oldItem: Item,
            newItem: Item
        ): Boolean {
            return oldItem.etag == newItem.etag
        }
    }
}
  • coil 이미지 로드 라이브러리를 통해 ImageView에 API 통신으로 부터 받은 Thumbnail URL 이미지를 적용시킨다.
  • DiffCallback을 통해 이전 목록과 새 목록 간의 차이를 지정해 효율적인 RecyclerView Item 관리를 할 수 있다.

 

 

 

[ 5 ] MainActivity, HomeFragment 코드 작성

 

MainActivity.kt

class MainActivity : AppCompatActivity() {

    lateinit var searchViewModel: SearchViewModel
    private lateinit var binding: MainActivityBinding
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = MainActivityBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val searchRepository = VideoRepositoryImpl()
        val factory = SearchViewModelProviderFactory(searchRepository,this)
        searchViewModel = ViewModelProvider(this,factory)[SearchViewModel::class.java]

        supportFragmentManager.beginTransaction().replace(R.id.main_frame, HomeFragment()).commit()
        
    }
}
  • VideoRepositoryImpl 클래스를 통해 VideoRepository 객체를 생성하여 Repository 객체를 생성한다.
    • Repository는 데이터의 소스로부터 데이터를 가져오거나 조작하는 역할을 담당하는데, 이때 데이터베이스, 네트워크 등과 같은 다양한 소스의 로직을 캡슐화하여 제공한다. 이를 통해 비즈니스 로직은 Repository에 의존하며 Activity나 Fragment 등의 컴포넌트는 Repository를 통해 필요한 데이터에 접근할 수 있다
    • VideoRepository를 액티비티에서 직접 생성하는 것이 아닌 외부에서 주입받도록 하는 이유로는 객체지향 5원칙 중 DIP의 원칙에 해당하며, 이를 통해 MainActivity와 Repository 간의 결합도를 낮추어 유연한 구조와 구간 별 단위 테스트에 효과적이다.
  • [ 3 ] 에서 생성해둔 ViewModelProviderFactory를 MainActivity에서 사용하여 ViewModel의 인스턴스가 앱 전반적인 데이터 Repository에 접근할 수 있도록 해준다.

 

HomeFragment.kt

class HomeFragment : Fragment() {

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

    private lateinit var searchViewModel: SearchViewModel
    private lateinit var homeTrendingRcvViewAdapter: HomeTrendingRcvAdapter

    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)
        
        // MainActivity에서 생성해둔 ViewModel 인스턴스에 접근할 수 있도록 한다
        searchViewModel = (activity as MainActivity).searchViewModel


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

            homeTrendingRcvViewAdapter.submitList(result)
        }
    }

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

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

 

OnViewCreate

  • searchViewModel = (activity as MainActivity).searchViewModel
    • searchViewModel 객체를 생성한다. 이때 searchViewModel은 (activity as MainActivity) 를 통해 현재 HomeFragment가 속한 Activity를 가져오와 MainActivity에서 정의해둔 searchViewModel 인스턴스에 접근해 Fragment 멤버변수인 searchViewModel에 할당한다. 이로인해 Fragment에서도 MainActivity에서 생성된 SearchViewModel 인스턴스에 접근하여 데이터와 상태를 공유하거나 조작할 수 있다. 
  • searchViewModel.searchTrending()
    • ViewModel에서 정의해둔 searchTrending() 함수를 실행한다.
  • searchViewModel.trendingResult.observe( ... ) {}
    •  LiveData의 데이터가 변경되면 RecyclerView의 Item lIst에 변경된 데이터의 값을 넘겨주어 submitList()를 통해 새로운 데이터를 RecyclerView에 업데이트해 정상적으로 변경된 데이터가 나올 수 있도록 해준다.
      • submitList() - 이전의 데이터 리스트와 새로운 데이터 리스트를 비교하여 변경된 부분을 확인하고, 변경된 UI 부분만 갱신 작업한다

 


 

[오늘 복습한 내용]

1. RecyclerView는 파고파도 계속 새로운 게 나온다

 


[오류,에러 등등]

1. 특별한 에러는 없었다

 

 

 


[느낀 점]

1. 다양한 라이브러리 소스를 어떻게 알아나가야 할지 감을 못 잡겠다. 라이브러리를 많이 배워보고 싶다.

 

2.  모든 것을 완벽하게 기억하기가 어려우니까 미리미리 기록해두는 것이 중요한 것 같다. 

 

 


[Reference]