프로그래밍/Android

[안드로이드] 실시간 네크워크 상태 callbackFlow를 이용해 만들어보자! (Youtube 인터넷 연결처럼 구현)

Lou Park 2022. 8. 13. 00:11

Youtube를 보다가 네트워크가 끊겨버렸을때 앱은 이를 알아차리고 "네트워크 연결이 불안정합니다" 같은 텍스트가 보여진다. 그러다 네트워크가 연결이되면 별 액션을 취하지 않아도 영상 목록이 뜨게된다. 계속 polling을 하는건가? 싶었지만 찾아보니 그렇게 하지 않아도 되었다.

 

바로 NetworkCallback을 이용하면 쉽게 구현할 수 있는데, 이 글에서는 Kotlin CallbackFlow를 이용하여 Flow로 만들어서 사용해보려고 한다.

 

NetworkStatusTracker

우리가 만들 NetworkStatusTracker는 다음과 같은 기능을 가진다.

  • 네트워크가 연결되면 이벤트를 방출한다.
  • 네트워크가 끊어지면 이벤트를 방출한다.

 

먼저, 네트워크 연결 상태를 정의해준다.

sealed class NetworkStatus {
    object Available : NetworkStatus()
    object Unavailable: NetworkStatus()
}

 

다음은 NetworkStatusTracker다.

class NetworkStatusTracker(context: Context) {

    private val connectivityManager =
        context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

    val networkStatus = callbackFlow<NetworkStatus> {
        val networkStatusCallback = object: ConnectivityManager.NetworkCallback() {
            override fun onUnavailable() {
                trySend(NetworkStatus.Unavailable)
            }

            override fun onAvailable(network: Network) {
                trySend(NetworkStatus.Available)
            }

            override fun onLost(network: Network) {
                trySend(NetworkStatus.Unavailable)
            }
        }
    }.distinctUntilChanged()
}

ConnectivityManagerNetworkCallback은 네트워크 상태에 대한 Callback을 제공해주는데, 이러한 Callback기반 API를 Flow로 변환하기 위해서 callbackFlow를 사용했다.

 

callbackFlow는 내부적으로 Channel을 사용하는데, send()를 통해서 채널에 요소를 추가할 수 있다. offer()는 deprecated되었으니 현재 시점에서는 send()trySend() 둘 중 하나를 사용해야한다.

 

send()는 suspend 함수이므로 coroutine 안에서만 사용할 수 있다. trySend()send()의 동기적인 버전으로, send()가 Exception을 던지거나 suspend되는 상황을 피하기 위해 사용된다. 그래서 여기에서는trySend()를 이용했다.

 

마지막에는 distinctUntilChanged()를 붙여주었는데 이는 같은 값이 연속적으로 반복하여 나오는 것을 필터링하는 플로우를 반환한다. 그래서 예를들면 Wifi가 커넥트되고, 이어서 셀룰러도 커넥트 되었을때 NetworkStatus.Available이 불필요하게 2번 오는 것을 막아주는 것이다.

 

어떤 값이든 바뀌어야 값을 방출하는 StateFlow는 이 distinctUntilChanged()가 이미 적용되어 있음을 상기해보면 된다.

 

NetworkCallback Register하기

val networkStatus = callbackFlow<NetworkStatus> {
    val networkStatusCallback = object: ConnectivityManager.NetworkCallback() {
        override fun onUnavailable() {
            trySend(NetworkStatus.Unavailable)
        }

        override fun onAvailable(network: Network) {
            trySend(NetworkStatus.Available)
        }

        override fun onLost(network: Network) {
            trySend(NetworkStatus.Unavailable)
        }
    }

    val request = NetworkRequest.Builder()
        .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
        .build()

    connectivityManager.registerNetworkCallback(request, networkStatusCallback)

    awaitClose {
        connectivityManager.unregisterNetworkCallback(networkStatusCallback)
    }
}.distinctUntilChanged()

이렇게 하면 인터넷 연결의 변화가 있을때 flow의 값이 방출되고, 채널이 닫힐때는 callback을 해제한다.

 

NetoworkCallback까지 register해주었지만 하나의 문제점이 있었다. 셀룰러와 Wifi 둘다 켜진 상태에서, Wifi만 꺼졌을때 onLost()가 호출되고, 셀룰러로 인터넷이 연결이 됨에도 불구하고 onAvailable() 상태로 회복되지 않는 것이다.

 

모든 Android 앱은 기본 네트워크를 가지는데, 기본 네트워크는 앱 이용중 언제든지 바뀔 수 있다. (동시에 2개의 인터페이스를 참조하진 않는다) 예로들면 셀룰러로 하다가, 제한 없고 빠른 Wifi 연결이 가능해지면 Wifi가 기본 네트워크가 된다. 이 기본 네트워크의 변경에 대한 콜백을 받고 싶다면 registerDefaultNeworkCallback()을 이용해야 한다.

 

Android O 이하 대응

하지만 치명적 약점이 있었는데, 바로 Android O (26)부터 이 메소드가 이용가능했던 것이다! 지금 개발중인 앱은 minSdk 24까지 지원하므로 하위 버전에 대해서 책임을 져야 했다.

private var checkPing = false

val networkStatus = callbackFlow<NetworkStatus> {
    val job = launch {
        while (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
            if (checkPing && ping()) {
                trySend(NetworkStatus.Available)
            }
            delay(3000L)
        }
    }

    val networkStatusCallback = object: ConnectivityManager.NetworkCallback() {
        override fun onUnavailable() {
            checkPing = true
            trySend(NetworkStatus.Unavailable)
        }

        override fun onAvailable(network: Network) {
            checkPing = false
            trySend(NetworkStatus.Available)
        }

        override fun onLost(network: Network) {
            checkPing = true
            trySend(NetworkStatus.Unavailable)
        }
    }

    val request = NetworkRequest.Builder()
        .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
        .build()

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        connectivityManager.registerDefaultNetworkCallback(networkStatusCallback)
    } else {
        connectivityManager.registerNetworkCallback(request, networkStatusCallback)
    }

    awaitClose {
        job.cancel()
        connectivityManager.unregisterNetworkCallback(networkStatusCallback)
    }
}.distinctUntilChanged()

Android O이상에서는 기본 네트워크 콜백을 수신하도록 했고, 그 아래는 3초마다 ping 체크를 해서 하나의 네트워크가 다운되었더라도 인터넷 연결이 살아있는지 체크를 하도록 추가 구현했다. awaitClose시에 ping 체크 job은 취소된다.

private fun ping(): Boolean {
    val runtime = Runtime.getRuntime()
    try {
        val process = runtime.exec("/system/bin/ping -c 1 8.8.8.8")
        val exitCode = process.waitFor()
        return exitCode == 0
    } catch (ioe: IOException) {
        ioe.printStackTrace()
    } catch (ie: InterruptedException) {
        ie.printStackTrace()
    }
    return false
}

Runtime.getRuntime().exec()를 이용하면 shell 명령어를 입력 할 수 있는데, ping을 이용해 구글 DNS 서버인 8.8.8.8에 핑을 날려서 네트워크 연결이 정상적인지 확인할 수 있다. 뭐....다운될까 찜찜하다면 CloudFlare DNS인 1.1.1.1을 믿어봐도 된다. (둘 다 다운되면 지구에 무슨일이 생긴거겠지?ㅋㅋㅋ)

inline fun <Result> Flow<NetworkStatus>.map(
    crossinline onUnavailable: suspend () -> Result,
    crossinline onAvailable: suspend () -> Result,
): Flow<Result> = map { status ->
    when (status) {
        NetworkStatus.Unavailable -> onUnavailable()
        NetworkStatus.Available -> onAvailable()
    }
}

추가로, 만든 flow를 좀 더 쉽게 활용하기 위해 확장함수도 만들어 준다.

 

활용하기

ViewModel에서는 어떻게 사용할까?

class MyViewModel (application: Application) : AndroidViewModel(application) {

    private val requestHome = MutableLiveData<Boolean>()

    val home: LiveData<Home> = requestHome.switchMap { clearCache ->
        // do some api call
    }

    init {
        viewModelScope.launch(ioDispatcher) {
            NetworkStatusTracker(application).networkStatus.map(
                onAvailable = {
                    requestHome.postValue(true)
                },
                onUnavailable = {}
            ).collect {}
        }
    }
}

 

플레이오 (스크린샷)


ViewModel의 생명주기동안 network 상태를 관찰하고, 네트워크가 가능할때 api를 호출해준다. onAvailable()이나 onUnavailable()에서 상태변화를 liveData로 바꿔서 스크린샷 처럼 네트워크 연결이 되었다거나 안되었다는 스낵바 같은 것을 띄워줘도 좋을 것이다.

 

 

구현에 참고한 원문

https://markonovakovic.medium.com/android-better-internet-connection-monitoring-with-kotlin-flow-feac139e2a3