Apple is Apple

일일 회고

 

우선 개인과제 기능 구현 및 수정 단계는 마무리가 되었다. 그래서 오늘은 TIL을 정리 글로 써보았다!

 

아마 추가적인 기능구현은 안 할 것 같고, 버그가 발견되면 수정하는 정도로만 진행할 것 같다.

 

내일부터는 선발대 과제 및 아키텍쳐 관련해서 공부를 해봐야겠다.

 

공부하면 할 수록 할게 더 많이 나오는 느낌이다... 어렵지만 해보자!


오늘의 키워드

  • 개인 과제 정리  

개인 과제 정리

첫 번째 fragment : 검색 결과

  • 검색어를 입력할 수 있습니다.
    • EditText를 이용하여 텍스트 필드를 받을 수 있도록 함
    • EditText에 검색 옵션을 주기 위하여 imeOption 속성을 'actionSearch'로 지정
  • 검색된 이미지 리스트가 나타납니다. 각 아이템에는 이미지와 함께 날짜와 시간을 표시합니다. + 두 검색 결과를 datetime 필드를 이용해 정렬하여 출력합니다. (최신부터 나타나도록)
    • 검색 버튼을 누르거나, 엔터키를 치거나, 가상키보드의 검색 버튼을 누르면 검색을 시작함 (단, 검색어가 비어있으면 검색되지 않도록 막아놓음)
    • 엔터키, 가상키보드는 EditText의 setOnEditorActionListener를 활용
    • 검색 액션 시, 구현해놓은 Retrofit 연결을 통해 KaKao search service를 실행하여 데이터를 가져옴
    • 최신순 데이터를 가져오기 위해 쿼리 파리미터 중 'sort'를 'recency'로 고정
    • 가져온 데이터는 속성이 굉장히 많은데,  그중에서 필요한 thumnail, title, time을 가져옴 (기타로 이미지 크기가 너무 제각각이길래 크 조정을 위해 width, height값도 가져와봤음)
    • time 속성의 경우 DateTime 형식으로 온다고 했는데, JSON으로 받는 순간, DateTime 형식이 유지된 String타입으로 들어와 확장함수를 통해 출력형식 변환 및 정렬을 위해 Date로 형변환 해주는 함수를 구현함 (최신 순으로 가져왔어도, 다시 한번 정렬을 해주기 위해 Date로 형변환)
searchEditText.setOnEditorActionListener { editText, actionId, keyEvent ->
    if (actionId == EditorInfo.IME_ACTION_SEARCH || keyEvent.keyCode == KeyEvent.KEYCODE_ENTER) {
        // 검색 로직...
    }
    true
}
  • 스크롤을 통해 다음 페이지를 불러옵니다.
    • 문서를 보면 is_end라는 속성이 있는데, 다음과 같다.
    • 현재 페이지가 마지막 페이지인지 여부, 값이 false page를 증가시켜 다음 페이지를 요청할 수 있음
    • 이 속성을 활용해서 다음 페이지 불러오는 기능을 구현하였다.
    • 데이터를 가져올 떄, Response값에서 is_end값을 따로 뽑아내 저장한다.
    • 그리고 RecyclerView의 OnScrollListener를 통해 최하단이 감지되면 페이지 수를 1 증가시키고, 다시 데이터를 가져와 RecylclerView에 바인딩시킨다. 이로써 스크롤을 통해 다음페이지를 불러와볼 수 있다.
private val endScrollListener by lazy {
    object : RecyclerView.OnScrollListener() {
        override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
            super.onScrollStateChanged(recyclerView, newState)
               // 스크롤이 움직이고 있는 상태이고
            if (newState == RecyclerView.SCROLL_STATE_IDLE
                    // 최하단이고
                && !binding.searchRecyclerView.canScrollVertically(1)
            ) {
            	 // is_end 값이 false라면
                if (searchViewModel.isEndClip == false && searchViewModel.isEndImage == false) {
                    // 페이지를 증가시키고, 다음 페이지 가져옴
                    page++
                    fetchItems(binding.searchEditText.text.toString(), page, SCROLL_BOTTOM)
                }
            }
        }  
    }
}
  • 리스트에서 특정 이미지를 선택하여 '내 보관함'으로 저장할 수 있습니다. + 이미 보관된 이미지는 특별한 표시를 보여줍니다. (좋아요/별표/하트 등) 
    • RecyclerView Item에 먼저 표시를 할 별표를 추가
    • CheckedChangeListener를 통해 체크가 되면, SharedPreference를 통해 현재 Recycler Item의 모델을 저장 (보관함에서 보여주려면 모델 정보가 필요하므로 모델 통째로 저장함)
    • 모델을 쉽게 SharedPreference에 저장하기 위해서 Gson 라이브러리를 사용
    • 저장이 완료되면 노란 별 표시로 바뀜
    fun setModel(key: String, value: IntegratedModel?) {
        val editor = prefs.edit()
        // Gson을 통해 모델 객체를 Json 형태의 문자열로 변환
        val model = Gson().toJson(value, IntegratedModel::class.java)
        if(value != null){
            editor.putString(key, model)
        } else {
            editor.putString(key, null)
        }
        editor.apply()
    }

    fun getModel(key: String): IntegratedModel? {
        // json 값을 받아와 리스트로 바꿀 준비
        val jsonList = prefs.getString(key, null)
        val gson = GsonBuilder()
        return gson.gsonToIntegrateModel(jsonList)
    }
    
    fun GsonBuilder.gsonToIntegrateModel(value: String?): IntegratedModel? {
    	// Gson을 통해 Json 형식의 문자열을 Model 타입으로 변환
        return this.create().fromJson(value, IntegratedModel::class.java)
    }
  • 보관된 이미지를 다시 선택하여 보관함에서 제거 가능합니다.
    • 별표시가 된 이미지를 다시 한번 선택하면, 해제되며, SharedPreference에서 삭제
    • 보관함을 가보면 사라진 것을 볼 수 있음
  • 마지막 검색어는 저장되며, 앱 재시작 시 마지막 검색어가 입력 필드에 자동으로 입력됩니다. + 검색어 삭제 기능
    • 검색 액션(엔터, 버튼, 가상키보드)을 할 때마다, 현재의 검색어를 SharedPreference에 저장
    • 검색할 때마다, 모든 검색어를 저장하는 것이 아닌 1개의 preference에다가 업데이트를 시킴
    • 앱 재시작, 다른 화면으로 갔다 올 시 등 검색화면으로 돌아오면 검색어를 EditText에 넣고 자동 검색 실행
    • EditText의 우측에 X 버튼이 있는데, 누르면 SharedPreference에서 검색어를 삭제 

 

두 번째 fragment: 내 보관함

  • 검색 결과에서 보관했던 이미지들이 보관한 순서대로 보입니다. + 보관한 이미지 리스트는 앱 재시작 후 다시 보여야 합니다.    (DB 관련 라이브러리 사용 금지. SharedPreferences 사용 권장)
    • 위의 보관함 기능과 연계됨
    • 이미지를 보관을 할 때, 시간 순이 아닌 보관한 순서대로 보이려면, 클릭을 한 순서를 기억하고 있어야 함
    • 그래서 별표를 누를 때, 모델에 ordering이라는 속성을 추가해서 클릭한 순서를 같이 저장했음
    • 문제는 모델에 넣는 값이기 때문에, 앱을 껐다 켜면, ordering값을 넣는 부분에서 값이 초기화가 되는데, 이러한 현상을 막기 위해, ordering 값도 sharedPreference에 저장하여 순서를 기억하도록 하였음
    • 이렇게 해서, 앱을 껐다 켜서, 별표를 다시 해도 순서가 유지된 채로 보관함 목록을 볼 수 있음
    • ordering 값은 별표를 누를 때마다 1씩 증가시켜 주었음 (Long 타입)
likedCheckBox.setOnCheckedChangeListener { _, isChecked ->
     isLikedResources(isChecked, likedCheckBox)
     if (model.isLiked != isChecked) {
         // 순서를 1개 증가시키고
         App.prefs.id = ++prefsId
         onStarChecked(
            // 증가 시킨 모델을 copy해서, onStarChecked lambda식으로 넘김
            // onStarChecked는 fragment에서 상세 구현됨
            model.copy(
               isLiked = isChecked,
               ordering = prefsId
            )
         )
     }
}
  •  보관함 전체 삭제 기능 추가
    • 보관함 화면에서 Floating Action Button을 추가하였음
    • FAB를 누르면 SharedPreference에 있는 모든 항목을 제거하고 (보관함 순서도 함께 제거) 
    • RecyclerView의 list를 clear 시킴   (RecyclerView.Adapter를 사용했을 때는 clear를 하였고, ListAdapter - DiffUtil로 전환하면서 submitList(emptyList())를 통해 빈 리스트로 바꾸는 동작을 함)
fun clear() {
    // 보관함  모델 제거
    prefs.edit().clear().apply()
    // 보관함 순서 제거
    orderingPrefs.edit().clear().apply()
}


// RecyclerAdapter
saveAdapter.clearItems()

fun clearItems() {
    _list.clear()
    notifyDataSetChanged()
}


// listAdapter - diffutil
saveAdapter.submitList(emptyList())

 

 

적혀있지 않은 내용은 자유롭게 작성하시면 됩니다. (요건을 침해하지 않는 범위에서 기능 추가 등)

 

1. MVVM 패턴 연습 ( + Repository 패턴 적용)

UI Layer - Data Layer를 나누어서 UI Layer에서는 RecyclerView의 Item이나 검색, 스크롤 관련 이벤트만 맡도록 하고,

Data Layer에서 API를 통해 가져온 데이터를 뷰모델 및 Repository에서 가공하였음

UI Layer의 View(fragment)에서는, ViewModel의 LiveData를 Observe 하여 API에서 가져온 값의 변화를 관찰하여, RecyclerView를 업데이트해주도록 함

ViewModel 및 View의 연결은 Sealed Class를 통해 API의 상태 (Success, Error, Loading)에 따라, View의 동작을 처리 해주었음

private fun fetchItems(query: String, page: Int, scrollFlag: Int) = with(binding) {
        searchViewModel.getDatas(AUTHORIZATION, query, page, scrollFlag)
        searchViewModel.state.observe(viewLifecycleOwner) {
            when (it) {
                is APIResponse.Error -> {
                    searchRecyclerView.isVisible = false
                    progressbar.isVisible = false
                    updateProgressbar.isVisible = false
                    requireActivity().toast("오류 발생")
                    Log.e("error", it.message.toString())
                }

                is APIResponse.Loading -> {
                    // 최하단 스크롤일때 -- 하단에서 업데이트 로딩바를 보여줌
                    if (it.data == null) {
                        progressbar.isVisible = false
                        updateProgressbar.isVisible = true
                        searchRecyclerView.isVisible = true
                    } else { // 그외 - 검색 액션
                        updateProgressbar.isVisible = false
                        progressbar.isVisible = true
                        searchRecyclerView.isVisible = false
                    }
                }

                is APIResponse.Success -> {
                    searchRecyclerView.isVisible = true
                    // 마지막 스크롤일떄마다 늘어나는 거 확인
                    Log.e("!!!!!size!!!!!!!! ", searchAdapter.currentList.size.toString())
                    it.data?.let { data ->
                        searchAdapter.submitList(data.toMutableList())
                    }
                    progressbar.isVisible = false
                    updateProgressbar.isVisible = false
                }
            }
        }
    }

 

2. ListAdapter - DiffUtil 적용

기존 RecyclerView.Adapter에서 ListAdapter - DiffUtil로 전환을 하였다.

 

우선 사용하는 모델에 DiffUtil 객체를 만들어 주었다.

data class IntegratedModel(
    val thumbnailUrl: String?,
    val title: String?,
    val dateTime: Date?,
    var height: Int = 500,
    var width: Int = 1000,
    var isLiked: Boolean = false,
    var ordering: Long = 0L,
    var isEnd: Boolean?
) {
    companion object {
        val DIFF_CALLBACK = object: DiffUtil.ItemCallback<IntegratedModel>() {
            override fun areItemsTheSame(
                oldItem: IntegratedModel,
                newItem: IntegratedModel
            ): Boolean = oldItem.thumbnailUrl == newItem.thumbnailUrl


            override fun areContentsTheSame(
                oldItem: IntegratedModel,
                newItem: IntegratedModel
            ): Boolean = oldItem == newItem
        }
    }
}

DiffUtil을 적용하려면 모델의 특유의 고유 값이 필요한데, thumbnail_url이 이미지마다 각각 다른 url이어서 따로 필드를 만들지 않고, Uril을 ID 값으로 삼아 사용하였다.

 

어댑터 같은 경우는

RecyclerView.Adapter<SearchListAdapter.SearchViewHolder>()

에서

ListAdapter<IntegratedModel, SearchListViewAdapter.SearchViewHolder>(IntegratedModel.DIFF_CALLBACK)

타입으로 변경하였고, 기존 Recylcer View Adapter 내에서 item list를 관리하였는데, List Adapter는 자체적으로 관리하는 리스트가 있고, submitList()라는 메서드로 리스트를 갱신할 수 있기 때문에, 리스트 관리하는 코드를 모두 제거할 수 있었다.

 

 

3. 인터넷 감지 기능 적용해 보기

ConnectivityManager의 NetworkCallback과 LiveData 활용해서 간단하게 구현해 보았다. 

 

내일배움캠프 7기 Android TIL 46일차 (2023.09.18)

일일 회고 오늘은 개인 과제 수정과 선발대 과제 위주로 진행을 하였다. 개인과제는 디자인적인 면이나 코드 가독성적인 측면에서 수정을 진행하였다. 이제 진행해볼 만한 것은 viewbinding -> databi

aaapple.tistory.com

 

4. Data Binding 시도해 보기

Data Binding은 완전 처음 해보는 것이라, 미숙해서 Main Branch에는 올리지 않았고 feature/databinding 인 서브 branch에 올려두었다. ( 연습입니다!)

 

내일배움캠프 7기 Android TIL 47일차 (2023.09.19)

일일 회고 과제 수정하고, 수정하고, 리팩토링 해보고? 하느라 하루가 훌쩍 갔다. 시간이 너무너무너무 빨리 간다. 사실 오늘 과제 정리 글을 쓰려했는데, Data Binding을 적용해 보겠다고 했다가,

aaapple.tistory.com

 

 

++ 가능하다면 보관함에서 개별 삭제 기능도 구현해 보기! (완료! 2번째 시연영상에서 확인 가능)

  • ViewHolder의 LongClickListener를 통해 구현

 

 

 

시연 영상

 

 

 

 

 

 

 

ref.

 

저장소 패턴  |  Android Developers

저장소 패턴을 사용하여 기존 앱에서 캐싱을 구현합니다.

developer.android.com

 

[Design Pattern] Repository Pattern 이란 - HERSTORY

개요 발생 배경 비즈니스 로직은 프로그램의 핵심이 되는 요소이며, 비즈니스 로직을 잘 짜야 원하는 결과를 올바르게 도출할 수 있다. 이때 비즈니스 로직은 보통 데이터베이스나 웹서비스 등

4z7l.github.io

 

profile

Apple is Apple

@mjjjjjj