티스토리 뷰

앱개발 심화 과제

 

필수 과제 구현

 

구현 사항

// 이미지 검색 Fragment
- 검색어를 입력할 수 있도록 검색창을 구현합니다.
- 검색어를 입력하고 검색 버튼을 누르면 검색된 이미지 리스트를 보여줍니다.
- 검색 버튼을 누르면 키보드는 숨김 처리하도록 구현합니다.
- API 검색 결과에서 thumbnail_url, display_sitename, datetime을 받아오도록 구현 합니다.
- RecyclerView의 각 아이템 레이아웃을 썸네일 이미지, 사이트이름, 날짜 시간 으로 구현 합니다.
- API 검색 결과를 RecyclerView에 표시하도록 구현합니다.
- 날짜 시간은 "yyyy-MM-dd HH:mm:ss" 포멧으로 노출되도록 구현합니다.
- 검색 결과는 최대 80개까지만 표시하도록 구현합니다.
- 리스트에서 특정 이미지를 선택하면 특별한 표시를 보여주도록 구현합니다.
- 선택된 이미지는 MainActivity의 ‘선택된 이미지 리스트 변수’에 저장합니다.
- 마지막 검색어는 저장 되며, 앱 재시작시 마지막 검색어가 검색창 입력 필드에 자동으로 입력됩니다.

// 내 보관함 Fragment
- MainActivity의 ‘선택된 이미지 리스트 변수’에서 데이터를 받아오도록 구현합니다.
- 내 보관함 Recyclerview는 이미지 검색’과 동일하게 구현합니다.
- 내 보관함에 보관된 이미지를 선택하면 보관함에서 제거할 수 있도록 구현합니다.

 

인터넷 권한 설정

<uses-permission android:name="android.permission.INTERNET"/>

api 호출을 위해 매니패스트 파일에 인터넷 사용 권한을 추가한다.

 

 

dependencies 추가

dependencies {
    implementation("androidx.fragment:fragment-ktx:1.8.2")
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("com.squareup.retrofit2:converter-gson:2.11.0")
    implementation("com.github.bumptech.glide:glide:4.16.0")
    ...
}

프래그먼트, retrofit, gson, glide를 사용하기 위해 의존성을 추가한다.

 

 

레이아웃

 

메인화면은 프래그먼트를 띄울 frameLayout과 하단에

프래그먼트 전환 버튼을 LInearLayout 내에 배치시켜 구성한다.

 

 

생각해보니까 프래그먼트에서 여백을 줄게 아니라

프레임 레이아웃에서 여백을 주면 됐을 거 같다.

 

 

아이템에선 이미지, 출처 사이트, 작성일자를 출력한다.

이미지 선택 표시는 우측 상단에 배치해 보이지 않게 설정한다.

 

 

프로젝트 폴더 구조

 

프로젝트 내에 3개의 패키지를 추가해서 파일을 분류하였다.

 

api 폴더에는 api 호출을 위해 필요한 파일들이 들어갈 것이고,

util 폴더에는 전역적으로 사용될 변수나 함수들을 정의해놓은 파일들이 들어갈 것이다.

 

 

Utils

// util/Utils.kt

object Utils {
    // 날짜 시간 포맷팅
    fun getFormatDate(date: String): String {
        val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.getDefault())
        val outputFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
        val date: Date? = inputFormat.parse(date)
        return date?.let { outputFormat.format(it) } ?: ""
    }
    
    // 마지막 검색어 저장
    fun saveLastQuery(context: Context, query: String) {
        val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
        prefs.edit() { putString(LAST_QUERY, query) }
    }

    // 마지막 검색어 불러오기
    fun getLastQuery(context: Context): String? {
        val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
        return prefs.getString(LAST_QUERY, null)
    }

    // 토스트 메시지 띄우기
    fun showToast(context: Context, text: String ){
        Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
    }
}

 

Utils는 싱글톤 객체이기에 인스턴스를 생성하지 않고 바로 내부에 있는 함수들을 사용할 수 있다.

 

 

REST API 키 발급

 

kakao developers 들어가서 로그인 > 상단 탭 내 애플리케이션 클릭 > 애플리케이션 추가 후 클릭

 

 

좌측 탭에서 앱 키 눌러서 앱 키 확인 페이지로 이동 후 필요한 키를 복사한다.

 

 

상수 정의

// util/Constans.kt

object Constants {
    // Kakao Image Search API의 기본 URL
    const val BASE_URL = "https://dapi.kakao.com/"

    // Kakao REST API 키
    const val API_KEY = "REST API 키 여기다 붙여넣으시면 됩니다."

    // 앱의 Shared Preferences 파일 이름
    const val PREFS_NAME = "com.example.search.pref."
    
    // 마지막 검색어를 저장하기 위한 키 값
    const val LAST_QUERY = "LAST_QUERY"
}

앱 내에서 전역적으로 사용될 상수들을 정의해준다.

PREFS_NAME 은 보통 애플리케이션 이름이나 특정 모듈의 이름을 사용한다.

 

 

데이터 클래스 설계

// api/ApiResponse.kt

import com.google.gson.annotations.SerializedName

data class APIResponse(
    val documents: ArrayList<Document>
)

data class Document(
    @SerializedName("thumbnail_url")
    val thumbnailUrl: String,
    
    @SerializedName("display_sitename")
    val displaySitename: String,
    
    val datetime: String,
    
    var isSelected: Boolean = false
)

API 검색 결과에서 thumbnail_url, display_sitename, datetime을 받아오도록 구현한다.

@SerializedName 어노테이션은 JSON 데이터와 Kotlin 클래스의 필드 이름을 매핑할 때 사용된다.

 

APIResponse는 api 호출 결과를 반환하며, Document는 각 검색결과 오브젝트에 해당한다.

datatime은 출력할 때 포맷팅을 진행할 것이기 때문에 Date가 아닌 String으로 받아온다.

 

 

API 호출 설정

 

카카오 이미지 검색 api를 호출하려면 지정된 URL에 GET요청을 보내야 한다.

 

 

엔드포인트에는 위와같은 것들이 있다.

 

// api/RetrofitInterface.kt

import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Query

interface RetrofitInterface {
    @GET("v2/search/image")
    fun searchImage(
        @Header("Authorization") key: String,
        @Query("query") query : String,
        @Query("sort") sort : String = "recency",
        @Query("page") page: Int = 1,
        @Query("size") size: Int = 80
    ) : Call<APIResponse>
}

Retrofit2 라이브러리를 사용하여 API 엔드포인트와 상호작용하는 인터페이스를 정의한다.

결과는 최신순으로 정렬하고 검색 결과를 최대 80개까지 표시하도록 한다. 

 

// api/RetrofitInstance.kt

import com.example.search.util.Constants.BASE_URL
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

object RetrofitInstance {
    val apiService: RetrofitInterface by lazy {
        // Retrofit 인스턴스 생성
        Retrofit.Builder() 
            // API 요청을 보낼 기본 URL 설정
            .baseUrl(BASE_URL)
            // JSON 데이터를 Kotlin 데이터 클래스 객체로 변환할 수 있도록 Gson 컨버터 추가
            .addConverterFactory(GsonConverterFactory.create())
            // Retrofit 객체 생성 후 해당 인터페이스의 인스턴스 생성
            .build().create(RetrofitInterface::class.java)
    }
}

Retrofit 라이브러리를 사용해 API와 통신하기 위한 설정을 캡슐화한 싱글톤 객체를 선언한다.

지연 초기화를 통해 apiService는 api가 처음 호출되는 시점에 초기화 되도록 한다.

 

 

메인 액티비티

class MainActivity : AppCompatActivity(){
    
    // 선택된 아이템들을 저장하는 리스트
    var selectedItems: ArrayList<Document> = ArrayList()
    
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
		
        // 바인딩 설정
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
		
        // 레이아웃 설정
        enableEdgeToEdge()
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }
		
        // 버튼 클릭시 프래그먼트 전환 설정
        binding.apply {
            btn1.setOnClickListener { setFragment(SearchFragment()) }
            btn2.setOnClickListener { setFragment(StoreFragment()) }
        }
		
        // 앱 시작 시 기본적으로 SearchFragment를 표시
        setFragment(SearchFragment())
    }
	
    // 프래그먼트 화면에 표시
    private fun setFragment(frag : Fragment) {
        supportFragmentManager.commit {
            replace(R.id.frameLayout, frag)
            setReorderingAllowed(true)
            addToBackStack(null)
        }
    }
	
    // 선택된 아이템을 리스트에 추가
    fun addLikedItem(item: Document) {
        if(!selectedItems.contains(item)) {
            selectedItems.add(item)
        }
    }
	
    // 선택 취소된 아이템을 리스트에서 제거
    fun removeLikedItem(item: Document) {
        selectedItems.remove(item)
    }
}

메인에서 선택된 이미지 리스트 변수와 그 리스트에서 아이템을 추가 및 제거하는 함수들을 선언한다. 

 

 

검색 프래그먼트 구현

// fragment/store/SearchAdapter.kt

// 이미지 검색 결과를 표시하는 어댑터 클래스
class SearchAdapter(private val mContext: Context) : RecyclerView.Adapter<SearchAdapter.ViewHolder>() {
	
    // api 호출 시 초기화 되는 아이템 리스트
    private var items = ArrayList<Document>()

    inner class ViewHolder(private val binding: ItemBinding) :
        RecyclerView.ViewHolder(binding.root), View.OnClickListener {

        var thumbnail = binding.ivThumbnail
        var sitename = binding.tvSitename
        var datetime = binding.tvDatetime
        var likeIcon = binding.ivLike
            
        fun bind(item: Document) {
            // Glide를 사용해 썸네일 이미지 로드 후 레이아웃에 바인딩
            Glide.with(mContext).load(item.thumbnailUrl).into(binding.ivThumbnail)
            
            sitename.text = item.displaySitename
            
            // date형의 문자열을 "yyyy-MM-dd HH:mm:ss" 형태로 포맷팅
            datetime.text = getFormatDate(item.datetime)
            
            // 아이템 선택 여부에 따라 아이콘 표시
            likeIcon.visibility = if (item.isSelected) View.VISIBLE else View.INVISIBLE
        }
        
        init {
            // 썸네일 및 아이콘 누르면 onClick 실행
            thumbnail.setOnClickListener(this)
            likeIcon.setOnClickListener(this)
        }

        // 아이템 클릭 시 발생하는 이벤트 처리
        override fun onClick(view: View) {
            val item = items[adapterPosition]
            
            // 아이템 선택/해제
            item.isSelected = !document.isSelected
            
            // 아이템 선택 여부에 따라 메인의 selectedItems에
            // 해당 아이템을 추가하거나 제거한다
            if(item.isSelected){
                (mContext as MainActivity).addLikedItem(item)
            } else{
                (mContext as MainActivity).removeLikedItem(item)
            }
            
            // 객체의 상태를 변경했으면 어댑터에 통지해야 함
            notifyItemChanged(adapterPosition)
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val binding = ItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val item = items[position]
        holder.bind(item)
    }

    override fun getItemCount(): Int = items.size

    // 아이템 목록 갱신
    fun updateItem(newItems: ArrayList<Document>) {
        items = newItems
        notifyDataSetChanged()
    }

    // 아이템 목록 초기화
    fun clearItem() {
        items.clear()
        notifyDataSetChanged()
    }
}

notifyDataSetChanged()를 호출하면 어댑터의 데이터를 전체적으로 갱신한다.

 

// fragment/store/SearchFragment.kt

// 사용자에게 이미지 검색 기능을 제공하는 Fragment 클래스
class SearchFragment : Fragment() {
	
    private var _binding: FragmentSerachBinding? = null
    private val binding get() = _binding!!

    private lateinit var mContext: Context
    private lateinit var adapter: SearchAdapter
	
    // 어댑터 아이템
    private var items: ArrayList<Document> = ArrayList()
	
    // 컨텍스트 초기화
    override fun onAttach(context: Context) {
        super.onAttach(context)
        mContext = context
    }
	
    // 뷰가 생성되는 시점에서 프래그먼트 설정
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentSerachBinding.inflate(inflater, container, false)

        setUpView()
        setUpListener()

        return binding.root
    }

    // 뷰의 초기설정 진행
    private fun setUpView(){
        // 어댑터 설정
        adapter = SearchAdapter(mContext)
        binding.recyclerView.adapter = adapter
        binding.recyclerView.layoutManager = GridLayoutManager(context, 2)
        
        // 마지막 검색어 검색창에 불러오기
        val lastSearch = getLastQuery(requireContext())
        binding.etSearch.setText(lastSearch)
    }
	
    // 리스너 동작 정의
    private fun setUpListener(){
        binding.btnSearch.setOnClickListener {
            hideKeyboard(it)

            val query = binding.etSearch.text.toString()
            if (query.isNotBlank()) {
                adapter.clearItem()
                search(query)
            } else {
                showToast(requireContext(), "검색어를 입력해주세요.")
            }

        }
    }
	
    // 키보드 숨기기
    private fun hideKeyboard(view:View){
        val inputMethodManager = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
        inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0)
        view.clearFocus()
    }
	
    // 입력한 검색어로 이미지 검색
    private fun search(query: String) {
        val authHeader = "KakaoAK ${API_KEY}"
        val call = RetrofitInstance.apiService.searchImage(authHeader, query)
		
        // api 호출에 성공했다면 검색결과(아이템)을 어댑터에 추가 후 검색어 저장
        call.enqueue(object : Callback<APIResponse> {
            override fun onResponse(
                call: Call<APIResponse>,
                response: Response<APIResponse>
            ) {
                if (response.isSuccessful) {
                    val apiResponse = response.body()!!
                    items = apiResponse.documents
                    adapter.updateItem(items)
                    saveLastQuery(requireContext(),query)
                }
            }

            override fun onFailure(call: Call<APIResponse>, t: Throwable) {
                Log.e("DEBUG", "통신 실패: ${t.message}")
            }
        })
    }

    // 메모리 누수를 방지하기 위해 뷰가 파괴될 때 바인딩 객체를 null로 설정
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

searchImage에 쿼리와 헤더를 넣어 get 요청을 보내 api를 호출하고

응답 결과(Document 객체들)를 어댑터의 아이템으로 초기화한다. 

 

// response.body

APIResponse(
    documents=[Document(thumbnailUrl=https://search2.kakaocdn.net/argon/130x130_85_c/kuloXm808K, displaySitename=전업농신문, datetime=2024-08-13T11:53:50.000+09:00, isSelected=false), 
               Document(thumbnailUrl=https://search4.kakaocdn.net/argon/130x130_85_c/CfA1IjIkXc5, displaySitename=이지경제, datetime=2024-08-13T11:53:00.000+09:00, isSelected=false),
               ...]
)

api를 호출하면 설계한 데이터 클래스에 맞춰 response가 반환된다.

따라서 아이템은 response.body()!!.documents에 해당한다. 

 

 

보관함 프래그먼트 구현

// fragment/store/StoreAdpater.kt

class StoreAdapter(private val mContext: Context) :
    RecyclerView.Adapter<StoreAdapter.ViewHolder>() {

    var items = mutableListOf<Document>()

    inner class ViewHolder(private val binding: ItemBinding) :
        RecyclerView.ViewHolder(binding.root) {
        fun bind(item: Document) {
            ...
            binding.ivThumbnail.setOnClickListener {
            
                val position = adapterPosition
                
                // 아이템 위치가 유효한지 확인 후 아이템 삭제
                if (position != RecyclerView.NO_POSITION) {
                    items.removeAt(position)
                    notifyItemRemoved(position)
                }
            }
        }
    }
    
    ...
}

검색 어댑터에서 이미지 선택여부에 따른 아이콘 설정 구문을 지우고

썸네일을 눌렀을 때 아이템 리스트에서 선택한 아이템을 지우는 로직을 추가한다.

 

아이템을 삭제했을 땐 notifyItemRemoved로 아이템 제거 사실을 어댑터에 통지해줘야 한다.

 

// fragment/store/StoreFragment.kt

class StoreFragment : Fragment() {

    ...

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        val mainActivity = activity as MainActivity

        adapter = StoreAdapter(mContext).apply {
            items = mainActivity.selectedItems.toMutableList()
        }

        ...

        return binding.root
    }

    ...
}

메인의 선택된 이미지 리스트를 어댑터의 아이템에 할당한다.

 

 

문제점

구현사항은 전부 충족하였으나 현재 앱에는 치명적인 문제점이 있다.

 

내 보관함에서 이미지를 지우고 검색 프래그먼트로 갔다가 다시 돌아오면

이미지 삭제 여부가 반영되지 않고 이미지를 지우기 전의 상태로 돌아온다는 것이다.

 

마찬가지로 검색 후 내 보관함으로 갔다가 다시 돌아오면

검색 결과가 저장되지 않고 빈화면이 나오는 문제점 또한 존재한다.

 

 

해결 방안

이러한 문제점들은 SharedPreference를 활용해 변경사항을 저장하고 

프래그먼트가 생성되는 시점에서 변경사항을 불러오는 식으로 해결할 수 있다.

 

이거는 선택과제에서 구현할 것이다. 근데 말이 선택이지 거의 필수과제 급인데 ㅋㅋㅋ

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함