2020년 8월 1일 토요일

[안드로이드] Dagger & Hilt 기본개념 설명

Dagger 기본

gradle 설정

모듈레벨의 build.gradle에 다음을 추가합니다.
// 코틀린인 경우에 추가
//apply plugin: 'kotlin-kapt'
...
dependencies {
    implementation "com.google.dagger:dagger:2.28.2"
    // 자바
    annotationProcessor "com.google.dagger:dagger-compiler:$dagger2_version"
    // 코틀린
    //kapt "com.google.dagger:dagger-compiler:$dagger2_version"
}

Module

프로그램에서 사용하는 객체를 실제로 생성/제공해주는 역할을 담당합니다. Dagger에서는 특정 객체가 필요할 때, 그 객체를 제공하는 모듈이 있는지 확인하여, 모듈로부터 객체를 제공받아서 의존성주입을 수행합니다. 모듈 클래스의 기본구조는 다음과 같습니다.
class TestData {...}

@Module
class AppModule {
    @Provides
    fun provideTestData(): TestData = TestData()
}
모듈 역할을 수행할 클래스에 @Module 어노테이션을 추가해서 Dagger에게 모듈의 존재를 알려줍니다. 모듈이 어떤 객체를 제공할 수 있는지는 반환타입이 있는 메서드에 @Provides 어노테이션을 붙여줌으로써 지정할 수 있습니다.
Note : 클래스명과 메서드명은 아무렇게나 해도 상관없지만 모듈 클래스명은XXXModule로 하고, 객체를 제공하는 메서드명은 provideXXX() 하도록 권장하고 있습니다.

Component

외부에서 객체를 사용할 때에는 모듈로부터 직접 받는것이 아니라, 컴포넌트라는 인터페이스를 통하여 객체를 제공받아야 합니다. 즉, 컴포넌트는 외부에서 객체를 제공받기 위해 사용하는 인터페이스입니다. 컴포넌트의 기본구조는 다음과 같습니다.
@Component(modules = [AppModule::class])    //여러 모듈을 넣을 수 있음
interface AppComponent {
    fun getTestData(): TestData
}
컴포넌트 역할을 수행할 인터페이스에 @Component어노테이션을 추가해서 Dagger에게 컴포넌트의 존재를 알려줍니다. 어노테이션의 modules 필드에는 이 컴포넌트가 객체를 외부에 제공하기 위해서 필요한 모듈들을 의미합니다. Dagger라이브러리는 앱을 컴파일할 때, 이 어노테이션이 붙은 인터페이스들의 실제구현체 클래스를 자동생성해 줍니다.
Note : 인터페이스명과 메서드명은 아무렇게나 해도 상관없지만, 모듈과 비슷하게 컴포넌트 인터페이스명은 XXXComponent로 권장하고 있습니다. 또한, 컴파일후에 자동생성되는 구현체 클래스의 이름은 인터페이스명 앞에 Dagger가 prefix로 추가됩니다. (ex DaggerAppComponent)

컴포넌트에 요청하여 객체 직접 얻기

Dagger를 사용하는 목적은 객체간 의존성의 자동주입이지만, 필요하다면 객체를 직접 얻어서 사용할 수도 있습니다. 다음의 코드는 컴포넌트를 생성하고 객체를 얻는 방법을 보여줍니다.
//Factory or Builder 패턴
val appComponent: AppComponent = DaggerAppComponent.create()
//val appComponent: AppComponent = DaggerAppComponent.builder().build()
val testData: TestData = appComponent.getTestData()
자동 생성되는 컴포넌트 클래스에는 기본 Factory와 Builder가 자동적으로 포함되어 있어서, 이걸 이용하여 컴포넌트 객체를 생성할 수 있습니다.

Inject

Dagger가 객체들을 자동으로 생성하고 의존성을 관리하려면, 객체들이 의존성을 어떻게 주입받을지를 Dagger에게 명시해주어야 합니다. Dagger는 객체가 의존성을 주입받는 방법으로 다음 2가지를 지원합니다.
  • 생성자를 통한 주입
  • 멤버필드에 직접 주입

생성자를 통한 주입

클래스에서 필요한 의존 객체를 생성자로부터 주입받는 방법입니다. @Inject 어노테이션을 생성자의 앞에 붙여주어 Dagger에게 이 클래스를 생성할 때 객체를 주입해야 한다는 사실을 알려줍니다.
class TestData2 @Inject constructor (
    private val testData: TestData
) {...}
이 클래스의 생성을 모듈에서 하도록 추가해두면 추후에 객체가 생성될 때, 생성자에 넣을 객체를 모듈의 @Providers 메서드들로부터 제공받게 됩니다.
@Module
class AppModule {
    @Provides
    fun provideTestData(): TestData = TestData()
    
    @Provides
    fun provideTestData2(testData: TestData) = TestData2(testData)
}

멤버변수에 직접 주입

클래스에서 필요한 의존 객체를 멤버변수에 직접 지정하는 방법입니다. 멤버변수 앞에 @Inject 어노테이션을 붙여주어 Dagger에게 이 클래스에는 어떤 의존성이 주입되어야 하는지 알려줍니다. 이 방법은 모듈에서 객체를 직접 생성할 수 없는 경우에 사용할 수 있습니다. (ex 안드로이드의 컴포넌트들)
class TestData2 {
    @Inject
    lateinit var testData: TestData
}
외부에서 객체를 생성한 다음에 컴포넌트의 인자로 객체를 넘겨주면, Dagger가 @Inject어노테이션이 붙은 멤버변수들에게 객체를 주입해 줍니다.
// TestData2객체를 반환하는 메서드는 없어도 됨
@Module
class AppModule {
    @Provides
    fun provideTestData(): TestData = TestData()
}

// 외부에서 생성된 TestData2 객체를 받는 메서드만 존재
@Component(modules = [AppModule::class])
interface AppComponent {
    fun inject(testData2: TestData2)
}

// 실제 사용
val testData2: TestData2()
DaggerAppComponent.create().inject(testData2)

Binding Instance

객체를 생성할 때, 모듈에서 제공(@Provides)하는 것만으로는 인자를 주입하기 어려운 경우가 종종 있습니다. 가장 쉬운 예로는 안드로이드에서 객체 생성에 Context 객체가 필요한 경우입니다. Context는 모듈에서 생성할 수가 없기때문에, 이미 생성된 객체를 밖에서 받아야 합니다. 다음 예시와 같은 경우를 가정해 봅시다.
class TestData @Inject constructor (
    private val context: Context
) {...}

@Module
class AppModule {
    @Provides
    fun getTextData(context: Context) = TestData(context)
}
getTextData() 메서드에서 context 객체를 필요로 하지만, 모듈에서는 Context 객체를 제공해 줄 수가 없는 상황입니다. 이런 경우에는 필요한 context 객체를 컴포넌트를 이용하여 외부로부터 받아야 합니다.
@Component(modules = [AppModule::class])
interface AppComponent {
    fun getTestData(): TestData
    
    @Component.Builder
    interface Builder {
        fun setContext(@BindsInstance context: Context): Builder
        fun build(): AppComponent
    }
}

// 실제 사용
class TestApplication: Application() {
    ...
    override fun onCreate() {
        super.onCreate()
        val appComponent = DaggerAppComponent.Builder().setContext(this).build()
        val testData: TestData = appComponent.getTestData()
    }
}
Dagger로부터 자동 생성되는 구현체 클래스는 빌더 클래스도 자동으로 생성해 주지만, 별도의 인터페이스에@Component.Builder 어노테이션을 붙여서 빌더에 인자를 추가할 수도 있습니다. 추가하는 인자에 @BindsInstance 어노테이션을 붙여주어 외부로부터 객체를 받았음을 Dagger에 알려줍니다. Dagger는 모듈에서 이 인자를 필요로하는 경우에 자동으로 바인딩을 수행합니다.
Note : 빌더 인터페이스에는 컴포넌트를 반환하는 build() 메서드를 반드시 추가해 주어야 합니다.

Scope

컴포넌트는 외부로부터 객체를 요청받거나 inject 시점에 모듈에게 객체를 요청하는데, 이 때 모듈은 기본적으로 매번 새로운 객체를 생성하여 제공합니다. 그러나, 때로는 객체를 매번 재생성하는게 좋지 않을 수도 있습니다. Dagger에서는 이전에 생성했던 객체를 반환하도록 할 수 있습니다.

@Singleton

Dagger에서 이미 생성된 객체를 재사용하는 가장 쉬운 방법은 @Singleton 어노테이션을 사용하는 것입니다. 이 어노테이션을 다음과 같이 모듈과 컴포넌트에 붙여줍니다.
// provide 메서드에 @Singleton 어노테이션 붙여줌
@Module
class AppModule {
    @Singleton
    @Provides
    fun provideTestData(): TestData = TestData()
}

// 컴포넌트 클래스에 @Singleton 어노테이션 붙여줌
@Singleton
@Component(modules = [AppModule::class])
interface AppComponent {
    fun getTestData(): TestData
}

// 실제 사용 - testData1, testData2는 동일객체
val appComponent = DaggerAppComponent.create()
val testData1 = appComponent.getTestData()
val testData2 = appComponent.getTestData()
@Singleton어노테이션은 객체의 수명(Scope)을 지정하고 Dagger에 알려주는 역할을 합니다. provide 메서드에 이 어노테이션을 사용하여 @Singleton이라는 수명을 가진 객체를 제공할 것을 명시했으며, 컴포넌트 클래스에 이 어노테이션을 사용하여 @Singleton이라는 수명은 컴포넌트 객체의 수명과 동일하도록 지정했습니다. 즉, 해당 컴포넌트의 객체가 살아있는 동안에는 계속 같은 객체를 사용하게 됩니다.
// 실제 사용 - testData1, testData2는 다른객체
val appComponent1 = DaggerAppComponent.create()
val appComponent2 = DaggerAppComponent.create()
val testData1 = appComponent1.getTestData()
val testData2 = appComponent2.getTestData()
위의 사용 예시에서는 appComponent1과 appComponent2가 각각 생성된 다른 객체입니다. 그렇기 때문에 testData1과 testData2도 다른 수명의 다른 객체입니다.
Note : @Singleton은 javax.inject 패키지 하위에 있는 어노테이션으로 Dagger의 것은 아닙니다.

커스텀 Scope

스코프를 지정하기 위해 직접 어노테이션을 만들어 사용할 수도 있습니다. @Scope어노테이션을 사용하여 우리가 사용할 어노테이션을 먼저 만들어야 합니다.
@Scope
@kotlin.annotation.Retention(AnnotationRetention.RUNTIME)
annotation class CustomScope
만든 어노테이션은 @Singletone을 대체하여 사용할 수 있습니다.
// provide 메서드에 @Singleton 어노테이션 붙여줌
@Module
class AppModule {
    @CustomScope
    @Provides
    fun provideTestData(): TestData = TestData()
}

// 컴포넌트 클래스에 @Singleton 어노테이션 붙여줌
@CustomScope
@Component(modules = [AppModule::class])
interface AppComponent {
    fun getTestData(): TestData
}

// 실제 사용 - testData1, testData2는 동일객체
val appComponent = DaggerAppComponent.create()
val testData1 = appComponent.getTestData()
val testData2 = appComponent.getTestData()
얼핏 보기에 스코프를 직접 만들어 사용하는 것은 불필요해 보일 수 있지만, 여러 컴포넌트가 있거나 서브컴포넌트가 있는 경우에 객체의 수명을 관리하기 위해 필요합니다.
Note : @Scope은 javax.inject 패키지 하위에 있는 어노테이션으로 Dagger의 것은 아닙니다.

SubComponent

Dagger에서는 복잡한 의존성 그래프를 효율적으로 관리하기 위해 서브컴포넌트를 지원합니다. 즉, 컴포넌트간에 상하관계를 지정할 수 있습니다. 서브컴포넌트는 기본적으로 컴포넌트를 구성하는 방법과 동일하되, @SubComponent 어노테이션을 사용하는 점과 @SubComponent.Builder어노테이션으로 반드시 빌더를 구성해주어야 한다는 점만 다릅니다.
class TestData {...}
class TestData2 {
    @Inject
    lateinit var testData: TestData
}

// 서브컴포넌트에서 사용할 새로운 모듈
@Module
class TestModule {
    @Provides
    fun provideTestData(): TestData = TestData()
}

// 서브컴포넌트
@Subcomponent(modules = [TestModule::class])
interface AppSubComponent {
    fun getTestData(): TestData
    fun inject(testData2: TestData2)
    
    // 기존 컴포넌트와는 다르게 명시적으로 선언해 주어야 외부에서 사용가능함
    @Subcomponent.Builder
    interface Builder {
        fun build(): AppSubComponent
    }
}
위와 같이 서브컴포넌트를 구성한 후에, 이를 상위컴포넌트와 연결해주어야 합니다.
// 모듈에서 서브컴포넌트를 지정함
@Module(subcomponents = [AppSubComponent::class])
class AppModule {
    @Provides
    fun provideTestData2(): TestData2 = TestData2()
}

// 서브컴포넌트의 빌더를 얻어오는 메서드 추가함
@Component(modules = [AppModule::class])
interface AppComponent {
    fun getTestData2(): TestData2 = TestData2()
    fun getSubComponent(): AppSubComponent.Builder
}

// 실제 사용
val appComponent = DaggerAppComponent.create()
val testData2 = appComponent.getTestData2()

val subComponent = appComponent.getSubComponent().build()
subComponent.inject(testData2)

Dagger Hilt

Dagger Hilt 는 Dagger를 안드로이드에서 좀 더 쉽게 사용할 수 있도록, Jetpack에 추가된 DI 라이브러리입니다. Hilt는 Dagger2를 기반으로 하여 Dagger의 개념을 기본적으로 차용하고 있기 때문에, 기존에 Dagger를 썼다면 쉽게 마이그레이션 할 수 있습니다.

Gradle 설정

프로젝트 레벨의 build.gradle에 다음을 추가합니다.
buildscript {
    ...
    dependencies {
        ...
        classpath 'com.google.dagger:hilt-android-gradle-plugin:2.28-alpha'
    }
}
모듈 레벨의 build.gradle에 다음을 추가합니다.
// 코틀린인 경우에 추가
//apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'
...
dependencies {
    implementation "com.google.dagger:hilt-android:2.28.1-alpha"
    // 자바
    annotationProcessor "com.google.dagger:hilt-android-compiler:2.28.1-alpha"
    // 코틀린
    //kapt "com.google.dagger:hilt-android-compiler:2.28.1-alpha"
}
Note : Dagger의 의존성이 이미 설정되어 있었다면, Hilt의 의존성으로 대체하고 다시 빌드해줍니다.

Hilt Components

기본적인 Dagger에는 앱 코드에서 컴포넌트를 직접 초기화하고 객체를 얻거나 전달하는 등의 동작이 필요했지만, Hilt는 컴포넌트를 직접 초기화하지 않도록 하고 있습니다. Hilt에는 안드로이드 앱의 라이프사이클을 고려한 컴포넌트들과 스코프들을 미리 생성하고 제공해 주고 있습니다.




위의 그림은 Hilt에서 제공해주고 있는 컴포넌트와 관련 스코프의 관계를 보여주고 있습니다. 화살표의 시작에 있는 컴포넌트는 상위 컴포넌트이고, 화살표의 끝에 있는 컴포넌트는 하위 컴포넌트입니다. Dagger와 마찬가지로 하위 컴포넌트에서는 상위 컴포넌트의 의존성을 모두 사용할 수 있습니다. 아래 표는 각 컴포넌트의 특성들을 나열한 것입니다.
ComponentScopeCreate atDestroyed atinjector forDefault Binding
ApplicationComponent@SingletonApplication#onCreate()Application#onDestroy()ApplicationApplication
ActivityRetainedComponent@ActivityRetainedScopeActivity#onCreate()Activity#onDestroy()ViewModelApplication
ActivityComponent@ActivityScopedActivity#onCreate()Activity#onDestroy()ActivityApplication, Activity
FragmentComponent@FragmentScopedFragment#onAttach()Fragment#onDestroy()FragmentApplication, Activity, Fragment
ViewComponent@ViewScopedView#super()View destroyedViewApplication Activity, View
ViewWithFragmentComponent@ViewScopedView#super()View destroyed@WithFragmentBindings 있는 ViewApplication, Activity, Fragment, View
ServiceComponent@ServiceScopedService#onCreate()Service#onDestroy()ServiceApplication, Service
Injector for 컬럼은 각 컴포넌트가 멤버필드 주입을 수행하는 대상을 나타냅니다. @AndroidEntryPoint 어노테이션을 Injector for에 해당하는 클래스에 붙여주면 컴포넌트들이 해당 클래스에 멤버필드 주입을 수행하게 됩니다. Default Binding 컬럼은 컴포넌트에 기본적으로 바인딩되는 클래스를 의미합니다. Dagger에서 @BindsInstance 어노테이션으로 컴포넌트에 바인드하는 것과 동일합니다.

커스텀 컴포넌트 정의하기

기본 제공되는 컴포넌트 외에도 DefineComponent어노테이션을 이용하여 컴포넌트를 추가로 정의할 수도 있습니다. (Dagger의 @Component와 비슷합니다.)
// 별도 스코프 지정
@Scope
@MustBeDocumented
@Retention(value = AnnotationRetention.RUNTIME)
annotation class TestScope

// Hilt 방식의 Component 추가
@TestScope
@DefineComponent(parent = ApplicationComponent::class)
interface AppComponent {
    // Builder 추가 - 방식은 기존 Dagger와 동일
    @DefineComponent.Builder
    interface Builder {
        fun setTestData(@BindsInstance testData: TestData): AppComponent.Builder
        fun build(): AppComponent
    }
}
기존 Dagger의 컴포넌트와 비슷한 선언방식이지만, 한가지 다른 점은 @DefineComponent의 parent 필드를 지정한 것입니다. parent는 해당 컴포넌트의 상위 컴포넌트를 지정해주는 필드로, 반드시 Hilt에서 기본 제공하는 컴포넌트중 하나여야 합니다.

Hilt Module

기존 Dagger에서 사용하는 모듈을 Hilt에서도 그대로 사용할 수 있습니다. 단, @InstallIn어노테이션을 모듈클래스에 추가로 명시해 주어야 합니다. 이 어노테이션은 모듈이 어떤 컴포넌트에 연결(인스톨)되어야 할 지를 지정해 줍니다. (기존 Dagger에서는 @Component / @Subcomponent에서 수행)
@Module
@InstallIn(ApplicationComponent::class)
class AppModule {
    @Provides
    fun provideTestData(application: Application): TestData = TestData()
}
모듈에서는 @InstallIn로 연결된 컴포넌트에서 바인딩된 안드로이드 클래스의 객체를 인자로 받을 수도 있습니다.

모듈에 여러 컴포넌트를 연결하기

하나의 모듈에는 여러 Hilt 컴포넌트를 연결할 수도 있습니다. @InstallIn에 Array로 값을 주면 됩니다.
@InstallIn({ViewComponent::class, ViewWithFragmentComponent::class})
단, 이 경우에는 세가지 규칙이 있습니다.
  • @Provides는 연결한 컴포넌트들이 모두 동일한 스코프에 속해있을 경우에만 스코프를 지정할 수 있습니다.
  • @Provides는 연결한 컴포넌트가 서로 간 요소에게 접근이 가능한 경우에만 주입이 가능합니다. 예를들어, ViewComponent와 ViewWithFragmentComponent는 서로 간 요소에 접근이 가능하기 때문에 View에 주입이 가능하지만, FragmentComponent와 ServiceComponent는 한쪽에게 주입이 불가능합니다.
  • 상위 컴포넌트와 하위 컴포넌트에 동시에 연결될 수 없습니다. 대신, 하위 컴포넌트는 상위 컴포넌트의 모듈에 접근할 수 있습니다.

Hilt Application

Hilt를 사용하려면 반드시 @HiltAndroidApp어노테이션을 붙인 Application 클래스가 있어야 합니다. 이 어노테이션은 Hilt의 컴포넌트들을 생성하는 역할을 담당합니다. 또한 @AndroidEntryPoint어노테이션을 붙인 것처럼, Application에 멤버필드 주입을 수행합니다.
@HiltAndroidApp
class TestApplication: MultiDexApplication {
    @Inject
    lateinit var testData: TestData
    
    override fun onCreate() {
        // super.onCreate()가 수행될 때 멤버필드 주입이 완료됨
        super.onCreate()
        // testData 사용 가능
        ...
    }
}

Android Entry Point

@AndroidEntryPoint어노테이션을 이용하여 Application을 제외한 안드로이드 프레임워크의 클래스들에 멤버필드 주입을 수행할 수 있습니다. (@HiltAndroidApp어노테이션의 동작이 선행되어야 합니다.) 이 어노테이션을 붙일 수 있는 클래스는 다음과 같습니다.
  • Activity
  • Fragment
  • View
  • Service
  • BroadcastReceiver
Note : ContentProvider의 경우는 Application보다도 먼저 onCreate가 불리기 때문에 이 어노테이션을 이용한 주입을 지원하지 않습니다. ViewModel의 경우는 JetPack의 추가 라이브러리를 사용해야 합니다.
다음은 Activity의 멤버필드 주입을 사용하는 예시입니다. 다른 클래스들도 동일한 방법으로 사용할 수 있습니다.
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    // Bindings in ApplicationComponent or ActivityComponent
    @Inject lateinit var testData: TestData

    override fun onCreate(savedInstanceState: Bundle?) {
        // super.onCreate()가 수행될 때 멤버필드 주입이 완료됨
        super.onCreate()
        // testData 사용 가능
        ...
    }
}
Note : 현재 Hilt는 AndroidX의 Activity / Fragment만 지원하고 있습니다.

Retained Fragment

Fragment는 setRetainInstance(true) 메서드를 이용하여 Activity의 ConfigurationChange 시점에도 인스턴스를 보존하는 API를 지원하고 있습니다. (이 메서드는 JetPack의 Fragment에서 Deprecate 되었습니다.) Hilt에서는 컴포넌트가 멤버필드 주입 등의 이유로 Activity의 레퍼런스를 가지고 있기 때문에, 프레그먼트의 인스턴스가 보존된다면 메모리 릭이 발생하게 됩니다. 그래서 Configuration Change중에 setRetainInstance(true) 중인 Hilt Fragment가 발견되면 런타임 크래시를 발생시킵니다.

뷰에서의 Fragment binding

기본적으로는 ApplicationComponent와 ActivityComponent바인딩만 뷰에 주입이 됩니다. 뷰에서 Fragment 바인딩을 활성화하려면, @WithFragmentBindings어노테이션을 뷰 클래스에 추가해줍니다.
@AndroidEntryPoint
@WithFragmentBindings
class MyView : View {
  // Bindings in ApplicationComponent, ActivityComponent,
  // FragmentComponent, and ViewComponent
  @Inject lateinit var bar: Bar

  constructor(context: Context) : super(context)
  constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)

  init {
    // Do something with bar ...
  }

  override fun onFinishInflate() {
    super.onFinishInflate();

    // Find & assign child views from the inflated hierarchy.
  }
}

Entry Point

Hilt에서는 EntryPoint라는 개념을 이용하여 Hilt 내에서 관리하는 객체들을 얻어오는 방법을 제공합니다. Dagger의 컴포넌트에 정의된 메서드를 통해 외부에서 객체를 얻는 방법과 동일합니다. 이 방법은 생성자 주입, 멤버필드 주입이 어려워서 직접 객체를 가져와야 하는 경우에 사용될 수 있습니다.
// EntryPoint 선언
@EntryPoint
@InstallIn(ActivityComponent::class)
interface TestEntryPoint {
    fun getTestData(): TestData
}

// 실제 사용
class MainActivity: AppCompatActivity {
    override fun onCreate(savedInstanceState: Bundle?) {
        val testEntryPoint = EntryPoints.get(this, TestEntryPoint::class.java)
        val testData = testEntryPoint.getTestData()
    }
}
Dagger의 컴포넌트처럼 외부에서 객체를 얻기 위해서 사용할 인터페이스를 정의하고, @EntryPoint@InstallIn어노테이션을 붙여줍니다. Hilt가 자동적으로 구현체를 생성하여 주는데, 그 구현체는 EntryPoints.get()메서드를 이용하여 얻어올 수 있습니다. get() 메서드의 첫번째 인자로는 @InstallIn어노테이션에서 명시했던 표준컴포넌트의 바인딩 된 안드로이드 클래스를 넘겨주면 되는데, 이 메서드는 타입 unsafe하게 동작하므로 타입 safe하게 EntryPoint의 구현체를 얻어오려면 EntryPointAccessors 클래스의 메서드들을 이용하면 됩니다.

참고자료

2020년 2월 24일 월요일

[안드로이드] 폰트 리소스 적용

폰트 리소스 적용기

발단

현재 담당하고 있는 앱에서 앱의 모든 텍스트뷰에 시스템폰트 대신 커스텀 폰트를 적용해야 하는 스펙이 있었고, 이를 쉽게 적용하기 위해 Typekit 이라는 라이브러리를 사용하고 있었습니다. 그러나 targetApi를 29로 올리면 이 라이브러리에서 크래시가 발생합니다. 내부에서 리플렉션을 이용하여 모든 텍스트뷰에 폰트를 주입하고 있었는데, 이 리플렉션을 이용하는 부분이 문제가 되었습니다. 이 라이브러리는 2016년 7월16일 이후로 커밋이 올라오지 않아서 사실상 관리되지 않는 라이브러리로 개선을 기대할 수 없어서 다른 방법을 찾아야만 했습니다.

8.0 미만 버전에서 폰트를 적용하는 방법

초창기부터 안드로이드는 커스텀폰트를 적용하기 위해 Assets 공간을 활용했습니다. Assets 폴더에 커스텀 폰트 파일(.ttf, .otf 등등)을 넣어두고 Typeface.createFromAsset() 메서드를 이용하여 해당 폰트를 불러와서 텍스트뷰에 적용했습니다.
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        val assetManager = resources.assets
        val customTypeface = Typeface.createFromAsset(assetManager, "font/custom_font.ttf")
        
        val textView = ...
        textView.typeface = customTypeface
    }
}

그럼 Typekit은?

위의 방법으로 앱의 모든 TextView에 폰트를 적용하기란 매우 번거롭고 코드량도 많아집니다. Typekit은 Application.onCreate() 등 앱의 최초 초기화시에 폰트를 로드하고, BaseActivity를 만들어 attachBaseContext()메서드를 상속받아 새로운 Context를 등록해주면 끝입니다.
// 폰트 초기화
class MyApplication : Application() {
    override fun onCreate() {
        ...
        val assetManager = resources.assets
        val customTypeface = Typeface.createFromAsset(assetManager, "font/custom_font.ttf")
        Typekit.getInstace().addNormal(customTypeface)
    }
}
// 폰트 적용
class BaseActivity : AppCompatActivity() {
    override fun attachBaseContext(newBase: Context?) {
        super.attachBaseContext(TypekitContextWrapper(newBase))
    }
}
사용은 간단한데, 내부적으로 살펴보니 TypeContextWrapper 클래스는 getSystemService()를 오버라이드하여 TypekitLayoutInflater라는 커스텀 인플레이터를 반환하도록 되어 있었습니다. 이 인플레이터가 뷰 생성/인플레이팅시에 리플렉션으로 폰트를 주입해주는 핵심 역할을 담당하고 있었습니다.

8.0 이상에서 폰트 적용하기

8.0(API 26 오레오)부터 폰트를 리소스로 취급하도록 변경되었으며, font-family를 xml로 작성하여 쉽게 사용할 수 있도록 되었습니다. res/font/ 폴더 내에 사용할 폰트를 넣어두면 리소스로 인식됩니다. 리소스로 인식할 수 있는 폰트파일은 .ttf, .ttc, .otf, .xml 가 있습니다.

xml로 font-family 작성하기

<font-family xmlns:android="http://schemas.android.com/apk/res/android">
    <font
        android:fontStyle="normal"
        android:fontWeight="400"
        android:font="@font/lobster_regular" />
    <font
        android:fontStyle="italic"
        android:fontWeight="400"
        android:font="@font/lobster_italic" />
</font-family>
  • font-family : 폰트 xml은 반드시 이 루트 엘리먼트로 시작
  • font
    • fontStyle : 폰트의 스타일. italic / normal 상수값 대입 가능
    • fontWeight : 폰트의 두께. 100 ~ 900 사이의 100단위 양수
    • font : res/font 폴더 내에 있는 폰트 리소스

layout.xml에서 폰트를 직접 적용하기

<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    ...
    android:fontFamily="@font/my_font_family" />

Style에서 폰트를 적용하기

<style name="MyTextStyle" parent="@android:style/TextAppearance.Small">
    <item name="android:fontFamily">@font/my_font_family</item>
</style>

폰트 리소스를 코드에서 사용하기

  • Resources 클래스에 API 26부터 새로생긴 getFont() 메서드 이용
val typeface = resources.getFont(R.font.my_font_family)
textview.typeface = typeface
  • 코드에서의 하위호환을 유지하기 위해 AndroidX(구 SupportLibrary v26.x)의 ResourcesCompat.getFont()를 이용
  • API 16이상부터 사용가능 (minApi = 16)
  • font-family xml을 작성하는 경우 android 네임스페이스 대신, app 네임스페이스를 사용해야 함
<font-family xmlns:app="http://schemas.android.com/apk/res-auto">
    <font
        app:fontStyle="normal"
        app:fontWeight="400"
        app:font="@font/lobster_regular" />
    <font
        app:fontStyle="italic"
        app:fontWeight="400"
        app:font="@font/lobster_italic" />
</font-family>
val typeface = ResourcesCompat.getFont(context, R.font.my_font_family)

다운로더블 폰트

APK에 폰트파일을 포함하는 대신 Provider 애플리케이션으로부터 폰트를 요청하거나, 폰트를 다운로드 받을 수 있는 API가 안드로이드 8.0 (API 26) / Support Library 26에 추가되었습니다. 이 기능은 아이스크림 샌드위치(API 14)이상의 기기에서 Support Library 26 이상 버전을 이용하는 경우에 이용할 수 있습니다.

동작 방식

폰트를 관리해주는 Font Provider를 도입했습니다. 이 Provider는 폰트를 다운로드 받거나 캐싱하여 다른 앱에서 요청하는 폰트를 공유하도록 하는 역할을 담당합니다. 즉, 각각의 앱에서는 필요한 폰트를 FontsContract라는 API로 이 Provider로 요청하고, 요청한 결과를 콜백으로 받습니다.

사용방법

1. 리소스를 이용

res/font 리소스에 xml로 다운로더블 폰트의 제공자 정보를 작성하는 방법입니다. xml로 font-family를 추가하고, 태그에서 속성값을 주어야 합니다.
<font-family xmlns:android="http://schemas.android.com/apk/res/android"
    app:fontProviderAuthority="com.google.android.gms.fonts"
    app:fontProviderPackage="com.google.android.gms"
    app:fontProviderQuery="Goudy Bookletter 1911"
    app:fontProviderCerts="@array/com_google_android_gms_fonts_certs">
</font-family>
  • fontProviderAuthority : 폰트를 가져올 FontProvider의 소유자 정보. 매니페스트에서 Provider를 지정할 때 넣어주는 android:authority값을 의미함
  • fontProviderPackage : 해당 프로바이더의 패키지
  • fontProviderQuery : 프로바이더에게 요청할 폰트의 식별자(이름)
  • fontProviderCerts : 해당 폰트를 사용하기 위한 인증키(?)

2. 코드를 이용

FontsContract.requeestFont() 메서드를 이용하여 코드에서 Typeface를 바로 얻을 수 있습니다. FontRequest 객체에 Provider에 대한 정보를 추가하여, requestFont()메서드의 인자로 전달하면, 해당 폰트를 콜백메서드로 반환해 줍니다. AndroidX(Support Library v26)에서 하위호환을 지원하기 위해 FontsContractCompat 클래스를 지원합니다.
val request = FontRequest(
        "com.example.fontprovider.authority",
        "com.example.fontprovider",
        "my font",
        certs
)
val callback = object : FontsContract.FontRequestCallback() {

    override fun onTypefaceRetrieved(typeface: Typeface) {
        // Your code to use the font goes here
        ...
    }

    override fun onTypefaceRequestFailed(reason: Int) {
        // Your code to deal with the failure goes here
        ...
    }
}
FontsContract.requestFonts(context, request, handler, null, callback)
  • FontRequest 생성자 파라미터
    • String : 프로바이더 소유자
    • String : 프로바이더 패키지
    • String : 쿼리할 이름
    • List<List<byte[]> : 인증키
  • FontsContract.requestFont() 파라미터
    • Context : 프로바이더 사용을 위한 컨텍스트
    • Request : 프로바이더 정보를 담은 FontRequest 객체
    • Handler : 폰트조회를 수행할 핸들러
    • CancellationSignal : 폰트조회를 취소할 객체
    • FontRequestCallback : 폰트조회결과를 받을 콜백

3. 추가사항

1) 매니페스트에 사용할 폰트를 미리 선언하기(Optional)
뷰 인플레이션과 리소스 검색은 메인스레드에서 진행되는 동기 작업입니다. 그렇기에 앱 구동후에 사용할 폰트를 프로바이더로부터 쿼리하는 작업도 메인스레드에서 동작합니다. 이는 최초 레이아웃에 걸리는 시간을 증가시켜서 앱의 성능을 조금 저하시킵니다. 이를 피하기 위해 매니페스트에 미리 폰트를 선언해두면, 시스템이 미리 폰트를 쿼리해두어, 바로 사용가능하도록 준비해줍니다. 만약 쿼리한 폰트가 없다면 기본폰트로 지정됩니다.
res/values 폴더에 폰트의 목록을 array로 지정해 둡니다.
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <array name="preloaded_fonts" translatable="false">
        <item>@font/goudy_bookletter_1911</item>
    </array>
</resources>
그 다음, 매니페스트에 로 해당 array를 명시합니다.
<meta-data
    android:name="preloaded_fonts"
    android:resource="@array/preloaded_fonts" />
2) 인증 추가
사용할 FontProvider가 미리 설치되어있지 않거나 Support Library를 사용하여 구현하는 경우라면, 반드시 이 인증키를 명시해 주어야 합니다.
res/values 폴더에 인증키 목록을 string-array로 지정합니다.
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <array name="com_google_android_gms_fonts_certs">
        <item>@array/com_google_android_gms_fonts_certs_dev</item>
        <item>@array/com_google_android_gms_fonts_certs_prod</item>
    </array>
    <string-array name="com_google_android_gms_fonts_certs_dev">
        <item>
            MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs=
        </item>
    </string-array>
    <string-array name="com_google_android_gms_fonts_certs_prod">
        <item>
            MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK
        </item>
    </string-array>
</resources>
이 인증키를 리소스 / 코드에서 사용하면 됩니다.

참고