[Android] 과거 코드 고치기: 상속 대신 NetworkHandler

uuranus·2024년 3월 10일
post-thumbnail

이전 구현 상황

상속구조로 공통 코드 뽑아내기

  • 액티비티나 프래그먼트에서 네트워크 상태에 따른 UI 대처 코드를 구현하게 되는데 사실 여러 화면에서 코드가 비슷하다.
  • 그래서 이 부분을 이전에는 상속구조로 NetworkActivity, NetworkFragment로 만들어서 분리를 하였다.
  • 하지만, 상속구조는 부모 클래스의 유연함이 떨어지고 캡슐화가 깨질 수 있다는 위험성이 있어서 IS-A 구조가 아니면 사용하지 않는걸 권한다.
  • 하지만, 저 당시에는 상속구조 외에는 다른 코드를 분리하는 방식을 몰라서 그대로 했었다.

새로운 도전

  • 시간이 지나서 상속구조 대신 공통 코드를 사용할 수 있는 방식으로 컴포지션 (Composition) 이 있고 이걸 사용하는 디자인 패턴인 Decorator Pattern이 있다는 걸 알았다.

  • 그래서 decorator pattern으로 MainActivity에서 NetworkDecorator를 가져다가 기능을 추가하는 방식으로 구현해보려 했다.

    • 하지만, NetworkFragment에서 lifecycleScope도 그렇고 dialog도 그렇고 Snackbar도 그렇고 분리를 했지만 결국에는 생성자로 binding이나 activity, fragment를 전달해주어야 해서 결합도가 높은 느낌이 들었다.

발견한 문제점

  • 애초에 상속구조가 될 수 있는 방식이 아니었다.
    - GoBongFragment <- NetworkFragment <- HomeFragment 관계에서 각 클래스는 부모 클래스의 기능을 확장하는 것도 아니고 하는 일이 1도 관계가 없다.

  • 코드를 다른 클래스로 한 번에 많이 뺀다고 좋은 게 아니다.
    그저 lifecycleScope ~ repeatOnLifecycle까지 다 NetworkFragment로 뽑아내니까 코드도 줄어든 것 같고 좋은 건 줄 알았는데 사실 클래스의 역할을 생각하지 않고 그저 코드 중복에만 집중해서 잘못된 클래스 분리를 하게 되었다.


코드 고쳐보기

NetworkHandler

Fragment의 역할은 무엇인가?

  • view를 inflate하고 view의 생명주기 내 발생한 이벤트에 따른 비즈니스 로직 처리

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)
            }
        }
    }
}

NetworkViewModel

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
        }
    }

}
  • NetworkViewModel이 networkState를 collect하면서 NetworkState에 따라서 관련 코드를 처리해주는 구조였다.
  • NetworkState 저장 뿐만 아니라 서버나 앱 내부 상 오류로 인해 네트워크가 전송 or 응답이 되지 않아서 계속 Loading 화면이 뜨는 걸 방지하기 위해 5초가 지나도 Loading 상태면 Done 상태로 가도록 했다.

그러나!! 사실 이 코드를 고민할 필요가 없었다.

private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
        .connectTimeout(5, TimeUnit.SECONDS) // 연결 시간 초과 설정
        .readTimeout(5, TimeUnit.SECONDS) // 읽기 시간 초과 설정
        .writeTimeout(5, TimeUnit.SECONDS) // 쓰기 시간 초과 설정
        .build()

Retrofit에서 설정할 수 있었다...

  • 연결 : tcp 연결 시간
  • 읽기 : 서 -> 클 까지 응답이 오는 시간
  • 쓰기 : 클 -> 서 까지 요청을 보내는 시간

타임아웃이 되면 SocketTimeoutException 에러가 발생한다.

NetworkViewModel은 이제 쓸모가 없어졌다. 삭제하자


NetworkState 처리

이제 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()를 호출해주고 있다.

  • 이건 Flow를 활용해보자
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을 활용하였다.


NetworkHandler 수정

  • 그리고 이제 타임아웃이 Fail로 가도록 변경했기 때문에 onDone 코드는 필요 없다.
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를 상속받는다.

최종 코드

GoBongFragment

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
    }
}

GoBongViewModel

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
    }

}

HomeFragment

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)
                }
            }
        }
    }
}

HomeViewModel

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 }
        }
    }

}

NetworkState

enum class NetworkState {
    Loading,
    Success,
    TokenExpired,
    Fail
}

NetworkStateHandler

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으로도 가능하도록 변경하는 걸 고려해봐야겠다.

profile
Frontend Developer

0개의 댓글