Skip to content
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

[REFACTOR] 마이페이지 리팩토링 #357

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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 @@ -2,11 +2,15 @@ package com.runnect.runnect.presentation.base

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runnect.runnect.domain.common.toLog
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import retrofit2.HttpException
import timber.log.Timber
Expand All @@ -15,16 +19,39 @@ import java.net.UnknownHostException

abstract class BaseViewModel : ViewModel() {

sealed interface EventState {
object Empty : EventState
data class ShowToast(val message: String) : EventState
data class ShowSnackBar(val message: String) : EventState
data class NetworkError(val message: String) : EventState
data class UnknownError(val message: String) : EventState
}
Copy link
Member

@leeeha leeeha Jun 19, 2024

Choose a reason for hiding this comment

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

매~~우 사소하고 주관적인 의견이지만! EventState라는 네이밍이 약간 고민되네요!

보통 SharedFlow는 여러 이벤트를 처리할 때, StateFlow는 수신자를 최신 상태로 갱신할 때 사용하는 것으로 알고 있습니다. 그래서 EventState보다는 Event 또는 EventBus 같은 네이밍은 어떨까라는 생각이 들었어요.

EventState라고 하면 이게 이벤트인지, 상태인지 아주 약간 헷갈릴 수도 있을 거 같아서요 :)

Copy link
Member

Choose a reason for hiding this comment

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

참고로 SharedFlow 내부 코드에서도 아래처럼 EventBus 라는 클래스가 있더라구요!

class EventBus {
    private val _events = MutableSharedFlow<Event>() // private mutable shared flow
    val events = _events.asSharedFlow() // publicly exposed as read-only shared flow

    suspend fun produceEvent(event: Event) {
        _events.emit(event) // suspends until all subscribers receive it
    }
}

Copy link
Member Author

Choose a reason for hiding this comment

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

@leeeha 오호 좋은 것 같습니다
UI State와 1회성 이벤트를 구분하기 위해 SharedFlow를 추가했으니 네이밍도 확실히 구분되면 좋을 것 같네요

EventBus는 파이프라인이라는 인식이 좀 강한 것 같아서 Event 케이스를 나누는 목적과는 쪼금 차이가 있는 것 같긴하네요

한 번 고민해보고 수정해볼게요 좋은 네이밍 생각나면 말씀해주세요~


private val _eventState: MutableSharedFlow<EventState> = MutableSharedFlow()
val eventState: SharedFlow<EventState> = _eventState.asSharedFlow()

protected fun sendEvent(event: EventState) {
viewModelScope.launch {
_eventState.emit(event)
}
}

open val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Timber.tag(throwable::class.java.simpleName).e(throwable)

when (throwable) {
is SocketException,
is HttpException,
is UnknownHostException -> {
sendEvent(
EventState.NetworkError(throwable.toLog())
)
Timber.e(throwable)
}
else -> {
sendEvent(
EventState.UnknownError(throwable.toLog())
)
Timber.e(throwable)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.runnect.runnect.presentation.mypage

import android.app.Activity.RESULT_OK
import android.content.ContentValues
import android.content.Intent
import android.os.Bundle
import android.view.View
Expand All @@ -18,162 +17,196 @@ import com.runnect.runnect.R
import com.runnect.runnect.binding.BindingFragment
import com.runnect.runnect.databinding.FragmentMyPageBinding
import com.runnect.runnect.presentation.MainActivity
import com.runnect.runnect.presentation.base.BaseViewModel
import com.runnect.runnect.presentation.login.LoginActivity
import com.runnect.runnect.presentation.mypage.dto.UserDto
import com.runnect.runnect.presentation.mypage.editname.MyPageEditNameActivity
import com.runnect.runnect.presentation.mypage.history.MyHistoryActivity
import com.runnect.runnect.presentation.mypage.reward.MyRewardActivity
import com.runnect.runnect.presentation.mypage.setting.MySettingFragment
import com.runnect.runnect.presentation.mypage.upload.MyUploadActivity
import com.runnect.runnect.presentation.state.UiState
import com.runnect.runnect.util.analytics.Analytics
import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_GOAL_REWARD
import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_RUNNING_RECORD
import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_UPLOADED_COURSE
import com.runnect.runnect.util.extension.applyScreenEnterAnimation
import com.runnect.runnect.util.extension.getStampResId
import com.runnect.runnect.util.extension.repeatOnStarted
import com.runnect.runnect.util.extension.showSnackbar
import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber

@AndroidEntryPoint
class MyPageFragment : BindingFragment<FragmentMyPageBinding>(R.layout.fragment_my_page) {

private val viewModel: MyPageViewModel by activityViewModels()

private val userData: UserDto
get() = viewModel.userData.value

private val stampResId: Int
get() = activity?.run {
getStampResId(
stampId = userData.stampId,
resNameParam = RES_NAME,
resType = RES_STAMP_TYPE,
packageName = packageName
)
} ?: 0

private lateinit var resultEditNameLauncher: ActivityResultLauncher<Intent>
var isVisitorMode: Boolean = MainActivity.isVisitorMode
private var isVisitorMode: Boolean = MainActivity.isVisitorMode
Copy link
Member

Choose a reason for hiding this comment

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

방문자 모드 체크할 때 이런 식으로 companion object로 처리하는 게 최선인지 고민되더라구요,,
shared preference 사용하는 것도 괜찮을 거 같고,, 이런 데이터는 어떻게 처리하는 게 현명할까요??

Copy link
Member Author

Choose a reason for hiding this comment

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

@leeeha
저도 이 부분에 대해 깊게 고민해보지는 않았지만 Shared Preference 등을 이용하는게 가장 깔끔할 것 같습니다
대신 Presentation 레이어에서 Preference를 참조하는 것이 아닌 Data 레이어로 옮기는게 좋아보이네요!

방문자 모드인지를 판별하는 로직도 ViewModel로 들어가야하지 않을까 싶습니다.

Copy link
Contributor

Choose a reason for hiding this comment

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

shared preference를 사용하는 것이 기존의 companion object를 사용하는 방법과 비교했을 때 어떤 장점이 있을까요?

Copy link
Member

Choose a reason for hiding this comment

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

기존 코드에서 고민이 되었던 건 '굳이 저 변수를 메인 액티비티의 전역 변수로 만들어야 하는가?' 였습니다.
지금 상태로는 isVisitorMode 라는 변수가 메인 액티비티의 생명주기를 따르게 될텐데
프로그램 전역으로 사용되는 거라면 차라리 로컬 저장소인 shared preferences를 사용하는 게 낫지 않을까? 라는 생각이 들었어요!

Copy link
Member

@leeeha leeeha Jun 26, 2024

Choose a reason for hiding this comment

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

현재 코드를 살펴보니 액세스 토큰 값을 기준으로 방문자 모드 여부를 체크하던데, 그 결과값을 메인 액티비티의 전역 변수가 아닌 shared preferences에 저장해두고 사용해도 편할 거 같다는 생각이 들었습니다~!
isVisitorMode 변수 자체는 뷰의 라이프사이클에 종속될 필요가 없는 데이터인 거 같아서요 :)


override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

if (isVisitorMode) {
activateVisitorMode()
} else {
deactivateVisitorMode()
}

}

private fun activateVisitorMode() {
with(binding) {
ivVisitorMode.isVisible = true
tvVisitorMode.isVisible = true
btnVisitorMode.isVisible = true
constraintInside.isVisible = false
setVisitorMode(true)

btnVisitorMode.setOnClickListener {
val intent = Intent(requireContext(), LoginActivity::class.java)
startActivity(intent)
requireActivity().finish()
activity?.let {
Intent(it, LoginActivity::class.java).let(::startActivity)
it.finish()
}
Copy link
Member

Choose a reason for hiding this comment

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

reflection을 평소에 자주 사용하시는 거 같은데 특별한 이유가 있는지 궁금합니다!

Copy link
Member Author

Choose a reason for hiding this comment

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

@leeeha
그냥 코드가 간단해져서 자주 씁니다😄

Copy link
Member

Choose a reason for hiding this comment

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

궁금해서 GPT한테 물어봤어요 ㅎㅎ

장점

  • 런타임 동적 처리: 리플렉션을 사용하면 프로그램 실행 중에 동적으로 클래스, 메서드, 프로퍼티 등을 검사하고 조작할 수 있습니다. 이를 통해 유연하고 동적인 프로그래밍이 가능합니다.
  • 코드 생성 및 분석: 리플렉션을 활용하면 코드를 동적으로 생성하거나 분석할 수 있습니다. 이는 프레임워크나 라이브러리 개발 시 유용합니다.
  • 디버깅 및 테스트 지원: 리플렉션을 통해 객체의 내부 구조를 검사할 수 있어 디버깅과 테스트에 도움이 됩니다.

단점

  • 성능 저하: 리플렉션은 일반적인 메서드 호출보다 성능이 떨어집니다. 따라서 성능이 중요한 코드에서는 사용을 자제해야 합니다.
  • 컴파일 타임 타입 검사 약화: 리플렉션을 사용하면 컴파일 타임에 타입 검사가 어려워지므로, 런타임 오류가 발생할 가능성이 높아집니다.
  • 코드 가독성 저하: 리플렉션을 과도하게 사용하면 코드가 복잡해지고 가독성이 떨어질 수 있습니다.
  • 보안 위험: 리플렉션을 통해 악의적인 코드 실행이 가능하므로 보안 측면에서 주의가 필요합니다.

}
}
}

private fun deactivateVisitorMode() {
setVisitorMode(false)
addListener()
addObserver()
setResultEditNameLauncher()

viewModel.getUserInfo()
Copy link
Contributor

Choose a reason for hiding this comment

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

viewModel.getUserInfo()
vm = viewModel
lifecycleOwner = [email protected]

개인적인 생각인데 이 부분 각각 별도의 함수로 분리해서 deactivateVisitorMode() 함수 내부의 추상화 수준을 다른 것들과 맞춰주는 건 어떨까요?

Copy link
Member Author

Choose a reason for hiding this comment

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

@unam98 함수 내부의 추상화 수준을 맞춘다는게 어떤 뜻인지 설명해줄 수 있을까요?

Copy link
Member Author

Choose a reason for hiding this comment

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

@unam98 추가로 어차피 Compose로 리팩토링할 화면이기 때문에 제거될 코드 (dataBinding 등)는 그냥 유지했습니다~

Copy link
Contributor

Choose a reason for hiding this comment

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

    private fun deactivateVisitorMode() {
        setVisitorMode(false)
        addListener()
        addObserver()
        setResultEditNameLauncher()

        viewModel.getUserInfo()

        with(binding) {
           vm = viewModel
            lifecycleOwner = [email protected]
        }
    }

예를 들어서 setVisitorMode()부터 setResultEditNameLauncher()까지는 내부 로직이 드러나지 않게 함수로 감싸져있지만 아래부터는 내부 로직이 그대로 드러나는 모양이라 아랫부분들도 위처럼 맞춰주자는 것이었습니다. 추상화 수준이라는 표현보다는 캡슐화 수준이라는 표현이 좀 더 와닿을 수도 있겠네요.

클린코드라는 책에서 봤던 내용인데 이 책이 항상 정답은 아니겠지만 함수 분리를 통한 장점도 누리면서 좀 더 깔끔한 것 같아 개인적인 의견 내봤습니당.

with(binding) {
ivVisitorMode.isVisible = false
tvVisitorMode.isVisible = false
btnVisitorMode.isVisible = false
constraintInside.isVisible = true

binding.vm = viewModel
binding.lifecycleOwner = [email protected]
viewModel.getUserInfo()
addListener()
addObserver()
setResultEditNameLauncher()
vm = viewModel
lifecycleOwner = [email protected]
}
}

private fun setResultEditNameLauncher() {
resultEditNameLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
val name =
result.data?.getStringExtra(EXTRA_NICK_NAME) ?: viewModel.nickName.value
viewModel.setNickName(name!!)
val name = result.data?.getStringExtra(EXTRA_NICK_NAME) ?: ""
viewModel.updateUser(
userData.copy(nickName = name)
Copy link
Member

Choose a reason for hiding this comment

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

var을 사용하지 않을 수 있는 방법이어서 좋은 거 같습니다!
그런데 객체 하나가 담고 있는 데이터가 많다면 복사하는데 시간이 조금 걸릴 수도 있을 거 같다는 생각이 드네요!
트레이드 오프가 있는 거 같은데, 현재는 객체 복사 시간이 그렇게 오래 걸릴 거 같지 않아서 지금 방법이 괜찮아보입니다 :)

Copy link
Member Author

Choose a reason for hiding this comment

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

@leeeha
좋은 의견인 것 같습니다.
기본적으론 불변성을 유지하는 방안으로 작업하고 데이터가 크거나 많은 경우에는 불변성을 포기하는 방법도 고려해봐야할 것 같네요!

)
}
}
}

private fun addListener() {
binding.ivMyPageEditFrame.setOnClickListener {
val intent = Intent(requireContext(), MyPageEditNameActivity::class.java)
intent.putExtra(EXTRA_NICK_NAME, "${viewModel.nickName.value}")
val stampResId = requireContext().getStampResId(
stampId = viewModel.stampId.value,
resNameParam = RES_NAME,
resType = RES_STAMP_TYPE,
packageName = requireContext().packageName
)
intent.putExtra(EXTRA_PROFILE, stampResId)
resultEditNameLauncher.launch(intent)
Intent(requireContext(), MyPageEditNameActivity::class.java).apply {
putExtra(EXTRA_NICK_NAME, userData.nickName)
putExtra(EXTRA_PROFILE, stampResId)
}.let(resultEditNameLauncher::launch)
}

binding.viewMyPageMainRewardFrame.setOnClickListener {
Analytics.logClickedItemEvent(EVENT_CLICK_GOAL_REWARD)
startActivity(Intent(requireContext(), MyRewardActivity::class.java))
requireActivity().overridePendingTransition(
R.anim.slide_in_right, R.anim.slide_out_left
)
startActivityWithAnimation(MyRewardActivity::class.java)
}

binding.viewMyPageMainHistoryFrame.setOnClickListener {
Analytics.logClickedItemEvent(EVENT_CLICK_RUNNING_RECORD)
startActivity(Intent(requireContext(), MyHistoryActivity::class.java))
requireActivity().overridePendingTransition(
R.anim.slide_in_right, R.anim.slide_out_left
)
startActivityWithAnimation(MyHistoryActivity::class.java)
}

binding.viewMyPageMainUploadFrame.setOnClickListener {
Analytics.logClickedItemEvent(EVENT_CLICK_UPLOADED_COURSE)
startActivity(Intent(requireContext(), MyUploadActivity::class.java))
requireActivity().overridePendingTransition(
R.anim.slide_in_right, R.anim.slide_out_left
)
startActivityWithAnimation(MyUploadActivity::class.java)
}

binding.viewMyPageMainSettingFrame.setOnClickListener {
moveToSettingFragment()
}

binding.viewMyPageMainKakaoChannelInquiryFrame.setOnClickListener {
inquiryKakao()
}
}

private fun moveToSettingFragment() {
val bundle = Bundle().apply { putString(ACCOUNT_INFO_TAG, viewModel.email.value) }
val bundle = Bundle().apply {
putString(ACCOUNT_INFO_TAG, userData.email)
}

requireActivity().supportFragmentManager.commit {
this.setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left)
replace<MySettingFragment>(R.id.fl_main, args = bundle)
}
}

private fun addObserver() {
viewModel.nickName.observe(viewLifecycleOwner) { nickName ->
binding.tvMyPageUserName.text = nickName.toString()
repeatOnStarted(
{
viewModel.userState.collect {
when (it) {
is MyPageViewModel.UserState.Loading -> handleLoading(true)
is MyPageViewModel.UserState.UserUpdated -> handleSuccess()
is MyPageViewModel.UserState.Failure -> handleFailure()
}
}
},
{
viewModel.eventState.collect {
when (it) {
is BaseViewModel.EventState.ShowSnackBar -> {
context?.showSnackbar(binding.root, it.message)
}
Copy link
Member

Choose a reason for hiding this comment

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

스낵바가 반복적으로 뜨는 문제.. 위니에서도 발생했었는데 덕분에 해결 방법 알 수 있었네요! 저도 얼른 적용해봐야겠어요!

is BaseViewModel.EventState.NetworkError -> {
context?.showSnackbar(binding.root, it.message)
}
else -> Unit
}
}
}
)
}

private fun handleLoading(isLoading: Boolean) {
with(binding) {
indeterminateBar.isVisible = isLoading
ivMyPageEditFrame.isClickable = !isLoading
viewMyPageMainSettingFrame.isClickable = !isLoading
}
}

viewModel.userInfoState.observe(viewLifecycleOwner) {
when (it) {
UiState.Empty -> binding.indeterminateBar.isVisible = false
UiState.Loading -> {
binding.indeterminateBar.isVisible = true
binding.ivMyPageEditFrame.isClickable = false
binding.viewMyPageMainSettingFrame.isClickable = false
}
private fun handleSuccess() {
handleLoading(false)
viewModel.updateUser(
userData.copy(profileImgResId = stampResId)
)
binding.tvMyPageUserName.text = userData.nickName
}

UiState.Success -> {
binding.indeterminateBar.isVisible = false
val stampResId = requireContext().getStampResId(
viewModel.stampId.value,
RES_NAME,
RES_STAMP_TYPE,
requireContext().packageName
)
viewModel.setProfileImg(stampResId)
binding.ivMyPageEditFrame.isClickable = true
binding.viewMyPageMainSettingFrame.isClickable = true
}
private fun handleFailure() {
binding.indeterminateBar.isVisible = false
}

UiState.Failure -> {
binding.indeterminateBar.isVisible = false
Timber.tag(ContentValues.TAG).d("Failure : ${viewModel.errorMessage.value}")
}
}
private fun setVisitorMode(isVisitor: Boolean) {
with(binding) {
ivVisitorMode.isVisible = isVisitor
tvVisitorMode.isVisible = isVisitor
btnVisitorMode.isVisible = isVisitor
constraintInside.isVisible = !isVisitor
}
}

private fun startActivityWithAnimation(activityClass: Class<*>) {
activity?.let {
Intent(it, activityClass).let(::startActivity)
it.applyScreenEnterAnimation()
}
Copy link
Member

Choose a reason for hiding this comment

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

ActivityExt.kt 파일에 확장함수로 만들어둬도 좋을 거 같습니다!

}

Expand Down
Loading