Group Study (2023-2024)/Android 심화

[Android 심화] 5주차 스터디 - 의존성 주입(DI), Hilt, Room

푸리우 2023. 12. 20. 17:36

의존성 주입(DI, Dependency Injection)이란?

외부에서 두 객체 간의 관계를 결정해 주는 디자인 패턴

의존성이란 한 객체가 다른 객체를 사용할 때 의존성이 있다고 한다. 예를 들어 다음과 같이 Car 객체가 Engine 객체를 사용하고 있는 경우에 Car 객체가 Engine 객체에 의존성이 있다고 표현한다.

public class Car {
    private val engine = Engine()
}

 

의존성 주입이 필요한 이유

의존성 주입을 하지 않은 경우

아래와 같이 Car 클래스 내부에서 Engine을 직접 생성하는 경우 의존성 주입이 이루어지지 않았다고 볼 수 있다.

class Car {
    private val engine = Engine()

    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val car = Car()
    car.start()
}

위 예시 코드는 다음과 같은 문제점을 갖는다.

  • Car와 Engine은 강하게 결합되어 있다. Car 인스턴스는 한 가지 유형의 Engine을 사용하므로 서브클래스 또는 대체 구현을 쉽게 사용할 수 없다. Car가 자체 Engine을 구성했다면 Gas  Electric 유형의 엔진에 동일한 Car를 재사용하는 대신 두 가지 유형의 Car를 생성해야 한다.
  • Engine의 종속성이 높은 경우 테스트하기가 더욱 어려워진다.

의존성 주입을 했을 경우

다음과 같이 Car의 각 인스턴스는 초기화 시 자체 Engine 객체를 구성하는 대신 Engine 객체를 생성자의 매개변수로 받는다.

class Car(private val engine: Engine) {
    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val engine = Engine()
    val car = Car(engine)
    car.start()
}

위와 같이 의존성 주입을 했을 경우 이점은

  • Car의 재사용 가능 - Engine의 다양한 구현을 Car에 전달할 수 있다. 예를 들어 Car에서 사용할 ElectricEngine이라는 새로운 Engine 서브클래스를 정의할 수 있다. DI를 사용한다면 업데이트된 ElectricEngine 서브클래스의 인스턴스를 전달하기만 하면 되며 Car는 추가 변경 없이도 계속 작동한다.
  • Car의 테스트 편의성

 

Android에서 의존성 주입하는 두 가지 주요 방법은

  • 생성자 삽입 : 클래스의 dependency를 생성자에 전달한다. 위에서 설명한 방법과 같다.
  • 필드 삽입(또는 setter 삽입) : Activity 및 Fragment와 같은 특정 Android 프레임워크 클래스는 시스템에서 인스턴스화하므로 생성자 삽입이 불가능하다. 필드 삽입을 사용하면 depencendy는 클래스가 생성된 후 인스턴스화된다. 예시 코드는 아래와 같다.
class Car {
    lateinit var engine: Engine

    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val car = Car()
    car.engine = Engine()
    car.start()
}

 

Hilt란?

  • DI를 하기 위한 Jetpack의 권장 라이브러리
  • Hilt는 프로젝트의 모든 Android 클래스에 컨테이너를 제공하고 수명 주기를 자동으로 관리함으로써 애플리케이션에서 DI를 실행하는 표준 방법을 정의한다.
  • DI 라이브러리인 Dagger를 기반으로 만들어졌다.

Hilt 이용하기

build.gradle에 아래와 같은 코드를 추가한다.

plugins {
  ...
  id 'com.google.dagger.hilt.android' version '2.44' apply false
}

app/build.gradle에 아래와 같은 코드를 추가한다.

plugins {
  id 'kotlin-kapt'
  id 'com.google.dagger.hilt.android'
}

dependencies {
  implementation "com.google.dagger:hilt-android:2.44"
  kapt "com.google.dagger:hilt-compiler:2.44"
}

// Allow references to generated code
kapt {
  correctErrorTypes true
}

@HiltAndroidApp

Hilt를 사용하는 모든 앱은 @HiltAndroidApp으로 주석이 지정된 Application 클래스를 포함해야 한다.

@HiltAndroidApp은 Hilt의 코드 생성을 유발한다. 생성된 이 Hilt 컴포넌트는 Application 객체의 수명 주기에 연결되며 이와 관련한 dependencies를 제공한다.

@HiltAndroidApp
class NoteApplication : Application() {
}

@AndroidEntryPoint

이제 의존성 주입을 받아 보자. Hilt는 @AndroidEntryPoint 주석이 있는 클래스에 DI를 제공할 수 있다.

Hilt는 Application, ViewModel, Activity, Fragment, View, Service, BroadcastReceiver에 의존성 주입을 도와준다.

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
}

@AndroidEntryPoint는 프로젝트의 각 클래스에 관한 개별 Hilt 컴포넌트를 생성한다. 이러한 컴포넌트는 각 상위 클래스에서 의존성 주입을 받을 수 있다. 컴포넌트에서 의존성 주입을 가져오려면 @Inject 주석을 사용하여 필드 삽입을 실행한다.

@AndroidEntryPoint
class ExampleActivity : AppCompatActivity() {

  @Inject lateinit var analytics: AnalyticsAdapter
  ...
}

의존성 주입 방법

1. constructor inject 할 수 있는 클래스

  • 내가 구현한 클래스

2. constructor inject 할 수 없는 클래스

  • 인터페이스 or abstract class 구현체(@Module 사용)
  • 내가 구현할 수 없는 클래스(3rd party library)(@Module 사용)

constructor inject 할 수 있는 경우

해당 구현 클래스에 @Inject constructor(…)를 넣어준다.

class AnalyticsAdapter @Inject constructor(
  private val service: AnalyticsService
) { ... }

constructor inject 할 수 없는 경우

Hilt 모듈이 필요하다. Hilt 모듈은 @Module로 주석이 지정된 클래스이다.

@InstallIn: 모듈이 사용되거나 설치될 Android 클래스를 Hilt에 알려준다.

@InstallIn(SingletonComponent::class)
@Module
object AppModule {
    @Singleton
    @Provides
    fun provideNotesDao(noteDatabase:NoteDatabase):NoteDatabaseDao
    =noteDatabase.noteDao()

    @Singleton
    @Provides
    fun provideAppDatabase(@ApplicationContext context: Context):NoteDatabase
    = Room.databaseBuilder(
        context,
        NoteDatabase::class.java,
        "notes_db")
        .fallbackToDestructiveMigration()
        .build()
}

의존성을 제공하는 클래스에 @Module을 붙이고, 의존성을 제공하는 메서드에 @Binds, @Provides을 붙인다.

@Binds - abstract인 경우

interface AnalyticsService {
  fun analyticsMethods()
}

class AnalyticsServiceImpl @Inject constructor(
  ...
) : AnalyticsService { ... }

@Module
@InstallIn(ActivityComponent::class)
abstract class AnalyticsModule {

  @Binds
  abstract fun bindAnalyticsService(
    analyticsServiceImpl: AnalyticsServiceImpl
  ): AnalyticsService
}

@Provides - 클래스가 외부 라이브러리에서 제공되어 클래스를 소유하지 않은 경우(Retrofit, OkHttpClient 또는 Room 데이터베이스와 같은 클래스) 또는 builder 패턴으로 인스턴스를 생성해야 하는 경우

@Module
@InstallIn(ActivityComponent::class)
object AnalyticsModule {

  @Provides
  fun provideAnalyticsService(
    // Potential dependencies of this type
  ): AnalyticsService {
      return Retrofit.Builder()
               .baseUrl("https://example.com")
               .build()
               .create(AnalyticsService::class.java)
  }
}

 

Coroutine과 suspend function

코루틴은 비동기 작업을 처리하는 데 유용하다. 네트워크 호출, 파일 입출력, 데이터베이스 쿼리와 같은 I/O 작업은 주로 비동기적으로 처리되어야 하는데, 코루틴을 사용하면 비동기 코드를 보다 간결하게 작성할 수 있다.

Coroutine이란?

비동기적으로 실행되는 코드를 간소화하기 위해 Android에서 사용할 수 있는 동시 실행 설계 패턴

이때 동기란, 하나를 하면서 다른 작업을 같이 할 수 없는 것이고 비동기란 여러 작업을 동시에 하는 것이다.

suspend 키워드

Kotlin에서 코루틴을 사용하는 함수를 선언할 때 사용되는 특별한 키워드, 코루틴에서 일시 중단되거나 재개되는 함수임을 나타낸다.

 

Room

Room은 SQLite에 추상화 계층을 제공하여 SQLite를 완벽히 활용하면서 더 견고한 데이터베이스 액세스를 가능하게 한다.

Room에는 다음 3가지 주요 구성요소가 있다.

  • Database class: 데이터베이스를 보유하고 앱의 영구 데이터와의 기본 연결을 위한 기본 액세스 포인트 역할을 한다.
  • Data entities: 앱 데이터베이스의 테이블을 나타낸다.
  • Data access objects(DAO): 앱이 데이터베이스의 데이터를 쿼리, 업데이트, 삽입, 삭제하는 데 사용할 수 있는 메서드를 제공한다.

 

Room을 사용하여 로컬 데이터베이스 사용하기

app/build.gradle에 아래와 같은 코드를 추가한다.

dependencies {
    def room_version = "2.5.0"

    implementation "androidx.room:room-runtime:$room_version"
    annotationProcessor "androidx.room:room-compiler:$room_version"
}

Data entity 생성

@Entity(tableName = "notes_tbl")
data class Note (
    @PrimaryKey
    val id: UUID = UUID.randomUUID(),

    @ColumnInfo(name="note_title")
    val title:String,

    @ColumnInfo(name="note_description")
    val description: String,

    @ColumnInfo(name="note_entry_date")
    val entryDate:Date=Date.from(Instant.now())
)

DAO 생성

@Dao
interface NoteDatabaseDao {
    @Query("SELECT * from notes_tbl")
    fun getNotes(): Flow<List<Note>>

    @Query("SELECT * from notes_tbl where id = :id")
    suspend fun getNoteById(id: String): Note

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(note: Note)

    @Update(onConflict = OnConflictStrategy.REPLACE)
    suspend fun update(note: Note)

    @Query("DELETE from notes_tbl")
    suspend fun deleteAll()

    @Delete
    suspend fun deleteNote(note: Note)
}

Database class 생성

@Database(entities=[Note::class], version = 1, exportSchema = false)
abstract class NoteDatabase:RoomDatabase() {
    abstract fun noteDao(): NoteDatabaseDao
}

Room의 구성요소를 모두 작성하였으니 이제 MVVM 모델에 적용시켜 보자.

Android의 MVVM 모델은 아래와 같다.

Android 앱 애플리케이션 그래프 모델

Repository 생성

class NoteRepository @Inject constructor(private val noteDatabaseDao:NoteDatabaseDao) {
    suspend fun addNote(note:Note)=noteDatabaseDao.insert(note)
    suspend fun updateNote(note:Note) = noteDatabaseDao.update(note)
    suspend fun deleteNote(note:Note)=noteDatabaseDao.deleteNote(note)
    suspend fun deleteAllNotes()=noteDatabaseDao.deleteAll()
    fun getAllNotes():Flow<List<Note>> = noteDatabaseDao.getNotes().flowOn(Dispatchers.IO)
    .conflate()
}

ViewModel 생성

@HiltViewModel
class NoteViewModel @Inject constructor(private val repository:NoteRepository):ViewModel()  {
    private val _noteList=MutableStateFlow<List<Note>>(emptyList())
    val noteList=_noteList.asStateFlow()

    init{
        viewModelScope.launch(Dispatchers.IO) {
            repository.getAllNotes().distinctUntilChanged()
                .collect{listOfNotes->
                    if(listOfNotes.isNullOrEmpty()){
                        Log.d("Empty",":Empty list")
                    }else{
                        _noteList.value= listOfNotes
                    }
                }
        }
    }
    fun addNote(note:Note) = viewModelScope.launch { repository.addNote(note) }
    fun updateNote(note:Note)=viewModelScope.launch { repository.updateNote(note) }
    fun removeNote(note:Note)=viewModelScope.launch { repository.deleteNote(note) }
}

UI에 적용

@Composable
fun NotesApp(noteViewModel: NoteViewModel){
    val notesList=noteViewModel.noteList.collectAsState().value

    NoteScreen(notes= notesList,
        onAddNote = {noteViewModel.addNote(it)},
        onRemoveNote = { noteViewModel.removeNote(it) }
    )
}
@Composable
fun NoteScreen(
    notes:List<Note>,
    onAddNote: (Note)->Unit,
    onRemoveNote: (Note)->Unit
){
    var title by remember{
        mutableStateOf("")
    }

    var description by remember {
        mutableStateOf("")
    }

    val context=LocalContext.current

    Column(modifier = Modifier.padding(6.dp)){
        ...

        Column(modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally){
            NoteInputText(
            	...
                text = title,
                onTextChange = {
                    if (it.all{char->
                        char.isLetter() || char.isWhitespace()
                        })title=it
                })

            NoteInputText(
                ...
                text = description,
                onTextChange = {
                    if (it.all{char->
                            char.isLetter() || char.isWhitespace()
                        })description=it
                })

            NoteButton(text = "Save",
                onClick = {
                    if(title.isNotEmpty() && description.isNotEmpty()){
                    	//노트 추가
                        onAddNote(Note(title=title,description=description))
                        title=""
                        description=""
                    }
                })
        }

        LazyColumn{
            items(notes){note->
                NoteRow(note=note,
                onNoteClicked={
                	//노트 삭제
                    onRemoveNote(note)
                })
            }
        }
    }
}

 

 

참고 자료

DI

https://developer.android.com/training/dependency-injection

https://developer.android.com/jetpack/androidx/releases/room?hl=ko

 

 

Hilt

https://developer.android.com/training/dependency-injection/hilt-android#hilt-and-dagger

 

https://f2janyway.github.io/android/hilt/

Coroutine

https://developer.android.com/kotlin/coroutines?hl=ko

https://kotlinlang.org/docs/coroutines-overview.html#how-to-start

Room

https://developer.android.com/training/data-storage/room?hl=ko