-
Notifications
You must be signed in to change notification settings - Fork 1
MVI 베이스 클래스 도입 및 MyPage 레퍼런스 구현 #373
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| package com.runnect.runnect.presentation.base | ||
|
|
||
| import androidx.lifecycle.ViewModel | ||
| import androidx.lifecycle.viewModelScope | ||
| import kotlinx.coroutines.CoroutineExceptionHandler | ||
| import kotlinx.coroutines.flow.Flow | ||
| import kotlinx.coroutines.flow.MutableSharedFlow | ||
| import kotlinx.coroutines.flow.MutableStateFlow | ||
| import kotlinx.coroutines.flow.SharedFlow | ||
| import kotlinx.coroutines.flow.StateFlow | ||
| import kotlinx.coroutines.flow.asSharedFlow | ||
| import kotlinx.coroutines.flow.asStateFlow | ||
| import kotlinx.coroutines.flow.catch | ||
| import kotlinx.coroutines.flow.onStart | ||
| import kotlinx.coroutines.launch | ||
| import timber.log.Timber | ||
|
|
||
| abstract class MviViewModel<STATE, INTENT, EFFECT>( | ||
| initialState: STATE | ||
| ) : ViewModel() { | ||
|
|
||
| private val _state = MutableStateFlow(initialState) | ||
| val state: StateFlow<STATE> = _state.asStateFlow() | ||
|
|
||
| private val _effect = MutableSharedFlow<EFFECT>() | ||
| val effect: SharedFlow<EFFECT> = _effect.asSharedFlow() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: In a default MutableSharedFlow (replay=0, extraBufferCapacity=0, onBufferOverflow=BufferOverflow.SUSPEND), values emitted before any collector is active are dropped/lost. The emit call returns immediately without suspending, as there is no buffer and no subscribers; the value is simply not stored. For one-off UI effects (e.g., toasts, navigation), the recommended pattern is to use MutableSharedFlow with replay=0 (to avoid replaying to new collectors after configuration changes) exposed as SharedFlow, collected in UI with lifecycle-aware scopes like repeatOnLifecycle(Lifecycle.State.STARTED) or LaunchedEffect. Some sources suggest Channel + receiveAsFlow for guaranteed delivery via buffering, but official docs and common practice favor SharedFlow configured with replay=0 for events. Citations:
🏁 Script executed: # Get the file path and read relevant sections
fd -t f "MviViewModel.kt" --path "*/presentation/base/*"Repository: Runnect/Runnect-Android Length of output: 300 🏁 Script executed: # Read the MviViewModel.kt file to understand context around lines 28-29 and 50-53
cat -n app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt | sed -n '1,70p'Repository: Runnect/Runnect-Android Length of output: 2735 🏁 Script executed: # Search for where effect is collected in the codebase to see if lifecycle-aware collection is used
rg "\.effect\b" --type kotlin -A 3 -B 3 | head -100Repository: Runnect/Runnect-Android Length of output: 49 🏁 Script executed: # Search for effect collection pattern in the codebase
rg "\.effect\s*\." --type kotlin -B 2 -A 5Repository: Runnect/Runnect-Android Length of output: 49 🏁 Script executed: # Search for LaunchedEffect or repeatOnLifecycle patterns with effect
rg "LaunchedEffect|repeatOnLifecycle" --type kotlin -B 2 -A 5 | head -80Repository: Runnect/Runnect-Android Length of output: 2099 🏁 Script executed: # Look for specific MyPage files mentioned in the comment to see how effects are collected
fd "MyPage" --type f -e kt -e java | head -20Repository: Runnect/Runnect-Android Length of output: 482 🏁 Script executed: # Check MyPageFragment to see how effect is collected
cat -n app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt | head -150Repository: Runnect/Runnect-Android Length of output: 7228 🏁 Script executed: # Check MyPageViewModel to see what effects it posts
cat -n app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageViewModel.kt | head -100Repository: Runnect/Runnect-Android Length of output: 2166 🏁 Script executed: # Get the full MyPageFragment to see if effect is collected
cat -n app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt | tail -100Repository: Runnect/Runnect-Android Length of output: 4430 🏁 Script executed: # Search for any effect collection in MyPageFragment
rg "viewModel\.effect" app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt -A 5Repository: Runnect/Runnect-Android Length of output: 49 🏁 Script executed: # Check MyPageContract to see what effects are defined
cat app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageContract.ktRepository: Runnect/Runnect-Android Length of output: 832 Collect effects in the UI layer with lifecycle awareness. The Also applies to: 50-53 🤖 Prompt for AI Agents
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재 코드 유지
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
제 원래 코멘트에서 "dropped"라고 표현한 것은 부정확했습니다. ( ˘▾˘)~♪ ✏️ Learnings added
|
||
|
|
||
| val currentState: STATE get() = _state.value | ||
|
|
||
| private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> | ||
| Timber.tag(throwable::class.java.simpleName).e(throwable) | ||
| handleException(throwable) | ||
| } | ||
|
|
||
| fun intent(intent: INTENT) { | ||
| viewModelScope.launch(exceptionHandler) { | ||
| handleIntent(intent) | ||
| } | ||
| } | ||
|
|
||
| protected abstract suspend fun handleIntent(intent: INTENT) | ||
|
|
||
| protected fun reduce(reducer: STATE.() -> STATE) { | ||
| _state.value = currentState.reducer() | ||
| } | ||
|
|
||
| protected fun postEffect(effect: EFFECT) { | ||
| viewModelScope.launch { | ||
| _effect.emit(effect) | ||
| } | ||
| } | ||
|
|
||
| protected fun <T> collectFlow( | ||
| flow: suspend () -> Flow<Result<T>>, | ||
| onLoading: () -> Unit = {}, | ||
| onSuccess: (T) -> Unit, | ||
| onFailure: (Throwable) -> Unit | ||
| ) { | ||
| viewModelScope.launch(exceptionHandler) { | ||
| flow() | ||
| .onStart { onLoading() } | ||
| .catch { onFailure(it) } | ||
| .collect { result -> | ||
| result.fold(onSuccess, onFailure) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| protected open fun handleException(throwable: Throwable) { | ||
| Timber.e(throwable) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package com.runnect.runnect.presentation.mypage | ||
|
|
||
| import com.runnect.runnect.R | ||
|
|
||
| data class MyPageUiState( | ||
| val isLoading: Boolean = true, | ||
| val nickname: String = "", | ||
| val stampId: String = STAMP_LOCK, | ||
| val profileImgResId: Int = R.drawable.user_profile_basic, | ||
| val level: String = "", | ||
| val levelPercent: Int = 0, | ||
| val email: String = "", | ||
| val error: String? = null | ||
| ) { | ||
| companion object { | ||
| const val STAMP_LOCK = "lock" | ||
| } | ||
|
Comment on lines
+15
to
+17
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 여기서 단순히 매직 리터럴을 없애겠단 이유 말고도 companion object를 쓴 이유가 있는지? 메모리 효율에 대한 고려는 했는지?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
여기서 companion을 쓴 이유는 단순히 data class 바깥에 상수를 두면서도
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 팩트 체크는 안 해봤는데 너가 한 말에 따르면 아래와 같이 이해했는데 맞아?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 거의 맞는데 하나만 정정:
정리하면 |
||
| } | ||
|
|
||
| sealed interface MyPageIntent { | ||
| data object LoadUserInfo : MyPageIntent | ||
| data class UpdateNickname(val nickname: String) : MyPageIntent | ||
| data class UpdateProfileImg(val resId: Int) : MyPageIntent | ||
| } | ||
|
|
||
| sealed interface MyPageEffect { | ||
| data class ShowError(val message: String) : MyPageEffect | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,66 +1,48 @@ | ||
| package com.runnect.runnect.presentation.mypage | ||
|
|
||
| import androidx.lifecycle.LiveData | ||
| import androidx.lifecycle.MutableLiveData | ||
| import com.runnect.runnect.R | ||
| import com.runnect.runnect.domain.common.toLog | ||
| import com.runnect.runnect.domain.repository.UserRepository | ||
| import com.runnect.runnect.presentation.base.BaseViewModel | ||
| import com.runnect.runnect.presentation.state.UiState | ||
| import com.runnect.runnect.util.extension.collectResult | ||
| import com.runnect.runnect.presentation.base.MviViewModel | ||
| import dagger.hilt.android.lifecycle.HiltViewModel | ||
| import kotlinx.coroutines.flow.onStart | ||
| import javax.inject.Inject | ||
|
|
||
| @HiltViewModel | ||
| class MyPageViewModel @Inject constructor( | ||
| private val userRepository: UserRepository | ||
| ) : BaseViewModel() { | ||
|
|
||
| val nickName: MutableLiveData<String> = MutableLiveData<String>() | ||
| val stampId: MutableLiveData<String> = MutableLiveData<String>(STAMP_LOCK) | ||
| val profileImgResId: MutableLiveData<Int> = MutableLiveData<Int>(R.drawable.user_profile_basic) | ||
| val level: MutableLiveData<String> = MutableLiveData<String>() | ||
| val levelPercent: MutableLiveData<Int> = MutableLiveData<Int>() | ||
| val email: MutableLiveData<String> = MutableLiveData<String>() | ||
|
|
||
| private val _userInfoState = MutableLiveData<UiState>(UiState.Loading) | ||
| val userInfoState: LiveData<UiState> | ||
| get() = _userInfoState | ||
|
|
||
| val errorMessage = MutableLiveData<String>() | ||
| fun setNickName(nickName: String) { | ||
| this.nickName.value = nickName | ||
| } | ||
|
|
||
| fun setProfileImg(profileImgResId: Int) { | ||
| this.profileImgResId.value = profileImgResId | ||
| ) : MviViewModel<MyPageUiState, MyPageIntent, MyPageEffect>(MyPageUiState()) { | ||
|
|
||
| override suspend fun handleIntent(intent: MyPageIntent) { | ||
| when (intent) { | ||
| is MyPageIntent.LoadUserInfo -> loadUserInfo() | ||
| is MyPageIntent.UpdateNickname -> reduce { copy(nickname = intent.nickname) } | ||
| is MyPageIntent.UpdateProfileImg -> reduce { copy(profileImgResId = intent.resId) } | ||
| } | ||
| } | ||
|
|
||
| fun getUserInfo() = launchWithHandler { | ||
| userRepository.getUserInfo() | ||
| .onStart { | ||
| _userInfoState.value = UiState.Loading | ||
| }.collectResult( | ||
| onSuccess = { user -> | ||
| user.let { | ||
| level.value = it.level.toString() | ||
| nickName.value = it.nickname | ||
| stampId.value = it.latestStamp | ||
| levelPercent.value = it.levelPercent | ||
| email.value = it.email | ||
| } | ||
|
|
||
| _userInfoState.value = UiState.Success | ||
| }, | ||
| onFailure = { | ||
| errorMessage.value = it.toLog() | ||
| _userInfoState.value = UiState.Failure | ||
| private fun loadUserInfo() { | ||
| collectFlow( | ||
| flow = { userRepository.getUserInfo() }, | ||
| onLoading = { | ||
| reduce { copy(isLoading = true, error = null) } | ||
| }, | ||
| onSuccess = { user -> | ||
| reduce { | ||
| copy( | ||
|
Comment on lines
+29
to
+30
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이 파일에서 전반적으로 reduce랑 copy가 많이 쓰이는데 역할이 뭐고 이유가 뭔지?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MVI에서 상태 변경은 반드시
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. reduce랑 copy는 세트인가? 그렇다면 이걸 하나로 합쳐서 편하게 쓸 수 있는 api를 하나 만들면 좋을 듯
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재 // reduce 하나로 "현재 상태에서 이 필드만 바꿔" 를 표현
reduce { copy(nickname = "새이름") }
|
||
| isLoading = false, | ||
| nickname = user.nickname, | ||
| stampId = user.latestStamp, | ||
| level = user.level.toString(), | ||
| levelPercent = user.levelPercent, | ||
| email = user.email, | ||
| error = null | ||
| ) | ||
| } | ||
| ) | ||
| } | ||
|
|
||
| companion object { | ||
| const val STAMP_LOCK = "lock" | ||
| }, | ||
| onFailure = { throwable -> | ||
| val message = throwable.toLog() | ||
| reduce { copy(isLoading = false, error = message) } | ||
| postEffect(MyPageEffect.ShowError(message)) | ||
| } | ||
| ) | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.