[Android / Kotlin] Kakao API 이미지 검색

Subeen·2024년 1월 30일
1

Android

목록 보기
54/73

KAKAO REST API를 사용하여 검색어를 통해 이미지 검색이 가능하고 검색 결과를 리스트로 보여주는 앱이 이번 챕터 과제여서 구현 한 내용을 정리하고자 한다. 🤯

결과 화면

인터넷 접근 권한

  • Retrofit으로 이미지 검색 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>

DTO 작성

  • 이미지 검색 API의 Json 응답에 대응하는 DTO를 작성한다.
  • Json은 객체를 중괄호로 감싸서 표시하는데, 이걸 DTO로 변환할 때는 객체 하나를 데이터 클래스 하나로 대응시킨다.
  • Document, MetaData, 그리고 두 개를 포함하는 SearchResponse 데이터 클래스를 생성한다.
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

GET 요청

  • api 키와 인자를 전달 받아서 이미지 검색 api에 GET 요청을 수행하는 서비스를 만들어준다.
  • GET 요청에 필요한 주소와 인증에 필요한 Header를 정의한다.
  • 그 외의 parameter는 Query annotation을 작성하여 전달한다.
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 클래스를 반환한다.
   

Retrofit 인스턴스 생성

  • 위에서 만들어준 서비스를 사용하기 위한 Retrofit 인스턴스를 만들어준다.
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)

}

Repository

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?
}
  • retrofit api에 searchImage를 실행시켜서 Response를 반환받도록 구현한다.
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, "")    

}

SearchViewModel

  • Repository에서 받아온 데이터를 화면에 표시하기 위해 View Model을 생성한다.

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")
    }
}

SearchListFragment

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)
}

ListAdapter와 DiffUtil

  • 이미지 검색 결과를 리스트 형태의 데이터로 표현하기 위해 ListAdapter를 생성하여 RecyclerView에 연결한다.
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)
            }
        }
    }

}
  • DiffUtil 동작을 위한 콜백 등록
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
    }
}
profile
개발 공부 기록 🌱

0개의 댓글