KAKAO REST API를 사용하여 검색어를 통해 이미지 검색이 가능하고 검색 결과를 리스트로 보여주는 앱이 이번 챕터 과제여서 구현 한 내용을 정리하고자 한다. 🤯
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
...
</manifest>
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?, // 문서 작성시간
)
// Document 형식을 그대로 사용해도 되겠지만, 데이터 클래스를 새로 만들어 사용하였다.
@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
interface KaKaoSearchApi {
@Headers("Authorization: ${Constants.AUTH_HEADER}") // GET 요청에 필요한 주소
@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> // ImageDocument 타입을 가지는 SearchResponse 클래스를 반환한다.
object NetWorkClient {
private fun createOkHttpClient(): OkHttpClient {
val interceptor = HttpLoggingInterceptor()
// 통신이 안 될 때 디버깅을 위한 용도
if (BuildConfig.DEBUG)
interceptor.level = HttpLoggingInterceptor.Level.BODY
else
interceptor.level = HttpLoggingInterceptor.Level.NONE
return OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.addNetworkInterceptor(interceptor)
.build()
}
private val dustRetrofit = Retrofit.Builder()
.baseUrl(BASE_URL).addConverterFactory(GsonConverterFactory.create()).client( // json 파일 convert
createOkHttpClient()
).build()
// retrofit의 create 명령을 이용해서 이미지 검색 api의 인스턴스를 생성
val ImageNetWork: KaKaoSearchApi = dustRetrofit.create(KaKaoSearchApi::class.java)
}
interface ImageSearchRepository {
suspend fun searchImage(
query: String,
sort: String = SORT_TYPE,
page: Int,
size: Int = MAX_SIZE_IMAGE
): SearchResponse<ImageDocument>
suspend fun saveStorageItem(searchModel: SearchModel)
suspend fun removeStorageItem(searchModel: SearchModel)
suspend fun getStorageItems(): List<SearchModel>
suspend fun searchResults(
query: String,
imagePage: Int
): SearchUiState
suspend fun saveSearchData(searchWord: String)
suspend fun loadSearchData(): String?
}
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)
}
private val pref: SharedPreferences = context.getSharedPreferences(Constants.PREFERENCE_NAME, 0)
/**
* 이미지 검색 화면에서 이미지를 클릭하면 보관함에 저장하기 위한 함수
* id를 비교해서 존재하지 않을 경우에만 아이템을 추가한다.
*/
override suspend fun saveStorageItem(searchModel: SearchModel) {
val favoriteItems = getPrefsStorageItems().toMutableList()
val findItem = favoriteItems.find { it.id == searchModel.id }
if (findItem == null) {
favoriteItems.add(searchModel)
savePrefsStorageItems(favoriteItems)
}
}
/**
* 보관함에 저장된 이미지를 삭제하기 위한 함수
* 해당 아이템이 보관함에 존재하면 아이템을 삭제한다.
*/
override suspend fun removeStorageItem(searchModel: SearchModel) {
val favoriteItems = getPrefsStorageItems().toMutableList()
favoriteItems.removeAll { it.id == searchModel.id }
savePrefsStorageItems(favoriteItems)
}
override suspend fun searchResults(
query: String,
imagePage: Int
): 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
}
}
imageDeferred.await()
}
/**
* 보관함에 저장되어 있는 아이템을 리스트 목록으로 가져온다.
*/
override suspend fun getStorageItems(): List<SearchModel> {
return getPrefsStorageItems()
}
private fun getPrefsStorageItems(): List<SearchModel> {
val jsonString = pref.getString(STORAGE_ITEMS, "")
return if (jsonString.isNullOrEmpty()) {
emptyList()
} else {
/**
* Gson()을 사용하여 Json 문자열을 SearchModel 객체로 변환
*/
Gson().fromJson(jsonString, object : TypeToken<List<SearchModel>>() {}.type)
}
}
/**
* SearchModel 객체 아이템을 Json 문자열로 변환한 후 저장
*/
private fun savePrefsStorageItems(items: List<SearchModel>) {
val jsonString = Gson().toJson(items)
pref.edit().putString(STORAGE_ITEMS, jsonString).apply()
}
/**
* 검색 된 단어 저장
*/
override suspend fun saveSearchData(searchWord: String) {
pref.edit {
putString(Constants.SEARCH_WORD, searchWord)
}
}
/**
* 검색 된 단어 불러오기
*/
override suspend fun loadSearchData(): String? =
pref.getString(Constants.SEARCH_WORD, "")
}
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
init {
// 저장 되어 있는 검색 단어를 불러온다.
getStorageSearchWord()
}
// 검색 된 단어를 저장한다.
fun saveStorageSearchWord(query: String) = viewModelScope.launch {
imageSearchRepository.saveSearchData(query)
_searchWord.value = query
}
private fun getStorageSearchWord() = viewModelScope.launch {
_searchWord.value = imageSearchRepository.loadSearchData() ?: ""
}
/**
* viewModelScope.launch를 사용하여 네트워크 요청을 비동기적으로 수행한다.
* 검색을 통해 수신된 데이터를 리스트로 보여주기 위해 LiveData를 업데이트한다.
*/
fun searchResults(query: String) = viewModelScope.launch {
try {
val pageCounts = _pageCounts.value ?: SearchPageCountUiState.init()
val imageResponse =
imageSearchRepository.searchResults(
query = query,
imagePage = pageCounts.imagePageCount,
)
_searchResult.value = SearchUiState(
list = imageResponse.list.sortedByDescending { it.datetime }
)
} catch (e: Exception) {
e.printStackTrace()
}
}
/*
* 검색하기 화면으로 돌아왔을 때 보관홤 화면에서 변경 된 값이 있는지 확인 후 업데이트
*/
fun reloadStorageItems() = viewModelScope.launch {
val storageItems = imageSearchRepository.getStorageItems()
_searchResult.value = _searchResult.value?.copy(
showSnackMessage = false,
list = _searchResult.value?.list?.map { currentItem ->
currentItem.copy(isSaved = storageItems.any { it.id == currentItem.id })
} ?: emptyList()
)
}
/*
* 검색하기 화면에서 이미지 아이템을 클릭하면 내 보관함에 이미지 데이터를 저장한다.
*/
private fun saveStorageImage(searchModel: SearchModel) = viewModelScope.launch {
imageSearchRepository.saveStorageItem(searchModel)
updateSnackMessage(R.string.snack_image_save) // 아이템 저장 메세지
}
/*
* 보관함에 있는 아이템을 한 번 더 클릭하면 보관함에서 이미지 데이터를 삭제한다.
*/
private fun removeStorageItem(searchModel: SearchModel) = viewModelScope.launch {
imageSearchRepository.removeStorageItem(searchModel)
updateSnackMessage(R.string.snack_image_delete) // 아이템 삭제 메세지
}
// 스낵바 메세지 업데이트
private fun updateSnackMessage(snackMessage: Int) {
_searchResult.value = _searchResult.value?.copy(
showSnackMessage = true,
snackMessage = snackMessage
)
}
/*
* 아이템을 클릭할 때 호출되는 함수로 아이템을 보관함에 저장할 때는 saveStorageImage,
* 저장 된 아이템을 삭제할 때 removeStorageItem 함수가 수행된다.
*/
fun updateStorageItem(searchModel: SearchModel) {
val updatedItem = searchModel.copy(isSaved = !searchModel.isSaved)
viewModelScope.launch {
if (updatedItem.isSaved) {
saveStorageImage(updatedItem)
} else {
removeStorageItem(updatedItem)
}
_searchResult.value = _searchResult.value?.copy(
list = _searchResult.value?.list?.map {
if (it.id == updatedItem.id) updatedItem else it
} ?: emptyList()
)
}
}
// 페이지 카운트 초기화
fun resetPageCount() {
_pageCounts.value = SearchPageCountUiState.init()
}
/*
* api에서 검색 가능한 페이지 수가 정해져있으므로 최대 페이지 수를 넘기지 않도록 한다.
* 최대 페이지 수 일 경우에는 다시 1페이지부터 시작한다.
*/
fun plusPageCount() {
val currentCounts = _pageCounts.value ?: SearchPageCountUiState.init()
val imageCount = if (currentCounts.imagePageCount < MAX_PAGE_COUNT_IMAGE)
currentCounts.imagePageCount + 1
else 1
_pageCounts.value = SearchPageCountUiState(
imagePageCount = imageCount,
)
}
}
/*
* SearchViewModel에서 초기값으로 imageSearchRepository를 전달 받기 위해 Factory 생성
* imageSearchRepository를 초기값으로 전달 받아서 SearchViewModel을 반환하는 ViewModelProviderFactory 생성
*/
class SearchViewModelProviderFactory(
private val imageSearchRepository: ImageSearchRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(SearchViewModel::class.java)) {
return SearchViewModel(
imageSearchRepository
) as T
}
throw IllegalArgumentException("ViewModel class not found")
}
}
class SearchListFragment : Fragment() {
companion object {
fun newInstance() = SearchListFragment()
}
private var _binding: FragmentSearchBinding? = null
private val binding get() = _binding!!
// ViewModel 초기화
private val viewModel: SearchViewModel by viewModels {
SearchViewModelProviderFactory(
ImageSearchRepositoryImpl(requireActivity())
)
}
private val searchListAdapter by lazy {
SearchListAdapter(
itemClickListener = {
// 이미지 아이템 클릭시
viewModel.updateItem(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.updateSavedStatus()
}
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) {
searchResult.observe(viewLifecycleOwner) {
// RecyclerView 데이터셋 업데이트
searchListAdapter.submitList(it.list)
// 아이템 추가, 삭제시 스낵바 메세지 표시
if (it.showSnackMessage) {
it.snackMessage?.let { resId ->
showSnackBar(resId)
}
}
}
// 저장 되어 있는 검색 단어 불러오기
searchWord.observe(viewLifecycleOwner) {
binding.searchView.setQuery(it, false)
}
// 스크롤 끝 감지시 페이지 수 + 1
pageCounts.observe(viewLifecycleOwner) {
val query = binding.searchView.query.toString()
viewModel.searchCombinedResults(query)
}
}
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
override fun onStop() {
// 검색 된 단어 저장
val query = binding.searchView.query.toString()
viewModel.saveStorageSearchWord(query)
super.onStop()
}
// 이미지 검색을 위한 SearchView 생성
private fun initSearchView() {
binding.searchView.isSubmitButtonEnabled = true // 검색 버튼 활성화
binding.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 {
// 텍스트 입력, 수정시 호출
return false
}
})
binding.searchView.setQuery(loadData(), false)
}
// 이미지 클릭시 스낵바를 사용하여 메세지 표시
private fun showSnackBar(resId: Int) {
Snackbar.make(
binding.searchFragment,
getString(resId),
Snackbar.LENGTH_SHORT
).show()
}
// 플로팅 버튼 클릭시 리스트의 최상단으로 이동
fun smoothScrollToTop() =
binding.recyclerSearch.smoothScrollToPosition(0)
}
class SearchListAdapter(
private val itemClickListener: (SearchModel) -> Unit
) : ListAdapter<SearchModel, SearchListAdapter.ViewHolder>(SearchDiffUtil) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding =
ImageSearchItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding, itemClickListener)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = getItem(position)
holder.bind(item)
}
class ViewHolder(
private val binding: ImageSearchItemBinding,
private val itemClickListener: ((SearchModel) -> Unit)?
) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: SearchModel) = with(binding) {
ivImage.loadImage(item.thumbnailUrl)
tvImageSitename.text = item.siteName
tvImageDatetime.text = FormatManager.formatDateToString(item.datetime)
ivHeart.isVisible = item.isSaved
ivImage.setOnClickListener {
itemClickListener?.invoke(item)
}
}
}
}
object SearchDiffUtil : DiffUtil.ItemCallback<SearchModel>() {
override fun areItemsTheSame(oldItem: SearchModel, newItem: SearchModel): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: SearchModel, newItem: SearchModel): Boolean {
return oldItem == newItem
}
}