Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: 06.1 Create a Room database 부터.

Room database

A database library that is part of Android Jetpack. (SQLite)

# Recommended architecture

[ [observer] UI Controller (activity/fragment) ]
       ↑        ↓
[ [LiveData] ViewModel ]
                ↓
      [ Repository ]
         ↓      ↓
[ Database ]  [ Network ]

Codelab dependencies:

// app level build.gradle
// Support libraries
implementation "androidx.appcompat:appcompat:1.2.0"
implementation "androidx.fragment:fragment:1.2.5"
implementation "androidx.constraintlayout:constraintlayout:2.0.0-rc1"

// Android KTX
implementation 'androidx.core:core-ktx:1.3.1'

// Room and Lifecycle dependencies
implementation "androidx.room:room-runtime:$room_version"
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"

// Kotlin Extensions and Coroutines support for Room
implementation "androidx.room:room-ktx:$room_version"

// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutine_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutine_version"

// Navigation
implementation "android.arch.navigation:navigation-fragment-ktx:$navigationVersion"
implementation "android.arch.navigation:navigation-ui-ktx:$navigationVersion"

// Testing
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'

Create the entity

Define each entity as an annotated data class (Data access object, DAO).

Open SleepNight.kt in the .database package.

@Entity(tableName = "daily_sleep_quality_table")
data class SleepNight(
  @PrimaryKey(autoGenerate = true)
  var nightId: Long = 0L,

  @ColumnInfo(name = "start_time_milli")
  val startTimeMilli: Long = System.currentTimeMillis(),

  @ColumnInfo(name = "end_time_milli")
  var endTimeMilli: Long = startTimeMilli,

  @ColumnInfo(name = "quality_rating")
  var sleepQuality: Int = -1
)

Create the DAO

Open SleepDatabaseDao.kt in .database package.

@Dao
interface SleepDatabaseDao {
    @Insert
    fun insert(night: SleepNight)

    @Update
    fun update(night: SleepNight)

    @Query("SELECT * from daily_sleep_quality_table WHERE nightId = :key")
    fun get(key: Long): SleepNight?

    @Query("DELETE FROM daily_sleep_quality_table")
    fun clear()

    @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC LIMIT 1")
    fun getTonight(): SleepNight?

    @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC")
    fun getAllNights(): LiveData<List<SleepNight>>
}

Note: @Get gets one item. @Delete deletes one item.

Create database

Open SleepDatabase.kt in .database package.

@Database(
  entities = [SleepNight::class],
  version = 1,
  exportSchema = false
)
abstract class SleepDatabase : RoomDatabase() {
  abstract val sleepDatabaseDao: SleepDatabaseDao

  companion object {
    // it makes up-to-date, no caching
    @Volatile
    private var INSTANCE: SleepDatabase? = null

    fun getInstance(context: Context): SleepDatabase {
      // guarantee the singleton instance
      synchronized(this) {
        var instance = INSTANCE
        if (instance == null) {
          instance = Room.databaseBuilder(
              context.applicationContext,
              SleepDatabase::class.java,
              "sleep_history_database"
            )
            .fallbackToDestructiveMigration()
            .build()
          INSTANCE = instance
        }
        return instance
      }
    }
  }
}

Test the database.

// SleepDatabaseTest.kt in androidTest
@RunWith(AndroidJUnit4::class)
class SleepDatabaseTest {

  private lateinit var sleepDao: SleepDatabaseDao
  private lateinit var db: SleepDatabase

  @Before
  fun createDb() {
    val context = InstrumentationRegistry.getInstrumentation().targetContext
    // Using an in-memory database because the information stored here disappears when the
    // process is killed.
    db = Room.inMemoryDatabaseBuilder(context, SleepDatabase::class.java)
        // Allowing main thread queries, just for testing.
        .allowMainThreadQueries()
        .build()
    sleepDao = db.sleepDatabaseDao
  }

  @After
  @Throws(IOException::class)
  fun closeDb() {
    db.close()
  }

  @Test
  @Throws(Exception::class)
  fun insertAndGetNight() {
    val night = SleepNight()
    sleepDao.insert(night)
    val tonight = sleepDao.getTonight()
    assertEquals(tonight?.sleepQuality, -1)
  }
}

Run the test and check the result.

<merge> tag in layout

Eliminate redundant layouts when including layouts.

ViewModel with a database

// in SleepTrackerViewModel.kt
class SleepTrackerViewModel(
  val database: SleepDatabaseDao,
  application: Application) : AndroidViewModel(application) {
}
// in SleepTrackerViewModelFactory.kt
class SleepTrackerViewModelFactory(
    private val dataSource: SleepDatabaseDao,
    private val application: Application) : ViewModelProvider.Factory {
  @Suppress("unchecked_cast")
  override fun <T : ViewModel?> create(modelClass: Class<T>): T {
    if (modelClass.isAssignableFrom(SleepTrackerViewModel::class.java)) {
      return SleepTrackerViewModel(dataSource, application) as T
    }
    throw IllegalArgumentException("Unknown ViewModel class")
  }
}

Then, update the fragment.

// add after `binding` at `onCreateView()` in the fragment
// assemble the dependencies manually
val application = requireNotNull(this.activity).application
val dataSource = SleepDatabase.getInstance(application).sleepDatabaseDao
val viewModelFactory = SleepTrackerViewModelFactory(dataSource, application)
val sleepTrackerViewModel = ViewModelProvider(this, viewModelFactory)
        .get(SleepTrackerViewModel::class.java)

Add data binding for the viewModel at fragment xml file.

<data>
  <variable
    name="sleepTrackerViewModel"
    type="com.example.android.trackmysleepquality.sleeptracker.SleepTrackerViewModel" />
</data>

Register lifecycle owner of the binding and connect the viewModel.

// add after `binding` at `onCreateView()` in the fragment
binding.setLifecycleOwner(this)
binding.sleepTrackerViewModel = sleepTrackerViewModel

Coroutines

The logic can block the thread but coroutines make them suspend. It is good for long-running tasks because it is non-blocking and asynchronous via suspend.

Update DAO functions as suspend functions.

@Dao
interface SleepDatabaseDao {
  @Insert
  suspend fun insert(night: SleepNight)

  @Update
  suspend fun update(night: SleepNight)

  @Query("SELECT * from daily_sleep_quality_table WHERE nightId = :key")
  suspend fun get(key: Long): SleepNight?

  @Query("DELETE FROM daily_sleep_quality_table")
  suspend fun clear()

  @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC LIMIT 1")
  suspend fun getTonight(): SleepNight?

  @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC")
  fun getAllNights(): LiveData<List<SleepNight>>
}

Add dependencies if needed.

// app level build.gradle
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
// Kotlin Extensions and Coroutines support for Room
implementation "androidx.room:room-ktx:$room_version"

Init data from the ViewModel.

// SleepTrackerViewModel.kt
private var tonight = MutableLiveData<SleepNight?>()

init {
  initializeTonight()
}

private fun initializeTonight() {
  // Start coroutine in the `ViewModelScope`
  viewModelScope.launch {
    tonight.value = getTonightFromDatabase()
  }
}

private suspend fun getTonightFromDatabase(): SleepNight?
{
  var night = database.getTonight()
  if (night?.endTimeMilli != night?.startTimeMilli) {
    night = null
  }
  return night
}

Add click hander for the start button

// in the ViewModel
fun onStartTracking() {
  viewModelScope.launch { 
    val newNight = SleepNight()
    insert(newNight)
    tonight.value = getTonightFromDatabase()
  }
}

private suspend fun insert(night: SleepNight) {
  database.insert(night)
}

Then, add data binding on fragment xml file.

<Button
  android:id="@+id/start_button"
  ...
  android:onClick="@{() -> sleepTrackerViewModel.onStartTracking()}" />

Display the data

Room handles all LiveData magic. Transformation needed to show the data as an appropriated format.

// Util.kt
fun formatNights(nights: List<SleepNight>, resources: Resources): Spanned {
    val sb = StringBuilder()
    sb.apply {
        append(resources.getString(R.string.title))
        nights.forEach {
            append("<br>")
            append(resources.getString(R.string.start_time))
            append("\t${convertLongToDateString(it.startTimeMilli)}<br>")
            if (it.endTimeMilli != it.startTimeMilli) {
                append(resources.getString(R.string.end_time))
                append("\t${convertLongToDateString(it.endTimeMilli)}<br>")
                append(resources.getString(R.string.quality))
                append("\t${convertNumericQualityToString(it.sleepQuality, resources)}<br>")
                append(resources.getString(R.string.hours_slept))
                // Hours
                append("\t ${it.endTimeMilli.minus(it.startTimeMilli) / 1000 / 60 / 60}:")
                // Minutes
                append("${it.endTimeMilli.minus(it.startTimeMilli) / 1000 / 60}:")
                // Seconds
                append("${it.endTimeMilli.minus(it.startTimeMilli) / 1000}<br><br>")
            }
        }
    }
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        return Html.fromHtml(sb.toString(), Html.FROM_HTML_MODE_LEGACY)
    } else {
        return HtmlCompat.fromHtml(sb.toString(), HtmlCompat.FROM_HTML_MODE_LEGACY)
    }
}

Note: Spanned type is HTML-formatted string.

Add the member in the ViewModel so that returns formatted string.

// in the ViewModel
private val nights = database.getAllNights()

val nightsString = Transformations.map(nights) { nights ->
  formatNights(nights, application.resources)
}

Open the fragment xml file and add nightsString.

<TextView
  android:id="@+id/textview"
  ...
  android:text="@{sleepTrackerViewModel.nightsString}" />

Add the click handlers for the Stop and Clear button

// in the ViewModel
// for the stop button
fun onStopTracking() {
  viewModelScope.launch {
    val oldNight = tonight.value ?: return@launch
    oldNight.endTimeMilli = System.currentTimeMillis()
    update(oldNight)
  }
}

private suspend fun update(night: SleepNight) {
    database.update(night)
}

// for the clear button
fun onClear() {
   viewModelScope.launch {
       clear()
       tonight.value = null
   }
}

suspend fun clear() {
    database.clear()
}
<Button
  android:id="@+id/stop_button"
  ...
  android:onClick="@{() -> sleepTrackerViewModel.onStopTracking()}" />

...

<Button
  android:id="@+id/clear_button"
  ...
  android:onClick="@{() -> sleepTrackerViewModel.onClear()}" />

Pattern of the coroutine scopes

fun someWorkNeedsToBeDone {
  viewModelScope.launch {
    suspendFunction()
  }
}

suspend fun suspendFunction() {
  // Switch to the IO dispatcher
  withContext(Dispatchers.IO) {
    longrunningWork()
  }
}

// Using Room
fun someWorkNeedsToBeDone {
  viewModelScope.launch {
    suspendDAOFunction()
  }
}

suspend fun suspendDAOFunction() {
  // No need to specify the Dispatcher, Room uses Dispatchers.IO.
  longrunningDatabaseWork()
}

다음 챕터: Android Kotlin Fundamentals: Use LiveData to control button states

색상을 바꿔요

눈에 편한 색상을 골라보세요 :)

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.