의존성 주입(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 모델은 아래와 같다.
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
'Group Study (2023-2024) > Android 심화' 카테고리의 다른 글
[Android 심화] 4주차 스터디 - AAC ViewModel (1) | 2023.11.27 |
---|---|
[Android 심화] 3주차 스터디 - Movie App 만들기(Scaffold, LazyColumn, Navigation) (1) | 2023.11.20 |
[Android 심화] 2주차 스터디 - Jetpack Compose 입문(2) (1) | 2023.11.13 |
[Android 심화] 1주차 스터디 - Jetpack Compose 입문(1) (1) | 2023.11.06 |