[Android] 상속으로 중복코드 없애기(1): NetworkFragment

uuranus·2023년 8월 24일
0
post-thumbnail

코드를 짜다보면 비슷비슷하게 계속짜는 코드가 있다.
binding 생성한는 코드도 그렇고 나의 경우

lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.snackBarMessage.collectLatest {
                    if (it.isNotEmpty()) {
                        Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT).show()
                    }
                }
            }
        }

이런 코드나 네트워크 요청 시 로딩화면을 보여주기 위해 NetworkState를 만든 경우

lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.networkState.collectLatest {
                    when (it) {
                        NetworkState.DONE -> {
                            loadingDialog.dismiss()
                            onNetworkStateChange.onDone()
                        }

                        NetworkState.LOADING -> {
                            loadingDialog.show()
                        }

                        NetworkState.SUCCESS -> {
                            loadingDialog.dismiss()
                            onNetworkStateChange.onSuccess()
                        }

                        NetworkState.FAIL -> {
                            loadingDialog.dismiss()
                            onNetworkStateChange.onFail()
                        }

                        NetworkState.TOKEN_EXPIRED -> {
                            Toast.makeText(
                                this@NetworkActivity,
                                "시간이 지나 앱을 재실행합니다",
                                Toast.LENGTH_SHORT
                            ).show()
                            val intent = packageManager.getLaunchIntentForPackage(packageName)
                            val componentName = intent!!.component
                            val mainIntent = Intent.makeRestartActivityTask(componentName)
                            startActivity(mainIntent)
                            System.exit(0)
                        }
                    }
                }
            }
        }

이런 코드를 사용하게 되는데 많은 화면에서 네트워크 요청을 하기 때문에 위 코드가 계속 반복되었다.
그래서 이를 NetworkActivity, NetworkFragment로 상위 클래스로 분리하고 ViewModel도 NetworkViewModel로 만들어서 snackBarMessage, NetworkState를 변수로 가지고 있게 하면 어떨까 생각하였다.


NetworkActivity

먼저 Activity부터 만들어보자

abstract class NetworkActivity<T : ViewDataBinding, VM : NetworkViewModel>(
    @LayoutRes private val layoutRes: Int,
) : AppCompatActivity() {
 	private var _binding: T? = null
    val binding get() = _binding!!

    val appContainer by lazy {
        (application as GoBongApplication).container
    }
    
    abstract val viewModel: VM

    abstract val onNetworkStateChange: NetworkStateListener

    private val loadingDialog: AlertDialog by lazy {
        val dialogView = DialogLoadingBinding.inflate(this.layoutInflater)
        AlertDialog.Builder(this)
            .setView(dialogView.root)
            .setCancelable(false)
            .create().apply {
                window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
            }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
         _binding = DataBindingUtil.setContentView(this, layoutRes)
        binding.lifecycleOwner = this
        
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.snackBarMessage.collectLatest {
                    if (it.isNotEmpty()) {
                        Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT).show()
                    }
                }
            }
        }

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.toastMessage.collectLatest {
                    if (it.isNotEmpty()) {
                        Toast.makeText(this@NetworkActivity, it, Toast.LENGTH_SHORT).show()
                    }
                }
            }
        }

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.networkState.collectLatest {
                    when (it) {
                        NetworkState.DONE -> {
                            loadingDialog.dismiss()
                            onNetworkStateChange.onDone()
                        }

                        NetworkState.LOADING -> {
                            loadingDialog.show()
                        }

                        NetworkState.SUCCESS -> {
                            loadingDialog.dismiss()
                            onNetworkStateChange.onSuccess()
                        }

                        NetworkState.FAIL -> {
                            loadingDialog.dismiss()
                            onNetworkStateChange.onFail()
                        }

                        NetworkState.TOKEN_EXPIRED -> {
                            Toast.makeText(
                                this@NetworkActivity,
                                "시간이 지나 앱을 재실행합니다",
                                Toast.LENGTH_SHORT
                            ).show()
                            val intent = packageManager.getLaunchIntentForPackage(packageName)
                            val componentName = intent!!.component
                            val mainIntent = Intent.makeRestartActivityTask(componentName)
                            startActivity(mainIntent)
                            System.exit(0)
                        }
                    }
                }
            }
        }
    }
}

로딩화면

  • 로딩화면은 로딩화면이 나오는 동안 다른 뷰들을 선택하지 못하게 막기 위해 AlertDialog를 이용해서 만들었다.
    progress view하나만 있고 다이얼로그의 배경을 투명하게 만들었다.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:background="@android:color/transparent"
    android:layout_height="match_parent">

    <ProgressBar
        android:layout_width="0dp"
        app:layout_constraintWidth_percent="0.25"
        android:layout_height="0dp"
        app:layout_constraintDimensionRatio="1:1"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

실행하면 다음과 같이 나온다.
로딩화면 실행화면

AppContainer

  • 의존성을 수동으로 주입하기 위해 만든 AppConatiner이다.
  • 왜 Hilt나 Dagger를 안 썼냐고 하면 코틀린 버전을 1.8로 올리고 자바 버전도 17로 올리면서 kapt를 사용하면 debugCompile 오류가 났기 때문이다. 이것저것 다 해봤지만 결국 실패해서 그냥 수동으로 의존성을 주입하였다.

NetworkStateListener

interface NetworkStateListener {
    fun onSuccess()
    fun onFail()
    fun onDone()
}

각 네트워크 상태일때마다 하고 싶은 일을 해당 listener함수를 오버라이드해서 사용할 수 있게 하였다.
근데 막상 구현해보니 onSuccess()나 onFail()도 가끔 사용하는 정도 (ex. 성공적으로 저장했으면 액티비티를 finish()하기)여서 abstract으로 항상 구현하도록 강제하는게 오히려 안 쓰는 코드를 더 많이 만들었다.
open으로 만들어서 필요한 함수만 오버라이드 하도록 하는게 더 좋았을 것 같다.


NetworkFragment

abstract class NetworkFragment<T : ViewDataBinding, VM : NetworkViewModel>(
    @LayoutRes private val layoutRes: Int,
) : Fragment() {
	private var _binding: T? = null
    val binding get() = _binding!!
    val  appContainer by lazy{
        (requireActivity().application as GoBongApplication).container
    }
    
    abstract val viewModel: VM

    abstract val onNetworkStateChange: NetworkStateListener

    private val loadingDialog: AlertDialog by lazy {
        val dialogView = DialogLoadingBinding.inflate(requireActivity().layoutInflater)
        AlertDialog.Builder(requireContext())
            .setView(dialogView.root)
            .setCancelable(false)
            .create().apply {
                window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
            }
    }

	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)

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

        viewLifecycleOwner.lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.networkState.collectLatest {
                    when (it) {
                        NetworkState.DONE -> {
                            loadingDialog.dismiss()
                            onNetworkStateChange.onDone()
                        }

                        NetworkState.LOADING -> {
                            loadingDialog.show()
                        }

                        NetworkState.SUCCESS -> {
                            loadingDialog.dismiss()
                            onNetworkStateChange.onSuccess()
                        }

                        NetworkState.FAIL -> {
                            loadingDialog.dismiss()
                            onNetworkStateChange.onFail()
                        }

                        NetworkState.TOKEN_EXPIRED -> {
                            Toast.makeText(
                                requireContext(),
                                "시간이 지나 앱을 재실행합니다",
                                Toast.LENGTH_SHORT
                            ).show()
                            val intent = requireActivity().packageManager.getLaunchIntentForPackage(
                                requireActivity().packageName
                            )
                            val componentName = intent!!.component
                            val mainIntent = Intent.makeRestartActivityTask(componentName)
                            startActivity(mainIntent)
                            System.exit(0)
                        }
                    }
                }
            }
        }
    }
    
     override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

}

NetworkActivity랑 거의 비슷하다.


NetworkViewModel

abstract class NetworkViewModel : GoBongViewModel() {
	
    private val _snackBarMessage = MutableStateFlow("")
    val snackBarMessage: StateFlow<String> get() = _snackBarMessage

    private val _toastMessage = MutableStateFlow("")
    val toastMessage: StateFlow<String> get() = _toastMessage

    private val _networkState = MutableStateFlow(NetworkState.DONE)
    val networkState: StateFlow<NetworkState> get() = _networkState

	fun setSnackBarMessage(message: String) {
        _snackBarMessage.value = message
    }

    fun setToastMessage(message: String) {
        _toastMessage.value = message
    }
    
    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("MyApp", snackBarMessage.value)
        }
    }

    protected fun finishNetwork() {
        if (_networkState.value == NetworkState.LOADING) {
            _networkState.value = NetworkState.DONE
        }
    }

}

NetworkActivity, NetworkFragment에서 제너릭 타입의 상한을 NetworkViewModel로 하였기 때문에 NetworkViewModel을 상속하지 않으면 오류가 나게 하였다.

setNetworkState에서 Loading상태일 때 코루틴을 하나 실행하는게 있는데 이건 네트워크 요청 후 서버상의 문제나 앱 상의 오류로 시간이 지나도 응답값이 오지 못해 Success나 Fail의 상태값이 되지 못하는 경우, 로딩화면이 무한히 돌아가는 현상이 발생한다.

그래서 로딩인 경우 자체적으로 코루틴을 실행시켜 5초나 지났을 경우에 done으로 변경시켜 로딩화면을 종료하도록 만들었다!


사용하기

  • NetworkFragment를 상속받아서 HomeFragment와 HomeVewModel을 만들어보자
class HomeFragment : NetworkFragment<FragmentHomeBinding, HomeViewModel>(R.layout.fragment_home) {

	override val viewModel: HomeViewModel by lazy {
        HomeViewModel(appContainer.appRepository, appContainer.userRepository)
    }

	override val onNetworkStateChange: NetworkStateListener = 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?) {
        binding.run {
            vm = viewModel
        }

        super.onViewCreated(view, savedInstanceState)

 		viewModel.getFollowingRecipes()
	}	
}

onViewCreated에서 super.onViewCreated를 호출하기 전에 binding.vm = viewModel 코드를 넣어줘야 한다.
이것도 거의 중복되어서 상위 클래스에 BR.으로 넣어줄 수 있지만 이게 되려면 모든 xml에 vm이라는 변수를 넣어줘야 한다.
모든 xml이 데이터바인딩을 쓰는 것도 아닌데 강제하는 건 아니라 생각해 하위 클래스에서 선언해주는 걸로 선택하였다.

HomeViewmodel에서 네트워크 요청하는 코드의 경우

fun getFollowingRecipes() {
        viewModelScope.launch {
            setNetworkState(NetworkState.LOADING)
            try {
                requestFollowingRecipes()
                setNetworkState(NetworkState.SUCCESS)
            } catch (e: Exception) {
                setNetworkState(NetworkState.FAIL)
                setSnackBarMessage(e.message ?: "")
            }
        }
    }

    private suspend fun requestFollowingRecipes() {
        _recipes.value = goBongRepository.getFollowingRecipes()
    }

이렇게 NetworkState를 사용해서 호출하였다.
repository 이후 영역에서 Exception으로 에러를 날리면 try~catch로 받는 형식으로 구현하였다.


결론

  • 이렇게 구성하는 방식이 정답인 건 아니다!
  • NetworkState에 TOKEN_EXPIRED 상태로 변경하는 코드가 없는데 catch에서 Exception을 구분하여 TokenExpireException 같은 커스텀 에러를 만들어서 따로 catch를 하면 된다.
profile
Frontend Developer

0개의 댓글