Reuse code by Modularizing with Dagger 2 in Android

WindSekirun (wind.seo)·2022년 4월 26일
0

이 글은 기존 운영했던 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는 다음과 같다.

  • fun speak(msg: String)
    • msg가 비어있거나, TextToSpeech가 제대로 Initialize 되지 않았을 경우에는 실행하지 않고 바로 반환한다.
    • 사용자 설정에서 '음성 안내' 가 켜져있을 때만 작업을 진행한다.
    • TextToSpeech로 통해 재생할 메세지에 대한 고유 키인 UtteranceID 를 생성하고 이를 TextToSpeech 클래스에 반환한다. 이 때, SDK 버전이 21 미만일 경우에는 HashMap<String, String>TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID 를 Key로 하여 반환하고, 21 이상일 경우에는 TextToSpeech.speak() 메서드의 4번째 파라미터에 값을 반환한다.
  • override fun onInit(status: Int)
    • TextToSpeech 클래스의 생성자 파라미터 중 두 번째 파라미터인 TextToSpeech.OnInitListener 인터페이스의 메서드로 TextToSpeech의 엔진 초기화 상태를 알려주는 메서드이다.
    • status 가 TextToSpeech.SUCCESS 일 때 초기화가 성공했다는 플래그로 전환하고, TextToSpeech 의 설정을 변경한다. 여기에서는 대상 언어를 한국어로 하고 TTS 시작, 완료, 에러에 대해 알 수 있는 UtteranceProgressListener 를 설정한다.

이 두 가지 메서드와 기능으로 비롯해, 제작할 클래스가 필요로 하는 클래스(의존성)는 다음과 같다.

  • Application: TextToSpeech 생성자 파라미터 중 첫번째 파라미터에 Context를 반환해야 한다.
  • PreferenceRepository: 사용자 설정을 불러오는 클래스

그리고 제작할 클래스에 필요한 필드는 다음과 같다.

  • application: Application
  • preferenceRepository: PreferenceRepository
  • textToSpeech: TextToSpeech
  • isInitialize: Boolean

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(status: Int)

onInit의 기능을 다시 살펴보면 다음과 같다.

  • TextToSpeech 클래스의 생성자 파라미터 중 두 번째 파라미터인 TextToSpeech.OnInitListener 인터페이스의 메서드로 TextToSpeech의 엔진 초기화 상태를 알려주는 메서드이다.
  • status 가 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(msg: String)

speak의 기능을 다시 살펴보면 다음과 같다.

  • msg가 비어있거나, TextToSpeech가 제대로 Initialize 되지 않았을 경우에는 실행하지 않고 바로 반환한다.
  • 사용자 설정에서 '음성 안내' 가 켜져있을 때만 작업을 진행한다.
  • TextToSpeech로 통해 재생할 메세지에 대한 고유 키인 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 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로 버전 관리도 되고 있다.

이번에 제작한 클래스 자체의 기능은 그렇게 크지 않지만, 규모를 늘려가며 구현을 지속적으로 하다보면 좀 더 효율성이 있는 개발을 할 수 있지 않을까 생각해본다.

profile
Android Developer @kakaobank

0개의 댓글