Hanbit the Developer

ViewModel 분석 본문

Mobile/Android

ViewModel 분석

hanbikan 2023. 12. 4. 13:01

배경

개발을 하다보면 아래와 같은 코드를 자주 쓰게 된다.

viewModelScope.launch(Dispatchers.IO) {
    // ...
}

viewModelScope는 어떻게 구현되어 있는가? 이 글에서는 viewModelScope를 시작으로 ViewModel의 전체 구현을 알아보고자 한다.

viewModelScope

viewModelScope는 아래처럼 구현되어 있다.

// ViewModel.kt
/**
 * [CoroutineScope] tied to this [ViewModel].
 * This scope will be canceled when ViewModel will be cleared, i.e [ViewModel.onCleared] is called
 *
 * This scope is bound to [Dispatchers.Main.immediate]
 */
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)
        )
    }

코드를 보면 다음과 같은 특징이 있다는 걸 알 수 있다.

  • 정의: ViewModel이 clear될 때 같이 취소되는 CoroutineScope
    • ViewModel onCleared(): ViewModel이 더이상 사용되지 않고 destroy될 때 호출되는 함수
    // ViewModel.java
    /**
     * This method will be called when this ViewModel is no longer used and will be destroyed.
     * <p>
     * It is useful when ViewModel observes some data and you need to clear this subscription to
     * prevent a leak of this ViewModel.
     */
    @SuppressWarnings("WeakerAccess")
        protected void onCleared() {
    }
    
    @MainThread
    final void clear() {
        // ...
        onCleared();
    }
    
    • CoroutineScope: 코루틴의 범위를 정의하며 launch, async 등 코루틴을 실행하기 위한 코루틴 빌더 함수는 코루틴 스코프의 확장 함수이다. 내부에 CoroutineContext를 가지고 있다.
    /**
     * Defines a scope for new coroutines. Every **coroutine builder** (like [launch], [async], etc.)
     * is an extension on [CoroutineScope] and inherits its [coroutineContext][CoroutineScope.coroutineContext]
     * to automatically propagate all its elements and cancellation.
     *
     * The best ways to obtain a standalone instance of the scope are [CoroutineScope()] and [MainScope()] factory functions,
     * taking care to cancel these coroutine scopes when they are no longer needed (see section on custom usage below for
     * explanation and example).
     *
     * Additional context elements can be appended to the scope using the [plus][CoroutineScope.plus] operator.
     *
     * ...
     */
    public interface CoroutineScope {
        /**
         * The context of this scope.
         * Context is encapsulated by the scope and used for implementation of coroutine builders that are extensions on the scope.
         * Accessing this property in general code is not recommended for any purposes except accessing the [Job] instance for advanced usages.
         *
         * By convention, should contain an instance of a [job][Job] to enforce structured concurrency.
         */
        public val coroutineContext: CoroutineContext
    }
    
  • 기본 디스패처가 Dispatchers.Main이다.(CPU-bound 작업이나 네트워크 작업 시 디스패처 변경할 것)
  • CloseableCoroutineScope라는 코루틴 스코프를 사용함
    • CloseableCoroutineScope: ‘Closeable’이라는 인터페이스를 구현한 CoroutineScope
    // ViewModel.kt
    internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
        override val coroutineContext: CoroutineContext = context
    
        override fun close() {
            coroutineContext.cancel()
        }
    }
    
    • Closeable: ViewModel에서 mBagOfTags, mCloseables라는 멤버 변수에 ViewModel과 연관된 다양한 객체들을 저장하여 관리하며, 메모리 누수나 불필요한 작업을 방지하기 위해 ViewModel이 닫힐 때 같이 ‘청소(clean up)’되어야 하는 객체들이 있다. ViewModel.clear()에서 이 값들을 청소하며 이를 구현하는 데 쓰인 인터페이스가 Closeable이다.(사실 뷰모델에서만 쓰이는 것은 아니고 수많은 곳에서 범용적으로 쓰인다.)
    // Closeable.class
    public interface Closeable extends AutoCloseable {
        void close() throws IOException;
    }
    
    // ViewModel.java
    @MainThread
    final void clear() {
        mCleared = true;
        // Since clear() is final, this method is still called on mock objects
        // and in those cases, mBagOfTags is null. It'll always be empty though
        // because setTagIfAbsent and getTag are not final so we can skip
        // clearing it
        if (mBagOfTags != null) {
            synchronized (mBagOfTags) {
                for (Object value : mBagOfTags.values()) {
                    // see comment for the similar call in setTagIfAbsent
                    closeWithRuntimeException(value);
                }
            }
        }
        // We need the same null check here
        if (mCloseables != null) {
            synchronized (mCloseables) {
                for (Closeable closeable : mCloseables) {
                    closeWithRuntimeException(closeable);
                }
            }
        }
        onCleared();
    }
    // ...
    private static void closeWithRuntimeException(Object obj) {
        if (obj instanceof Closeable) {
            try {
                ((Closeable) obj).close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }
    

ViewModel.java 전체 코드

viewModelScope를 따라가며 분석하다보니 뷰모델의 대부분을 커버하게 되었다. 아직 다루지 않은 것에는 getTag() 함수 등이 있는데 설명이 필요 없을 정도로 간단하다. 다음은 뷰모델의 전체 코드이다:

// ViewModel.java
/**
 * ViewModel is a class that is responsible for preparing and managing the data for
 * an {@link android.app.Activity Activity} or a {@link androidx.fragment.app.Fragment Fragment}.
 * It also handles the communication of the Activity / Fragment with the rest of the application
 * (e.g. calling the business logic classes).
 * <p>
 * A ViewModel is always created in association with a scope (a fragment or an activity) and will
 * be retained as long as the scope is alive. E.g. if it is an Activity, until it is
 * finished.
 * <p>
 * In other words, this means that a ViewModel will not be destroyed if its owner is destroyed for a
 * configuration change (e.g. rotation). The new owner instance just re-connects to the existing model.
 * <p>
 * The purpose of the ViewModel is to acquire and keep the information that is necessary for an
 * Activity or a Fragment. The Activity or the Fragment should be able to observe changes in the
 * ViewModel. ViewModels usually expose this information via {@link LiveData} or Android Data
 * Binding. You can also use any observability construct from your favorite framework.
 * <p>
 * ViewModel's only responsibility is to manage the data for the UI. It <b>should never</b> access
 * your view hierarchy or hold a reference back to the Activity or the Fragment.
 * <p>
 * Typical usage from an Activity standpoint would be:
 * <pre>
 * public class UserActivity extends Activity {
 *
 *     {@literal @}Override
 *     protected void onCreate(Bundle savedInstanceState) {
 *         super.onCreate(savedInstanceState);
 *         setContentView(R.layout.user_activity_layout);
 *         final UserModel viewModel = new ViewModelProvider(this).get(UserModel.class);
 *         viewModel.getUser().observe(this, new Observer&lt;User&gt;() {
 *             {@literal @}Override
 *             public void onChanged(@Nullable User data) {
 *                 // update ui.
 *             }
 *         });
 *         findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {
 *             {@literal @}Override
 *             public void onClick(View v) {
 *                  viewModel.doAction();
 *             }
 *         });
 *     }
 * }
 * </pre>
 *
 * ViewModel would be:
 * <pre>
 * public class UserModel extends ViewModel {
 *     private final MutableLiveData&lt;User&gt; userLiveData = new MutableLiveData&lt;&gt;();
 *
 *     public LiveData&lt;User&gt; getUser() {
 *         return userLiveData;
 *     }
 *
 *     public UserModel() {
 *         // trigger user load.
 *     }
 *
 *     void doAction() {
 *         // depending on the action, do necessary business logic calls and update the
 *         // userLiveData.
 *     }
 * }
 * </pre>
 *
 * <p>
 * ViewModels can also be used as a communication layer between different Fragments of an Activity.
 * Each Fragment can acquire the ViewModel using the same key via their Activity. This allows
 * communication between Fragments in a de-coupled fashion such that they never need to talk to
 * the other Fragment directly.
 * <pre>
 * public class MyFragment extends Fragment {
 *     public void onStart() {
 *         UserModel userModel = new ViewModelProvider(requireActivity()).get(UserModel.class);
 *     }
 * }
 * </pre>
 * </>
 */
public abstract class ViewModel {
    // Can't use ConcurrentHashMap, because it can lose values on old apis (see b/37042460)
    @Nullable
    private final Map<String, Object> mBagOfTags = new HashMap<>();
    @Nullable
    private final Set<Closeable> mCloseables = new LinkedHashSet<>();
    private volatile boolean mCleared = false;

    /**
     * Construct a new ViewModel instance.
     * <p>
     * You should <strong>never</strong> manually construct a ViewModel outside of a
     * {@link ViewModelProvider.Factory}.
     */
    public ViewModel() {
    }

    /**
     * Construct a new ViewModel instance. Any {@link Closeable} objects provided here
     * will be closed directly before {@link #onCleared()} is called.
     * <p>
     * You should <strong>never</strong> manually construct a ViewModel outside of a
     * {@link ViewModelProvider.Factory}.
     */
    public ViewModel(@NonNull Closeable... closeables) {
        mCloseables.addAll(Arrays.asList(closeables));
    }

    /**
     * Add a new {@link Closeable} object that will be closed directly before
     * {@link #onCleared()} is called.
     *
     * @param closeable The object that should be {@link Closeable#close() closed} directly before
     *                  {@link #onCleared()} is called.
     */
    public void addCloseable(@NonNull Closeable closeable) {
        // As this method is final, it will still be called on mock objects even
        // though mCloseables won't actually be created...we'll just not do anything
        // in that case.
        if (mCloseables != null) {
            synchronized (mCloseables) {
                mCloseables.add(closeable);
            }
        }
    }

    /**
     * This method will be called when this ViewModel is no longer used and will be destroyed.
     * <p>
     * It is useful when ViewModel observes some data and you need to clear this subscription to
     * prevent a leak of this ViewModel.
     */
    @SuppressWarnings("WeakerAccess")
    protected void onCleared() {
    }

    @MainThread
    final void clear() {
        mCleared = true;
        // Since clear() is final, this method is still called on mock objects
        // and in those cases, mBagOfTags is null. It'll always be empty though
        // because setTagIfAbsent and getTag are not final so we can skip
        // clearing it
        if (mBagOfTags != null) {
            synchronized (mBagOfTags) {
                for (Object value : mBagOfTags.values()) {
                    // see comment for the similar call in setTagIfAbsent
                    closeWithRuntimeException(value);
                }
            }
        }
        // We need the same null check here
        if (mCloseables != null) {
            synchronized (mCloseables) {
                for (Closeable closeable : mCloseables) {
                    closeWithRuntimeException(closeable);
                }
            }
        }
        onCleared();
    }

    /**
     * Sets a tag associated with this viewmodel and a key.
     * If the given {@code newValue} is {@link Closeable},
     * it will be closed once {@link #clear()}.
     * <p>
     * If a value was already set for the given key, this call does nothing and
     * returns currently associated value, the given {@code newValue} would be ignored
     * <p>
     * If the ViewModel was already cleared then close() would be called on the returned object if
     * it implements {@link Closeable}. The same object may receive multiple close calls, so method
     * should be idempotent.
     */
    @SuppressWarnings("unchecked")
    <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) {
            // It is possible that we'll call close() multiple times on the same object, but
            // Closeable interface requires close method to be idempotent:
            // "if the stream is already closed then invoking this method has no effect." (c)
            closeWithRuntimeException(result);
        }
        return result;
    }

    /**
     * Returns the tag associated with this viewmodel and the specified key.
     */
    @SuppressWarnings({"TypeParameterUnusedInFormals", "unchecked"})
    <T> T getTag(String key) {
        if (mBagOfTags == null) {
            return null;
        }
        synchronized (mBagOfTags) {
            return (T) mBagOfTags.get(key);
        }
    }

    private static void closeWithRuntimeException(Object obj) {
        if (obj instanceof Closeable) {
            try {
                ((Closeable) obj).close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

 

Q&A by ChatGPT

이번에 코드를 보며 다음과 같은 내용들이 궁금하였다:

  • 뷰모델이 어떻게 생성되는가?(ViewModel Factory, Hilt 등)
  • 뷰모델의 생명주기를 누가 관리하는가?

Answers


  1.  

뷰모델 생성 방법

ViewModel은 Android 앱에서 UI 관련 데이터를 저장하고 관리하는 구성 요소입니다. 그 생성 방법은 주로 다음 두 가지 방법을 포함합니다:

  1. ViewModel Factory:
    • ViewModel이 기본 생성자가 아닌 생성자를 사용할 때, ViewModelProvider.Factory 인터페이스를 구현하는 커스텀 팩토리 클래스를 사용해 ViewModel 인스턴스를 생성합니다.
    • 이 팩토리는 필요한 의존성을 ViewModel에 주입할 수 있게 해 줍니다.
    • 예시:
    • class MyViewModelFactory(private val repository: MyRepository) : ViewModelProvider.Factory { override fun <T : ViewModel?> create(modelClass: Class<T>): T { return MyViewModel(repository) as T } }
  2. Hilt를 사용한 종속성 주입:
    • Hilt는 Android의 종속성 주입 라이브러리로, ViewModel에 필요한 의존성을 자동으로 주입하는 데 사용됩니다.
    • @HiltViewModel 어노테이션을 사용하여 ViewModel 클래스를 정의하고, 필요한 종속성을 생성자에 주입합니다.
    • Hilt는 내부적으로 적절한 팩토리를 생성하여 ViewModel 인스턴스를 관리합니다.
    • 예시:
    • @HiltViewModel class MyViewModel @Inject constructor(private val repository: MyRepository) : ViewModel() { // ... }

뷰모델의 생명주기 관리

ViewModel의 생명주기는 ViewModelProvider에 의해 관리됩니다. 이는 Activity나 Fragment와 같은 UI 컨트롤러와 연결되어 있으며, 다음과 같은 방식으로 작동합니다:

  • ViewModel은 보통 Activity나 Fragment의 ViewModelProvider를 통해 요청되고, 생성된 ViewModel 인스턴스는 이 UI 컨트롤러의 수명주기와 연결됩니다.
  • Activity나 Fragment가 파괴될 때, ViewModel의 onCleared() 메소드가 호출되어 정리(clean-up) 작업이 수행됩니다.
  • 하지만 Activity나 Fragment가 구성 변경(예: 화면 회전)으로 인해 재생성되는 경우, ViewModel은 자동으로 재사용되어 데이터의 유지를 보장합니다.
  • ViewModel은 UI 컨트롤러가 완전히 파괴되고 더 이상 사용되지 않을 때까지 메모리에 남아 있습니다. 예를 들어, Activity의 경우 onDestroy()가 호출되어 isFinishing()이 true일 때입니다.

이러한 방식으로 ViewModel은 UI 관련 데이터를 수명주기에 걸쳐 유지하고, 구성 변경이나 다른 중단 이벤트로 인한 데이터 손실을 방지합니다.


실제 코드에서 확인

“Activity나 Fragment가 파괴될 때, ViewModel의 onCleared() 메소드가 호출되어 정리(clean-up) 작업이 수행됩니다."

마치며

ViewModelProvider, ViewModelStore, ViewModelFactory, 액티비티 및 프래그먼트와 뷰모델의 결합 방식 등 의문이 해소되지 않은 부분이 있어 다음에는 뷰모델이 외부에서 어떻게 연결되는지를 위주로 살펴보고자 한다.