Android

[Android] 초기 데이터 로드 2 : 의문점 해소하기

참깨빵위에참깨빵_ 2024. 10. 13. 19:03
728x90
반응형

이 포스팅은 아래 링크를 번역한 글이다.

 

https://proandroiddev.com/loading-initial-data-part-2-clear-all-your-doubts-0f621bfd06a0

 

Loading Initial Data on Android Part 2: Clear All Your Doubts

When a user enters a screen, the default data should be fetched by triggering the business logic, whether from the network or local…

proandroiddev.com

 

유저가 입력하면 네트워크 또는 DB에서 비즈니스 로직을 트리거해서 기본 데이터를 가져와야 한다. 사이드 이펙트를 피하기 위해 올바른 트리거 지점을 선택하는 동시에 데이터를 상태로 보존해서 안드로이드에서 configuration change를 처리하는 게 중요하다.

이전 글을 게시한 후 모든 것에 Flow를 사용하는 지연 관찰 접근 방식을 적용하기 어려운 조건에 관한 질문을 받았다. 여기선 질문들을 하나씩 해결한다.

 

초기 데이터를 로드할 때 매개변수를 전달하려면 어떻게 해야 하는가?

 

이건 커뮤니티에서 자주 접하는 질문 중 하나고 그 이유는 쉽게 이해할 수 있다. 자주 직면하는 상황이기 때문이다.

메인 화면, 상세 화면 2개가 있을 때 메인 화면에서 리스트 아이템을 클릭하면 세부 화면으로 이동해서 아이템과 관련된 ID 또는 기타 식별 정보 같은 데이터를 전달한다. 초기 데이터를 불러오기 전에 뷰모델에 데이터를 전달하는 방법이 불확실하다고 느낄 수 있다. 이 때 SavedStateHandle을 hilt, navigation compose와 같이 활용할 수 있다.

HiltViewModel은 기본적으로 2개의 내장 바인딩을 제공하므로 SavedStateHandle에 대한 생성자 주입을 지원한다. 내부 뷰모델 컴포넌트 빌더와 HiltViewModelFactory에 지정된 대로 SavedStateHandle, ViewModelLifecycle이다.

HiltViewModel은 메인 화면에서 전달된 매개변수를 보유하는 생성자에 SavedStateHandle을 직접 주입할 수 있다. 매개변수가 성공적으로 검색되고 UI에서 활성된 구독자가 있을 때 네트워크에서 세부 정보를 가져오는 작업이 트리거된다.

 

// https://github.com/skydoves/pokedex-compose
@HiltViewModel
class DetailsViewModel @Inject constructor(
  detailsRepository: DetailsRepository,
  savedStateHandle: SavedStateHandle,
) : ViewModel() {
  
  val pokemon = savedStateHandle.getStateFlow<Pokemon?>("pokemon", null)
  val pokemonInfo: StateFlow<PokemonInfo?> =
    pokemon.filterNotNull().flatMapLatest { pokemon ->
      detailsRepository.fetchPokemonInfo(
        name = pokemon.nameField.replaceFirstChar { it.lowercase() },
        onComplete = { uiState.tryEmit(key, DetailsUiState.Idle) },
        onError = { uiState.tryEmit(key, DetailsUiState.Error(it)) },
      )
    }.stateIn(
      scope = viewModelScope,
      started = SharingStarted.WhileSubscribed(5_000),
      initialValue = null,
    )
}

 

어떻게 이게 가능한가? 전체 프로세스를 간소화하는 Jetpack Navigation, Navigation Compose, lifecycle-viewmodel-savestate, hilt 라이브러리가 잘 통합된 결과다. SavedStateViewModelFactory는 NavBackStackEntry 안의 기본 뷰모델 팩토리에서 쓰이며, NavHost는 현재 대상에 해당하는 적절한 NavBackStackEntry를 위임한다.

주어진 NavBackStackEntry는 전용 뷰모델 팩토리를 제공하므로 HiltViewModelFactory 생성에 쓰인 다음 HiltViewModelFactory를 만드는 데 쓰인다.

마지막으로 HiltViewModelFactory는 hilt 뷰모델을 만들 때 SavedStateHandle을 주입한다. 따라서 route의 일부로 매개변수를 전달하거나 타입 안정성이 있는 경우 아래와 같은 함수를 통해 쉽게 매개변수를 검색할 수 있다.

 

sealed interface PokedexScreen {
  @Serializable
  data object Home : PokedexScreen

  @Serializable
  data class Details(val pokemon: Pokemon) : PokedexScreen {
    companion object {
      val typeMap = mapOf(typeOf<Pokemon>() to PokemonType)
    }
  }
}

fun NavGraphBuilder.pokedexNavigation() {
  composable<PokedexScreen.Home> {
    PokedexHome(this)
  }

  composable<PokedexScreen.Details>(
    typeMap = PokedexScreen.Details.typeMap,
  ) { backStackEntry ->

    val id = backStackEntry.savedStateHandle.get<String>("id")
    PokedexDetails(this, id)
  }
}

 

또는 생성자 주입을 써서 매개변수를 보유한 SavedStateHandle을 뷰모델에 직접 주입할 수 있다.

 

@Composable
fun PokedexDetails(
  // SavedStateHandle with the argument "pokemon" will be injected
  detailsViewModel: DetailsViewModel = hiltViewModel(),
) {
  ..
}

@HiltViewModel
class DetailsViewModel @Inject constructor(
  savedStateHandle: SavedStateHandle,
) : ViewModel() {
  ..
}

 

refresh하려면 어떻게 해야 하는가?

 

응답이 실패할 경우 데이터를 다시 가져와서 유저가 다시 시도하고 전반적인 경험을 개선할 수 있게 해야 하는 가장 일반적인 시나리오 중 하나다. 이 질문은 현재 주제에서 벗어나는데 새로고침은 일반적으로 초기화가 끝난 후 동작을 수행하는 걸 의미하기 때문이다.

'초기'가 무엇을 의미하는지 자세히 설명하지 않겠지만 mapLatest 또는 flatMapLatest를 써서 다른 MutableStateFlow를 결합하면 쉽게 구현할 수 있다. 구글 안드로이드 툴킷 팀의 Ian Lake가 명확한 예시를 추가했다.

 

 

이건 다른 MutableStateFlow와 결합해 실행을 다시 트리거함으로써 Flow를 재시작하는 가장 간단하고 명확한 방법이다.

더 우아한 솔루션을 찾는다면 RestartableFlow라는 자체 StateFlow를 구현할 수 있다. 이를 위해 재시작 가능한 Flow를 만드는 방법을 설명하는 솔루션이 이미 아래에 게시돼 있다.

 

https://medium.com/@der.x/restartable-stateflows-in-compose-46316ce670a9

 

Restartable StateFlows (in Compose)

If StateFlows<T>, SharingStarted.WhileSubscribed(4711), and ViewModels are your daily bread, feel free to jump straight to the restart…

medium.com

 

자세한 내용을 보는 대신 바로 코드를 확인한다.

 

// RestartableStateFlow that allows you to re-run the execution
interface RestartableStateFlow<out T> : StateFlow<T> {
  fun restart()
}

interface SharingRestartable : SharingStarted {
  fun restart()
}

// impementation of the sharing restartable
private data class SharingRestartableImpl(
  private val sharingStarted: SharingStarted,
) : SharingRestartable {

  private val restartFlow = MutableSharedFlow<SharingCommand>(extraBufferCapacity = 2)
  
  // combine the commands from the restartFlow and the subscriptionCount
  override fun command(subscriptionCount: StateFlow<Int>): Flow<SharingCommand> {
    return merge(restartFlow, sharingStarted.command(subscriptionCount))
  }

  // stop and reset the replay cache and restart
  override fun restart() {
    restartFlow.tryEmit(SharingCommand.STOP_AND_RESET_REPLAY_CACHE)
    restartFlow.tryEmit(SharingCommand.START)
  }
}

// create a hot flow, which is restartable by manually from a cold flow
fun <T> Flow<T>.restartableStateIn(
  scope: CoroutineScope,
  started: SharingStarted,
  initialValue: T
): RestartableStateFlow<T> {
  val sharingRestartable = SharingRestartableImpl(started)
  val stateFlow = stateIn(scope, sharingRestartable, initialValue)
  return object : RestartableStateFlow<T>, StateFlow<T> by stateFlow {
    override fun restart() = sharingRestartable.restart()
  }
}

 

SharingRestartableImpl을 보면 restartFlow, subscriptionCount의 두 Flow를 병합해서 일반 StateFlow처럼 작동하지만 restart()를 써서 리플레이 캐시를 중지, 재설정하고 실행(execution)을 재시작할 수 있다.

sharing 매개변수는 restartableStateIn 확장 함수를 통해 적용되며 stateFlow 인스턴스에 위임해서 RestartableStateFlow를 만든다. 이렇게 하면 아래처럼 뷰모델 안에서 Flow 실행을 재시작할 수 있다.

 

@HiltViewModel
class MainViewModel @Inject constructor(
  repository: TimelineRepository
): ViewModel() {

  val timelineUi: RestartableStateFlow<ScreenUi?> = repository.fetchTimelineUi()
    .flatMapLatest { response -> flowOf(response.getOrNull()) }
    .restartableStateIn(
      scope = viewModelScope,
      started = SharingStarted.WhileSubscribed(5000),
      initialValue = null
    )
   
  // This can be launched from UI side, such as LaunchedEffect or anywhere.
  fun restartTimeline() {
    timelineUi.restart()
  }
}

 

restartTimeline()을 호출해서 repository.fetchTimeUi() 실행을 재시작할 수 있다. 이 함수는 컴포즈의 LaunchedEffect 안 또는 앱의 다른 부분 같은 UI 측에서 트리거할 수 있다. 다시 말하지만 유저 입력 기반으로 작업을 재시작, 재시도, 실행하는 건 이 논의의 범위를 벗어난다. 여기선 첫 초기화 프로세스가 완료된 후 발생하는 작업이 아닌 초기 데이터 로딩에 초점을 맞추고 있다.

 

ViewModel.init 사이드 이펙트가 잠재적으로 문제가 되는 이유는 뭔가?

 

이전 글에서 ViewModel.init()에서 초기 데이터를 로드하면 뷰모델 생성 중 사이드 이펙트가 발생해서 기본 목적에서 벗어나고 생명주기 관리가 복잡해지는 걸 확인했다. 왜 이게 문제가 되는지 다시 확인한다.

뷰모델 지속성, 복원을 내부적으로 처리하는 뷰모델 또는 hiltViewModel()을 쓰지 않고 컴포저블 안에서 뷰모델을 수동 생성한다고 가정한다. 이는 단위 테스트 때문일 수 있다.

뷰모델에서 함수를 호출해 작업을 트리거하지 않았거나 콜드 플로우를 구독하지 않았어도 뷰모델 생성 즉시 ViewModel.init()이 해당 작업을 실행한다. 이로 인해 뷰모델을 예측할 수 없게 되서 의도치 않은 동작이 발생할 수 있고 테스트가 더 어려워진다.

컴포즈에서 또 다른 중요한 이유는 뷰모델을 만든 컴포지션이 중단돼도 viewModelScope.launch를 써서 ViewModel.init()에서 작업을 트리거하면 실행(execution)이 시작된다. 이는 뷰모델이 컴포넌트 생명주기를 인식하지 못해 의도치 않은 실행으로 이어지고 결국 테스트, 디버깅이 더 어려워져서 발생한다. 이안 레이크는 아래와 같은 이유에 대해 설명을 덧붙였다.

 

 

컴포저블 함수는 병렬 실행될 수 있어서 백그라운드 쓰레드 풀에서 실행될 수 있다. 컴포저블 함수가 뷰모델과 상호작용하는 경우 컴포즈는 여러 쓰레드에서 해당 함수를 동시 호출할 수 있다.

네비게이션 그래프, 액티비티처럼 더 넓은 범위가 아닌 특정 컴포저블 함수 안에서 뷰모델 범위를 지정하는 경우(바텀 시트, 다이얼로그, 캡슐화된 뷰에 뷰모델을 컴포저블 함수로 지정하는 등의 이유로) 뷰모델이 컴포저블 생명주기를 인식하지 못해서 ViewModel.init()에서 시작된 모든 작업이 컴포저블 종료 후에도 백그라운드 쓰레드에서 계속 실행될 수 있다.

 

WhileSubscribed(5_000)에서 Flow 재방출을 방지하는 법

 

이전 글에서 이안 레이크가 지적한 것처럼 LaunchedEffect, ViewModel.init()에서 초기 데이터를 불러오는 게 모두 안티 패턴으로 간주되는 것을 설명했다.

 

 

대신 콜드 or 핫 플로우를 써서 stateIn 또는 shareIn과 결합해 초기 데이터를 불러오고 시작 매개변수로 SharingStarted.WhileSubscribed를 활용할 걸 제안했다. 이 접근법은 UI 레이어에 활성 구독이 있을 때만 값이 느리게 방출되게 한다. 또한 collectAsStateWithLifecycle을 쓰면 UI 계층 안에서 플로우를 안전하게 구독할 수 있으므로 생명주기 인식 상태 관리를 보장한다.

그러나 이걸 네비게이션 컴포즈 라이브러리와 같이 쓸 때 문제가 있다. NavHost는 현재 대상에 해당하는 적절한 NavBackStackEntry에 위임한다. 문제는 NavBackStackEntry가 그 자체로 LifecycleOwner고, NavHost 안의 대상에 따라 다른 LocalLifecycleOwner를 제공한다는 것이다. 메인 화면에서 상세 화면으로 이동할 때 메인 화면 안의 컴포저블 함수에서 수집되는 모든 StateFlow는 메인 화면의 생명주기 상태가 더 이상 Lifecycle.State.STARTED가 아니므로 수집한 모든 StateFlow 구독을 중지한다.

5초 후 다시 메인으로 돌아오면 Flow가 다시 시작되고 네트워크 요청, 기타 작업 같은 비즈니스 로직이 다시 트리거된다.

 

 

대부분의 경우 같은 조건으로 네트워크 데이터를 가져오는 등의 비즈니스 로직을 재실행해도 앱에 큰 영향을 주지 않으므로 이는 중요한 문제가 되지 않는다. 그러나 리소스를 많이 쓰는 작업이면 성능 문제가 발생할 수 있다.

이 경우 커스텀 SharingStarted 전략을 만들어서 문제를 해결할 수 있다. 이는 업스트림 플로우를 제어하고 방출 여부를 결정하는 데 중요한 역할을 하며 핵심적으로 집중해야 하는 부분이다.

우리 목표는 업스트림 플로우를 트리거하고 비즈니스 로직을 한 번만 실행하는 것이며 UI 계층에서 활성 구독자가 있을 때 느리게 실행하는 것이므로 SharingStarted.WhileSubscribed, StateFlow에서 영감을 얻을 수 있다.

동시에 값을 캐시하고 다운스트림 구독자가 다시 나타날 때마다 재생해서 탐색 변경 or 구성 변경 후에도 가져온 데이터가 복원되게 해야 한다.

아래는 OnetimeWhileSubscribed라는 새 SharingStarted 전략을 구현할 수 있다. 15~20줄만 보면 된다. 이 코드에선 활성 구독자가 있고 값이 아직 수집되지 않은 경우에만 전송이 시작되는 걸 볼 수 있다. 구독자에 의해 값이 수집되면 재방출되지 않고 캐시된 최신 값을 발행할 뿐이다.

 

// Designed and developed by skydoves (Jaewoong Eum)
public class OnetimeWhileSubscribed(
  private val stopTimeout: Long,
  private val replayExpiration: Long = Long.MAX_VALUE,
) : SharingStarted {

  private val hasCollected: MutableStateFlow<Boolean> = MutableStateFlow(false)

  init {
    require(stopTimeout >= 0) { "stopTimeout($stopTimeout ms) cannot be negative" }
    require(replayExpiration >= 0) { "replayExpiration($replayExpiration ms) cannot be negative" }
  }

  override fun command(subscriptionCount: StateFlow<Int>): Flow<SharingCommand> =
    combine(hasCollected, subscriptionCount) { collected, counts ->
      collected to counts
    }
      .transformLatest { pair ->
        val (collected, count) = pair
        if (count > 0 && !collected) {
          emit(SharingCommand.START)
          hasCollected.value = true
        } else {
          delay(stopTimeout)
          if (replayExpiration > 0) {
            emit(SharingCommand.STOP)
            delay(replayExpiration)
          }
          emit(SharingCommand.STOP_AND_RESET_REPLAY_CACHE)
        }
      }
      .dropWhile {
        it != SharingCommand.START
      } // don't emit any STOP/RESET_BUFFER to start with, only START
      .distinctUntilChanged() // just in case somebody forgets it, don't leak our multiple sending of START
}

 

궁극적으로 아래처럼 쓸 수 있다.

 

val pokemon = savedStateHandle.getStateFlow<Pokemon?>("pokemon", null)
val pokemonInfo: StateFlow<PokemonInfo?> =
  pokemon.filterNotNull().flatMapLatest { pokemon ->
    detailsRepository.fetchPokemonInfo(pokemon.id)
  }.stateIn(
    scope = viewModelScope,
    started = OnetimeWhileSubscribed(5_000),
    initialValue = null,
  )

 

반응형