Overview

안드로이드 앱 개발시 뷰모델을 구현할 때, 액티비티 또는 프래그먼트의 구성 변경에도 데이터가 유지되는 AAC-ViewModel 클래스를 상속하는 것을 권장합니다. AAC-ViewModel 을 사용한다면 뷰모델 객체를 생성할 때 생명주기에 맞춰 데이터를 보존하기 위해 프로그래머는 ViewModelProvider.Factory 만 구현하고 ViewModelProvider 클래스가 뷰모델 인스턴스를 관리하도록 해야 합니다.

class MainActivity : AppCompatActivity() {

    private val viewModel: MainViewModel by lazy {
        ViewModelProvider(
            this,
            object : ViewModelProvider.Factory {
                override fun <T : ViewModel?> create(modelClass: Class<T>): T {
                    return MainViewModel() as T
                }
            }
        ).get(MainViewModel::class.java)
    }

		// ...
}

매번 ViewModelProvider.Factory 를 구현 하려면 많은 보일러 플레이트 코드가 생길 수밖에 없는데, 기존에는 위와 같이 직접 팩토리를 생성해주어야 했지만 Dagger2, Koin 등의 의존성 주입 라이브러리를 통해 비교적 편리하게 이용할 수 있었습니다.

하지만 Dagger2 라이브러리를 사용하더라도 복잡한 어노테이션, 높은 러닝커브 때문에 불편함이 많았는데 최근 Dagger Hilt 가 새로 나오면서 적은 코드만으로도 뷰모델 주입이 가능해졌습니다. Dagger Hilt 에 대해 학습해보면서 어떻게 아래의 코드가 동작하는 것인지 궁금증이 생겨서 동작 원리를 알아보게 되었습니다.

@HiltAndroidApp
class MyApplication : Application()

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private val viewModel : MainViewModel by viewModels()
}

@HiltViewModel
class MainViewModel @Inject constructor() : ViewModel()

결론부터 말하자면, Dagger hilt 도 Dagger2 에서의 방식과 마찬가지로 멀티바인딩 기능을 활용하여 뷰모델을 생성하고 주입해줍니다. 따라서 대거의 멀티바인딩 기능, 기존 Dagger2 에서 뷰모델 생성 방식, 마지막으로 Hilt 에서 뷰모델 생성 방식을 차례대로 알아보겠습니다.

멀티바인딩

우선 먼저 알아야 하는 기능이 있습니다. 기존 Dagger2 를 사용할 때, Dagger2 에서 지원하는 기능인 멀티바인딩을 활용하여 ViewModelProvider.Factory 를 매번 구현하지 않고도 사용할 수 있었습니다. 멀티바인딩은 대거 모듈 안에 @IntoMap 어노테이션이 지정된 모든 주입 대상 객체를 컴포넌트 내부에 선언된 Map 인스턴스에 모아주는 기능입니다.

@Module
class SamsungModule {
    @Provides
    @IntoMap
    @StringKey("daeyeon")
    fun provideGalaxyS21() = "galaxy s21"

    @Provides
    @IntoMap
    @StringKey("sungkyu")
    fun provideGalaxyNote20() = "galaxy note 20"
}

@Module
class GoogleModule {
    @Provides
    @IntoMap
    @StringKey("boye")
    fun providePixel5() = "google pixel 5"

    @Provides
    @IntoMap
    @StringKey("sohee")
    fun providePixel3L() = "google pixel 3L"
}

@Component(modules = [SamsungModule::class, GoogleModule::class])
interface AndroidComponent {
    fun phones(): Map<String, String>

    @Component.Factory
    interface Factory {
        fun build(): AndroidComponent
    }
}

컴포넌트에서 phones() 메서드를 호출해서 로그를 출력해보면 모듈에 선언해놓은 StringKey 및 문자열과 동일하게 표시됩니다.

val component = DaggerAndroidComponent.create()
val phones = component.phones()
println("$phones")

// {daeyeon=galaxy s21, sungkyu=galaxy note 20, boye=google pixel 5, sohee=google pixel 3L}

대거 컴포넌트에 생성된 코드를 보면 아래와 같이 각각의 모듈에 선언 해놓은 스마트폰 객체가 Map 안에 넣어지는 것을 확인할 수 있습니다.

@Override
public Map<String, String> phones() {
  return MapBuilder.<String, String>newMapBuilder(4)
    .put("daeyeon", SamsungModule_ProvideGalaxyS21Factory.provideGalaxyS21(samsungModule))
    .put("sungkyu", SamsungModule_ProvideGalaxyNote20Factory.provideGalaxyNote20(samsungModule))
    .put("boye", GoogleModule_ProvidePixel5Factory.providePixel5(googleModule))
    .put("sohee", GoogleModule_ProvidePixel3LFactory.providePixel3L(googleModule))
    .build();
}

기존 Dagger2 에서 뷰모델 생성 방식

먼저 Dagger2 에서의 뷰모델 생성 방식을 보겠습니다. Dagger2 에서 멀티바인딩 기능을 사용하여 뷰모델을 식별해줄 Key 와 ViewModel 객체를 모듈에 선언합니다.

@Module
abstract class MainModule {
    @Binds
    @IntoMap
    @ViewModelKey(MainViewModel::class.java)
    fun bindHomeViewModel(mainViewModel: MainViewModel): ViewModel
}

ViewModelProvder.Factory 를 상속받은 뷰모델팩토리(ViewModelFactory) 클래스를 만들어서 대거를 통해 주입받은 map(creators)에서 뷰모델 key 로 조회하여 뷰모델을 생성하도록 구현하였습니다.

class ViewModelFactory @Inject constructor(
    private val creators: @JvmSuppressWildcards Map<Class<out ViewModel>, Provider<ViewModel>>
) : ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        val found = creators.entries.find { 
          modelClass.isAssignableFrom(it.key) 
        }
        val creator = found?.value 
          ?: throw IllegalArgumentException("unknown model class $modelClass")
        try {
            @Suppress("UNCHECKED_CAST")
            return creator.get() as T
        } catch (e: Exception) {
            throw RuntimeException(e)
        }
    }
}

@Module
abstract class ViewModelFactoryModule {
  @Binds
  @Singleton
  fun bindViewModelFactory(
    viewModelFactory: ViewModelFactory
  ): ViewModelProvider.Factory
}

액티비티에서는 viewModelFactory 를 주입 받아서 뷰모델을 생성할 수 있습니다.

class MainActivity : AppCompatActivity() {

 @Inject
 val viewModelFactory: ViewModelProvider.Factory

 private val viewModel: MainViewModel by viewModels({ viewModelFactory })
 // ...
}

Dagger2 를 사용 했을 때는 뷰모델 클래스가 추가될 때마다 매번 viewModelFactory 를 만들어주어야 하는 불편함은 개선 되었지만, 멀티바인딩 기능에 대한 학습이 필요하고 의존성 주입을 하기 위해 필수적으로 구현해야 하는 모듈이 많았습니다.

Dagger Hilt 에서 뷰모델 생성 방식

Dagger Hilt 도 Dagger2 사용할 때와 동일하게 멀티바인딩을 통해 뷰모델을 주입 받습니다. 우선 뷰모델에 선언한 @HiltViewModel 어노테이션을 읽어서 ViewModel 주입을 위한 모듈을 생성해줍니다. 생성된 메서드는 XXXViewModel_HiltModules.java 파일에 추가되고, DaggerApp_HiltComponents_SingletonC.java 파일 내부를 보면 getHiltViewModelMap() 메서드에 뷰모델이 추가된 것을 확인할 수 있습니다.

// MainViewModel_HiltModules.java
@Module
@InstallIn(ViewModelComponent.class)
public abstract static class BindsModule {
  @Binds
  @IntoMap
  @StringKey("com.android.hiltsample.MainViewModel")
  @HiltViewModelMap
  public abstract ViewModel binds(MainViewModel vm);
}

// DaggerApp_HiltComponents_SingletonC
@Override
public Map<String, Provider<ViewModel>> getHiltViewModelMap() {
  return Collections.<String, Provider<ViewModel>>singletonMap(
    "com.android.hiltsample.MainViewModel", (Provider) mainViewModelProvider()
	);
}

액티비티에서는 @AndroidEntryPoint 어노테이션을 읽어서 Hilt_XXXActivity 를 생성합니다.

Hilt Gradle plugin 이 바이트코드 변환을 실행해서 실제로는 아래와 같은 상속 구조를 만들어 줍니다.

@AndroidEntryPoint
public class MainActivity extends Hilt_HomeActivity { /* ... */ }

abstract class Hilt_HomeActivity extends AppCompatActivity { /* ... */ }

안드로이드 스튜디오에서 [Build] - [Analyze APK…] 를 실행하고 MainActivity 의 바이트코드를 보면 Hilt_MainActivity 를 상속하는 것을 확인할 수 있습니다.

athena

Hilt_XXXActivity 내부에서는 getDefaultViewModelProviderFactory() 메서드를 오버라이드하여 앞서 Dagger2 를 사용할 때 직접 만들었던 ViewModelFactory 와 동일한 역할을 수행하기 위한 HiltViewModelFactory 를 반환하도록 합니다.

public abstract class Hilt_MainActivity extends AppCompatActivity implements GeneratedComponentManagerHolder {

	// ...

  @Override
  public ViewModelProvider.Factory getDefaultViewModelProviderFactory() {
    return DefaultViewModelFactories.getActivityFactory(this);
  }
}

HiltViewModelFactory 를 보면 hiltViewModelMap 에서 뷰모델을 가져와서 생성해주는 것을 알 수 있습니다.

public final class HiltViewModelFactory implements ViewModelProvider.Factory {	

  public HiltViewModelFactory(...) {
    // ...
    this.hiltViewModelFactory =
        new AbstractSavedStateViewModelFactory(owner, defaultArgs) {
          @NonNull
          @Override
          @SuppressWarnings("unchecked")
          protected <T extends ViewModel> T create(
              @NonNull String key, @NonNull Class<T> modelClass, @NonNull SavedStateHandle handle) {
           ViewModelComponent component =
               viewModelComponentBuilder.savedStateHandle(handle).build();
           Provider<? extends ViewModel> provider =
              EntryPoints.get(component, ViewModelFactoriesEntryPoint.class)
                  .getHiltViewModelMap()
                  .get(modelClass.getName());
           // ...
           return (T) provider.get();
         }
      };
  }

액티비티에서는 defaultViewModelProviderFactory 로 HiltViewModelFactory 를 가져올 수 있게 되어서 activity-ktx 라이브러리에 선언된 확장함수를 이용하여 간단하게 AAC-ViewModel 을 생성하고 사용할 수 있게 되었습니다.

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

  private val viewModel : MainViewModel by viewModels()
}

Conclusion

이번에 궁금증을 해결하기 위해 학습하면서 기존 Dagger2 의 불편함을 많이 개선했다는 것을 알 수 있었습니다. AAC-뷰모델을 생성하는 것만으로도 많은 보일러 플레이트 코드와 학습이 필요 했었는데, Dagger Hilt 에서 개발자가 직접 구현했어야 했던 모듈, 컴포넌트를 모두 미리 생성해주어서 간단하게 DI 을 할 수 있게 되었습니다. 높은 러닝커브가 개선되고 이전에 비해 편리해졌기 때문에 정식 버전이 나오면 충분히 도입할 가치가 있다고 생각됩니다. 이상으로 글을 마치겠습니다!

참고 사이트

https://medium.com/@jungil.han/아키텍처-컴포넌트-viewmodel-이해하기-2e4d136d28d2

https://dagger.dev/dev-guide/


김대연 | APP실 MA팀
브랜디, 오직 예쁜 옷만