코드를 짜다보면 비슷비슷하게 계속짜는 코드가 있다.
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를 변수로 가지고 있게 하면 어떨까 생각하였다.
먼저 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)
}
}
}
}
}
}
}
<?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>
실행하면 다음과 같이 나온다.
interface NetworkStateListener {
fun onSuccess()
fun onFail()
fun onDone()
}
각 네트워크 상태일때마다 하고 싶은 일을 해당 listener함수를 오버라이드해서 사용할 수 있게 하였다.
근데 막상 구현해보니 onSuccess()나 onFail()도 가끔 사용하는 정도 (ex. 성공적으로 저장했으면 액티비티를 finish()하기)여서 abstract으로 항상 구현하도록 강제하는게 오히려 안 쓰는 코드를 더 많이 만들었다.
open으로 만들어서 필요한 함수만 오버라이드 하도록 하는게 더 좋았을 것 같다.
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랑 거의 비슷하다.
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으로 변경시켜 로딩화면을 종료하도록 만들었다!
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로 받는 형식으로 구현하였다.