[Android] ViewModel이 Configuration Change에서도 유지되는 이유
주의 : 글이 깁니다. 티스토리는 코드 가독성이 매우 떨어집니다. 편집기에 복사해서 보시는 걸 추천합니다.
개요
안드로이드를 개발하는 방법이 다양해지면서 .xml로 앱을 만들어 봤고, Compose를 사용해서 앱을 만들고도 있습니다. View를 그리는 방법과 ViewModel에서 데이터를 홀드하는 방법이 다양해졌는데, 제 생각에 그 중에서 ViewModel이 유일하게 기존에 사용법과 동일한 사용법으로 안드로이드 개발에서 사용되고 있다고 생각됩니다. 물론 구글에서 동일하게 사용할 수 있도록 작업을 해주셨기에 가능한 일이겠지만, 이런 ViewModel을 조금 더 자세히 알고 싶어졌습니다. ViewModel의 Lifecycle과 흔히 Configuration Change가 발생하더라도 데이터를 유지할 수 있기 때문에 ViewModel 사용을 권장하는데 어떻게 데이터를 유지할 수 있는 지 알아보도록 하겠습니다.
ViewModel 객체 생성
ViewModel 객체는 ViewModelProvider를 통해서 생성됩니다. Fragment class나 Composable function에서 ViewModel을 생성하는 코드는 다양한 형태로 존재하지만 모든 코드는 결국 ViewModelProvider를 사용해서 ViewModel 객체를 생성합니다.
private val viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
ViewModelProvider의 매개변수로 ViewModelStoreOwner를 전달합니다. ViewModelStoreOwner를 상속하고 있어서 ViewModelProvider에 전달될 수 있는 클래스는 세 가지가 있습니다.
- ComponentActivity
- Fragment
- NavBackStackEntry
interface ViewModelStoreOwner {
/**
* The owned [ViewModelStore]
*/
val viewModelStore: ViewModelStore
}
Deep Dive
먼저, ViewModelProvider안에 있는 get()함수를 보겠습니다. get() 함수는 ViewModel 인스턴스를 가져오기 위한 함수입니다. 가져올 ViewModel이 있다면 가져오고, 없다면 인스턴스를 새로 생성합니다.
@MainThread
public open operator fun <T : ViewModel> get(modelClass: Class<T>): T {
val canonicalName = modelClass.canonicalName
?: throw IllegalArgumentException("Local and anonymous classes can not be ViewModels")
return get("$DEFAULT_KEY:$canonicalName", modelClass)
}
@Suppress("UNCHECKED_CAST")
@MainThread
public open operator fun <T : ViewModel> get(key: String, modelClass: Class<T>): T {
val viewModel = store[key]
if (modelClass.isInstance(viewModel)) {
(factory as? OnRequeryFactory)?.onRequery(viewModel!!)
return viewModel as T
} else {
@Suppress("ControlFlowWithEmptyBody")
if (viewModel != null) {
// TODO: log a warning.
}
}
...
}
전달된 ViewModel class의 이름과 String 조합으로 키를 만들고 ("$DEFAULT_KEY:$canonicalName") ViewModelStore class 안에 Map<String, ViewModel> 형태로 저장되어 있는 value, ViewModel을 가져오는 것을 볼 수 있습니다. (val viewModel = store[key])
open class ViewModelStore {
private val map = mutableMapOf<String, ViewModel>()
/**
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
fun put(key: String, viewModel: ViewModel) {
val oldViewModel = map.put(key, viewModel)
oldViewModel?.onCleared()
}
/**
* Returns the `ViewModel` mapped to the given `key` or null if none exists.
*/
/**
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
operator fun get(key: String): ViewModel? {
return map[key]
}
/**
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
fun keys(): Set<String> {
return HashSet(map.keys)
}
/**
* Clears internal storage and notifies `ViewModel`s that they are no longer used.
*/
fun clear() {
for (vm in map.values) {
vm.clear()
}
map.clear()
}
}
ViewModel과 Configuration Change 사이에 핵심은 ViewModelStore인 것으로 생각됩니다. 앞 서 ViewModelProvider에 전달했던 ViewModelStoreOwner의 ViewModelStore 안에 Map<String, ViewModel> 형태로 ViewModel을 저장하고 있으며, get()으로 ViewModel에 접근할 수 있었습니다. ViewModelStore 클래스가 유지되고 map을 clear()하지 않는다면, map에서 ViewModel 값을 가져올 수 있습니다.
이제 ViewModelStoreOwner에 대해서 알아보기 위해 Fragment를 살펴보면 ViewModelStoreOwner를 상속 받고 있는 것을 확인할 수 있습니다. 또한, ViewModelStoreOwner를 상속 받고 있기 때문에 getViewModelStore()를 오버라이드 하고 있습니다. 결국, ViewModel이 Configuration Change에서 살아 남은 이유는 ViewModelStoreOwner Interface에 있는 ViewModelStore라고 하는 객체가 ViewModel에 대해서 저장하고 있기 때문입니다. 이제 이 ViewModelStore가 어디서 저장되고 어디서 해제되는지 확인할 차례입니다.
- put()은 일반적으로 ViewModel 객체를 생성했기 때문에 객체를 집어 넣는 함수라면,
- get()은 Configuration Change가 발생하더라도 지우지 않은 객체에 접근하기 위한 함수.
- clear()는 ViewModel이 onCleared 되었을 때 삭제하는 함수라고 생각됩니다.
public class Fragment implements ComponentCallbacks, OnCreateContextMenuListener, LifecycleOwner,
ViewModelStoreOwner, HasDefaultViewModelProviderFactory, SavedStateRegistryOwner,
ActivityResultCaller {
...
@NonNull
@Override
public ViewModelStore getViewModelStore() {
if (mFragmentManager == null) {
throw new IllegalStateException("Can't access ViewModels from detached fragment");
}
if (getMinimumMaxLifecycleState() == Lifecycle.State.INITIALIZED.ordinal()) {
throw new IllegalStateException("Calling getViewModelStore() before a Fragment "
+ "reaches onCreate() when using setMaxLifecycle(INITIALIZED) is not "
+ "supported");
}
return mFragmentManager.getViewModelStore(this);
}
...
}
return 문에서 FragmentManager에서 호출하는 것을 확인하였습니다. 계속 따라가보겠습니다.
@NonNull
ViewModelStore getViewModelStore(@NonNull Fragment f) {
return mNonConfig.getViewModelStore(f);
} // in FragmentManager.java
@NonNull
ViewModelStore getViewModelStore(@NonNull Fragment f) {
ViewModelStore viewModelStore = mViewModelStores.get(f.mWho);
if (viewModelStore == null) {
viewModelStore = new ViewModelStore();
mViewModelStores.put(f.mWho, viewModelStore);
}
return viewModelStore;
} // in FragmentManaverViewModel.java
FragmentManager → FragmentManagerViewModel 에서 getViewModelStore를 호출하는 것을 확인했습니다. Fragment에서 사용하는 ViewModel 관련 코드는 최종적으로 FragmentManagerViewModel까지 오는 것 같습니다. FragmentManagerViewModel을 보면 ViewModelStore는 HashMap 형태로 저장되고 있습니다. if 문 안에서 ViewModelStore의 null을 체크하고 put() or get()을 하는 것을 확인했습니다.
저는 여기서 한 번 막혔습니다. FragmentManagerViewModel에서 HashMap<String, ViewModelStore> 형태로 저장하는 것은 알았는데, 결정적인 뭔가가 아직 채워지지 않았습니다. 코드를 좀 더 살펴보면 fragment가 detach 되었을 때 clear하는 것은 확인할 수 있었고, restore 관련 코드도 있었는데 이건 deprecated 되었습니다.
Configuration Changed되었을 때, 아닐 때를 구분해서 map에 저장되어 있는 ViewModel을 가져오거나 삭제하거나 하는 것은 이미 알고 있는 내용이었기에, 앞 서 ViewModelStoreOwner를 상속 받고 있는 ComponentActivity로 넘어 가서 다시 살펴보기로 하였습니다. ComponentActivity는 안드로이드에서 Activity를 사용할 때 상속받는 AppCompatActivity의 조상입니다. FragmentManager를 AppCompatActivity에서 관리하니까 여기가 맞겠다 싶어서 ComponentActivity에서 다시 찾아봤습니다.
ComponentActivity
getLifecycle().addObserver(new LifecycleEventObserver() {
@Override
public void onStateChanged(@NonNull LifecycleOwner source,
@NonNull Lifecycle.Event event) {
if (event == Lifecycle.Event.ON_DESTROY) {
// Clear out the available context
mContextAwareHelper.clearAvailableContext();
// And clear the ViewModelStore
if (!isChangingConfigurations()) {
getViewModelStore().clear();
}
}
}
});
결론을 스포일러 당한 느낌이긴 합니다. ComponentActivity 코드에서 알기 쉬운 이름 덕분에 Lifecycle에 따라서 Configuration Changed가 아니라면 getViewModelStore().clear(); 구문을 확인할 수 있었습니다. 어쨋든 반대의 경우, clear() 하지 않았으니 유지가 된다는 결론입니다. 여기서 결론을 받았다는 건 자세한 과정도 연결되어 있을 것 같았습니다.
Fragment와 마찬가지로 ComponentActivity도 ViewModelStoreOwner를 상속 받고 있습니다. 앞 서 Fragment에서 본 것처럼 getViewModelStore() 함수를 찾아보았습니다.
@NonNull
@Override
public ViewModelStore getViewModelStore() {
if (getApplication() == null) {
throw new IllegalStateException("Your activity is not yet attached to the "
+ "Application instance. You can't request ViewModel before onCreate call.");
}
ensureViewModelStore();
return mViewModelStore;
}
@SuppressWarnings("WeakerAccess") /* synthetic access */
void ensureViewModelStore() {
if (mViewModelStore == null) {
NonConfigurationInstances nc =
(NonConfigurationInstances) getLastNonConfigurationInstance();
if (nc != null) {
// Restore the ViewModelStore from NonConfigurationInstances
mViewModelStore = nc.viewModelStore;
}
if (mViewModelStore == null) {
mViewModelStore = new ViewModelStore();
}
}
} // in ComponentActivity.java
ensureViewModelStore(); 함수를 호출하고 있고, // Restore the ViewModelStore from NonConfigurationInstances 라는 주석을 찾았습니다. mViewModelStore의 null을 체크하고 기존에 존재하는 값이 있으면 찾고 없으면 새로운 ViewModelStore 객체를 할당해주고 있는 if문이 있습니다. 기존에 존재하는 ViewModelStore 값은 getLastNonConfigurationInstance(); 함수에서 NonConfigurationInstances 형태로 받습니다.
static final class NonConfigurationInstances {
Object custom;
ViewModelStore viewModelStore;
} // in ComponentActivity.java
ComponentActivity.java 안에 있는 NonConfigurationInstances는 ViewModelStore를 위해서 존재하는 것으로 보입니다. (custom 관련 코드는 deprecated 되어 있고 확인하지 않아서 잘 모르겠습니다.)
@Nullable
public Object getLastNonConfigurationInstance() {
return mLastNonConfigurationInstances != null
? mLastNonConfigurationInstances.activity : null;
} // in Activity.java
코드를 따라서 Activity까지 오게 되었습니다. getLastNonConfigurationInstance()에서 값을 리턴하는데 함수가 매우 간단해서 당황했습니다. mLastNonConfigurationInstances라는 변수의 null을 체크하고 값을 리턴하는 함수입니다. mLastNonConfigurationInstances은 Activity.java 안에 정의되어 있는 NonConfigurationInstances static final class 변수입니다.
static final class NonConfigurationInstances {
Object activity;
HashMap<String, Object> children;
FragmentManagerNonConfig fragments;
ArrayMap<String, LoaderManager> loaders;
VoiceInteractor voiceInteractor;
}
mLastNonConfigurationInstances를 attach 함수에서 set 합니다. NonConfigurationInstances로 검색해서 코드를 조금 더 찾아봤습니다. 또한, attach 함수는 여기가 끝이 아니라 어딘가에서 호출하는 것 같은데 안드로이드 스튜디오에서는 더 이상 확인할 수 없었습니다.
코드를 보다가 retainNonConfigurationInstances라는 함수에서 NonConfigurationInstances의 인스턴스를 생성하고 리턴하는 코드를 발견하였습니다. 이름도 retain이기 때문에 기존에 생성한 객체를 찾는다는 느낌이 팍 옵니다.
NonConfigurationInstances retainNonConfigurationInstances() {
Object activity = onRetainNonConfigurationInstance();
HashMap<String, Object> children = onRetainNonConfigurationChildInstances();
FragmentManagerNonConfig fragments = mFragments.retainNestedNonConfig();
...
NonConfigurationInstances nci = new NonConfigurationInstances();
nci.activity = activity;
nci.children = children;
nci.fragments = fragments;
nci.loaders = loaders;
if (mVoiceInteractor != null) {
mVoiceInteractor.retainInstance();
nci.voiceInteractor = mVoiceInteractor;
}
return nci;
}
맨 위에 세 줄에서 기존에 갖고 있던 객체를 activity, children, fragments 변수에 할당하는 것으로 보입니다.
안드로이드 스튜디오에서 retainNonConfigurationInstances함수를 호출하는 곳을 찾아봤지만 더 이상은 찾을 수 없었습니다.
지금까지 흘러 온 상황을 정리하면 이렇습니다.
#Fragment
FragmentManager - getViewModelStore()
FragmentManagerViewModel - getViewModelStore()
Fragment의 ViewModelStore는 FragmentManagerViewModel에 HashMap<String, ViewModelStore> 형태로 저장되는 것을 확인하였습니다.
private final HashMap<String, FragmentManagerViewModel> mChildNonConfigs = new HashMap<>();
private final HashMap<String, ViewModelStore> mViewModelStores = new HashMap<>();
// in FragmentManagerViewModel.java
Configuration changed가 발생했다면,
- mChildNonConfig HastMap final 변수에 FragmentMangerViewModel을 put()하고,
- mViewModelStores HashMap final 변수에 ViewModelStore를 put()해서 저장합니다.
만약 Configuration changed가 발생하지 않았고, destroy된다면,
- Fragment의 UUID를 키 값으로 해당 값을 찾고 mChildNonConfig에서 FragmentMangerViewModel를 remove() 합니다.
- 마찬가지로 mViewModelStores도 동일한 방법으로 remove() 합니다.
#ComponentActivity
ComponentActivity - getViewModelStore() - ensureViewModelStore()
Activity - getLastNonConfigurationInstance()
ViewModelStore는 Acitivty.java 안에 NonConfigurationInstances 형태로 저장된다는 것까지 확인하였습니다.
Activity.java 클래스 안에 NonConfigurationInstances 변수의 인스턴스를 생성하거나 값을 할당하는 코드는 두 곳이 있었습니다. attach(), retainNonConfigurationInstances() 였습니다.
attach()는 Acivity를 새로 생성할 때 호출되는 곳이기 때문에 남은 곳은 retainNonConfigurationInstances() 함수가 됩니다. 이 함수를 어디서 호출하는지 알고 싶었습니다.
함수를 호출하는 곳에서, 혹은 그보다 더 위에 ViewModelStore 저장과 관련된 코드가 있을 것이라고 생각했기 때문입니다.
코드 검색
안드로이드는 오픈 소스이기 때문에 코드 검색으로 코드를 확인할 수 있습니다. retainNonConfigurationInstances()를 호출하는 곳을 찾아봅니다. https://cs.android.com/?hl=ko (아주 감사한 일입니다.)
Deep and Deep Dive
ActivityThread.java에서 retainNonConfigurationInstances()함수를 호출하는 것을 확인했습니다. 검색이 매우 잘 되어서 순조롭게 찾을 수 있었습니다.
/** Core implementation of activity destroy call. */
void performDestroyActivity(ActivityClientRecord r, boolean finishing,
int configChanges, boolean getNonConfigInstance, String reason) {
...
if (getNonConfigInstance) {
try {
r.lastNonConfigurationInstances = r.activity.retainNonConfigurationInstances();
} catch (Exception e) {
if (!mInstrumentation.onException(r.activity, e)) {
throw new RuntimeException("Unable to retain activity "
+ r.intent.getComponent().toShortString() + ": " + e.toString(), e);
}
}
}
...
} // in ActivityThread.java
주석에서 확인할 수 있듯이 Core implementation of activity destroy call.라고 쓰여있습니다. 제대로 찾아온 것 같습니다. 여기서 r이라는 ActivityClientRecord라는 객체 안에 lastNonConfigurationInstances 값에 retainNonConfigurationInstances() 함수를 할당하고 있습니다. ActivityClientRecord를 찾아봅니다.
/** Activity client record, used for bookkeeping for the real {@link Activity} instance. */
public static final class ActivityClientRecord {
...
Activity.NonConfigurationInstances lastNonConfigurationInstances;
....
}
@UnsupportedAppUsage
final ArrayMap<IBinder, ActivityClientRecord> mActivities = new ArrayMap<>();
ActivityClientRecord에 Activity client record, used for bookkeeping for the real {@link Activity} instance. 주석이 있는 것으로 보아 Activity 인스턴스 안에 있는 activity 자신, 상태 등 굉장히 다양한 값을 꺼내서 사용하기 위해 만들어진 class로 생각됩니다.
ActivityClientRecord class 안에 lastNonConfigurationInstances에 ViewModelStore가 저장됩니다. ActivityThread class 안에서 ActivityClientRecord는 ArrayMap<IBinder, ActivityClientRecord> 형태로 저장되고 있습니다.
드디어 찾았습니다. 최종적으로 ViewModelStore 저장과 관련된 코드가 있는 곳은 ActivityThread.java 클래스였고 ActivityClientRecord static final class 형태 안에 저장되어 있었습니다.
정리
1.ActivityThread.java
ActivityThread 안에 ArrayMap<IBinder, ActivityClientRecord>이 있습니다. 만약 Activity의 상태가 ON_DESTORY되어야 한다면, ActivityThread에 performDestroyActivity()가 호출됩니다.
/** Core implementation of activity destroy call. */
void performDestroyActivity(ActivityClientRecord r, boolean finishing,
int configChanges, boolean getNonConfigInstance, String reason) {
...
if (getNonConfigInstance) {
try {
r.lastNonConfigurationInstances = r.activity.retainNonConfigurationInstances();
} catch (Exception e) {
if (!mInstrumentation.onException(r.activity, e)) {
throw new RuntimeException("Unable to retain activity "
+ r.intent.getComponent().toShortString() + ": " + e.toString(), e);
}
}
}
...
} // in ActivityThread.java
getNonConfigInstance flag 변수에 따라서 configuration change가 발생하였다면, ViewModel은 이 때 제거하면 안되기 때문에 NonConfigurationInstances 인스턴스를 생성해서 값을 저장합니다. 여기에 저장되는 값은 결국 ViewModelStore 입니다. 그 값은 r.activity.retainNonConfigurationInstances()에서 생성하고 r.lastNonConfigurationInstances에 할당합니다. 나중에 다시 불러오기 위함입니다.
2. Activity.java
앞서 할당한 r.lastNonConfigurationInstances 이 변수를 가져와야 합니다. 이 변수는 Activity class 안에 있는 NonConfigurationInstances 객체 타입이고 mLastNonConfigurationInstances에 저장되어 있으며, getLastNonConfigurationInstance() 함수에서 가져옵니다. mLastNonConfigurationInstances에 저장되는 것은 Activity.java 안에 attach() 함수에서 확인할 수 있었습니다.
@Nullable
public Object getLastNonConfigurationInstance() {
return mLastNonConfigurationInstances != null
? mLastNonConfigurationInstances.activity : null;
} // in Activity.java
3. ComponentActivity
ComponentActivity에서 getLastNonConfigurationInstance()를 호출합니다. 최종적으로 ActivityThread에 저장되어 있던 값을 getLastNonConfigurationInstance() 함수를 통해서 가져오고, 그 안에 있는 ViewModelStore를 가져옵니다. 아까 봤던 ensureViewModelStore() 이 함수가 핵심 함수였습니다.
@SuppressWarnings("WeakerAccess") /* synthetic access */
void ensureViewModelStore() {
if (mViewModelStore == null) {
NonConfigurationInstances nc =
(NonConfigurationInstances) getLastNonConfigurationInstance();
if (nc != null) {
// Restore the ViewModelStore from NonConfigurationInstances
mViewModelStore = nc.viewModelStore;
}
if (mViewModelStore == null) {
mViewModelStore = new ViewModelStore();
}
}
} // in ComponentActivity.java
결론
길었던 여정이 끝이 났습니다. 시작은 “ViewModel은 어떻게 Configuration Change에서도 유지되어 데이터를 갖고 있을 수 있는가?” 였습니다. 이 과정을 마무리할 수 있도록 도와 준 Google에 감사드립니다. 참고한 자료가 있었기 때문에 조금 더 수월하게 여정을 마무리 할 수 있었던 것 같습니다. 이 여정을 시작으로 궁금했던 다른 로직들 또한 내부 깊숙한 곳에서 어떻게 동작하는지 알아 볼 생각입니다.
참고자료 :
https://proandroiddev.com/how-viewmodel-works-under-the-hood-52a4f1ff64cf