ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Android] "좋아요"한 아이템 SharedPreferences에 저장하기 & 가져오기 & 삭제하기
    안드로이드 2024. 5. 21. 11:32

    ✏️ TIL(Today I Learned)

    좋아요 버튼 클릭 시, SharedPreferences에 저장이 되고 다시 클리하면 삭제되도록 구현했다.

    비디오를 클릭해서 VideoDetailFragment로 전환될 때, 해당 비디오가 SharedPreferences에 있는 지

    확인하고 있으면 좋아요 버튼을 빨간색으로 표시하도록 했다.

     

     

     

    우선 Repository이다.

    VideoRepository 인터페이스는 비디오 데이터를  API와 로컬 저장소에서 가져오고 관리하는 메소드를 정의한다.

    VideoRepositoryImpl 클래스는 VideoRepository를 구현한다.

    • RemoteDataSource를 통해 YouTube API에서 인기 비디오, 카테고리별 비디오, 검색어 기반 비디오 등을 가져온다. 
    • SharedPreferences를 사용하여 로컬에 비디오 아이템을 저장하고 관리한다. (좋아요 기능)

    좋아요한 비디오들은 리스트형태로 저장했다.

    interface VideoRepository {
        suspend fun searchPopularVideo(): List<PopularVideoItemModel>
        suspend fun searchVideoByCategory(categoryId: String): List<PopularVideoItemModel>
        suspend fun takeVideoCategories(): List<CategoryItemModel>
        suspend fun searchChannelByCategory(channelId: String): List<ChannelItemModel>
        suspend fun searchVideoByText(text: String): Pair<String, List<VideoItemModel>>
        suspend fun searchMoreVideoByText(text: String, token: String): Pair<String, List<VideoItemModel>>
        suspend fun saveVideoItem(videoItemModel: VideoItemModel)
        suspend fun removeVideoItem(videoItemModel: VideoItemModel)
        suspend fun getStorageItems(): List<VideoItemModel>
        fun isVideoLikedInPrefs(videoId: String?): Boolean
        fun clearPrefs()
    }
    
    class VideoRepositoryImpl(
        private val remoteDataSource: RemoteDataSource,
        private val context: Context
    ) : VideoRepository {
    
    	//중간 생략
    
        private val pref: SharedPreferences = context.getSharedPreferences(Const.PREFERENCE_NAME, 0)
    
        private fun getPrefsItems(): List<VideoItemModel> {
            val jsonString = pref.getString(Const.LIKED_ITEMS, "")
            return if (jsonString.isNullOrEmpty()) {
                emptyList()
            } else {
                Gson().fromJson(jsonString, object : TypeToken<List<VideoItemModel>>() {}.type)
            }
        }
    
        private fun savePrefsItems(items: List<VideoItemModel>) {
            val jsonString = Gson().toJson(items)
            pref.edit().putString(Const.LIKED_ITEMS, jsonString).apply()
        }
    
        override suspend fun saveVideoItem(videoItemModel: VideoItemModel) {
            val likedItems = getPrefsItems().toMutableList()
            val findItem = likedItems.find { it.videoId == videoItemModel.videoId }
    
            if (findItem == null) {
                likedItems.add(videoItemModel)
                savePrefsItems(likedItems)
            }
        }
    
        override suspend fun removeVideoItem(videoItemModel: VideoItemModel) {
            val likedItems = getPrefsItems().toMutableList()
    
            likedItems.removeAll { it.videoId == videoItemModel.videoId }
            savePrefsItems(likedItems)
        }
    
        override fun clearPrefs() {
            pref.edit().clear().apply()
        }
    
        override suspend fun getStorageItems(): List<VideoItemModel> {
            return getPrefsItems()
        }
    
        override fun isVideoLikedInPrefs(videoId: String?): Boolean {
            val likedItems = getPrefsItems()
            return likedItems.any { it.videoId == videoId }
        }
    }

     

     

     

    그 다음, ViewModel이다.

    VideoDetailFragment의 로직을 처리하며,

    VideoRepository와 상호작용하여 비디오의 저장, 삭제 및 좋아요 상태를 확인한다.

     

    저장 작업 결과를 관찰하기 위해,  LiveData인 _saveResult를 사용했다.

    SaveUiState는 ViewModel에서 비디오 아이템 저장/삭제 작업의 상태를 관리하고, 이 상태 변화를 UI에 전달한다. (ViewModel과 UI 계층 간의 상태 전달을 용이하게 해준다.)

     

    과정은 아래와 같다.

    1. _saveResult 라는 MutableLiveData<SaveUiState> 객체를 통해 UI에 상태 변화를 전달
    2. saveVideoItem, removeVideoItem 메서드에서 비디오 아이템 저장/삭제 작업을 수행한 후,
      _saveResult에 SaveUiState.Success를 할당하여 UI에 성공 메시지를 전달
    3. updateSaveItem 메서드에서 비디오 아이템의 좋아요 상태에 따라
      saveVideoItem 또는 removeVideoItem을 호출하여 저장/삭제 작업을 수행
    4. VideoDetailFragment에서 viewModel.saveResult를 observe하여 UI 업데이트를 수행
      (SaveUiState.Success일 때 SnackBar를 표시)
    class VideoDetailViewModel (
        private val videoRepository: VideoRepository,
        private val applicationContext: Context
    
    ) : ViewModel() {
    
        private val _saveResult = MutableLiveData<SaveUiState>()
        val saveResult: LiveData<SaveUiState> get() = _saveResult
    
        private fun saveVideoItem(videoItemModel: VideoItemModel) {
            viewModelScope.launch {
                videoRepository.saveVideoItem(videoItemModel.copy(isLiked = true))
                _saveResult.value = SaveUiState.Success(applicationContext.getString(R.string.videodetail_snack_save))
            }
        }
    
        private fun removeVideoItem(videoItemModel: VideoItemModel) {
            viewModelScope.launch {
                videoRepository.removeVideoItem(videoItemModel.copy(isLiked = false))
                _saveResult.value = SaveUiState.Success(applicationContext.getString(R.string.videodetail_snack_delete))
            }
        }
    
        fun updateSaveItem(videoItemModel: VideoItemModel) {
            viewModelScope.launch {
                if (videoItemModel.isLiked) {
                    saveVideoItem(videoItemModel)
                } else {
                    removeVideoItem(videoItemModel)
                }
            }
        }
    
        fun isVideoLikedInPrefs(videoId: String?): Boolean {
            return videoRepository.isVideoLikedInPrefs(videoId)
        }
    
        fun clearPreferences() {
            videoRepository.clearPrefs()
        }
    }
    
    class VideoDetailViewModelFactory(
        private val videoRepository: VideoRepository,
        private val applicationContext: Context
    ) : ViewModelProvider.Factory {
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            if (modelClass.isAssignableFrom(VideoDetailViewModel::class.java)) {
                @Suppress("UNCHECKED_CAST")
                return VideoDetailViewModel(videoRepository, applicationContext) as T
            }
            throw IllegalArgumentException("Unknown ViewModel class")
        }
    }

     

     

     

    SaveUiState는 sealed class로 정의했으며,

    비디오 아이템 저장/삭제 작업의 상태를 표현합니다. 세 가지 상태를 가지고 있다.

    • Init: 초기 상태
    • Success: 저장/삭제 작업이 성공했을 때의 상태로, 성공 메시지를 포함
    • Error: 예외가 발생했을 때의 상태로, 예외 객체를 포함
    sealed class SaveUiState {
        object Init : SaveUiState()
        data class Success(val message: String) : SaveUiState()
        data class Error(val exception: Throwable) : SaveUiState()
    }

     

     

     

    마지막으로, VideoDetailFragment이다.

    선택한 비디오의 상세 정보를 표시하는 UI 프래그먼트이며,

    좋아요 버튼을 클릭하면 VideoDetailViewModel updateSaveItem를 호출하여 비디오 아이템을 저장/삭제한다. 

    class VideoDetailFragment : Fragment() {
        private var _binding: FragmentVideoDetailBinding? = null
        private val binding get() = _binding!!
        private val viewModel: VideoDetailViewModel by viewModels {
            VideoDetailViewModelFactory(VideoRepositoryImpl(RepositoryClient.youtubeService, requireContext()),
                requireContext().applicationContext
            )
        }
    
        override fun onCreateView(
            inflater: LayoutInflater, container: ViewGroup?,
            savedInstanceState: Bundle?
        ): View {
            _binding = FragmentVideoDetailBinding.inflate(inflater, container, false)
            return binding.root
        }
    
        override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
            super.onViewCreated(view, savedInstanceState)
    
            initView()
        }
    
        private fun initView() {
            val item = arguments?.getParcelable<VideoItemModel>(BUNDLE_KEY_FOR_DETAIL_FRAGMENT)
                ?: arguments?.getParcelable<VideoItemModel>(ShortsFragment.BUNDLE_KEY_FOR_DETAIL_FRAGMENT_FROM_SHORTS)
            val isLikedInPrefs = viewModel.isVideoLikedInPrefs(item?.videoId)
    
            updateLikeButton(item, isLikedInPrefs)
    
            viewModel.saveResult.observe(viewLifecycleOwner) { state ->
                when (state) {
                    is SaveUiState.Success -> {
                        Snackbar.make(binding.root, state.message, Snackbar.LENGTH_SHORT).show()
                    }
                    else -> {}
                }
            }
    
            with(binding) {
                ivThumbnail.load(item?.videoThumbnail)
                tvChannelTitle.text = item?.channelTitle
                tvTitle.text = item?.videoTitle
                tvDescription.text = item?.videoDescription
    
                ivBack.setOnClickListener {
                    requireActivity().supportFragmentManager.popBackStack()
                }
    
                llLike.setOnClickListener {
                    item?.let {
                        it.isLiked = !it.isLiked
                        viewModel.updateSaveItem(it)
                        updateLikeButton(it, it.isLiked)
                    }
                }
    
                llShare.setOnClickListener {
                    val shareIntent = Intent(Intent.ACTION_SEND).apply {
                        type = "text/plain"
                        putExtra(Intent.EXTRA_TEXT, SHARE_URL + item?.videoId)
                    }
                    startActivity(Intent.createChooser(shareIntent, "Share Video Link"))
                }
            }
        }
    
        private fun updateLikeButton(item: VideoItemModel?, isLikedInPrefs: Boolean) {
            binding.ivLike.setImageResource(
                if (isLikedInPrefs) R.drawable.ic_liked
                else R.drawable.ic_like
            )
            item?.isLiked = isLikedInPrefs
        }
    
        override fun onDestroyView() {
            super.onDestroyView()
            _binding = null
        }
    
        companion object {
            const val BUNDLE_KEY_FOR_DETAIL_FRAGMENT = "BUNDLE_KEY_FOR_DETAIL_FRAGMENT"
    
            fun newInstance(bundle: Bundle): VideoDetailFragment {
                return VideoDetailFragment().apply {
                    arguments = bundle
                }
            }
        }
    }

     

     

     

    다른 프래그먼트에서의 호출은 아래와 같다.

    // SearchResultFragment.kt
    private fun selectItem(item: VideoItemModel) {
        val bundle = Bundle().apply {
            putParcelable(BUNDLE_KEY_FOR_DETAIL_FRAGMENT, item)
        }
    
        val detailFragment = VideoDetailFragment.newInstance(bundle)
        (requireActivity() as MainActivity).showVideoDetailFragment(detailFragment)
    }
    
    
    // HomeFragment.kt
    private fun selectItem(videoItem: PopularVideoItemModel) {
        val videoItemModel = videoItem.toVideoItemModel() // PopularVideoItemModel을 VideoItemModel로 변환
        val bundle = Bundle().apply {
            putParcelable(BUNDLE_KEY_FOR_DETAIL_FRAGMENT, videoItemModel) // VideoItemModel을 전달
        }
        val detailFragment = VideoDetailFragment.newInstance(bundle)
        (requireActivity() as MainActivity).showVideoDetailFragment(detailFragment)
    }
    
    
    // MainActivity.kt
    fun showVideoDetailFragment(detailFragment: Fragment) {
        supportFragmentManager.commit {
            setCustomAnimations(
                R.anim.zoom_in,
                R.anim.zoom_out,
                R.anim.zoom_in,
                R.anim.zoom_out
            )
            replace(R.id.fl_video_detail, detailFragment)
            addToBackStack(null)
        }
    }
Designed by Tistory.