[Android / Kotlin] Kakao API 이미지와 동영상 검색 같이 사용하기

Subeen·2024년 2월 1일
0

Android

목록 보기
56/71

이번에는 Kakao Api를 사용하여 이미지와 동영상 검색을 동시에 이루어지도록 했다. 사용자가 검색 화면에서 검색어를 입력한 후 버튼을 클릭하면 한 개의 검색 키워드로 이미지, 동영상 검색이 가능하다.
이번 포스팅에는 Kakao Api 이미지 검색 과 달라진 부분만 정리하였다 😌

결과 화면

이미지와 동영상 검색 결과를 같이 보여주며 날짜순으로 정렬하여 보여주고 있다.

DTO

  • 이미지 검색 API의 Json 응답에 대응하는 DTO를 작성한다.
  • 이미지 검색 결과에 포함된 구조를 나타내기 위한 ImageDocument와 동영상 구조를 나타내기 위한 VideoDocument 데이터 클래스를 따로 생성한다.
data class SearchResponse<T>(
    @SerializedName("meta")
    val metaData: MetaData?,
    @SerializedName("documents")
    var documents: MutableList<T>?
)
data class MetaData(
    @SerializedName("total_count")
    val totalCount: Int?,
    @SerializedName("is_end")
    val isEnd: Boolean?
)
data class ImageDocument(
    @SerializedName("collection")
    val collection: String?, // 컬렉션
    @SerializedName("datetime")
    val dateTime: Date?, // 미리보기 이미지 URL
    @SerializedName("display_sitename")
    val displaySiteName: String?, // 이미지 URL
    @SerializedName("doc_url")
    val docUrl: String?, // 이미지의 가로 길이
    @SerializedName("height")
    val height: Int?, // 이미지의 세로 길이
    @SerializedName("image_url")
    val imageUrl: String?, // 출처
    @SerializedName("thumbnail_url")
    val thumbnailUrl: String?, // 문서 URL
    @SerializedName("width")
    val width: Int?, // 문서 작성시간
)

data class VideoDocument(
    val title: String?, // 동영상 제목
    val url: String?, // 동영상 링크
    @SerializedName("datetime")
    val dateTime: Date?, // 동영상 등록일
    @SerializedName("play_time")
    val playTime: Int?, // 동영상 재생시간, 초 단위
    @SerializedName("thumbnail")
    val thumbnailUrl: String?, // 동영상 미리보기 URL
    val author: String?, // 동영상 업로더
)

Data Model

// 검색 결과 후 사용할 데이터 모델을 생성한다. 
@Parcelize
data class SearchModel(
    val id: String = UUID.randomUUID().toString(),
    val thumbnailUrl: String?,
    val siteName: String?,
    val datetime: Date?,
    val itemType: SearchListType,
    val isSaved: Boolean = false
) : Parcelable
/*
 * 데이터의 타입을 관리하기 위해 IMAGE, VIDEO 두 개의 상수 인스턴스가 포함된 enum class를 생성한다. 
 */
enum class SearchListType {
    IMAGE,
    VIDEO
    ;

    companion object {
        fun from(type: SearchListType): String = when (type) {
            IMAGE -> "이미지"
            VIDEO -> "비디오"
        }
    }
}
data class SearchUiState(
    val list: List<SearchModel>,
    val showSnackMessage: Boolean = false,
    val snackMessage: Int? = null
) {
    companion object {
        fun init() = SearchUiState(
            list = emptyList(),
            showSnackMessage = false,
            snackMessage = null
        )
    }
}

GET 요청

  • api 키와 인자를 전달 받아서 이미지와 동영상 검색 api에 GET 요청을 수행하는 서비스를 만들어준다.
interface KaKaoSearchApi {
    @Headers("Authorization: ${Constants.AUTH_HEADER}")
    @GET("v2/search/image")
    suspend fun searchImage(
        @Query("query") query: String, // 검색을 원하는 질의어
        @Query("sort") sort: String, // 결과 문서 정렬 방식, accuracy(정확도순) 또는 recency(최신순), 기본 값 accuracy
        @Query("page") page: Int, // 결과 페이지 번호, 1~50 사이의 값, 기본 값 1
        @Query("size") size: Int // 한 페이지에 보여질 문서 수, 1~80 사이의 값, 기본 값 80
    ): SearchResponse<ImageDocument>

    @Headers("Authorization: ${Constants.AUTH_HEADER}")
    @GET("v2/search/vclip")
    suspend fun searchVideo(
        @Query("query") query: String, // 검색을 원하는 질의어
        @Query("sort") sort: String, // 결과 문서 정렬 방식, accuracy(정확도순) 또는 recency(최신순), 기본 값 accuracy
        @Query("page") page: Int, // 결과 페이지 번호, 1~15 사이의 값
        @Query("size") size: Int // 한 페이지에 보여질 문서 수, 1~30 사이의 값, 기본 값 15
    ): SearchResponse<VideoDocument>

}

Repository

	/*
     * 이미지와 동영상 검색 결과를 Pair 클래스를 사용하여 두 개의 리스트를 묶어 반환한다. 
     * 매개변수로 사용자가 입력한 검색어와 검색 할 페이지를 받는다. 
     */
    suspend fun searchCombinedResults(
        query: String,
        imagePage: Int,
        videoPage: Int
    ): Pair<SearchUiState, SearchUiState>
interface ImageSearchRepository {

	// ImageDocument 타입을 가지는 SearchResponse 클래스를 반환한다.
    suspend fun searchImage(
        query: String,
        sort: String = SORT_TYPE,
        page: Int,
        size: Int = MAX_SIZE_IMAGE
    ): SearchResponse<ImageDocument>

	// VideoDocument 타입을 가지는 SearchResponse 클래스를 반환한다.
    suspend fun searchVideo(
        @Query("query") query: String,
        @Query("sort") sort: String = SORT_TYPE,
        @Query("page") page: Int,
        @Query("size") size: Int = MAX_SIZE_VIDEO
    ): SearchResponse<VideoDocument>

	/*
     * 이미지와 동영상 검색 결과를 Pair 클래스를 사용하여 두 개의 리스트를 묶어 반환한다. 
     */
    suspend fun searchCombinedResults(
        query: String,
        imagePage: Int,
        videoPage: Int
    ): Pair<SearchUiState, SearchUiState>

	...
    
}
class ImageSearchRepositoryImpl(context: Context) : ImageSearchRepository {
    override suspend fun searchImage(
        query: String,
        sort: String,
        page: Int,
        size: Int
    ): SearchResponse<ImageDocument> {
        return NetWorkClient.ImageNetWork.searchImage(query, sort, page, size)
    }

    override suspend fun searchVideo(
        query: String,
        sort: String,
        page: Int,
        size: Int
    ): SearchResponse<VideoDocument> {
        return NetWorkClient.ImageNetWork.searchVideo(query, sort, page, size)
    }

    private val pref: SharedPreferences = context.getSharedPreferences(Constants.PREFERENCE_NAME, 0)

	/*
     * 코루틴 빌더를 사용하여 두 개의 비동기 작업(이미지와 동영상 검색)이 동시에 시작된다. 
     * 이미지와 동영상 검색을 위한 함수로 Pair 객체를 사용하여 두 개의 검색 결과를 묶어 반환한다.
     * 
     */
    override suspend fun searchCombinedResults(
        query: String,
        imagePage: Int,
        videoPage: Int
    ): Pair<SearchUiState, SearchUiState> = coroutineScope {
    	/*
         * 이미지 검색 
         */
        val imageDeferred = async {
            try {
                val response = searchImage(query = query, page = imagePage)
                SearchUiState(list = response.documents?.map {
                    SearchModel(
                        thumbnailUrl = it.thumbnailUrl,
                        siteName = it.displaySiteName,
                        datetime = it.dateTime,
                        itemType = SearchListType.IMAGE
                    )
                } ?: emptyList())
            } catch (e: Exception) {
                throw e
            }
        }

		/*
         * 동영상 검색 
         */ 
        val videoDeferred = async {
            try {
                val response = searchVideo(query = query, page = videoPage)
                SearchUiState(list = response.documents?.map {
                    SearchModel(
                        thumbnailUrl = it.thumbnailUrl,
                        siteName = it.title,
                        datetime = it.dateTime,
                        itemType = SearchListType.VIDEO
                    )
                } ?: emptyList())
            } catch (e: Exception) {
                throw e
            }
        }
		// 이미지와 비디오 검색 결과의 리스트를 묶어 반환한다. 
        Pair(imageDeferred.await(), videoDeferred.await())
    }
    

    ...
    
    
}

SearchViewModel


class SearchViewModel(
    private val imageSearchRepository: ImageSearchRepository
) : ViewModel() {

    private val _searchResult = MutableLiveData(SearchUiState.init())
    val searchResult: LiveData<SearchUiState> get() = _searchResult

    private var _pageCounts = MutableLiveData(SearchPageCountUiState.init())
    val pageCounts: LiveData<SearchPageCountUiState> get() = _pageCounts

    private val _searchWord = MutableLiveData<String>()
    val searchWord: LiveData<String> get() = _searchWord

	...

	/*
     * 사용자가 검색어를 입력한 후 버튼을 클릭하면 호출되는 함수로 한 개의 키워드로 이미지, 동영상 검색이 동시에 일어난다.
     * 검색을 통해 수신된 데이터를 리스트로 보여주기 위해 LiveData를 업데이트 하며, 
     * 이미지, 동영상 검색 결과를 한 개의 리스트에 저장하여 관리한다. 
     */
    fun searchCombinedResults(query: String) = viewModelScope.launch {
        try {
            val pageCounts = _pageCounts.value ?: SearchPageCountUiState.init()

            val (imageResponse, videoResponse) =
                imageSearchRepository.searchCombinedResults(
                    query = query,
                    imagePage = pageCounts.imagePageCount,
                    videoPage = pageCounts.videoPageCount
                )

            _searchResult.value = SearchUiState(
                list = (imageResponse.list + videoResponse.list).sortedByDescending { it.datetime }
            )
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

    fun resetPageCount() {
        _pageCounts.value = SearchPageCountUiState.init()
    }

    fun plusPageCount() {
        val currentCounts = _pageCounts.value ?: SearchPageCountUiState.init()

        val imageCount = if (currentCounts.imagePageCount < MAX_PAGE_COUNT_IMAGE)
            currentCounts.imagePageCount + 1
        else 1

        val videoCount = if (currentCounts.videoPageCount < MAX_PAGE_COUNT_VIDEO)
            currentCounts.videoPageCount + 1
        else 1

        _pageCounts.value = SearchPageCountUiState(
            imagePageCount = imageCount,
            videoPageCount = videoCount
        )
    }
    
	...

}

class StorageViewModelProviderFactory(
    private val imageSearchRepository: ImageSearchRepository
) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(StorageViewModel::class.java)) {
            return StorageViewModel(
                imageSearchRepository
            ) as T
        }
        throw IllegalArgumentException("ViewModel class not found")
    }
}

SearchListFragment

package com.android.imagesearch.ui.main.search

import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.SearchView
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.RecyclerView
import com.android.imagesearch.databinding.FragmentSearchBinding
import com.android.imagesearch.repository.ImageSearchRepositoryImpl
import com.google.android.material.snackbar.Snackbar

class SearchListFragment : Fragment() {
    companion object {
        fun newInstance() = SearchListFragment()
    }

    private var _binding: FragmentSearchBinding? = null

    private val binding get() = _binding!!

    private val viewModel: SearchViewModel by viewModels {
        SearchViewModelProviderFactory(
            ImageSearchRepositoryImpl(requireActivity())
        )
    }

    private val searchListAdapter by lazy {
        SearchListAdapter(
            itemClickListener = {
                viewModel.updateStorageItem(it)
            }
        )
    }

    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)

        initView()
        initViewModel()
    }

    override fun onResume() {
        super.onResume()
        viewModel.reloadStorageItems()
    }

    private fun initView() {
        initSearchView()

        initRecyclerView()
    }

    private fun initRecyclerView() = with(binding) {
        recyclerSearch.adapter = searchListAdapter

        recyclerSearch.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
                super.onScrollStateChanged(recyclerView, newState)
                if (!binding.recyclerSearch.canScrollVertically(1)
                    && newState == RecyclerView.SCROLL_STATE_IDLE
                ) {
                    viewModel.plusPageCount()
                }
            }
        })
    }

    private fun initViewModel() = with(viewModel) {
    	/*
         * RecyclerView의 데이터셋을 업데이트한다. 
         * 이미지와 동영상 아이템이 한 개의 리스트에 표시된다. 
         */
        searchResult.observe(viewLifecycleOwner) {
            searchListAdapter.submitList(it.list)
        }

		// 스크롤 끝을 감지하면 다음 페이지를 검색한다. 
        pageCounts.observe(viewLifecycleOwner) {
            val query = binding.searchView.query.toString()
            viewModel.searchCombinedResults(query)
        }
    }

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

    override fun onStop() {
        super.onStop()
    }

    private fun initSearchView() = with(binding) {
        searchView.isSubmitButtonEnabled = true
        searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener,
            androidx.appcompat.widget.SearchView.OnQueryTextListener {
            override fun onQueryTextSubmit(query: String?): Boolean {
                if (query != null) {
                	// 새로 검색할 경우 현재 페이지 수를 초기화한다. 
                    viewModel.resetPageCount()
                }

                return false
            }

            override fun onQueryTextChange(newText: String?): Boolean = false
        })
    }

	...

}
profile
개발 공부 기록 🌱

0개의 댓글