Android-jetpack Compose

jetpack compose : (Step 3) ViewModel ☞ DI(Hilt), Room(NoteApp)

slow333 2023. 4. 2. 12:02

Hilt  Project 생성을 위한 gradle 구성

project => gradle

buildscript {
    ext {
        compose_ui_version = '1.3.3'
        hilt_version = '2.44'
    }
}
plugins {
   ....
    // hilt-android-gradle-plugin
    id 'com.google.dagger.hilt.android' version '2.44' apply false
}

app => gradle

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

android {
   ...
}

dependencies {
.......
    // Hilt-dagger
    //noinspection GradleDependency
    implementation "com.google.dagger:hilt-android:$hilt_version"
    kapt "com.google.dagger:hilt-compiler:$hilt_version"
}

// Hilt
kapt {
    correctErrorTypes true
}

kapt(Kotlin 주석 처리 도구)를 사용하면 Kotlin 코드에서 자바 주석 프로세서를 사용할 수 있습니다. 자바 주석 프로세서에 Kotlin 지원이 없는 경우에도 가능합니다. Kotlin 파일에서 프로세서가 읽을 수 있는 자바 스텁이 생성되어 자바 주석 프로세서의 사용이 지원됩니다. 이 스텁 생성은 비용이 많이 드는 작업으로 빌드 속도에 큰 영향을 줍니다.

KSP(Kotlin Symbol Processing)는 kapt의 Kotlin 우선 대안입니다. KSP는 Kotlin 코드를 직접 분석하기 때문에 시간이 최대 2배 빠릅니다. 또한 Kotlin의 언어 구성을 더 잘 이해합니다.

kapt는 현재 유지보수 모드로 전환되었으므로 가능한 경우 kapt에서 KSP로 이전하는 것을 권장합니다. 대부분의 경우 이전을 진행하려면 프로젝트의 빌드 구성만 변경하면 됩니다.

이전이 진행되는 동안 프로젝트에서 kapt와 KSP를 함께 실행할 수 있으며, 이전은 모듈 및 라이브러리별로 실행할 수 있습니다.

Hilt는 kapt를 사용 ??? ksp만 설정하면 kapt를 설정하라고 함...

 

=========================

Room Gradle 구성

module gradle

plugins {
    ...
    // hilt-android-gradle-plugin
    id 'com.google.devtools.ksp' version '1.8.10-1.0.9' apply false  // annotation관련
    id 'com.google.dagger.hilt.android' version '2.44' apply false
}

app => gradle

plugins {
...
    // hilt-android-gradle-plugin
    id 'kotlin-kapt'  // 아래 내용 참조(kapt보다 ksp를 권장) => hilt를 위해 필요
    id 'com.google.devtools.ksp'
    id 'com.google.dagger.hilt.android'
}

android {
    ...
        // room.schemaLocation: Configures and enables exporting database schemas
        //      into JSON files in the given directory. See Room Migrations for more information.
        // room.incremental: Enables Gradle incremental annotation processor.
        javaCompileOptions {
            annotationProcessorOptions {
                arguments += [
                        "room.schemaLocation": "$projectDir/schemas".toString(),
                        "room.incremental"   : "true"
                ]
            }
     ...
}

dependencies {
   ...
    // Room
    def room_version = "2.5.1"

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

    // To use Kotlin annotation processing tool (kapt)
    kapt "androidx.room:room-compiler:$room_version"
    // To use Kotlin Symbol Processing (KSP)
    ksp "androidx.room:room-compiler:$room_version"

    // Coroutines
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.4"

    // Hilt
    implementation "com.google.dagger:hilt-android:2.44.2"
    kapt "com.google.dagger:hilt-compiler:2.44.2"
}

// Hilt
kapt {
    correctErrorTypes true
}

※ Coroutine도 같이 설정 필요

* 공식 문서에는 없으나 전체 완료 후 실행하면 아래 내용 추가 하라고 애러 나옴.

implementation "androidx.room:room-ktx: @room_version"

===========================================================

Hilt Project(DI) 기본 구성

1.  Application 생성(MainActivity와 같은 위치에 생성)

// dependency => 모든 앱에 접근 가능 component scan을 위한 annotation => @Autowired
@HiltAndroidApp
class NoteApplication : Application() {}

2. manifests 수정(추가)

......
    <application
        android:name=".NoteApplication"
        ......
    </application>

3. MainActivity 수정(Annotation 추가)

@AndroidEntryPoint
class MainActivity : ComponentActivity() {

4. AppModule 생성(di 폴더)

@InstallIn(SingletonComponent::class)
@Module
object AppModule {}

[ 노트 App을 통한 Room DI 구현 ]

 

1. DAO 구성

SQLite DB는 기본적으로 내부에 구성되어 별도로 DB를 생성할 필요는 없음

외부 DB 연계는 추후에 ...

DB를 생성하고 기존 Data를 DB에 맞게 변경(Annotation) 구성

data/Note.kt(Data model) 변환

@Entity(tableName = "notes_tbl" )
data class Note(
   @PrimaryKey
   val id: UUID = UUID.randomUUID(),  // DB에 저장하기 위한 변환 필요

   @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()) // DB에 저장하기 위한 변환 필요
)

/*data class Note(
   val id: String = UUID.randomUUID().toString(),
   val title: String,
   val description: String,
   val entryDate: LocalDateTime = LocalDateTime.now()
)*/

data/Database.kt 생성

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

di/AppModule.kt 생성

@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()
}

data/DatabaseDao.kt 생성

@Dao
interface NoteDatabaseDao {

   // Flow: An asynchronous data stream that sequentially 
   // emits values and completes normally or with an exception.
   @Query("SELECT * from notes_tbl")
   fun getNotes():
           Flow<List<Note>>

   // suspend를 하면 coroutine을 타게됨.
   @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)
}

di/AppModule.kt 내용 적용

@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()
}

2. Repository 생성(옵션이나 꼭 필요함)

repository/NoteRepository

dependency injecting을 해야함: @Inject constructor(private val noteDatabaseDao: NoteDatabaseDao){}

class NoteRepository @Inject constructor(private val noteDatabaseDao: NoteDatabaseDao){

   suspend fun addNote(note: Note) = noteDatabaseDao.insert(note = note)
   suspend fun updateNote(note: Note) = noteDatabaseDao.update(note)
   suspend fun deleteNote(note: Note) = noteDatabaseDao.deleteNote(note)
   suspend fun deleteAllNotes() = noteDatabaseDao.deleteAll()
   fun getAllNotes() = noteDatabaseDao.getNotes().flowOn(Dispatchers.IO)
      .conflate()
   suspend fun getNoteById(note: Note) = noteDatabaseDao.getNoteById(note.id.toString())
}

screen/NoteViewModel.kt => 내용 수정(거의 다시 만들어야함)

dependency injecting을 해야함:@Inject constructor(private val repository: NoteRepository) : ViewModel() 

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

   init {
//      noteList.addAll(NotesDataSource().loadNotes())
      viewModelScope.launch(Dispatchers.IO) {// 동시 작업을 위함
         repository.getAllNotes().distinctUntilChanged()
            .collect { listOfNotes ->
               if (listOfNotes.isEmpty()) {
                  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)
   }

   fun getAllNotes() = viewModelScope.launch(Dispatchers.IO) {// 동시 작업을 위함
      repository.getAllNotes().distinctUntilChanged()
         .collect { listOfNotes ->
            if (listOfNotes.isEmpty()) {
               Log.d("empty", ": Empty list")
            } else {
               _noteList.value = listOfNotes
            }
         }
   }

   suspend fun getNoteById(note: Note) = viewModelScope.launch {
      repository.getNoteById(note)
   }
}

3. UI 수정

screen/NoteScreen.kt => 거의 변경할게 없음(일부 데이터 포맷 변환만 필요 => 안하면 애러 발생)

@OptIn(ExperimentalComposeUiApi::class)
@SuppressLint("UnusedMaterialScaffoldPaddingParameter")
@Composable
fun NoteScreen(
   notes: List<Note>,
   onAddNote: (Note) -> Unit,
   onRemoveNote: (Note) -> Unit,) {
   var title by remember { mutableStateOf("") }
   var description by remember { mutableStateOf("") }
   val keyboardController = LocalSoftwareKeyboardController.current
   Scaffold(topBar = {
      TopAppBar(modifier = Modifier.padding(4.dp), backgroundColor = Color.LightGray) {
         Text("Note App for viewModel exercise")
      }
   }) {
      Column(
         modifier = Modifier
            .padding(10.dp)
            .fillMaxWidth(),
         horizontalAlignment = Alignment.CenterHorizontally,
         verticalArrangement = Arrangement.Top
      ) {
         NoteInputTextField(
            text = title,
            label = "title",
            onTextChange = { title = it })
         Spacer(modifier = Modifier.height(8.dp))
         NoteInputTextField(
            text = description,
            label = "note",
            onTextChange = { description = it})
         NoteButton(text = "Click to Save",
            onClick = {
               if (title.isNotEmpty() && description.isNotEmpty()) {
                  // list에 저장, 추가 하기...
                  onAddNote(Note(title = title, description = description))
                  title = ""
                  description = ""
               }
               keyboardController?.hide() })
         Divider()
         LazyColumn() {
            items(notes) { note ->
               NoteRow(note = note, onNoteClicked = {
                  onRemoveNote(note)
               })
            }
         }
      }
   }
}

@Composable
fun NoteRow(
   modifier: Modifier = Modifier,
   note: Note,
   onNoteClicked: (Note) -> Unit,
) {
   Surface(
      modifier
         .padding(4.dp)
         .clip(RoundedCornerShape(topEnd = 20.dp, bottomStart = 20.dp))
         .fillMaxWidth(),
      color = Color.LightGray,
      elevation = 8.dp  ) {
      Column(
         modifier
            .clickable { onNoteClicked(note) }
            .padding(horizontal = 12.dp, vertical = 6.dp),
         horizontalAlignment = Alignment.Start  ) {
         Text( note.title, style = MaterialTheme.typography.subtitle2 )
         Text(text = note.description, style = MaterialTheme.typography.subtitle1)
         Text( text = note.entryDate //ofPattern("EEE, d MMM")
               .format(DateTimeFormatter.ofPattern("u-M-d(E) H:m")),
            style = MaterialTheme.typography.caption  )
      }
   }
}

@Composable
fun NoteButton(
   text: String,
   onClick: () -> Unit,
) {
   Button(
      onClick = onClick,
      shape = RoundedCornerShape(40.dp),
      modifier = Modifier
         .padding(6.dp)
   ) {
      Text(text = text, modifier = Modifier.padding(horizontal = 6.dp))
   }
}

//       Text( text = note.entryDate.format(DateTimeFormatter.ofPattern("u-M-d(E) H:m")),
         Text(text = formatDate(note.entryDate.time), => db의 데이터를 스트링 포맷으로 변환

 

id, entryDate는 DB 데이터 포팻을 맞추기 위해 변환 필요(util/DateConverter, UUIDConverter)

class UUIDConverter {
   @TypeConverter
   fun fromUUID(uuid: UUID): String? {
      return uuid.toString()
   }
   @TypeConverter
   fun uuidFromString(str: String): UUID? {
      return UUID.fromString(str)
   }
}
class DateConverter {
   @TypeConverter
   fun timeStampFromDate(data: Date) : Long{ // java.Date to DB timestamp
      return data.time
   }
   @TypeConverter
   fun dateFromTimestamp(timestamp: Long) : Date?{ // DB timestamp to java.Date
      return Date(timestamp)
   }
}

database에 아래 내용을 추가..

@TypeConverters(DateConverter::class, UUIDConverter::class) // :: method reference 매소드 참조

@Database(entities = [Note::class], version = 1, exportSchema = false)
@TypeConverters(DateConverter::class, UUIDConverter::class) // :: method reference 매소드 참조
abstract class NoteDatabase : RoomDatabase() {
   abstract fun noteDao(): NoteDatabaseDao
}

NoteScreen.kt에서도 이에 맞게 변환을 위한 formatDate 생성

fun formatDate(time: Long) :String {
   val date = Date(time)
   val myFormat = SimpleDateFormat("y-M-d(E) H:M aaa", Locale.KOREA)
   return myFormat.format(date)
}
//"EEE, d MMM hh:mm aaa" => Sun, 31 Oct 04:28 PM
//"y-M-d(E) H:M aaa", Locale.KOREA => 2023-4-2(일) 15:4 오후

NoteScreen.kt 내용 수정

         Text(text = formatDate(note.entryDate.time),
            style = MaterialTheme.typography.caption  )

...
               ) {
                  Text(note.title)
                  Text(" :  ${note.description}")
//                  Text(note.entryDate.format(DateTimeFormatter.ofPattern("u-M-d H:m", Locale.KOREA)))
                  Text(text = formatDate(note.entryDate.time))
               }
            }...

 

4. MainActivity 수정

입력 데이터를 기존 ViewModel에서 DB로 변경

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
      setContent {
         RoomDiTheme {
            Surface(color = MaterialTheme.colors.background) {
               //  val noteViewModel = viewModel<NoteViewModel>() 아래와 같은 것임
               val noteViewModel: NoteViewModel by viewModels()
               MyApp(noteViewModel = noteViewModel)
            }
         }
      }
   }
}

@Composable
fun MyApp(noteViewModel: NoteViewModel) {
//   val noteList = noteViewModel.getAllNote()
   val noteList = noteViewModel.noteList.collectAsState().value
   NoteScreen(notes = noteList,
      onAddNote = { noteViewModel.addNote(it) },
      onRemoveNote = { noteViewModel.removeNote(it) }
   )
}

애러 ...

Failed to resolve: androidx.room:room-ktx: 2.5.1

app gradle에서 빼서 sync하고 다시 넣어서 sync하면 애러 사라짐....