이전 구현 상황

새로운 도전
시간이 지나서 상속구조 대신 공통 코드를 사용할 수 있는 방식으로 컴포지션 (Composition) 이 있고 이걸 사용하는 디자인 패턴인 Decorator Pattern이 있다는 걸 알았다.
그래서 decorator pattern으로 MainActivity에서 NetworkDecorator를 가져다가 기능을 추가하는 방식으로 구현해보려 했다.
발견한 문제점
애초에 상속구조가 될 수 있는 방식이 아니었다.
- GoBongFragment <- NetworkFragment <- HomeFragment 관계에서 각 클래스는 부모 클래스의 기능을 확장하는 것도 아니고 하는 일이 1도 관계가 없다.
코드를 다른 클래스로 한 번에 많이 뺀다고 좋은 게 아니다.
그저 lifecycleScope ~ repeatOnLifecycle까지 다 NetworkFragment로 뽑아내니까 코드도 줄어든 것 같고 좋은 건 줄 알았는데 사실 클래스의 역할을 생각하지 않고 그저 코드 중복에만 집중해서 잘못된 클래스 분리를 하게 되었다.
코드 고쳐보기
Fragment의 역할은 무엇인가?
Fragment는 생명주기를 관리하는게 자신의 역할이다.
-> 그럼 NetworkFragment의 lifecycleScope ~ repeatOnLifecycle은 Fragment가 담당해야 하는 것.
그래서 lifecycleScope ~ repeatOnLifecycle은 다시 Fragment로 옮기고 NetworkHandler를 만들어서 when 부분만 따로 분리하였다.
class HomeFragment : GoBongFragment<FragmentHomeBinding>(R.layout.fragment_home) {
private val networkHandler: NetworkStateHandler by lazy {
NetworkStateHandler(requireActivity(), object : NetworkStateListener {
override fun onSuccess() {
binding.swipeRefresh.isRefreshing = false
}
override fun onFail() {
binding.swipeRefresh.isRefreshing = false
}
override fun onDone() {
binding.swipeRefresh.isRefreshing = false
}
})
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.networkState.collectLatest {
networkHandler.handleNetworkState(it)
}
}
}
}
}
NetworkHandler
class NetworkStateHandler(
private val activity: ComponentActivity,
private val networkStateListener: NetworkStateListener? = null,
) {
private val loadingDialog: AlertDialog by lazy {
val dialogView = DialogLoadingBinding.inflate(activity.layoutInflater)
AlertDialog.Builder(activity)
.setView(dialogView.root)
.setCancelable(false)
.create().apply {
window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
}
}
fun handleNetworkState(networkState: NetworkState) {
when (networkState) {
NetworkState.Empty -> {
loadingDialog.dismiss()
networkStateListener?.onDone()
}
NetworkState.Loading -> {
loadingDialog.show()
}
is NetworkState.Success -> {
loadingDialog.dismiss()
networkStateListener?.onSuccess()
}
NetworkState.Fail -> {
loadingDialog.dismiss()
networkStateListener?.onFail()
}
NetworkState.TokenExpired -> {
Toast.makeText(
activity,
"시간이 지나 앱을 재실행합니다",
Toast.LENGTH_SHORT
).show()
val intent = activity.packageManager.getLaunchIntentForPackage(activity.packageName)
val componentName = intent!!.component
val mainIntent = Intent.makeRestartActivityTask(componentName)
activity.startActivity(mainIntent)
exitProcess(0)
}
}
}
}
open class NetworkViewModel : GoBongViewModel() {
private val _networkState = MutableStateFlow(NetworkState.DONE)
val networkState: StateFlow<NetworkState> get() = _networkState
protected fun setNetworkState(state: NetworkState) {
_networkState.value = state
if (state == NetworkState.LOADING) {
CoroutineScope(Dispatchers.Unconfined).launch {
delay(5000)
finishNetwork()
}
}
if (state == NetworkState.FAIL) {
Log.e("GoBongBab", snackBarMessage.value)
}
}
protected fun finishNetwork() {
if (_networkState.value == NetworkState.LOADING) {
_networkState.value = NetworkState.DONE
}
}
}
그러나!! 사실 이 코드를 고민할 필요가 없었다.
private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS) // 연결 시간 초과 설정
.readTimeout(5, TimeUnit.SECONDS) // 읽기 시간 초과 설정
.writeTimeout(5, TimeUnit.SECONDS) // 쓰기 시간 초과 설정
.build()
Retrofit에서 설정할 수 있었다...
타임아웃이 되면 SocketTimeoutException 에러가 발생한다.
NetworkViewModel은 이제 쓸모가 없어졌다. 삭제하자
이제 networkState 변수가 HomeViewModel로 내려왔다.
class HomeViewModel(
private val goBongRepository: GoBongRepository,
private val userRepository: UserRepository,
) : GoBongViewModel() {
private val _networkState = MutableStateFlow<NetworkState>(NetworkState.Loading)
val networkState: StateFlow<NetworkState> = _networkState
private val _recipes = MutableStateFlow<List<Card>>(emptyList())
val recipes: StateFlow<List<Card>> = _recipes
fun getFollowingRecipes() {
viewModelScope.launch {
setNetworkState(NetworkState.LOADING)
try {
requestFollowingRecipes()
setNetworkState(NetworkState.SUCCESS)
} catch (e: Exception) {
setNetworkState(NetworkState.FAIL)
setSnackBarMessage(e.message ?: "")
}
finishNetwork()
}
}
private suspend fun requestFollowingRecipes() {
_recipes.value = goBongRepository.getFollowingRecipes()
}
}
getFollowingRecipes() 함수를 보니 일단 setNetworkState() 때문에 어지러워 보이고 getFollowingRecipes는 Fragment가 생성되자마자 바로 호출되는 함수인데 onStart()에서 viewModel.getFollowingRecipes()를 호출해주고 있다.
class HomeViewModel(
private val goBongRepository: GoBongRepository,
private val userRepository: UserRepository,
) : GoBongViewModel() {
private val _networkState: MutableStateFlow<NetworkState> =
MutableStateFlow(NetworkState.Loading)
val networkState: StateFlow<NetworkState> = _networkState
private val _recipes = MutableStateFlow<List<Card>>(emptyList())
val recipes: StateFlow<List<Card>> = _recipes
init{
requestFollowingRecipies()
}
fun requestFollowingRecipies() {
_networkState.value = NetworkState.Loading
viewModelScope.launch {
flow { emit(goBongRepository.getFollowingRecipes()) }
.map {
_recipes.value = it
NetworkState.Success
}
.catch {
setSnackBarMessage(it.message ?: "")
_networkState.value = NetworkState.Fail
}.collect {
_networkState.value = it
}
}
}
}
Flow를 활용한 코드로 체이닝을 사용했더니 Success와 Fail 코드가 확실히 구분되어서 훨씬 이해하기가 편해졌다.
onStart()에서 getFollowingRecipes()하는 코드를 지우고 init을 활용하였다.
interface NetworkStateListener {
fun onSuccess()
fun onFail()
}
class NetworkStateHandler(
val activity: ComponentActivity,
private val networkStateListener: NetworkStateListener? = null,
) {
private val loadingDialog: AlertDialog by lazy {
val dialogView = DialogLoadingBinding.inflate(activity.layoutInflater)
AlertDialog.Builder(activity)
.setView(dialogView.root)
.setCancelable(false)
.create().apply {
window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
}
}
fun handleNetworkState(networkState: NetworkState) {
when (networkState) {
NetworkState.Loading -> {
loadingDialog.show()
}
NetworkState.Success -> {
loadingDialog.dismiss()
networkStateListener?.onSuccess()
}
NetworkState.Fail -> {
loadingDialog.dismiss()
networkStateListener?.onFail()
}
NetworkState.TokenExpired -> {
Toast.makeText(
activity,
"시간이 지나 앱을 재실행합니다",
Toast.LENGTH_SHORT
).show()
val intent = activity.packageManager.getLaunchIntentForPackage(activity.packageName)
val componentName = intent!!.component
val mainIntent = Intent.makeRestartActivityTask(componentName)
activity.startActivity(mainIntent)
exitProcess(0)
}
}
}
}
사용할 때는
class HomeFragment : GoBongFragment<FragmentHomeBinding>(R.layout.fragment_home) {
private val networkHandler: NetworkStateHandler by lazy {
NetworkStateHandler(requireActivity(), object : NetworkStateListener {
override fun onSuccess() {
binding.swipeRefresh.isRefreshing = false
}
override fun onFail() {
binding.swipeRefresh.isRefreshing = false
}
})
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.networkState.collectLatest {
networkHandler.handleNetworkState(it)
}
}
}
}
이렇게 하면 된다!
NetworkFragment에 있던
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.snackBarMessage.collectLatest {
if (it.isNotEmpty()) {
Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT).show()
}
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.toastMessage.collectLatest {
if (it.isNotEmpty()) {
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
}
}
}
}
이 코드들은 GoBongFragment로 옮겨갔다.
모든 화면에는 다 SnackBarMessage나 ToastMessage를 띄울 수 있어야 한다고 생각했기에 상위 클래스에 넣어놓고 이를 GoBongActivity or GoBongFragment에서 collect하게 하였다. 따라서, 이 애플레이케이션 내 ViewModel은 GoBongViewModel을 상속받고 Activity는 GoBongActivity를, Fragment는 GoBogFragment를 상속받는다.
abstract class GoBongFragment<T : ViewDataBinding, VM : GoBongViewModel>
(@LayoutRes private val layoutRes: Int) : Fragment() {
abstract val viewModel: VM
private var _binding: T? = null
val binding get() = _binding!!
val appContainer by lazy {
(requireActivity().application as GoBongApplication).container
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
_binding = DataBindingUtil.inflate(inflater, layoutRes, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.snackBarMessage.collectLatest {
if (it.isNotEmpty()) {
Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT).show()
}
}
}
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.toastMessage.collectLatest {
if (it.isNotEmpty()) {
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
}
}
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
abstract class GoBongViewModel : ViewModel() {
private val _snackBarMessage = MutableStateFlow("")
val snackBarMessage: StateFlow<String> get() = _snackBarMessage
private val _toastMessage = MutableStateFlow("")
val toastMessage: StateFlow<String> get() = _toastMessage
fun setSnackBarMessage(message: String) {
_snackBarMessage.value = message
}
fun setToastMessage(message: String) {
_toastMessage.value = message
}
}
class HomeFragment : GoBongFragment<FragmentHomeBinding, HomeViewModel>(R.layout.fragment_home) {
override val viewModel: HomeViewModel by lazy {
HomeViewModel(appContainer.goBongRepository, appContainer.userRepository)
}
private val cardAdapter = CardRecyclerViewListAdapter(onFollowClick = {
if (it.followed) {
viewModel.unfollow(it.userId)
} else {
viewModel.follow(it.userId)
}
})
private val networkHandler: NetworkStateHandler by lazy {
NetworkStateHandler(requireActivity(), object : NetworkStateListener {
override fun onSuccess() {
binding.swipeRefresh.isRefreshing = false
}
override fun onFail() {
binding.swipeRefresh.isRefreshing = false
}
})
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.run {
vm = viewModel
}
super.onViewCreated(view, savedInstanceState)
binding.run {
recyclerView.adapter = cardAdapter
addRecipeButton.setOnClickListener {
val intent = Intent(
requireContext(), AddRecipeActivity::class.java
)
startActivity(intent)
}
swipeRefresh.setOnRefreshListener {
viewModel.requestFollowingRecipies()
}
}
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.networkState.collectLatest {
networkHandler.handleNetworkState(it)
}
}
}
}
}
class HomeViewModel(
private val goBongRepository: GoBongRepository,
private val userRepository: UserRepository,
) : GoBongViewModel() {
private val _networkState: MutableStateFlow<NetworkState> =
MutableStateFlow(NetworkState.Loading)
val networkState: StateFlow<NetworkState> = _networkState
private val _recipes = MutableStateFlow<List<Card>>(emptyList())
val recipes: StateFlow<List<Card>> = _recipes
init{
requestFollowingRecipies()
}
fun requestFollowingRecipies() {
_networkState.value = NetworkState.Loading
viewModelScope.launch {
flow { emit(goBongRepository.getFollowingRecipes()) }
.map {
_recipes.value = it
NetworkState.Success
}
.catch {
setSnackBarMessage(it.message ?: "")
_networkState.value = NetworkState.Fail
}.collect {
_networkState.value = it
}
}
}
fun follow(userId: Int) {
viewModelScope.launch {
flow { emit(userRepository.follow(userId)) }
.map {
_recipes.value = _recipes.value.map {
if (it.user.userId == userId) {
it.copy(user = it.user.copy(followed = true))
} else {
it
}
}
NetworkState.Success
}.catch {
setSnackBarMessage(it.message ?: "")
_networkState.value = NetworkState.Fail
}.collect { _networkState.value = it }
}
}
fun unfollow(userId: Int) {
viewModelScope.launch {
flow { emit(userRepository.follow(userId)) }
.map {
_recipes.value = _recipes.value.map {
if (it.user.userId == userId) {
it.copy(user = it.user.copy(followed = false))
} else {
it
}
}
NetworkState.Success
}.catch {
setSnackBarMessage(it.message ?: "")
_networkState.value = NetworkState.Fail
}.collect { _networkState.value = it }
}
}
}
enum class NetworkState {
Loading,
Success,
TokenExpired,
Fail
}
interface NetworkStateListener {
fun onSuccess()
fun onFail()
}
class NetworkStateHandler(
val activity: ComponentActivity,
private val networkStateListener: NetworkStateListener? = null,
) {
private val loadingDialog: AlertDialog by lazy {
val dialogView = DialogLoadingBinding.inflate(activity.layoutInflater)
AlertDialog.Builder(activity)
.setView(dialogView.root)
.setCancelable(false)
.create().apply {
window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
}
}
fun handleNetworkState(networkState: NetworkState) {
when (networkState) {
NetworkState.Loading -> {
loadingDialog.show()
}
NetworkState.Success -> {
loadingDialog.dismiss()
networkStateListener?.onSuccess()
}
NetworkState.Fail -> {
loadingDialog.dismiss()
networkStateListener?.onFail()
}
NetworkState.TokenExpired -> {
Toast.makeText(
activity,
"시간이 지나 앱을 재실행합니다",
Toast.LENGTH_SHORT
).show()
val intent = activity.packageManager.getLaunchIntentForPackage(activity.packageName)
val componentName = intent!!.component
val mainIntent = Intent.makeRestartActivityTask(componentName)
activity.startActivity(mainIntent)
exitProcess(0)
}
}
}
}
GoBongFragment를 모든 Fragment가 상속받도록 되어있는데 무조건 DataBinding으로 inflate를 하는게 걸린다. 이 부분을 ViewBinding으로도 가능하도록 변경하는 걸 고려해봐야겠다.