※ 부스트캠프 웹모바일 7기 그룹프로젝트를 진행하며 작성했던 Github Wiki의 일부를 수정하여 적은 글입니다.
https://github.com/boostcampwm-2022/android04-BEEP/wiki
문제 상황
- PIN 인증을 하는 PinDialog (Fragment)
- 첫번째 인증이 정상적으로 끝난 후, 다시 인증을 시도하면 인증 완료가 되지 않는 현상
- 6자리 PIN이 모두 입력되면 아래의 goNextStep() 이 호출된다.
- PIN 일치여부를 판단한 뒤 StateFlow<PinSettingType>의 값을 갱신한다.
override fun goNextStep() {
viewModelScope.launch {
if (getCorrespondWithPinUseCase(pinString.value)) {
_pinMode.value = PinSettingType.COMPLETE
} else {
_pinMode.value = PinSettingType.WRONG
}
delay(1000L)
_pinString.value = ""
_pinMode.value = PinSettingType.CONFIRM
}
}
주목할 점은 두가지가 보였다.
- 두번 모두 같은 ViewModel 객체에서 벌어졌다
- viewModelScope.launch()의 job 값이 각각 Completed, Cancelled 상태이다.
Job States
job은 코루틴의 상태를 가지고 있다.
wait children
+-----+ start +--------+ complete +-------------+ finish +-----------+
| New | -----> | Active | ---------> | Completing | -------> | Completed |
+-----+ +--------+ +-------------+ +-----------+
| cancel / fail |
| +----------------+
| |
V V
+------------+ finish +-----------+
| Cancelling | --------------------------------> | Cancelled |
+------------+ +-----------+
New: Job이 생성됨
→ Active: Job이 실행 중
→ Completing: 내 Job은 성공적으로 마쳤고, Child 코루틴이 종료하는 것을 기다리는 중
→ Completed: 모든 Child까지 종료된 상태
→ Cancelling: 취소하는 중. 리소스 반환 등의 작업을 수행
→ Cancelled: 완전히 Job이 취소된 상태
🙎♀️ 취소된 Job은 다시 실행될 수 없기 때문에 실행되지 않았던 거구나. 실행시키기 위해서는 새로운 Job을 만들어 사용해야 한다. 그런데.. 어디서 취소된거지??
~ 나의 Job은 어디서 취소되었는가 ~
ViewModel.viewModelScope
우선 viewModelScope가 취소되어 있었으니 당사자의 내부 구현을 먼저 살펴보았다.
private const val JOB_KEY = "androidx.lifecycle.ViewModelCoroutineScope.JOB_KEY"
public val ViewModel.viewModelScope: CoroutineScope
get() {
val scope: CoroutineScope? = this.getTag(JOB_KEY)
if (scope != null) {
return scope
}
return setTagIfAbsent(
JOB_KEY,
CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
)
}
internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
override val coroutineContext: CoroutineContext = context
override fun close() {
coroutineContext.cancel()
}
}
<T> T getTag(String key) {
if (mBagOfTags == null) {
return null;
}
synchronized (mBagOfTags) {
return (T) mBagOfTags.get(key);
}
}
---
<T> T setTagIfAbsent(String key, T newValue) {
T previous;
synchronized (mBagOfTags) {
previous = (T) mBagOfTags.get(key);
if (previous == null) {
mBagOfTags.put(key, newValue);
}
}
T result = previous == null ? newValue : previous;
if (mCleared) {
closeWithRuntimeException(result);
}
return result;
}
- getTag()에서 JOB_KEY로 mBagOfTags 속 CoroutineScope를 찾아온다.
- 찾은 CoroutineScope가 있다면 그걸 그대로 return, 없다면 CloseableCoroutineScope를 새로 만들어 mBagOfTags 에 저장하고 return
- CloseableCoroutineScope의 close()가 호출되면 coroutine이 cancel() 되는 듯하다
- mCleared 라는 값이 true면 해당 coroutine을 close하는 것 같다.
🙎♀️ 그렇담 close()는 언제 호출되는거지?? mCleared라는 플래그는 어디서 튀어나오는거지?
ViewModel.clear()
@MainThread
final void clear() {
mCleared = true;
if (mBagOfTags != null) {
synchronized (mBagOfTags) {
for (Object value : mBagOfTags.values()) {
closeWithRuntimeException(value);
}
}
}
if (mCloseables != null) {
synchronized (mCloseables) {
for (Closeable closeable : mCloseables) {
closeWithRuntimeException(closeable);
}
}
}
onCleared();
}
- mCleared 를 true로 만들고 있다.
- clear()에서 mBagOfTags 속 CoroutineScope를 모조리 close하고 있다.
- onCleard()를 호출한다.
🙎♀️ 여기서 ViewModel이 가진 CoroutineScope를 전부 close해서 Job이 cancel 되고 있구나!
ViewModel의 clear()가 호출되는 정확한 시점은?
ViewModelStore
open class ViewModelStore {
private val map = mutableMapOf<String, ViewModel>()
fun clear() {
for (vm in map.values) {
vm.clear()
}
map.clear()
}
}
ViewModelStore이 clear 될 때 가지고 있는 ViewModel을 모조리 clear한다.
🙎♀️ 그래서 ViewModelStore의 clear()는 언제 호출되는건데~~~~~
ActivityViewModel인 경우
ComponentActivity
@Override
public void onStateChanged(@NonNull LifecycleOwner source,
@NonNull Lifecycle.Event event) {
if (event == Lifecycle.Event.ON_DESTROY) {
...
if (!isChangingConfigurations()) {
getViewModelStore().clear();
}
}
}
➡ Activity의 lifecylce이 ON_DESTROY 일 때 clear된다. 단, ConfigurationChange로 호출된 Destory에서는 clear()하지 않는다.
ViewModel 인 경우
FragmentManagerViewModel
private void clearNonConfigStateInternal(@NonNull String who) {
...
ViewModelStore viewModelStore = mViewModelStores.get(who);
if (viewModelStore != null) {
viewModelStore.clear();
mViewModelStores.remove(who);
}
}
void clearNonConfigState(@NonNull Fragment f) {
if (FragmentManager.isLoggingEnabled(Log.DEBUG)) {
Log.d(TAG, "Clearing non-config state for " + f);
}
clearNonConfigStateInternal(f.mWho);
}
FragmentManager
private void clearBackStackStateViewModels() {
...
if (shouldClear) {
for (BackStackState backStackState : mBackStackStates.values()) {
for (String who : backStackState.mFragments) {
mFragmentStore.getNonConfig().clearNonConfigState(who);
}
}
}
}
void dispatchDestroy() {
mDestroyed = true;
...
clearBackStackStateViewModels();
}
➡ Fragment가 Destory 될 때
dispatchDestroy → clearNonConfigState → clearNonConfigStateInternal 을 거쳐 clear되고 있다.
231006 수정) 위의 코드는 Fragment가 Navigation으로 관리될 때의 코드로 확인된다.
In the case of an activity, when it finishes.
In the case of a fragment, when it detaches.
In the case of a Navigation entry, when it's removed from the back stack.
정확한 코드 블럭이 어딘지 잘 보이지 않아서 공식문서의 그림을 첨부한다. Fragment가 Detach될 때 ViewModel이 삭제된다.
그래 어째 Destory에서 없어지면 Configuration Change될 때 날아가겠지... 뭔가 이상하다했다
그래서 무슨 일이 있었냐면요...
- 처음으로 ViewModel.viewModelScope에 접근한 순간 ViewModel 속 mBagOfTags에 CloseableCoroutineScope인 viewModelScope가 등록된다.
- 첫 인증 후 Dialog Fragment가 Detach 되면서 그 Fragment의 ViewModelStore가 clear된다.
- ViewModel의 clear도 호출되어 mBagOfTags 속에 있던 viewModelScope가 cancel 되었다.
- 우리가 다시 만난 ViewModel 속 viewModelScope는... 이미 Cancel 되어 차갑게 식어 있었다....
🙎♀️ Fragment는 계속 Attach ~ Detach 까지의 과정을 반복하고 있지만, ViewModel은 첫 Detach() 이후로 쭉~ Clear 된 상태인 것이다.
왜 이런일이 일어났냐고요?
private fun authPin(supportFragmentManager: FragmentManager, authCallback: AuthCallback) {
if (::pinDialog.isInitialized.not()) {
pinDialog = PinDialog(authCallback)
}
pinDialog.show(supportFragmentManager, PIN_TAG)
}
- Fragment를 매번 생성하지 않고 처음 생성하고 show(), hide()로 보이고 숨기기만 하도록 해놨었거든요...
그렇다.. 무조건 재사용하면 좋지 않을까? 하고 구현했던 부분이 문제였다.
문제 상황은 아래처럼 매번 Fragment를 새로 생성하는 것으로 해결했다.
private fun authPin(supportFragmentManager: FragmentManager, authCallback: AuthCallback) {
PinDialog(authCallback).show(supportFragmentManager, PIN_TAG)
}
공식문서의 ViewModel 항목에도 떡하니 적혀있다. 그렇다.. 내가 저지른 짓 그 자체이다...
동일한 Fragment => 동일한 ViewModelStoreOwner => 동일한 ViewModelStore => 동일한 ViewModel 인스턴스가 튀어나옴. 근데 이미 Clear까지 불린...
범위 지정으로 인해 ViewModel은 화면 수준 상태 홀더의 구현 세부정보로 사용합니다. 칩 그룹이나 양식과 같은 재사용 가능한 UI 구성요소의 상태 홀더로 사용하지 마세요. 그러지 않으면 동일한 ViewModelStoreOwner의 동일한 UI 구성요소를 다른 방식으로 사용할 때 동일한 ViewModel 인스턴스가 생성됩니다.
Because of their scoping, use ViewModels as implementation details of a screen level state holder. Don't use them as state holders of reusable UI components such as chip groups or forms. Otherwise, you'd get the same ViewModel instance in different usages of the same UI component under the same ViewModelStoreOwner.
'Android' 카테고리의 다른 글
[Compose Navigation] 죽여도 죽여도 살아 돌아오는 끈질긴 ViewModel 본 사람? 저요 (2) | 2024.02.26 |
---|---|
[Animation] 단숨에 AnimatedVectorDrawable 장인이 되어보자 (feat. Shape Shifter) (1) | 2023.06.07 |
[Hilt] 보이지 않는 곳에서 무슨 일이 벌어지고 있었을까 (0) | 2023.05.24 |