안드로이드

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