Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ sealed interface MainSideEffect {
data class NavigateToUploadAlbumWithGallery(val uriStrings: List<String>) : MainSideEffect
data class NavigateToUploadAlbumWithQRScan(val imageUrl: String) : MainSideEffect
data class ShowToast(val message: String) : MainSideEffect
data object RefreshArchive : MainSideEffect
}
2 changes: 2 additions & 0 deletions app/src/main/java/com/neki/android/app/main/MainScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import com.neki.android.core.ui.component.LoadingDialog
import com.neki.android.core.ui.compose.collectWithLifecycle
import com.neki.android.core.ui.toast.NekiToast
import com.neki.android.feature.archive.api.ArchiveNavKey
import com.neki.android.feature.archive.api.ArchiveResult
import com.neki.android.feature.map.api.MapNavKey
import com.neki.android.feature.mypage.api.MyPageNavKey
import com.neki.android.feature.photo_upload.api.PhotoUploadNavKey
Expand Down Expand Up @@ -85,6 +86,7 @@ fun MainRoute(
is MainSideEffect.NavigateToUploadAlbumWithGallery -> navigateToUploadAlbumWithGallery(sideEffect.uriStrings)
is MainSideEffect.NavigateToUploadAlbumWithQRScan -> navigateToUploadAlbumWithQRScan(sideEffect.imageUrl)
is MainSideEffect.ShowToast -> nekiToast.showToast(sideEffect.message)
MainSideEffect.RefreshArchive -> resultBus.sendResult<ArchiveResult>(result = ArchiveResult.PhotoUploaded)
}
}

Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/neki/android/app/main/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ class MainViewModel @Inject constructor(
.onSuccess {
reduce { copy(isLoading = false, scannedImageUrl = null) }
postSideEffect(MainSideEffect.ShowToast("이미지를 추가했어요"))
postSideEffect(MainSideEffect.RefreshArchive)
}
.onFailure { e ->
Timber.e(e)
Expand All @@ -125,6 +126,7 @@ class MainViewModel @Inject constructor(
.onSuccess {
reduce { copy(isLoading = false, selectedUris = persistentListOf()) }
postSideEffect(MainSideEffect.ShowToast("이미지를 추가했어요"))
postSideEffect(MainSideEffect.RefreshArchive)
}
.onFailure { e ->
Timber.e(e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package com.neki.android.core.navigation.result
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.ProvidedValue
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateMapOf
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.BUFFERED
Expand Down Expand Up @@ -33,8 +35,9 @@ object LocalResultEventBus {
* It provides a solution for event based results.
*/
// https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/results/event/README.md
@Stable
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 상태에서 ResultEventBus.channelMap의 밸류에 직접 접근하는 부분이 없어 문제되지는 않을 것 같지만 Map의 밸류로 Cannel 타입을 갖기 때문에 @Stable 조건을 만족하지 못하는 것 같습니다!?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지적 감사합니다!
말씀하신 대로 Channel이 @stable 조건을 엄밀히 만족하지 않는 건 맞습니다. 다만 @stable을 제거하면 ResultEventBus 자체가 unstable로 추론되어 불필요한 recomposition이 발생할 수 있습니다.

MainRoute 에서 리컴포지션이 발생하면, ResultEffect 는 unstable 해지기 때문에 반드시 리컴포지션이 발생합니다.
image

따라서 해당 ResultEventBus 클래스를 stable 하진 않지만 @Stable 하다고 명시하여 불필요한 리컴포지션을 줄일 수 있습니다!

매번 새로운 인스턴스를 반환하지 않고, 매번 같은 인스턴스를 사용하지만 unstable 하기에 @stable 추가.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀하신 부분은 이해가 되어 로그를 통해 실제로 리컴포지션을 줄일 수 있는지 확인을 해 보았는데요. ResultEffect의 경우 @Stable 어노테이션에 관계 없이 리컴포지션이 동일하게 발생하는 것 같습니다.?.?

제가 확인한 바로는 ResultEffect의 경우 inline 함수이고, MainRoute에서 리컴포지션이 발생하면 독립적인 리컴포지션의 대상이 아니기 때문에(inline 함수의 경우 접근할 때 그대로 함수를 가지고 오기 때문에) 항상 다시 작성되는 것으로 이해했습니다.
결국 ResultEffect() 내부의 LaunchedEffect 구문에 설정된 키의 영향을 받는데 키로 설정된 resultKey, resultEventBus.channelMap[resultKey]MainRoute가 리컴포지션 되더라도 각각 StringChannel<Any?> 타입이기 때문에 MainRoute의 리컴포지션과는 무관하게 설정된 키의 변화에 따라 LaunchedEffect 블럭의 코드가 트리거 되는 것 같습니다.

제가 확인하지 못한 MainRoute의 리컴포지션 시에 해당 키의 변화가 있는지, 추가적으로 확인한 내용이 올바르게 이해한 것인지는 모르겠네요...! 리컴포지션 어렵다어려워

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아하, inline 함수가 된다면 실제 코드에서 이렇게 반영이 되는군요,
그래서 말씀하신 것처럼 ResultEffect 가 내부 함수로 펼쳐지기(?) 때문에 리컴포지션 대상 자체가 안되네요!
inline 이 기본 함수에만 작동되는 줄 알았는데 코틀린 기본 개념처럼 진짜 코드 인라인에 복사되는 거기 때문에 실행 환경에선 ResultEffect 가 없어진 거나 마찬가지가 되어 해당 함수의 Stability 를 설정할 필요가 없네요.
(무작정 복붙 가져오기의 폐해...)

LaunchedEffect 의 키에는 ResultEventBus 인스턴스 자체는 상관이 없으니 @Stable 어노테이션이 의미가 없겠네요.

fun MainRoute(...) {                                                                                                                                                        
    val resultBus = LocalResultEventBus.current
                                                                                                                                                                            
    // ...                                                                                                                                                                

    // ---- ResultEffect가 여기에 펼쳐짐 ----
    Log.d("ResultEffect", "Recompose")
    LaunchedEffect(resultKey, resultBus.channelMap[resultKey]) {
        resultBus.getResultFlow<QRScanResult>(resultKey)?.collect { result ->
            // ...
        }
    }
    // ---- 여기까지 ----

    // ...
}

08b4282 불필요한 @Stable 어노테이션 제거했습니다!

class ResultEventBus {
val channelMap: MutableMap<String, Channel<Any?>> = mutableMapOf()
val channelMap = mutableStateMapOf<String, Channel<Any?>>()

inline fun <reified T> getResultFlow(resultKey: String = T::class.toString()) =
channelMap[resultKey]?.receiveAsFlow()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ sealed interface ArchiveResult {
}

data class FavoriteChanged(val photoId: Long, val isFavorite: Boolean) : ArchiveResult

data object PhotoUploaded : ArchiveResult
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package com.neki.android.feature.archive.impl.main

import android.net.Uri
import androidx.compose.foundation.text.input.TextFieldState
import com.neki.android.core.model.AlbumPreview
import com.neki.android.core.model.Photo
import com.neki.android.core.model.UploadType
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

Expand All @@ -14,30 +12,19 @@ data class ArchiveMainState(
val favoriteAlbum: AlbumPreview = AlbumPreview(title = "즐겨찾는사진"),
val albums: ImmutableList<AlbumPreview> = persistentListOf(),
val recentPhotos: ImmutableList<Photo> = persistentListOf(),
val scannedImageUrl: String? = null,
val selectedUris: ImmutableList<Uri> = persistentListOf(),
val isShowSelectWithAlbumDialog: Boolean = false,
val isShowAddAlbumBottomSheet: Boolean = false,
val albumNameTextState: TextFieldState = TextFieldState(),
) {
val uploadType: UploadType
get() = if (scannedImageUrl != null) UploadType.QR_CODE else UploadType.GALLERY
}
)

sealed interface ArchiveMainIntent {
data object EnterArchiveMainScreen : ArchiveMainIntent
data object RefreshArchiveMainScreen : ArchiveMainIntent
data object RefreshArchiveMainPhotos : ArchiveMainIntent
data object ClickScreen : ArchiveMainIntent
data object ClickGoToTopButton : ArchiveMainIntent

// TopBar Intent
data object DismissToolTipPopup : ArchiveMainIntent
data object ClickQRScanIcon : ArchiveMainIntent

data class SelectGalleryImage(val uris: List<Uri>) : ArchiveMainIntent
data object DismissSelectWithAlbumDialog : ArchiveMainIntent
data object ClickUploadWithAlbumRow : ArchiveMainIntent
data object ClickUploadWithoutAlbumRow : ArchiveMainIntent
data object ClickNotificationIcon : ArchiveMainIntent

// Album Intent
Expand All @@ -54,23 +41,16 @@ sealed interface ArchiveMainIntent {
// Add Album BottomSheet Intent
data object DismissAddAlbumBottomSheet : ArchiveMainIntent
data object ClickAddAlbumButton : ArchiveMainIntent

// Result
data class QRCodeScanned(val imageUrl: String) : ArchiveMainIntent
data object ReceiveOpenGalleryResult : ArchiveMainIntent
}

sealed interface ArchiveMainSideEffect {
data object NavigateToQRScan : ArchiveMainSideEffect
data class NavigateToUploadAlbumWithGallery(val uriStrings: List<String>) : ArchiveMainSideEffect
data class NavigateToUploadAlbumWithQRScan(val imageUrl: String) : ArchiveMainSideEffect
data object NavigateToAllAlbum : ArchiveMainSideEffect
data class NavigateToFavoriteAlbum(val albumId: Long) : ArchiveMainSideEffect
data class NavigateToAlbumDetail(val albumId: Long, val title: String) : ArchiveMainSideEffect
data object NavigateToAllPhoto : ArchiveMainSideEffect
data class NavigateToPhotoDetail(val photos: List<Photo>, val index: Int) : ArchiveMainSideEffect

data object ScrollToTop : ArchiveMainSideEffect
data object OpenGallery : ArchiveMainSideEffect
data class ShowToastMessage(val message: String) : ArchiveMainSideEffect
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
package com.neki.android.feature.archive.impl.main

import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand Down Expand Up @@ -40,23 +37,18 @@ import com.neki.android.core.ui.toast.NekiToast
import com.neki.android.feature.archive.impl.component.AddAlbumBottomSheet
import com.neki.android.feature.archive.impl.const.ArchiveConst.ARCHIVE_GRID_ITEM_SPACING
import com.neki.android.feature.archive.impl.const.ArchiveConst.PHOTO_GRAY_LAYOUT_BOTTOM_PADDING
import com.neki.android.feature.archive.impl.main.component.AlbumUploadOption
import com.neki.android.feature.archive.impl.main.component.ArchiveMainAlbumList
import com.neki.android.feature.archive.impl.main.component.ArchiveMainPhotoItem
import com.neki.android.feature.archive.impl.main.component.ArchiveMainTitleRow
import com.neki.android.feature.archive.impl.main.component.ArchiveMainTopBar
import com.neki.android.feature.archive.impl.component.EmptyPhotoContent
import com.neki.android.feature.archive.impl.main.component.GotoTopButton
import com.neki.android.feature.archive.impl.main.component.SelectWithAlbumDialog
import kotlinx.collections.immutable.persistentListOf
import timber.log.Timber

@Composable
internal fun ArchiveMainRoute(
viewModel: ArchiveMainViewModel = hiltViewModel(),
navigateToQRScan: () -> Unit,
navigateToUploadAlbumWithGallery: (List<String>) -> Unit,
navigateToUploadAlbumWithQRScan: (String) -> Unit,
navigateToAllAlbum: () -> Unit,
navigateToFavoriteAlbum: (Long) -> Unit,
navigateToAlbumDetail: (Long, String) -> Unit,
Expand All @@ -67,19 +59,10 @@ internal fun ArchiveMainRoute(
val context = LocalContext.current
val lazyState = rememberLazyStaggeredGridState()
val nekiToast = remember { NekiToast(context) }
val photoPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(10)) { uris ->
if (uris.isNotEmpty()) {
viewModel.store.onIntent(ArchiveMainIntent.SelectGalleryImage(uris))
} else {
Timber.d("No media selected")
}
}

viewModel.store.sideEffects.collectWithLifecycle { sideEffect ->
when (sideEffect) {
ArchiveMainSideEffect.NavigateToQRScan -> navigateToQRScan()
is ArchiveMainSideEffect.NavigateToUploadAlbumWithGallery -> navigateToUploadAlbumWithGallery(sideEffect.uriStrings)
is ArchiveMainSideEffect.NavigateToUploadAlbumWithQRScan -> navigateToUploadAlbumWithQRScan(sideEffect.imageUrl)
ArchiveMainSideEffect.NavigateToAllAlbum -> navigateToAllAlbum()
is ArchiveMainSideEffect.NavigateToFavoriteAlbum -> navigateToFavoriteAlbum(sideEffect.albumId)
is ArchiveMainSideEffect.NavigateToAlbumDetail -> navigateToAlbumDetail(sideEffect.albumId, sideEffect.title)
Expand All @@ -88,7 +71,6 @@ internal fun ArchiveMainRoute(
ArchiveNavKey.PhotoDetail(photos = sideEffect.photos, initialIndex = sideEffect.index),
)
ArchiveMainSideEffect.ScrollToTop -> lazyState.animateScrollToItem(0)
ArchiveMainSideEffect.OpenGallery -> photoPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
is ArchiveMainSideEffect.ShowToastMessage -> nekiToast.showToast(text = sideEffect.message)
}
}
Expand All @@ -115,7 +97,6 @@ internal fun ArchiveMainScreen(
ArchiveMainTopBar(
showTooltip = uiState.isFirstEntered,
onClickQRCodeIcon = { onIntent(ArchiveMainIntent.ClickQRScanIcon) },
// onClickNotificationIcon = { onIntent(ArchiveMainIntent.ClickNotificationIcon) },
onDismissToolTipPopup = { onIntent(ArchiveMainIntent.DismissToolTipPopup) },
)
ArchiveMainContent(
Expand Down Expand Up @@ -166,18 +147,6 @@ internal fun ArchiveMainScreen(
errorMessage = errorMessage,
)
}

if (uiState.isShowSelectWithAlbumDialog) {
SelectWithAlbumDialog(
onDismissRequest = { onIntent(ArchiveMainIntent.DismissSelectWithAlbumDialog) },
onSelect = { option ->
when (option) {
AlbumUploadOption.WITHOUT_ALBUM -> onIntent(ArchiveMainIntent.ClickUploadWithoutAlbumRow)
AlbumUploadOption.WITH_ALBUM -> onIntent(ArchiveMainIntent.ClickUploadWithAlbumRow)
}
},
)
}
}

@Composable
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
package com.neki.android.feature.archive.impl.main

import android.net.Uri
import androidx.compose.foundation.text.input.TextFieldState
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.neki.android.core.dataapi.repository.FolderRepository
import com.neki.android.core.dataapi.repository.PhotoRepository
import com.neki.android.core.dataapi.repository.UserRepository
import com.neki.android.core.domain.usecase.UploadMultiplePhotoUseCase
import com.neki.android.core.domain.usecase.UploadSinglePhotoUseCase
import com.neki.android.core.model.Photo
import com.neki.android.core.model.UploadType
import com.neki.android.core.ui.MviIntentStore
import com.neki.android.core.ui.mviIntentStore
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
Expand All @@ -28,8 +23,6 @@ private const val DEFAULT_PHOTOS_SIZE = 20

@HiltViewModel
class ArchiveMainViewModel @Inject constructor(
private val uploadSinglePhotoUseCase: UploadSinglePhotoUseCase,
private val uploadMultiplePhotoUseCase: UploadMultiplePhotoUseCase,
private val photoRepository: PhotoRepository,
private val folderRepository: FolderRepository,
private val userRepository: UserRepository,
Expand All @@ -52,7 +45,7 @@ class ArchiveMainViewModel @Inject constructor(
if (intent != ArchiveMainIntent.EnterArchiveMainScreen) reduce { copy(isFirstEntered = false) }
when (intent) {
ArchiveMainIntent.EnterArchiveMainScreen -> fetchInitialData(reduce)
ArchiveMainIntent.RefreshArchiveMainScreen -> fetchInitialData(reduce)
ArchiveMainIntent.RefreshArchiveMainPhotos -> viewModelScope.launch { fetchPhotos(reduce) }
ArchiveMainIntent.ClickScreen -> reduce { copy(isFirstEntered = false) }
ArchiveMainIntent.ClickGoToTopButton -> postSideEffect(ArchiveMainSideEffect.ScrollToTop)

Expand All @@ -63,30 +56,6 @@ class ArchiveMainViewModel @Inject constructor(
}

ArchiveMainIntent.ClickQRScanIcon -> postSideEffect(ArchiveMainSideEffect.NavigateToQRScan)

is ArchiveMainIntent.SelectGalleryImage -> reduce {
copy(
isShowSelectWithAlbumDialog = true,
selectedUris = intent.uris.toImmutableList(),
)
}

ArchiveMainIntent.DismissSelectWithAlbumDialog -> reduce { copy(isShowSelectWithAlbumDialog = false) }
ArchiveMainIntent.ClickUploadWithAlbumRow -> {
reduce {
copy(
isShowSelectWithAlbumDialog = false,
scannedImageUrl = null,
selectedUris = persistentListOf(),
)
}
if (state.scannedImageUrl == null)
postSideEffect(ArchiveMainSideEffect.NavigateToUploadAlbumWithGallery(state.selectedUris.map { it.toString() }))
else postSideEffect(ArchiveMainSideEffect.NavigateToUploadAlbumWithQRScan(state.scannedImageUrl))
}

ArchiveMainIntent.ClickUploadWithoutAlbumRow -> uploadWithoutAlbum(state, reduce, postSideEffect)

ArchiveMainIntent.ClickNotificationIcon -> {}

// Album Intent
Expand All @@ -103,16 +72,6 @@ class ArchiveMainViewModel @Inject constructor(
// Add Album BottomSheet Intent
ArchiveMainIntent.DismissAddAlbumBottomSheet -> reduce { copy(isShowAddAlbumBottomSheet = false) }
ArchiveMainIntent.ClickAddAlbumButton -> handleAddAlbum(state.albumNameTextState.text.trim().toString(), reduce, postSideEffect)

// Result
is ArchiveMainIntent.QRCodeScanned -> reduce {
copy(
scannedImageUrl = intent.imageUrl,
isShowSelectWithAlbumDialog = true,
)
}

ArchiveMainIntent.ReceiveOpenGalleryResult -> postSideEffect(ArchiveMainSideEffect.OpenGallery)
}
}

Expand Down Expand Up @@ -151,7 +110,7 @@ class ArchiveMainViewModel @Inject constructor(
}

private suspend fun fetchPhotos(reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit, size: Int = DEFAULT_PHOTOS_SIZE) {
photoRepository.getPhotos()
photoRepository.getPhotos(size = size)
.onSuccess { data ->
reduce { copy(recentPhotos = data.toImmutableList()) }
}
Expand All @@ -170,77 +129,6 @@ class ArchiveMainViewModel @Inject constructor(
}
}

private fun uploadWithoutAlbum(
state: ArchiveMainState,
reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit,
postSideEffect: (ArchiveMainSideEffect) -> Unit,
) {
reduce { copy(isShowSelectWithAlbumDialog = false) }
val onSuccessSideEffect = {
reduce { copy(isLoading = false) }
postSideEffect(ArchiveMainSideEffect.ShowToastMessage("이미지를 추가했어요"))
}
if (state.uploadType == UploadType.QR_CODE) {
uploadSingleImage(
imageUrl = state.scannedImageUrl ?: return,
reduce = reduce,
postSideEffect = postSideEffect,
onSuccess = onSuccessSideEffect,
)
} else {
uploadMultipleImages(
imageUris = state.selectedUris,
reduce = reduce,
postSideEffect = postSideEffect,
onSuccess = onSuccessSideEffect,
)
}
}

private fun uploadSingleImage(
imageUrl: String,
reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit,
postSideEffect: (ArchiveMainSideEffect) -> Unit,
onSuccess: () -> Unit,
) {
viewModelScope.launch {
reduce { copy(isLoading = true) }

uploadSinglePhotoUseCase(
imageUrl = imageUrl,
).onSuccess {
fetchPhotos(reduce, 1) // 가장 최신 데이터 가져오기
onSuccess()
}.onFailure { e ->
Timber.e(e)
postSideEffect(ArchiveMainSideEffect.ShowToastMessage("이미지 업로드에 실패했어요"))
reduce { copy(isLoading = false) }
}
}
}

private fun uploadMultipleImages(
imageUris: List<Uri>,
reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit,
postSideEffect: (ArchiveMainSideEffect) -> Unit,
onSuccess: () -> Unit,
) {
viewModelScope.launch {
reduce { copy(isLoading = true) }

uploadMultiplePhotoUseCase(
imageUris = imageUris,
).onSuccess {
fetchPhotos(reduce)
onSuccess()
}.onFailure { e ->
Timber.e(e)
postSideEffect(ArchiveMainSideEffect.ShowToastMessage("이미지 업로드에 실패했어요"))
reduce { copy(isLoading = false) }
}
}
}

private fun handleFavoriteToggle(
photo: Photo,
reduce: (ArchiveMainState.() -> ArchiveMainState) -> Unit,
Expand Down
Loading