[Android] "좋아요"한 아이템 SharedPreferences에 저장하기 & 가져오기 & 삭제하기
✏️ 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 계층 간의 상태 전달을 용이하게 해준다.)
과정은 아래와 같다.
- _saveResult 라는 MutableLiveData<SaveUiState> 객체를 통해 UI에 상태 변화를 전달
- saveVideoItem, removeVideoItem 메서드에서 비디오 아이템 저장/삭제 작업을 수행한 후,
_saveResult에 SaveUiState.Success를 할당하여 UI에 성공 메시지를 전달 - updateSaveItem 메서드에서 비디오 아이템의 좋아요 상태에 따라
saveVideoItem 또는 removeVideoItem을 호출하여 저장/삭제 작업을 수행 - 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)
}
}