안녕하세요 남갯입니다.
오늘은 hilt code lab 에 대해 공부한것을 정리해보도록 하겠습니다.
힐트는 기존 Dagger(단검)가 사용하기 복잡하여 많은 사용자들이 사용을 못하고 있는부분을
Hilt(단검 손잡이) 더 쉽게 사용하도록 만들어주는 라이브러리입니다.
대부분 ServiceLocater 패턴을 통해 런타임에 생성해서 주입하거나
Koin을 이용하거나, 혹은 Dagger를 이용하겠지만
Hilt를 이용하면 Dagger를 더 쉽게 이용 가능합니다.
developer.android.com/codelabs/android-hilt?hl=ko#1
$ git clone https://github.com/googlecodelabs/android-hilt
을 통해 프로젝트 생성
@HlitAndroidApp 어노테이션을 을 안드로이드 어플리케이션쪽에 입력해두면
힐트코드가 포함된 곳에 자동으로 베이스코드가 포함된 코드를 생성한다.
@HiltAndroidAppclass LogApplication : Application() { ...}
이런 형태로 사용한 뒤
@AntoidEntryPoint 어노테이션을 사용해서 (AppCompatActivity/FragmentActivity) or (Fragment[Anroidx]) 에 주입합니다.
그 후 외부에서 주입했던 변수에 @Inject 키워드를 통해 주입
@Inject lateinit var logger: LoggerLocalDataSource
@Inject lateinit var dateFormatter: DateFormatter
주입을 하는 방법은 다양하게 있겠지만 필드 주입을 하기위해서는 @Inject 키워드를 필드옆에 작성하면 됩니다.
위의 테스트코드에서 보면
ServiceLocater 패턴에서 DateFormatter 생성하는부분을 보면 매번 이용할때마다 새로운 객체를 생성하고 있습니다.
ex)
fun provideDateFormatter() = DateFormatter()
생성자 자체를 주입하도록 변경하여 한개의 객체만 사용가능하도록 변경합니다. DateFormatter 부분에 @Inject 어노테이션 사용
class DateFormatter @Inject constructor() { ... }
또한 DataSource부분도 단일 객체를 사용하기 위해 @Inject를 사용합니다
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {..}
힐트를 통해서 @Inject를 통해 (Bindings)라고 부르는 객체를 제공한다.
여기서 우리는 주목해야하는건
val loggerLocalDataSource = LoggerLocalDataSource(logsDatabase.logDao())
위의 코드를 통해서 DataSource 라는 단일 객체를 제공했었는데, 위와같이 변경함으로써
ServiceLocator 에서는 항상 같은 단일 객체를 생성한다.
이것은 "Scoping an instance to a container" 라고 부른다.
이것이 어떻게 가능할까?
힐트는 다른 라이프 사이클을 가진 다른 컨테이너들을 생성 가능하다.
@Singleton 어노테이션을 사용하면 Application container 즉 어플리케이션 라이프사이클을 따르는
필드 주입과 다른 종속성과 관계없이 하나의 객체를 생성한다.
여기서 중요한건 컨테이너에서 더 상위계층의 Application container에서 생성한 객체는 하위계층인 Activity container에서 사용가능하다.
** Application Container 가 아닌 Activity Conatiner를 따르길 원한다면 @ActivityScoped 어노테이션을 사용해서
액티비티의 라이프 사이클내에 같은 객체를 사용할 수 있다.
Modules 는 bindings들을 Hilt에 추가하는데 사용한다. 힐드가 어떻게 다른 타입을 가진 인스턴스를 제공하는지 말해보면 힐트 모듈에서 인터페이스나 클래스에 생성자 Inejct를 할수 없다.
힐트 모듈은 클래스 @Module @InstallIn 를 가진다.
@Module은 힐트한테 힐트모듈이다라고 알려주는것이고
@InstallIn은 힐트한테 힐트 컴포넌트를 지정해서 container bindings를 사용할 수 있다고 알려주는것 이다.
InstallIn 주석에 참조할 수 있는 구성요소 : developer.android.com/training/dependency-injection/hilt-android?hl=ko#generated-components
각 안드로이드에서 제공하는 클래스들은 힐트에 의해 주입이 가능합니다. 예를들어 application의 경우
applicationComponent와 연관되어 있고, fragment container는 FragmentComponent와 연관이 있습니다.
** 모듈 생성
일단 DatabaseModule을 생성해보자.
이전에 사용하던 LoggerLocalDataSource는 application container의 스코프를 가지고 있기때문에
@InstallIn(ApplicationComponent::class)
@Module
object DatabaseModule {}
위와같이 LogDao를 사용하기 위해서는 Application Container가 필요하다.
@Provides
fun provideLogDao(database: AppDatabase) : LogDao{
return database.logDao()
}
위의 코드는 LogDao인스턴스를 제공하기 위해 database.logDao()가 실행되야한다고 힐트한테 말하고 있는것이다.
전이의존성로서 AppDatabase것을 갖고있고 힐트한테 어떻게 생성하는지 말하는것이다.
AppDatabase도 하나 생성해야한다.
DB는 동일한 객체를 제공하기 위해 @Singleton 어노테이션을 사용하면 된다.
또한 applicationContext를 접근하기 위해 @ApplicationContext 어노테이션 필드랑 함께 제공하면 된다.
@Provides
@Singleton
fun provideDatabase(@ApplicationContext appContext: Context): AppDatabase {
return Room.databaseBuilder(
appContext,
AppDatabase::class.java,
"logging.db"
).build()
}
이제 앱을 돌리려고 봤더니
LogFragment의 정보는 다 갖고있지만 실제 LogFragment가 호스트될 액티비티의 정보는 알지 못하고 있으므로
액티비티에도 @AnroidEntryPoint 어노테이션이 필요하다.
이제 메인액티비티쪽을 보면 navigator를 이용하고 있는데, 서비스로케이터를 통해 제공하고 있다.
하지만 AppNavigator는 인터페이스이기 때문에 우리는 생성자 주입을 사용할 수 없어서
@Binds 어노테이션을 통해 Hilt Module 내에 함수를 사용할 수 있다.
@Binds는 추상 함수같은걸로 명시되어야한다. 그리고 리턴타입은 우리가 제공하고 싶은 AppNavigator를
쓰되, 특정 인터페이스를 구체화한 타입을 반환하고 싶으면 파라미터로 추가하면된다.(AppNavigatorImpl)
** Module을 분리해야하는 이유
기존에 DatabaseModule에 말고 다른 이름의 모듈을 생성하자
1. 여기다가 넣을거였으면 이름을 DatabaseModule이라고 안짓는게 좋다
2. DatabaseModule은 ApplicationComponent에 설치되어 있고 바인딩들은 어플리케이션 컨테이너 내부에서 사용
가능하다. AppNavigator는 특정 앱 내부에서 사용하기 때문에 Activity Container 내부에 설치되야한다.
3. 힐트모듈들은 non Static과 abstract binding 메소드 같이 포함할수 없다. 그래서 @Binds와 @Provides는 같은
클래스 내부에 위치할 수 없다.
@InstallIn(ActivityComponent::class)
@Module
abstract class NavigationModule {
@Binds
abstract fun bindNavigator(impl: AppNavigatorImpl): AppNavigator
}
@InstallIn(ActivityComponent::class)
@Module
abstract class NavigationModule {
@Binds
abstract fun bindNavigator(impl: AppNavigatorImpl): AppNavigator
}
위와같은 코드를 작성한 뒤 다시 파라미터로 제공하는 AppNavigatorImpl는 어떻게 제공할지 힐트한테 알려줘야한다.
AppNavigatorImpl 는 FragmentActivity에 의존한다. AppNavigator 인스턴스는 Activity container에서 제공된다.
그리고 FragmentActivity는 이미 이용가능하다.
@Inject lateinit var navigator: AppNavigator
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if (savedInstanceState == null) {
navigator.navigateTo(Screens.BUTTONS)
}
}
ButtonFragment 도 hilt로 변경 가능하다.
@AndroidEntryPoint
class ButtonsFragment : Fragment() {
@Inject lateinit var logger: LoggerLocalDataSource
@Inject lateinit var navigator: AppNavigator
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_buttons, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
view.findViewById<Button>(R.id.button1).setOnClickListener {
logger.addLog("Interaction with 'Button 1'")
}
view.findViewById<Button>(R.id.button2).setOnClickListener {
logger.addLog("Interaction with 'Button 2'")
}
view.findViewById<Button>(R.id.button3).setOnClickListener {
logger.addLog("Interaction with 'Button 3'")
}
view.findViewById<Button>(R.id.all_logs).setOnClickListener {
navigator.navigateTo(Screens.LOGS)
}
view.findViewById<Button>(R.id.delete_logs).setOnClickListener {
logger.removeLogs()
}
}
}
그리고 서비스 로케이터도 삭제 가능하다.
**추가적인 Hilt에 대한 기능을 알아보자
일단 LoggerDataSource를 만든다.
기존에 사용하던 LoggerLocalDataSource를 사용하는 인터페이스를 만들어보자
interface LoggerDataSource {
fun addLog(msg : String)
fun getAllLogs(callback : (List<Log>) -> Unit)
fun removeLogs()
}
그리고 기존에 사용하던 LoggerLocalDataSource inject 코드를 위의 인터페이스로 변경한다.
@Inject lateinit var logger: LoggerDataSource
@Singleton
class LoggerLocalDataSource @Inject constructor(
private val logDao: LogDao
) : LoggerDataSource {
...
override fun addLog(msg: String) { ... }
override fun getAllLogs(callback: (List<Log>) -> Unit) { ... }
override fun removeLogs() { ... }
}
이렇게 변경한 뒤
메모리에서 갖고있을 수 있는 DataSource를 만들어보자.
class LoggerInMemoryDataSource : LoggerDataSource{
private val logs = LinkedList<Log>()
override fun addLog(msg: String) {
logs.addFirst(Log(msg,System.currentTimeMillis()))
}
override fun getAllLogs(callback: (List<Log>) -> Unit) {
callback(logs)
}
override fun removeLogs() {
logs.clear()
}
}
기존에 있는 InMemoryDataSource를 ActivityContainer로 변경하기 위해서 @Inject와 함께 생성자를 넣어주자
샘플코드에서는 하나의 액티비티만 사용하지만 여러 프래그먼트에서
이렇게 생성하게 되면 각 프래그먼트에서 매번 다른 객체를 생성한다. 따라서 액티비티 컨테이너를
따르기 위해서 @ActivityScoped 어노테이션을 추가해준다.
이렇게 추가해주면 두개의 DataSource를 힐트에서 제공받을 수 있다
하지만 인터페이스인 LoggerDataSource는 알지 못한다. 이전시간에 배웠던
@Binds annotation을 통해 받으면 된다. 하지만 둘다 같은 프로젝트에서 구체화시켜 받을방법이 있을까?
@InstallIn(ApplicationComponent::class)
@Module
abstract class LoggingDatabaseModule {
@Singleton
@Binds
abstract fun bindDatabaseLogger(impl: LoggerLocalDataSource) : LoggerDataSource
}
@InstallIn(ActivityComponent::class)
@Module
abstract class LoggingInMemoryModule {
@ActivityScoped
@Binds
abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource) : LoggerDataSource
}
실제 위와같이 두개의 코드를 만들면 힐트의 사용하는 위치에서는 어떤 타입인지를 알지 못한다.
따라서
@Qualifier 어노테이션을 이용한다.
@Qualifier
annotation class InMemoryLogger
@Qualifier
annotation class DatabaseLogger
실제 두개를 가리키는 Qulifier를 넣어주고
@Module
abstract class LoggingDatabaseModule {
@DatabaseLogger
@Singleton
@Binds
abstract fun bindDatabaseLogger(impl: LoggerLocalDataSource) : LoggerDataSource
}
@InstallIn(ActivityComponent::class)
@Module
abstract class LoggingInMemoryModule {
@InMemoryLogger
@ActivityScoped
@Binds
abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource) : LoggerDataSource
}
이렇게 어노테이션을 추가해준 코드를 작성한다.
두개 Logger는 위치하는 컨테이너가 다르기때문에 LogApplication에서 activity container에 주입된
InMemoryLogger는 사용할 수없다.
@InMemoryLogger
@Inject lateinit var logger: LoggerDataSource
...
실제 사용할 Logger에 어노테이션을 붙여주면 된다.
*** 힐트를 통한 UI Testing
// Hilt testing dependency
androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
// Make Hilt generate code in the androidTest folder
kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"
일단 androidTest folder에
class CustomTestRunner : AndroidJUnitRunner(){
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?
): Application {
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
}
의 클래스를 만들고,
// Hilt testing dependency
androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
// Make Hilt generate code in the androidTest folder
kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"
디펜던시 추가와
testInstrumentationRunner "com.example.android.hilt.CustomTestRunner"
config 설정에 추가한다.
기존에 테스트 코드에 있던 HiltAndroidTest를 수정한다.
@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class AppTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
...
}
'IT > 안드로이드 관련' 카테고리의 다른 글
[안드로이드] - Compose 문서 읽어보기 - 2 (0) | 2022.01.09 |
---|---|
[안드로이드] - Compose 문서 읽어보기 -1 (0) | 2022.01.08 |
[안드로이드] Compose 발표자료 (0) | 2020.09.20 |
[안드로이드] Clean Architecture (5) | 2020.06.25 |
[dagger] 대거 - 2 정리용 (0) | 2020.05.19 |