-
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 all 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() | ||
|
|
||
| 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" | ||
| } | ||
| } | ||
|
|
||
| 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)) | ||
| } | ||
| ) | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
여기서 단순히 매직 리터럴을 없애겠단 이유 말고도 companion object를 쓴 이유가 있는지? 메모리 효율에 대한 고려는 했는지?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
companion object안의const val은 컴파일 타임에 인라인되어 메모리에 별도 객체를 만들지 않습니다.const val STAMP_LOCK = "lock"은 바이트코드에서 사용처에 직접"lock"리터럴로 치환됨val이었다면 companion object 인스턴스가 힙에 올라가겠지만,const는 그렇지 않음여기서 companion을 쓴 이유는 단순히 data class 바깥에 상수를 두면서도
MyPageUiState.STAMP_LOCK으로 접근할 수 있게 스코핑한 것입니다.There was a problem hiding this comment.
Choose a reason for hiding this comment
The 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 comment
The reason will be displayed to describe this comment to others. Learn more.
거의 맞는데 하나만 정정:
val(로컬 변수): 스택에 할당 ✅val(클래스 프로퍼티): 힙에 할당 (객체의 일부이므로)companion object안의val: companion object 인스턴스가 힙에 올라가고, 그 안의val도 힙 ✅companion object안의const val: 컴파일 타임에 사용처에 인라인 → 런타임 메모리 할당 없음 ✅정리하면
val의 위치(로컬 vs 클래스 프로퍼티)에 따라 스택/힙이 달라지고,const val만 인라인되어 메모리 할당이 없습니다.참고: Kotlin const 공식 문서