중요한 것은 SAA (Single-Activity)
해당 주제를 편하게 구현하려면, 가능한 Single-Activity 구조에서 하는 것이 좋다. 어떤 화면에서도 동일한 네트워크 스트림에 의해 이벤트 트리거가 되어야하기 때문이다. 물론 아니어도 Manager 구조를 잘 정의하면 가능은 하다. 동일한 코드를 참조하는 곳이 여기저기 흩뿌려질 것이라 상대적으로 유지보수하기 힘들 뿐이다.
처음에는 단순하게 생각해서,
각 Fragment에서 아래처럼 requireActivity()를 이용해 스트림을 collect했다.
// PlanFragment.kt
viewLifecycleOwner.repeatOnLifecycle {
launch {
// network가 Available로 바뀌는 순간마다
// 데이터 reload를 하고 싶을 때
(requireActivity() as MainActivity).getNetworkFlow()
.collectLatest {
if (it == ConnectivityObserver.Status.Available) {
planViewModel.getPlan()
}
}
}
// ...
}
사실 One-Activity 구조에서 위와 같은 코드 로직이 꼭 틀린 것은 아니다. 하지만 이걸 보통 “하드코딩” 이라고 하므로, Google 정책이나 요구사항에 수정이 생겨서 불가피하게 고쳐야 한다면 모든 Fragment의 동일한 부분을 죄다 수정해야 할 것이다. 고쳐야하는 나도, 커밋 내역을 봐야하는 팀원도 피로감을 느낄 수 있는 구조다.
이 부분 관련해서 신입 기술 면접 때 질문을 받은 적이 있다. 각 Fragment에서 getNetworkFlow() 메소드 호출부를 Activity에 의존적이지 않게 바꿀 수 있겠냐는 질문이었다. 그 때의 나는 인스턴스에 대한 개념이 잘 잡혀있지 않은 상태였어서 아무 생각 없이 구현했던 건데, 지금 생각해보면 왜 준비를 못했을까 싶을 정도로 당연한 질문이었다. 그때부터 머리 새하얘져서 면접 다 말아 먹음.
이 코드가 들어간 프로젝트는 MVVM 패턴을 사?용 했는데 UI data를 Activity에도 정의하고 ViewModel에도 정의해놓은 요상한 구조였다. 집에 돌아와 복기하는 과정에서 해결한 방법은 activityViewModel을 이용하는 것이다.
Activity Lifecycle을 따르는 ViewModel에 해당 Flow 변수를 선언하고, MainActivity에 올려지는 Fragment 각각에서 그 변수를 구독하는 형태로 바꾸면 될 것 같다. 그럼 하드코딩도 없어지고, 코드 플로우 배치 실수로 인한 사이드 이펙트도 줄게 될 것 같다. 바로 해보자.
아래에 ConnectivityObserver나, NetworkConnectivityObserver라는 키워드가 있는데 일단 빨간 줄이 뜬 채로 진행해도 괜찮다. 나중에 그 클래스들을 직접 만들 것이다.
// ActivityViewModel
var networkFlow: SharedFlow<ConnectivityObserver.Status>? = null
private set
fun setNetworkFlow(flow: Flow<ConnectivityObserver.Status>) {
networkFlow = flow.shareIn(
scope = viewModelScope,
started = WhileSubscribed(stopTimeoutMillis = 1000),
replay = 1
)
}
// MainActivity
private val viewModel by viewModels<ActivityViewModel>()
private lateinit var connectivityObserver: ConnectivityObserver
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
connectivityObserver = NetworkConnectivityObserver(applicationContext)
setContentView(R.layout.activity_main)
// ...
viewModel.setNetworkFlow(connectivityObserver.observe())
}
// PlanFragment
private val activityViewModel by activityViewModels<ActivityViewModel>()
private fun observeData() {
viewLifecycleOwner.repeatOnLifecycle {
launch {
activityViewModel.networkFlow.
.collectLatest {
if (it == ConnectivityObserver.Status.Available) {
planViewModel.getPlan()
}
}
}
// ...
}
}
- 개인적으로 shareIn을 사용해 핫스트림으로 바꿔준 이유
먼저, networkFlow는 네트워크 통신이 필요한 모든 Fragment에서 이용하게 된다. 얼마나 많은 Fragment가 동시에 이용할지, 혹은 언제 다운스트림 구독자가 사라졌다 생겼다 할지 알 수 없는 경우다. 또한 각 Fragment에서 화면 초기화 데이터를 업데이트하는 시점이 network가 Available상태일 때만으로 제한해 놓는다면, 초기 데이터 요청 및 reload 요청 조건이 분리되지 않고 하나의 조건만으로 통합되기 때문에 유지보수하기도 매우 용이할 것 같다.
SharedFlow는 위와 같은 관점에서 최적이다. scope를 지정할 수 있고, replay를 통해 새로운 구독자들에게 고정적, 명시적으로 스트림을 발행할 수 있다. 또 stared에 업스트림이 유지되는 시간을 필요에 맞게 설정하여 리소스를 효율적으로 관리할 수 있다.
서두가 길었다. ConnectivityObserver를 정의해보자.
기본적으로 네트워크 상태에 대해 알아야한다. Android에서는 ConnectivityManager.NetworkCallback으로 Callback을 정의하여 네트워크를 체크할 수 있다. 공식문서에서 종류별 Status들을 확인할 수 있다.
- onAvailable : 새로운 네트워크에 연결되고 사용할 준비가 되었을 때 호출
- onUnavailable : 지정된 시간 동안 네트워크가 감지되지 않았을 때 호출
- onLost : 네트워크 연결이 끊어질 때나 요청 기준을 충족하는 다른 네트워크가 없을 때 호출
- onLosing : 남아 있는 특별한 요청이 없어 네트워크 Lost가 일어나려고 할 때 호출
인터페이스는 Status를 갖도록 아래처럼 정의해준다.
interface ConnectivityObserver {
fun observe(): Flow<Status>
enum class Status {
Available, Unavailable, Lost, Losing
}
}
좋다. 이제 콜백의 생존 주기를 관리해보자. 인스턴스 처음 생성 시 Callback을 register하고 Close 될 때 unregister하도록 구현하고 싶다. Activity Lifecycle에서 관리할 수도 있겠지만 Flow API에 유용한 기능이 있다.
- ❓ callbackFlow
여기서는 callbackFlow를 이용해 ConnectivityManager.NetworkCallback() 의 변경 값을 계속 observe 한다. send를 이용해서 스트림을 발행하고, awaitClose를 이용해서 unregisterNetworkCallback의 호출부를 정의해줄 수 있다.
호출되는 컴포넌트의 context에 종속되어야하기 때문에 매개변수로 context를 전달해준다. 또 SDK Nouget(7.0, 24) 미만에서는 Builder패턴의 NetworkRequest를 전달해줘야한다. (2023년 상반기 기준으로 default minSdkVersion이 24이므로 특별한 버전 요구사항이 없으면 신경쓰지 않아도 될 부분)
또 Network 특성 상 짧은 시간 내에 빠르게 변경이 일어날 경우가 있는데 그런 경우를 대비해 distinctUntilChanged()를 끝에 붙여주었다. 두드러지는 변화(동일한 값을 발행하지 않음)만 체크하여 발행해주는 역할을 한다.
class NetworkConnectivityObserver(
context: Context
) : ConnectivityObserver {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
override fun observe(): Flow<ConnectivityObserver.Status> {
return callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
launch {
send(ConnectivityObserver.Status.Available)
}
}
override fun onLost(network: Network) {
super.onLost(network)
launch {
send(ConnectivityObserver.Status.Lost)
}
}
override fun onUnavailable() {
super.onUnavailable()
launch {
send(ConnectivityObserver.Status.Unavailable)
}
}
override fun onLosing(network: Network, maxMsToLive: Int) {
super.onLosing(network, maxMsToLive)
launch {
send(ConnectivityObserver.Status.Losing)
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
connectivityManager.registerDefaultNetworkCallback(callback)
} else {
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build()
connectivityManager.registerNetworkCallback(request, callback)
}
awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
}
}.distinctUntilChanged()
}
}
번외 - 네트워크 대기 화면 만들기 && 인스턴스 상태 유지
네트워크 파트를 맡고 대기 화면을 어떻게 만들까 결정해야했는데, 디자인에 무지한 사람이 정말 어렵지 않게 떠올릴 수 있는 디자인이었다. 인터랙션을 모두 막으면서 사용자에게 대기중임을 지속적으로 알려줄 수 있는 다이얼로그, 또 네트워크가 감지되지 않는다고 알리는 토스트를 겹쳐 보여주기로 했다.
해당 화면 역할을 할 DialogFragment 정의를 Activity init 단에서 하고, show와 dismiss를 프래그먼트들과 동일하게 observe한 네트워크 Flow의 Status에 따라 조작하도록 한다.
// MainActivity
private val dialog = LoadingDialogFragment()
// ...
private fun observeNetwork() {
val connectivityManager = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
// 초기 진입 시 체크
checkNetwork(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
connectivityManager.activeNetwork != null
} else {
connectivityManager.isDefaultNetworkActive
}
)
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
connectivityObserver.observe().collect {
checkNetwork(it == ConnectivityObserver.Status.Available)
}
}
}
}
private fun checkNetwork(isActiveNetwork: Boolean) {
supportFragmentManager.executePendingTransactions()
if (isActiveNetwork) {
if (dialog.isAdded) dialog.dismiss()
} else if (!dialog.isAdded) {
dialog.show(supportFragmentManager, DIALOG_TAG)
Toast.makeText(
applicationContext,
getString(R.string.message_network),
Toast.LENGTH_LONG
).show()
}
}
감추고 띄우는 것까진 구현이 쉽게 완료되었다. executePendingTransactions()는 대기 중인 트랜잭션이 즉시 수행되어야할 때 사용한다. 대안으로 나온 commitNow()가 백스택에 트랜잭션을 추가하는 경우가 아니라면, 리소스 및 트랜잭션 관리하기에 더 안전하므로 그것을 사용하는 것이 좋다.
완성했답시고 좋아하고 있던 도중 토이 프로젝트의 동료가 QA를 해줬는데 가로 회전 시 로딩 다이얼로그가 사라진다는 이슈가 올라왔다. 인스턴스 상태를 보존해주지 못했나보다. 또한 로고 길이를 비율로 지정해놓았는데 가로로 돌리면 어마어마하게 커져버리는 문제도 있었다.
상태 변수를 관리하는 방법에는 ViewModel Lifecycle 이용해서 상태값 저장해두기, onSaveInstanceState로 상태값 저장해두기, onConfigurationChanged를 이용해서 재설정해주기 등이 있다. 딱히 상태값이라 명명할 건 없고, 그냥 화면 돌렸을 때 다이얼로그가 또 띄워져 있기만 하면 되어서 onConfigurationChanged를 정의했다.
근데 여기서 가로로 돌릴 때마다 사이즈를 재조정해주려고 했는데 빙글빙글 마구 돌려대면 width 계산 타이밍이 어긋나 UI가 난리가 나버리므로 반복해서 띄워주는 코드로 해결했다.
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
supportFragmentManager.executePendingTransactions()
if (dialog.isAdded) {
dialog.dismiss()
dialog.show(supportFragmentManager, DIALOG_TAG)
}
}
'안드로이드 응용' 카테고리의 다른 글
Android ellipsize로 TextView 안에 더보기 버튼 집어넣기 (0) | 2024.07.03 |
---|