이번에는 Kakao Api를 사용하여 이미지와 동영상 검색을 동시에 이루어지도록 했다. 사용자가 검색 화면에서 검색어를 입력한 후 버튼을 클릭하면 한 개의 검색 키워드로 이미지, 동영상 검색이 가능하다.
이번 포스팅에는 Kakao Api 이미지 검색 과 달라진 부분만 정리하였다 😌
이미지와 동영상 검색 결과를 같이 보여주며 날짜순으로 정렬하여 보여주고 있다.
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?, // 동영상 업로더
)
// 검색 결과 후 사용할 데이터 모델을 생성한다.
@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
)
}
}
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>
}
/* * 이미지와 동영상 검색 결과를 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())
}
...
}
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")
}
}
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
})
}
...
}