diff --git a/core/common/src/main/java/com/neki/android/core/common/exception/RandomPoseRetryExhaustedException.kt b/core/common/src/main/java/com/neki/android/core/common/exception/RandomPoseRetryExhaustedException.kt new file mode 100644 index 000000000..a08f3ac50 --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/exception/RandomPoseRetryExhaustedException.kt @@ -0,0 +1,5 @@ +package com.neki.android.core.common.exception + +class RandomPoseRetryExhaustedException( + message: String, +) : RuntimeException(message) diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt index 5765455d6..e81cbfb08 100644 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt @@ -19,7 +19,18 @@ interface PoseRepository { suspend fun getPose(poseId: Long): Result - suspend fun getRandomPose(headCount: PeopleCount): Result + suspend fun getSingleRandomPose( + headCount: PeopleCount, + excludeIds: Set, + maxRetry: Int, + ): Result + + suspend fun getMultipleRandomPose( + headCount: PeopleCount, + excludeIds: Set, + poseSize: Int, + maxRetry: Int, + ): Result> suspend fun updateScrap(poseId: Long, scrap: Boolean): Result } diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt index 900b828f3..53084699f 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt @@ -3,6 +3,7 @@ package com.neki.android.core.data.repository.impl import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData +import com.neki.android.core.common.exception.RandomPoseRetryExhaustedException import com.neki.android.core.data.paging.PosePagingSource import com.neki.android.core.data.paging.ScrapPosePagingSource import com.neki.android.core.data.remote.api.PoseService @@ -65,8 +66,41 @@ class PoseRepositoryImpl @Inject constructor( poseService.getPose(poseId).data.toModel() } - override suspend fun getRandomPose(headCount: PeopleCount): Result = runSuspendCatching { - poseService.getRandomPose(headCount = headCount.name).data.toModel() + override suspend fun getSingleRandomPose( + headCount: PeopleCount, + excludeIds: Set, + maxRetry: Int, + ): Result = runSuspendCatching { + repeat(maxRetry) { + val pose = poseService.getRandomPose(headCount = headCount.name).data.toModel() + if (pose.id !in excludeIds) { + return@runSuspendCatching pose + } + } + throw RandomPoseRetryExhaustedException("새로운 포즈를 찾지 못했어요") + } + + override suspend fun getMultipleRandomPose( + headCount: PeopleCount, + excludeIds: Set, + poseSize: Int, + maxRetry: Int, + ): Result> = runSuspendCatching { + val result = mutableListOf() + val collectedIds = excludeIds.toMutableSet() + var retryCount = 0 + + while (result.size < poseSize && retryCount < maxRetry) { + val pose = poseService.getRandomPose(headCount = headCount.name).data.toModel() + if (pose.id !in collectedIds) { + result.add(pose) + collectedIds.add(pose.id) + } else { + retryCount++ + } + } + + result.ifEmpty { throw RandomPoseRetryExhaustedException("새로운 포즈를 찾지 못했어요") } } override suspend fun updateScrap(poseId: Long, scrap: Boolean): Result = runSuspendCatching { diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/const/PoseConst.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/const/PoseConst.kt index 0ed219f22..6f004339f 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/const/PoseConst.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/const/PoseConst.kt @@ -3,7 +3,7 @@ package com.neki.android.feature.pose.impl.const internal object PoseConst { internal const val INITIAL_POSE_LOAD_COUNT = 4 internal const val POSE_PREFETCH_THRESHOLD = 3 - internal const val MAXIMUM_RANDOM_POSE_FALLBACK_COUNT = 7 + internal const val MAXIMUM_RANDOM_POSE_RETRY_COUNT = 7 internal const val POSE_LAYOUT_DEFAULT_TOP_PADDING = 12 internal const val POSE_LAYOUT_BOTTOM_PADDING = 28 diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt index 5d9663e7e..27e34861b 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt @@ -16,12 +16,9 @@ data class RandomPoseUiState( val hasPrevious: Boolean get() = currentIndex > 0 -} -internal sealed class FetchPoseResult(val tryCount: Int) { - class Success(tryCount: Int, val pose: Pose) : FetchPoseResult(tryCount) - class Duplicated(tryCount: Int) : FetchPoseResult(tryCount) - class Failure(tryCount: Int, val throwable: Throwable) : FetchPoseResult(tryCount) + val randomPoseIds: Set + get() = poseList.map { it.id }.toSet() } sealed interface RandomPoseIntent { diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt index b0f50bdae..24d50dbd1 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt @@ -3,9 +3,9 @@ package com.neki.android.feature.pose.impl.random import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.neki.android.core.common.coroutine.di.ApplicationScope +import com.neki.android.core.common.exception.RandomPoseRetryExhaustedException import com.neki.android.core.dataapi.repository.PoseRepository import com.neki.android.core.model.PeopleCount -import com.neki.android.core.model.Pose import com.neki.android.core.ui.MviIntentStore import com.neki.android.core.ui.mviIntentStore import com.neki.android.feature.pose.impl.const.PoseConst @@ -152,21 +152,16 @@ internal class RandomPoseViewModel @AssistedInject constructor( // 여분 포즈가 POSE_PREFETCH_THRESHOLD 이하이면 다음 포즈 미리 캐싱 if (state.poseList.lastIndex - nextIndex < PoseConst.POSE_PREFETCH_THRESHOLD) { viewModelScope.launch { - when (val result = fetchRandomPose(poseList = state.poseList)) { - is FetchPoseResult.Success -> reduce { - copy( - poseList = (poseList + result.pose).toImmutableList(), - committedScraps = committedScraps + (result.pose.id to result.pose.isScrapped), - ) - } - - is FetchPoseResult.Duplicated -> - Timber.d("프리페치 생략: ${result.tryCount}회 시도 후 중복 포즈") - - is FetchPoseResult.Failure -> { - Timber.e(result.throwable) - postSideEffect(RandomPoseEffect.ShowToast("포즈를 불러오는데 실패했어요")) - } + poseRepository.getSingleRandomPose( + headCount = peopleCount, + excludeIds = state.randomPoseIds, + maxRetry = PoseConst.MAXIMUM_RANDOM_POSE_RETRY_COUNT, + ).onSuccess { pose -> + reduce { copy(poseList = (poseList + pose).toImmutableList()) } + }.onFailure { error -> + if (error is RandomPoseRetryExhaustedException) + Timber.e(error, "중복 포즈") + else Timber.e(error) } } } @@ -179,73 +174,31 @@ internal class RandomPoseViewModel @AssistedInject constructor( viewModelScope.launch { reduce { copy(isLoading = true) } - val poses = mutableListOf() - var totalFallbackCount = 0 - // 초기에 INITIAL_POSE_LOAD_COUNT개 로드 - while ( - poses.size < PoseConst.INITIAL_POSE_LOAD_COUNT && - totalFallbackCount < PoseConst.MAXIMUM_RANDOM_POSE_FALLBACK_COUNT - ) { - val result = fetchRandomPose( - poseList = poses, - maxFallbackCount = PoseConst.MAXIMUM_RANDOM_POSE_FALLBACK_COUNT - totalFallbackCount, - ) - - totalFallbackCount += result.tryCount - - when (result) { - is FetchPoseResult.Success -> poses.add(result.pose) - is FetchPoseResult.Failure -> { - Timber.e(result.throwable) - postSideEffect(RandomPoseEffect.ShowToast("포즈를 불러오는데 실패했어요")) - break - } - - is FetchPoseResult.Duplicated -> - Timber.d("초기 로드: ${result.tryCount}회 시도 후 중복 포즈") - } - } - - if (poses.isNotEmpty()) { + poseRepository.getMultipleRandomPose( + headCount = peopleCount, + excludeIds = emptySet(), + poseSize = PoseConst.INITIAL_POSE_LOAD_COUNT, + maxRetry = PoseConst.MAXIMUM_RANDOM_POSE_RETRY_COUNT, + ).onSuccess { data -> reduce { copy( isLoading = false, - poseList = poses.toImmutableList(), - committedScraps = poses.associate { it.id to it.isScrapped }, + poseList = data.toImmutableList(), + committedScraps = data.associate { it.id to it.isScrapped }, currentIndex = 0, ) } - } else { + }.onFailure { error -> + Timber.e(error) reduce { copy(isLoading = false) } - postSideEffect(RandomPoseEffect.ShowToast("포즈를 불러오는데 실패했어요")) + if (error is RandomPoseRetryExhaustedException) + Timber.e(error, "중복 포즈") + else Timber.e(error) } } } - private suspend fun fetchRandomPose( - poseList: List, - maxFallbackCount: Int = PoseConst.MAXIMUM_RANDOM_POSE_FALLBACK_COUNT, - ): FetchPoseResult { - var tryCount = 0 - - while (tryCount < maxFallbackCount) { - tryCount++ - poseRepository.getRandomPose(headCount = peopleCount) - .onSuccess { pose -> - if (poseList.none { it.id == pose.id }) { - return FetchPoseResult.Success(tryCount, pose) - } - } - .onFailure { error -> - Timber.e(error) - return FetchPoseResult.Failure(tryCount, error) - } - } - - return FetchPoseResult.Duplicated(tryCount) - } - override fun onCleared() { super.onCleared()