이 글은 기존 운영했던 WordPress 블로그인 PyxisPub: Development Life (pyxispub.uzuki.live) 에서 가져온 글 입니다. 모든 글을 가져오지는 않으며, 작성 시점과 현재 시점에는 차이가 많이 존재합니다.
작성 시점: 2018-10-25
흔히 코딩의 효율성을 높이는 방법으로 '재사용성' 이 많이 강조된다. 이 이야기는 그렇게까지 새로운 이야기는 아니고, 예전부터 재사용성을 위해 클래스화를 하여 여러 곳에서 사용할 수 있게 하는 코딩 방법은 널리 사용되고 있었다.
하지만, 의존성 주입(Dependency Injection) 이 도입된 이후부터 이러한 클래스는 하나의 '의존성' 으로서 DI 프레임워크 등에 주입되고 다른 곳에서 새로운 인스턴스를 생성할 필요 없이 외부의 한 곳에서 관리할 수 있게 되었다.
이 글에서는 안드로이드에서 Dagger 2 라는 Google의 의존성 주입 라이브러리를 통해 앱의 프로세스 로직 어디서나 사용할 수 있는 TextToSpeech에 대한 Singleton 클래스를 만들고, 다양한 곳에서 사용하려 한다.
참고로 언어는 평소대로 Kotlin을 사용했다. 단 사용하는 부분은 호환성을 위해 Java를 사용했다.
먼저, 제작할 클래스에서 사용될 public methods에 대해 정의한다. 이는 다음에 나올 이야기와도 연결이 되는데, 제작할 클래스가 다른 클래스에 의존할 수 있기 때문이다.
상기했던 'TextToSpeech' 에 대해 구현해야 될 기능과 public method는 다음과 같다.
UtteranceID
를 생성하고 이를 TextToSpeech 클래스에 반환한다. 이 때, SDK 버전이 21 미만일 경우에는 HashMap<String, String>
에 TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID
를 Key로 하여 반환하고, 21 이상일 경우에는 TextToSpeech.speak()
메서드의 4번째 파라미터에 값을 반환한다.TextToSpeech.OnInitListener
인터페이스의 메서드로 TextToSpeech의 엔진 초기화 상태를 알려주는 메서드이다.TextToSpeech.SUCCESS
일 때 초기화가 성공했다는 플래그로 전환하고, TextToSpeech 의 설정을 변경한다. 여기에서는 대상 언어를 한국어로 하고 TTS 시작, 완료, 에러에 대해 알 수 있는 UtteranceProgressListener
를 설정한다.이 두 가지 메서드와 기능으로 비롯해, 제작할 클래스가 필요로 하는 클래스(의존성)는 다음과 같다.
Context
를 반환해야 한다.그리고 제작할 클래스에 필요한 필드는 다음과 같다.
applicaiton, preferenceRepository 의 경우 외부에서 주입될 것이므로 필드 생성자로 할당하고, 나머지 두 개는 내부 상태를 관리할 것이므로 일반적인 필드로 구성한다.
마지막으로, 제작할 클래스의 이름은 'TTSPlayer' 라 짓고, 다음부터 실제 구현에 들어갈 것이다.
먼저, 필요한 필드와 public methods를 전부 정의한다.
class TTSPlayer constructor(val application: MainApplication,
val preferenceRepository: PreferenceRepository) : TextToSpeech.OnInitListener {
private val textToSpeech: TextToSpeech = TextToSpeech(application, this)
private var isInitialize: Boolean = false
override fun onInit(status: Int) {
}
fun speak(msg: String) {
}
companion object {
@JvmField
val TAG = TTSPlayer::class.java.simpleName
}
}
이제 상기 메서드의 기능을 구현하면 되다.
onInit의 기능을 다시 살펴보면 다음과 같다.
TextToSpeech.OnInitListener
인터페이스의 메서드로 TextToSpeech의 엔진 초기화 상태를 알려주는 메서드이다.TextToSpeech.SUCCESS
일 때 초기화가 성공했다는 플래그로 전환하고, TextToSpeech 의 설정을 변경한다. 여기에서는 대상 언어를 한국어로 하고 TTS 시작, 완료, 에러에 대해 알 수 있는 UtteranceProgressListener
를 설정한다.이를 코드로 나타내면 다음과 같을 것이다.
override fun onInit(status: Int) {
if (status == TextToSpeech.SUCCESS) {
isInitialize = true
textToSpeech.language = Locale.KOREA
textToSpeech.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onDone(utteranceId: String?) {
Log.d(TAG, "onDone: done with $utteranceId")
}
override fun onError(utteranceId: String?, errorCode: Int) {
super.onError(utteranceId, errorCode)
Log.d(TAG, "onError: error with $utteranceId - code $errorCode")
}
override fun onError(utteranceId: String?) {
Log.d(TAG, "onError: error with $utteranceId")
}
override fun onStart(utteranceId: String?) {
Log.d(TAG, "onStart: start with $utteranceId")
}
})
}
}
먼저, status 가 TextToSpeech.SUCCESS 값을 나타내면, 초기화 플래그 필드인 isInitialize
를 true 로 만들고, 언어를 한국어로 설정한다.
다음에, UtteranceProgressListener
라는 추상 클래스를 익명함수로서 TextToSpeech에 구현하는데, API 21 기준으로는 onError(utteranceId: String?, errorCode: Int)
가 필요하고 그 미만으로는 onError(utteranceId: String?)
가 필요하다.
그러므로 양 쪽 API 버전 대응을 위해 onError 두 개의 메서드 둘 다 오버라이딩하고, 각각의 메서드에 로그 메세지를 출력하도록 한다.
speak의 기능을 다시 살펴보면 다음과 같다.
UtteranceID
를 생성하고 이를 TextToSpeech 클래스에 반환한다. 이 때, SDK 버전이 21 미만일 경우에는 HashMap<String, String>
에 TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID
를 Key로 하여 반환하고, 21 이상일 경우에는 TextToSpeech.speak()
메서드의 4번째 파라미터에 값을 반환한다.먼저, 첫 번째 기능과 두 번째 기능의 반환 기능은 쉽게 구현이 가능하다.
fun speak(msg: String) {
if (msg.isEmpty()) return
if (!isInitialize) {
Log.d(TAG, "speak: initialize failed")
return
}
if (!preferenceRepository.isCodeAuthed) {
Log.d(TAG, "speak: user doesn't authed")
return;
}
}
그 다음 세 번째 기능인 UtteranceID는 각 메세지에 대해 고유적이어야 하므로 UUID.randomUUID() 를 사용한다. 해당 API가 생성하는 UUID는 버전 4로 RFC4122 에 맞춰 랜덤으로 생성되는 문자열이다.
그리고, 상기한 API 버전에 따른 분기 처리를 진행한다.
fun speak(msg: String) {
if (msg.isEmpty()) return
if (!isInitialize) {
Log.d(TAG, "speak: initialize failed")
return
}
if (!preferenceRepository.isCodeAuthed) {
Log.d(TAG, "speak: user doesn't authed")
return;
}
val utteranceId = UUID.randomUUID().toString()
val map = hashMapOf<String, String>()
map[TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID] = utteranceId
if (Build.VERSION.SDK_INT >= 21) {
textToSpeech.speak(msg, TextToSpeech.QUEUE_FLUSH, null, utteranceId)
} else {
textToSpeech.speak(msg, TextToSpeech.QUEUE_FLUSH, map)
}
}
이렇게 해서, 두 개의 메서드에 대한 구현이 완료되었다. Dagger 2에 주입되기 위해서는 모듈 이라고 하는 클래스에 선언할 필요가 있는데, 이 글에서는 모듈의 정의나 모듈을 선언하는 컴포넌트 클래스에 대해서는 설명하지 않는다.
단순히 만든 클래스를 Dagger에 주입하려면 다음과 같은 코드를 모듈에 정의하면 된다.
@Provides
TTSPlayer provideTTSPlayer(MainApplication application, PreferenceRepository preferenceRepository) {
return new TTSPlayer(application, preferenceRepository);
}
그런데, 이번에 제작한 TTSPlayer의 경우에는 여러 번 인스턴스가 생성되면 안 되기 때문에, 싱글톤으로서의 선언이 필요하다.
이 때 사용하는 어노테이션은 @Singleton
으로 해당 클래스와 모듈에 정의된 메서드에 부착하면 된다.
@Singleton
class TTSPlayer @Inject constructor(val application: MainApplication,
val preferenceRepository: PreferenceRepository) : TextToSpeech.OnInitListener {
@Provides
@Singleton
TTSPlayer provideTTSPlayer(MainApplication application, PreferenceRepository preferenceRepository) {
return new TTSPlayer(application, preferenceRepository);
}
제작한 TTSPlayer를 사용하기 위해서는 필드나 생성자로 통해 TTSPlayer를 주입받고 사용하면 된다. 이 때 사용되는 어노테이션은 @Inject
로 생성자로 통해 주입받을 때에는 생성자 메서드에 부착을, 필드로 통해 주입받을 때에는 필드 하나마다 부착해주면 된다.
@InjectViewModel
public class CodeAuthViewModel extends BaseViewModel {
@Inject PreferenceRepository mPreferenceRepository;
@Inject TTSPlayer mTTSPlayer;
@Inject
public CodeAuthViewModel(@NonNull MainApplication application) {
super(application);
}
...
public void checkCode(String result) {
mPreferenceRepository.setCodeAuthed(true);
mTTSPlayer.speak("인증되었습니다.");
...
}
}
위 예제에서는 CodeAuthViewModel 이라는 뷰모델 클래스에서 사용하므로 필드로 통해 TTSPlayer를 주입받고, checkCode 라는 메서드에서 TTSPlayer.speak(msg)
코드를 사용했다.
위 예제를 보면 그 어디에도 mTTSPlayer
라는 필드에 의존성을 주입하는 부분이 없는데, 이 주입하는 부분은 사용자가 생성한 코드가 아닌 Dagger 2 라이브러리가 생성한 CodeAuthViewModel_MembersInjector
라는 클래스에서 담당한다.
@Generated(
value = "dagger.internal.codegen.ComponentProcessor",
comments = "https://google.github.io/dagger"
)
public final class CodeAuthViewModel_MembersInjector implements MembersInjector<CodeAuthViewModel> {
private final Provider<PreferenceRepository> mPreferenceRepositoryProvider;
private final Provider<TTSPlayer> mTTSPlayerProvider;
public CodeAuthViewModel_MembersInjector(
Provider<PreferenceRepository> mPreferenceRepositoryProvider,
Provider<TTSPlayer> mTTSPlayerProvider) {
this.mPreferenceRepositoryProvider = mPreferenceRepositoryProvider;
this.mTTSPlayerProvider = mTTSPlayerProvider;
}
public static MembersInjector<CodeAuthViewModel> create(
Provider<PreferenceRepository> mPreferenceRepositoryProvider,
Provider<TTSPlayer> mTTSPlayerProvider) {
return new CodeAuthViewModel_MembersInjector(mPreferenceRepositoryProvider, mTTSPlayerProvider);
}
@Override
public void injectMembers(CodeAuthViewModel instance) {
injectMPreferenceRepository(instance, mPreferenceRepositoryProvider.get());
injectMTTSPlayer(instance, mTTSPlayerProvider.get());
}
public static void injectMPreferenceRepository(
CodeAuthViewModel instance, PreferenceRepository mPreferenceRepository) {
instance.mPreferenceRepository = mPreferenceRepository;
}
public static void injectMTTSPlayer(CodeAuthViewModel instance, TTSPlayer mTTSPlayer) {
instance.mTTSPlayer = mTTSPlayer;
}
}
MemberInjector 클래스는 필드로 통해 주입받는 클래스 (필드 인젝션)이 사용된 클래스에 생성되는 파일이다. 이 MemberInjector 클래스는 해당 예제에서 사용한 두 개의 필드 인젝션인 PreferenceRepository 와 TTSPlayer 에 대한 Provider 클래스와 생성자, 그리고 CodeAuthViewModel 의 필드에 할당하는 코드를 가지고 있다.
MemberInjector 클래스는 마찬가지로 Dagger에 의해 생성된 Factory 클래스에서 관리되고, Factory 클래스는 DaggerAppComponent 의 클래스에서 Map<Class, Provider> 의 한 항목에 추가된다.
최종적으로 ViewModel를 얻어올 때 ViewModelProvider.of 에서 Map<Class, Provider> 에 접근하고, 해당 항목에 있는 CodeAuthViewModel_Factory 항목을 가져오는데 이 때 필요한 PreferenceRepository, TTSPlayer 가 주입되어 사용할 수 있는 방식이다.
이 글에서는 Dagger로 관리하기 위한 의존성 작성에 있어서 설계부터 작성, Dagger에 주입 및 사용하는 것 까지 살펴보았다.
언뜻보면 평소 기능을 구현하는 것과 같지만 공통적으로 사용한다는 것을 의식하면 프로젝트에 종속되지 않는 기능을 만들 수 있어 다양한 곳에서 활용할 수 있다. 특히 이러한 모듈을 하나씩 구현하다보면 프로젝트에 종속되지 않는 코어 모듈을 만들 수 있고, 하위 프로젝트들이 이를 사용함으로서 좀 더 빠른 개발을 진행할 수 있다.
실제로, 위치 계측이나 파일 다운로드, 저장소와 통신하기 위한 Repository 등 많은 클래스가 Dagger에 의해 관리되어 다른 곳에서도 계속 사용할 수 있게 구성이 되어있고 Upload Android Library into Gradle with Artifactory 글에서 도입된 Artifactory로 버전 관리도 되고 있다.
이번에 제작한 클래스 자체의 기능은 그렇게 크지 않지만, 규모를 늘려가며 구현을 지속적으로 하다보면 좀 더 효율성이 있는 개발을 할 수 있지 않을까 생각해본다.