안드로이드의 오피셜 가이드라인은 AAC 의 하나인 ViewModel의 사용을 권장하고 Dagger 2는 안드로이드에서 가장 많이 쓰이는 DI 툴이다. 따라서, 이 둘이 굉장히 잘 호완되길 바라지만, 사실은 그렇지 않다. 이번 글에서는 Dagger 2를 사용하여 ViewModel에 의존성을 주입할때 생기는 이슈, 그리고 그에 대한 몇가지 해결 방안에 대해 정리할 것이다.
뷰모델은 ViewModelProvider를 통해서만 생성할 수 있다. 그 이유는 한로니님의 블로그 글에 잘 설명되어 있으니 참고 한다.
someViewModel = new ViewModelProvider(this).get(ExampleViewModel.class);
뷰모델을 직접 생성자를 통해 생성할 수 없기 때문에, 의존성 주입이 필요한 뷰모델을 생성하기 위해서는 ViewModelProvider.Factory를 거쳐야한다.
/**
* Implementations of {@code Factory} interface are responsible to instantiate ViewModels.
*/
public interface Factory {
/**
* Creates a new instance of the given {@code Class}.
* <p>
*
* @param modelClass a {@code Class} whose instance is requested
* @param <T> The type parameter for the ViewModel.
* @return a newly created ViewModel
*/
@NonNull
<T extends ViewModel> T create(@NonNull Class<T> modelClass);
}
이 팩토리 인터페이스의 create(Class) 메소드는 필요한 뷰모델의 클래스 오브젝트를 매개변수로 받아 해당 클래스의 인스턴스를 생성해서 반환한다.
기본 생성자를 가지고 있지 않은 뷰모델을 생성하기 위해선, ViewModelProvider의 생성자에 팩토리 인터페이스 객체를 넘겨줘야 한다:
mMyViewModel = ViewModelProviders.of(this, new ViewModelFactory(mRepository).get(MyViewModel.class);
따라서 뷰모델의 의존성을 주입하기 위해선 팩토리의 의존성을 주입해야한다:
@Inject ViewModelFactory mViewModelFactory;
private MyViewModel mMyViewModel;
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mMyViewModel = ViewModelProviders.of(this, mViewModelFactory).get(MyViewModel.class);
}
이런식으로 뷰모델과 팩토리의 의존성을 주입하는데에는 여러가지 방법이 있다.
public class ViewModelFactory implements ViewModelProvider.Factory {
private final MyViewModel mMyViewModel;
@Inject
public ViewModelFactory(MyViewModel myViewModel) {
mMyViewModel = myViewModel;
}
@SuppressWarnings("unchecked")
@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
ViewModel viewModel;
if (modelClass == MyViewModel.class) {
viewModel = mMyViewModel;
}
else {
throw new RuntimeException("unsupported view model class: " + modelClass);
}
return (T) viewModel;
}
}
위 코드처럼 팩토리가 미리 생성된 뷰모델 객체를 매개변수로 받고 상응하는 뷰모델을 반환하는 방법이 있다. 하지만 이 방법에는 상당히 많은 문제가 있고 심각한 버그로 이어질수 있다.
우선, 상당한 양의 보일러 플레이트 코드를 요구한다. 예를 들어, 어플에서 20개의 뷰모델이 필요하다면, 매개변수로 20개의 뷰모델을 넘겨줘야하고, 또한 매번 뷰모델 객체를 생성할때마다 팩토리에 20개의 뷰모델 객체를 생성해서 넘겨줘야 할 것이다.
이보다 더 심각한 문제는 ViewModelProvider.Factory 인터페이스에서 요구하는 사항을 위반한다는 것이다 (참고: Liskov Substitution Principle). ViewModelProvider.Factory의 문서에 따르면 create(Class) 메소드는 뷰모델의 새로운 인스턴스를 생성해서 반환해야 한다.
Creates a new instance of the given {@code Class}.
하지만 위의 방법은 새로운 인스턴스를 생성하는게 아닌 이미 생성된 뷰모델 객체를 받아와서 그대로 반환하기 때문에 이 사항을 위반한다는 것이다.
이 사항을 지키지 않았을때 생길수 있는 버그의 예로, 액티비티 A와 프래그먼트 B에 같은 뷰모델을 사용한다고 해보자.
@Inject ViewModelFactory mViewModelFactory;
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mMyViewModel = ViewModelProviders.of(this, mViewModelFactory).get(MyViewModel.class);
mMyViewModelActivity = ViewModelProviders.of(requireActivity(), mViewModelFactory).get(MyViewModel.class);
}
mMyViewModel과 mMyViewModelActivity의 인스턴스가 서로 달라야하지만 위의 방법대로 팩토리를 구현한다면, 두 뷰모델은 같은 인스턴스를 가지게 된다.
public class ViewModelFactory implements ViewModelProvider.Factory {
private final Provider<NewsViewModel> mNewsViewModelProvider;
private final Provider<BookmarkViewModel> mBookmarkViewModelProvider;
@Inject
public ViewModelFactory(Provider<NewsViewModel> newsViewModelProvider, Provider<BookmarkViewModel> bookmarkViewModelProvider) {
mNewsViewModelProvider= newsViewModelProvider;
mBookmarkViewModelProvider = bookmarkViewModelProvider;
}
@SuppressWarnings("unchecked")
@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
ViewModel viewModel;
if (modelClass == NewsViewModel.class) {
viewModel = mNewsViewModelProvider.get();
}
else if (modelClass == BookmarkViewModel.class) {
viewModel = mBookmarkViewModelProvider.get();
}
else {
throw new RuntimeException("unsupported view model class: " + modelClass);
}
return (T) viewModel;
}
}
public class BookmarkFragment extends Fragment {
private BookmarkViewModel mVm;
@Inject
ViewModelFactory mVmFactory;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
mVm = new ViewModelProvider(this, mVmFactory).get(BookmarkViewModel.class);
}
}
위 코드처럼 해당 뷰모델의 Provider 객체를 매개변수로 넘겨주어, 뷰모델을 반환할때 사용하게 되면 개발자가 설정한 @Scope에 따라 같은 인스턴스를 넘겨줄수도, 다른 인스턴스를 넘겨줄수도 있기 때문에 안전한 방법이다.
하지만 이 방법 또한 많은 양의 보일러 플레이트 코드를 요구하기 때문에 큰 프로젝트에 적합한 방법은 아니다.
위 코드의 보일러 플레이트 코드를 줄이기 위해 Dagger의 Multi-Binding, 그 중에서도 Map을 사용하는 Multi-Binding을 이용할 수 있다.
우선, 뷰모델을 제공할 뷰모델 모듈에 아래 코드를 추가한다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@MapKey
@interface ViewModelKey {
Class<? extends ViewModel> value();
}
위의 @ViewModelKey 어노테이션을 사용하는 객체는 Provider가 Map에 자신의 클래스를 키로 가지면서 추가된다.
예를 들어, NewsViewModel을 제공하는 provideNewsViewModel 메소드에 @ViewModelKey(NewsViewModel.class) 를 붙히면, map.get(NewsViewModel.class) 을 통해 Provider 객체를 받아올수 있다.
@Module
public class ViewModelModule {
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@MapKey
@interface ViewModelKey {
Class<? extends ViewModel> value();
}
@PerFragment
@Provides
ViewModelFactory viewModelFactory(Map<Class<? extends ViewModel>, Provider<ViewModel>> providerMap) {
return new ViewModelFactory(providerMap);
}
@PerFragment
@Provides
@IntoMap
@ViewModelKey(NewsViewModel.class)
ViewModel provideNewsViewModel(NewsRepository repository) {
return new NewsViewModel(repository);
}
@PerFragment
@Provides
@IntoMap
@ViewModelKey(BookmarkViewModel.class)
ViewModel provideBookmarkViewModel(NewsRepository repository) {
return new BookmarkViewModel(repository);
}
}
public class ViewModelFactory implements ViewModelProvider.Factory {
private final Map<Class<? extends ViewModel>, Provider<ViewModel>> mProviderMap;
public ViewModelFactory(Map<Class<? extends ViewModel>, Provider<ViewModel>> providerMap) {
mProviderMap = providerMap;
}
@SuppressWarnings("unchecked")
@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return (T) mProviderMap.get(modelClass).get();
}
}