Skip to content

MVI 베이스 클래스 도입 및 MyPage 레퍼런스 구현#373

Merged
unam98 merged 3 commits intodevelopfrom
feature/wave1-mvi-pattern
Apr 2, 2026
Merged

MVI 베이스 클래스 도입 및 MyPage 레퍼런스 구현#373
unam98 merged 3 commits intodevelopfrom
feature/wave1-mvi-pattern

Conversation

@unam98
Copy link
Copy Markdown
Collaborator

@unam98 unam98 commented Apr 2, 2026

작업 배경

  • ViewModel마다 LiveData 6~10개가 분산되어 상태 정합성 보장 불가, 에러 핸들링 112회 이상 반복
  • Compose 전환을 위한 MVI 단방향 데이터 흐름 기반 아키텍처 도입

변경 사항

구분 파일 내용
신규 MviViewModel.kt MviViewModel<STATE, INTENT, EFFECT> 베이스 클래스 — reduce(), postEffect(), collectFlow() 제공
신규 MyPageContract.kt MyPageUiState (단일 data class) + MyPageIntent (sealed interface) + MyPageEffect 정의
수정 MyPageViewModel.kt LiveData 7개 필드 → StateFlow<MyPageUiState> 단일 스트림, collectResultcollectFlow
수정 MyPageFragment.kt observe() 2개 → repeatOnStarted { state.collectLatest { bindState(it) } }, DataBinding 참조 제거
수정 fragment_my_page.xml <data> 블록 비움, 바인딩 표현식 5개 제거 (코드로 이동)

영향 범위

  • MyPage 화면만 영향 (마이페이지 탭)
  • 런타임 동작 동일 — LiveData → StateFlow 전환으로 내부 구조만 변경
  • MviViewModel은 다른 화면에서 재사용할 베이스 클래스

Test Plan

  • 디버그 빌드 성공 확인
  • 마이페이지 탭 진입 후 프로필 정보(닉네임, 레벨, 프로그레스바, 스탬프) 정상 표시 확인
  • 닉네임 수정 후 마이페이지 복귀 시 변경 반영 확인
  • 방문자 모드에서 마이페이지 접근 시 visitor UI 표시 확인

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Refactor
    • Modernized My Page to a state-driven architecture for more consistent UI updates and centralized loading/error handling.
  • New Features
    • Nickname and profile-image changes are now handled via a unified intent flow.
    • Errors surface as transient messages (snackbars).
  • UI
    • Removed legacy view-model bindings; view now renders from runtime state and uses design-time placeholders in the layout.

- MviViewModel<STATE, INTENT, EFFECT> 베이스 클래스 생성 (reduce, postEffect, collectFlow)
- MyPageContract 정의 (MyPageUiState, MyPageIntent, MyPageEffect)
- MyPageViewModel: LiveData 7개 → StateFlow 단일 UiState로 통합
- MyPageFragment: observe 2개 + XML 바인딩 5개 → collectLatest + bindState()로 전환
- fragment_my_page.xml: DataBinding 표현식 제거, 코드로 이동
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 2, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds an MVI foundation (MviViewModel) and refactors the MyPage feature to an intent/state-driven MVI flow (contract, ViewModel, Fragment) and removes data-binding from the MyPage layout.

Changes

Cohort / File(s) Summary
MVI Architecture
app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt
New abstract MviViewModel<State, Intent, Effect> providing StateFlow state, SharedFlow effects, intent() entrypoint, reduce() helper, postEffect(), collectFlow() helper for Flow<Result<T>>, and handleException() hook.
MyPage Contract
app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageContract.kt
New contract types: MyPageUiState data class, MyPageIntent sealed interface (LoadUserInfo, UpdateNickname, UpdateProfileImg), and MyPageEffect sealed interface (ShowError).
MyPage ViewModel
app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageViewModel.kt
Replaced LiveData-based implementation with MviViewModel<MyPageUiState, MyPageIntent, MyPageEffect>; removed LiveData properties and imperative setters/getUserInfo(); implemented handleIntent() and loadUserInfo() via collectFlow(); emits state via reduce() and errors via postEffect().
MyPage Fragment
app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt
Switched to intent-driven usage: sends MyPageIntent.LoadUserInfo on init, collects viewModel.state with repeatOnStarted and collectLatest to bindState(), reads viewModel.currentState for navigation/launch, dispatches nickname/profile updates via intents, and removed direct ViewModel data-binding assignment.
MyPage Layout
app/src/main/res/layout/fragment_my_page.xml
Removed data-binding variable and binding expressions (@{vm.*}) for profile image, nickname, level, and progress; replaced with tools: design-time placeholders.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hop through states and intents so spry,

carrots of state that flow on by.
One intent, one source — a tidy chore,
I nibble bugs and track effects galore.
Hooray — MVI hops through the door! 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: introducing an MVI base class and implementing MyPage as a reference implementation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/wave1-mvi-pattern

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt (1)

118-122: Keep bindState() free of follow-up state writes.

UpdateProfileImg is derived entirely from the current stamp, so sending it from the renderer forces a second state emission after every successful load. Prefer deriving the drawable when binding, or publish the final image state before calling bindState().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt`
around lines 118 - 122, bindState() is mutating state by calling
viewModel.intent(MyPageIntent.UpdateProfileImg(...)) after loading the profile
image; instead avoid follow-up state writes in the renderer: derive the drawable
locally in bindState() using getStampResourceId() and set the ImageView
directly, or move the call to viewModel.intent(UpdateProfileImg(stampResId)) out
of bindState() to the place where the stamp is selected/confirmed so the
ViewModel publishes the final image state before bindState() runs (refer to
bindState(), getStampResourceId(), MyPageIntent.UpdateProfileImg and
viewModel.intent).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt`:
- Around line 28-29: The effect SharedFlow (_effect: MutableSharedFlow<EFFECT>
and public effect: SharedFlow<EFFECT>) is never collected in the UI so posted
effects like MyPageEffect.ShowError are dropped; in the Fragment that owns
MyPageViewModel (e.g., MyPageFragment) add a lifecycle-aware collector using the
existing repeatOnStarted pattern to collect viewModel.effect and dispatch each
emission to your effect handler (e.g., call handleEffect(effect) or the existing
effect routing method); keep the MutableSharedFlow as-is and ensure collection
is started in onViewCreated (or equivalent) so effects are received only while
the UI is active.

In `@app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt`:
- Around line 101-107: The current addObserver only collects viewModel.state and
calls bindState, so failures reported via state.error or via
MyPageEffect.ShowError from loadUserInfo() are never surfaced; update the
repeatOnStarted block in addObserver to also collect viewModel.effect (or
effects flow) with collectLatest and handle MyPageEffect.ShowError by invoking
the fragment's error UI (toast/dialog) and/or a method like showError(message),
and change bindState (or its caller) to render an explicit error view when
state.error is non-null instead of falling back to the empty default; ensure
both the state.error path and MyPageEffect.ShowError are consumed so spinner
removal shows the error UI consistently.

---

Nitpick comments:
In `@app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt`:
- Around line 118-122: bindState() is mutating state by calling
viewModel.intent(MyPageIntent.UpdateProfileImg(...)) after loading the profile
image; instead avoid follow-up state writes in the renderer: derive the drawable
locally in bindState() using getStampResourceId() and set the ImageView
directly, or move the call to viewModel.intent(UpdateProfileImg(stampResId)) out
of bindState() to the place where the stamp is selected/confirmed so the
ViewModel publishes the final image state before bindState() runs (refer to
bindState(), getStampResourceId(), MyPageIntent.UpdateProfileImg and
viewModel.intent).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7a2d1cd7-b9bf-4ab4-b00a-6234b54221a8

📥 Commits

Reviewing files that changed from the base of the PR and between 0c266e0 and 92b0427.

📒 Files selected for processing (5)
  • app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt
  • app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageContract.kt
  • app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageFragment.kt
  • app/src/main/java/com/runnect/runnect/presentation/mypage/MyPageViewModel.kt
  • app/src/main/res/layout/fragment_my_page.xml

Comment on lines +28 to +29
private val _effect = MutableSharedFlow<EFFECT>()
val effect: SharedFlow<EFFECT> = _effect.asSharedFlow()
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Apr 2, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Kotlin Coroutines, what happens to values emitted to a default MutableSharedFlow() before any collector is active, and what pattern is recommended for one-off UI effects?

💡 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 -100

Repository: 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 5

Repository: 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 -80

Repository: 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 -20

Repository: 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 -150

Repository: 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 -100

Repository: 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 -100

Repository: 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 5

Repository: 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.kt

Repository: Runnect/Runnect-Android

Length of output: 832


Collect effects in the UI layer with lifecycle awareness.

The effect flow is defined but never collected in MyPageFragment. Effects like MyPageEffect.ShowError are posted but will be lost if no collector is active. Add effect collection in the Fragment using the existing repeatOnStarted pattern: repeatOnStarted { viewModel.effect.collect { effect -> handleEffect(effect) } }. A default MutableSharedFlow() is appropriate for MVI when effects are properly collected with lifecycle-aware scopes; no need for Channel.

Also applies to: 50-53

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt`
around lines 28 - 29, The effect SharedFlow (_effect: MutableSharedFlow<EFFECT>
and public effect: SharedFlow<EFFECT>) is never collected in the UI so posted
effects like MyPageEffect.ShowError are dropped; in the Fragment that owns
MyPageViewModel (e.g., MyPageFragment) add a lifecycle-aware collector using the
existing repeatOnStarted pattern to collect viewModel.effect and dispatch each
emission to your effect handler (e.g., call handleEffect(effect) or the existing
effect routing method); keep the MutableSharedFlow as-is and ensure collection
is started in onViewCreated (or equivalent) so effects are received only while
the UI is active.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

현재 코드 유지

  • postEffect()viewModelScope.launch에서 emit()하므로 ViewModel이 살아있는 한 emit 자체는 실행됨
  • Fragment는 repeatOnStarted로 collect하므로, Fragment가 STARTED 이상일 때만 수신 — lifecycle-safe
  • extraBufferCapacity = 1은 effect가 "fire-and-forget" 성격일 때 적합하지만, 현재 effect가 에러 토스트뿐이고 이미 state.error로도 에러를 보여주고 있어 effect 미수신 시 실질적 문제 없음
  • SharedFlow 공식 문서: replay=0, extraBufferCapacity=0이 default이며, 이는 collector가 없으면 emit이 suspend되는 동작 — drop이 아님
  • 추후 effect 종류가 늘어나면 그때 extraBufferCapacity 추가 검토

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@unam98, 결정 존중합니다. 그리고 공식 문서 인용을 통한 중요한 정정 감사합니다.

제 원래 코멘트에서 "dropped"라고 표현한 것은 부정확했습니다. MutableSharedFlow의 기본 emit()은 collector가 없을 때 suspend 동작이며 (drop은 tryEmit()의 동작), viewModelScope.launch { _effect.emit(effect) }로 실행되므로 Fragment가 STARTED 상태가 되어 collect를 시작하면 해당 emission을 수신할 수 있습니다. 현재 구조에서 충분히 합리적입니다.

( ˘▾˘)~♪


✏️ Learnings added
Learnt from: unam98
Repo: Runnect/Runnect-Android PR: 373
File: app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt:25-26
Timestamp: 2026-04-02T17:59:43.992Z
Learning: In the MVI base class `MviViewModel.kt` (`app/src/main/java/com/runnect/runnect/presentation/base/MviViewModel.kt`), `postEffect()` uses `viewModelScope.launch { _effect.emit(effect) }` with the default `MutableSharedFlow(replay=0, extraBufferCapacity=0)`. The team intentionally keeps this configuration because: (1) `emit()` suspends (not drops) when no collector is active, (2) the Fragment collects with `repeatOnStarted` for lifecycle safety, and (3) current effects (ShowError) are also mirrored in `state.error`, making missed effects non-critical. `extraBufferCapacity` will be revisited if more effect types are added.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment on lines +29 to +30
reduce {
copy(
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

이 파일에서 전반적으로 reduce랑 copy가 많이 쓰이는데 역할이 뭐고 이유가 뭔지?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

MVI에서 상태 변경은 반드시 reduce를 통해서만 이루어집니다.

  • reduce { copy(nickname = "새이름") }는 현재 상태를 기반으로 변경된 부분만 교체한 새 객체를 만드는 패턴
  • copy()는 Kotlin data class의 기본 기능 — 불변 상태 유지하면서 특정 필드만 변경
  • 이렇게 하면 상태 변경 경로가 단일화되어, 어디서 상태가 바뀌었는지 추적이 쉬워짐 (LiveData처럼 여러 곳에서 .value = 하는 것과 대비)
  • Kotlin data class copy 공식 문서

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

reduce랑 copy는 세트인가? 그렇다면 이걸 하나로 합쳐서 편하게 쓸 수 있는 api를 하나 만들면 좋을 듯

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

현재 reduce가 이미 그 역할입니다.

// reduce 하나로 "현재 상태에서 이 필드만 바꿔" 를 표현
reduce { copy(nickname = "새이름") }
  • reduce는 베이스 클래스의 API
  • copy는 Kotlin data class의 기본 기능 (별도 구현 아님)
  • 둘을 더 합치려면 updateState(nickname = "새이름") 같은 형태가 되는데, data class 필드가 화면마다 다르므로 제네릭하게 만들 수 없음
  • 결론: 현재 reduce { copy(...) }가 가장 간결한 형태입니다

Comment on lines +15 to +17
companion object {
const val STAMP_LOCK = "lock"
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

여기서 단순히 매직 리터럴을 없애겠단 이유 말고도 companion object를 쓴 이유가 있는지? 메모리 효율에 대한 고려는 했는지?

Copy link
Copy Markdown
Collaborator Author

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는 그렇지 않음
  • Kotlin const val 공식 문서

여기서 companion을 쓴 이유는 단순히 data class 바깥에 상수를 두면서도 MyPageUiState.STAMP_LOCK으로 접근할 수 있게 스코핑한 것입니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

팩트 체크는 안 해봤는데 너가 한 말에 따르면 아래와 같이 이해했는데 맞아?

  • val : 일반적으로 스택에 쌓임
  • companion object 안의 val : 싱글턴이라 힙에 쌓임
  • companion object 안의 const val : 메모리 할당 안 됨

Copy link
Copy Markdown
Collaborator Author

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 공식 문서

unam98 added 2 commits April 3, 2026 02:57
- handleException: 불필요한 when 분기 제거, Timber.e() 단일 호출로 통합
- MyPageFragment.bindState(): error 상태 시 Snackbar 표시 추가
- Google Android 공식 샘플 컨벤션에 맞춰 PascalCase로 변경
- STATE → State, INTENT → Intent, EFFECT → Effect
@unam98 unam98 merged commit 9c62c23 into develop Apr 2, 2026
2 checks passed
@unam98 unam98 self-assigned this Apr 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant