Inspect the starter code
코드 살펴보기
이 코드랩의 스타터 코드는 이것과 같다.
activity_main.xml은 nav_host_fragment를 가지고 있다. merge 태그는 레이아웃을 포함할 때 불필요한 레이아웃을 제거할 수 있다. 불필요한 레이아웃이란 예를 들어 ConstraintLayout>LinearLayout>TextView가 있을 때 시스템이 LinearLayout을 제거할 수 있는 경우를 말한다. 이런 류의 최적화는 view hierarchy를 단순화하고 앱 성능을 향상시킬 수 있다.
Add a ViewModel
Step 1: SleepTrackerViewModel 추가하기
SleepTrackerViewModel.kt
class SleepTrackerViewModel(
val database: SleepDatabaseDao,
application: Application) : AndroidViewModel(application) {
}
이 클래스는 AndroidViewModel을 상속하는데 이는 ViewModel과 같지만 생성자 매개변수로 application context를 받고 속성값으로 가능하도록 만든다.
application(.context를 사용하는 것과 같은 방식)의 경우 AndroidViewModel을 상속함으로써 SleepTrackerViewModel에도 기본적으로 생성된다. 그런데 왜 매개변수로 받는 게 가능한지, 그리고 매개변수인데 왜 선언 형식이 아닌지는 모르겠다. 아마 두 번째 인자를 application에 바로 준다는 의미인 것 같다.
Step 2: SleepTrackerViewModelFactory 추가하기
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")
}
}
- SleepTrackerViewModelFactory는 ViewModel과 같은 인자를 받는다.
- 인자로 아무 클래스 타입을 받고 ViewModel을 리턴하는 create()를 오버라이드 한다.
- create()의 body를 보면 사용 가능한 SleepTrackerViewModel 클래스가 있는지 체크하고 사용 가능하다면 그의 인스턴스를 리턴한다.
Tip: 이는 거의 표준적인 코드이므로 이후에 다른 view-model factory에 재사용할 수 있다.
Step 3: SleepTrackerFragment 업데이트하기
SleepTrackerFragment.kt의 onCreateView
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
// Get a reference to the binding object and inflate the fragment views.
val binding: FragmentSleepTrackerBinding = DataBindingUtil.inflate(
inflater, R.layout.fragment_sleep_tracker, container, false)
val application = requireNotNull(this.activity).application
// Create an instance of the ViewModel Factory.
val dataSource = SleepDatabase.getInstance(application).sleepDatabaseDao
val viewModelFactory = SleepTrackerViewModelFactory(dataSource, application)
// Get a reference to the ViewModel associated with this fragment.
val sleepTrackerViewModel =
ViewModelProvider(
this, viewModelFactory).get(SleepTrackerViewModel::class.java)
return binding.root
}
view-model factory provider에 넘겨주려면 이 프래그먼트가 부착된 앱의 참조가 필요하다. requireNotNull Kotlin 함수는 값이 null이면 IllegalArgumentException 예외 처리를 한다.
SleepTrackerViewModel::class.java 매개변수는 이 객체의 런타임 Java 클래스를 참조한다.
Step 4: 뷰 모델에 데이터 바인딩 추가하기
fragment_sleep_tracker.xml
<data>
<variable
name="sleepTrackerViewModel"
type="com.example.android.trackmysleepquality.sleeptracker.SleepTrackerViewModel" />
</data>
SleepTrackerFragment.kt
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
// Get a reference to the binding object and inflate the fragment views.
val binding: FragmentSleepTrackerBinding = DataBindingUtil.inflate(
inflater, R.layout.fragment_sleep_tracker, container, false)
val application = requireNotNull(this.activity).application
// Create an instance of the ViewModel Factory.
val dataSource = SleepDatabase.getInstance(application).sleepDatabaseDao
val viewModelFactory = SleepTrackerViewModelFactory(dataSource, application)
// Get a reference to the ViewModel associated with this fragment.
val sleepTrackerViewModel =
ViewModelProvider(
this, viewModelFactory).get(SleepTrackerViewModel::class.java)
binding.sleepTrackerViewModel = sleepTrackerViewModel
binding.lifecycleOwner = this
return binding.root
}
Coroutines
Note: 보통 태스크를 끝낼 동안 다시 실행이 가능해질 때 까지 기다려야 할 때 어플리케이션은 실행이 막힐 수 있다. 예를 들어, 큰 파일을 읽거나 장문의 데이터베이스를 실행하는 호출이 앱의 전반적인 실행을 막는 태스크가 된다. 이는 어플리케이션이 유저에게 응답하는 성능을 저하시킬 뿐만 아니라 하드웨어 사용에도 효율적이지 않다. 메인 스레드를 block하지 않으면서 long-running task를 실행하기 위핸 패턴 중 하나가 callback을 사용하는 것이다. 이는 실용적인 패턴이지만 몇몇 단점도 있다. 이 개념에 대한 설명은 Multi-threading & callbacks primer를 참고하라.
Kotlin에서 coroutine은 callback 대신 long-running 태스크를 우아하고 효율적으로 다루는 방법이다. Kotlin coroutine은 callback 기반의 코드를 순차적인 코드로 변환해준다. 순차적으로 작성된 코드는 일반적으로 읽고 유지보수하기에 더 쉽다. callback과 다르게 coroutine은 exception 같은 가치 있는 언어 특성을 안전하게 사용할 수 있다. 그리고 가장 중요한 것은 coroutine은 높은 유지 능력(maintainability)과 유연성(flexibility)을 갖는다. 마지막으로 coroutine과 callback은 같은 기능을 수행한다: 둘 다 앱에서 잠재적으로 long-running asynchronous task를 다루는 방법이다.
coroutine은 다음과 같은 특성이 있다.
- coroutine은 비동기적이다
coroutine은 프로그램의 메인 실행 과정과 독립적으로 싱행된다. 이는 병렬적이거나 분리된 프로세서에 있을 수 있다. 이는 또한 앱의 나머지가 입력을 기다리는 동안 작은 처리를 수행할 수 있다는 것이다. 비동기적인 프로그래밍의 중요한 측면 중 하나는 명시적으로 결과를 기다릴 때 까지 그 결과가 즉각적으로 사용 가능한 것을 기대할 수 없다는 것이다. - coroutine은 non-blocking 하다
Non-blocking이란 coroutine이 메인 또는 UI 스레드의 진행을 block 하거나 간섭하지 않는다는 뜻이다. 그래서 coroutine을 사용하면 메인 스레드에서 실행회는 UI 상호작용이 항상 우선 순위이므로 사용자는 최대한 매끄러운 UX를 경험할 수 있다. - coroutine은 비동기적인 코드를 순차적으로 만들기 위해 suspend function을 사용한다
키워드 suspend는 Kotlin의 함수나 함수 타입을 coroutine이 사용 가능하다고 표시하는 방법이다. coroutine이 suspend가 표시된 함수를 호출할 때 일반적인 함수 호출처럼 함수가 리턴할 때까지 blocking 하는 대신에 coroutine은 결과가 준비될 때까지 실행을 중단한다. 그러고 나서 coroutine은 결과와 함께 잠시 중단해둔 것을 재개한다.
suspend 키워드는 코드가 실행되는 스레드를 명시하지 않는다. suspend function은 백그라운드 스레드 또는 메인 스레드에서 실행될 수 있다.
Tip: blocking과 suspending의 차이는 스레드가 block 되면 다른 일이 일어나지 않지만 스레드가 suspend 되면 결과가 나올 때까지 다른 일이 일어난다는 것이다.
Kotlin에서 coroutine을 사용하려면 세 가지가 필요하다.
- Job: 기본적으로 job은 취소될 수 있는 아무거나이다. 모든 coroutine은 job이 있고 coroutine을 취소하는 job을 사용할 수 있다. job은 부모-자식 계층으로 처리될 수 있다. 부모 job을 즉시 취소하면 job의 자식까지 모두 취소한다.
- Dispatcher: dispatcher는 다양한 스레드에서 실행할 중단된 coroutine을 보낸다. 예를 들어, Dispatcher.Main은 메인 스레드에서 실행되고 Dispatcher.IO는 block 하는 I/O 태스크를 공유되는 스레드 pool로 옮긴다.
- Scope: coroutine의 scope는 coroutine이 실행되는 context를 정의한다. scope는 coroutine의 job에 대한 정보와 dispatcher를 합친다. scope는 coroutine을 추적한다. coroutine을 시작할 때 그것은 scope에 있다. 이는 무슨 scope가 coroutine을 추적할 지 지시했다는 뜻이다.
Collect and display the data
Step 1: DAO 함수를 suspend 함수로 표시하기
SleepDatabaseDao.kt
@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>>
}
Room은 LiveData를 리턴하는 특정 @Query에 대해 이미 백그라운드 스레드를 사용하므로 getAllNights()에는 suspend 키워드를 달지 않는다.
Step 2: 데이터베이스 작업을 위해 coroutine 설정하기
앱 레벨의 build.gradle 파일의 depedencies 부분에 다음 코드가 있는지 확인한다.
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
// Kotlin Extensions and Coroutines support for Room
implementation "androidx.room:room-ktx:$room_version"
SleepTrackerViewModel.kt
private var tonight = MutableLiveData<SleepNight?>()
init {
initializeTonight()
}
private fun initializeTonight() {
viewModelScope.launch {
tonight.value = getTonightFromDatabase()
}
}
private suspend fun getTonightFromDatabase(): SleepNight? {
var night = database.getTonight()
if (night?.endTimeMilli != night?.startTimeMilli) {
night = null
}
return night
}
lauch의 괄호 부분에 주목하라. 이름 없는 함수인 lambda 표현을 정의하고 있다. 이 예시에서 lambda로 launch coroutine 빌더에 넘겨주고 있다. 이 빌더는 coroutine을 생성하고 lambda의 실행에 상응하는 dispatcher에 할당한다.
Step 3: Start 버튼에 클릭 핸들러 추가하기
SleepTrackerViewModel.kt
fun onStartTracking() {
// viewModelScope에 coroutine을 launch
viewModelScope.launch {
val newNight = SleepNight()
insert(newNight)
tonight.value = getTonightFromDatabase()
}
}
private suspend fun insert(night: SleepNight) {
database.insert(night)
}
Room을 사용하는 coroutine은 Dispatcher.IO를 사용하므로 메인스레드에서 실행되지 않음을 명심하라.
fragment_sleep_tracker.xml
<Button
android:id="@+id/start_button"
...
android:onClick="@{() -> sleepTrackerViewModel.onStartTracking()}"
이전에 설정한 데이터 바인딩을 이용하여 클릭 핸들러를 추가한다. @{() -> 함수 표기법은 인자를 취하지 않고 sleepTrackerViewModel의 클릭 핸들러를 호출하는 람다 함수를 생성한다.
기존에 사용하던 방법(android:onClick="onStartTracking")대로 쓰면 다음과 같은 에러가 발생한다.
E/AndroidRuntime: FATAL EXCEPTION: main
Process: com.example.android.trackmysleepquality, PID: 15821
java.lang.IllegalStateException: Could not find method onStartTracking(View) in a parent or ancestor Context for android:onClick attribute defined on view class androidx.appcompat.widget.AppCompatButton with id 'start_button'
Important: 이제 패턴에 주목해야 한다
- coroutine으로부터의 결과는 UI에 띄워진 것에 영향을 미치므로 메인 UI 스레드에서 실행되는 coroutine을 launch한다. 다음 예시에 보이는 것처럼 ViewModel의 viewModelScope 속성값(property)을 통해 ViewModel의 CoroutineScope에 접근할 수 있다.
- long-running한 작업을 하는 suspend 함수를 호출하면 결과를 기다리는 동안 UI 스레드를 block 하지 않는다.
- long-running한 작업의 결과는 UI에 영향을 줄 수 있지만 그것의 작업은 UI로부터 독립적이다. 효율성을 위해 I/O dispatcher로 바꾼다(Room은 이 코드를 자동 생성). I/O dispatcher는 세 종류의 작업을 위해 최적화되고 제쳐진 스레드 풀을 사용한다.
그러고 나서 작업을 하는 long running 함수를 호출한다.
Without Room
fun someWorkNeedsToBeDone {
viewModelScope.launch {
suspendFunction()
}
}
suspend fun suspendFunction() {
withContext(Dispatchers.IO) {
longrunningWork()
}
}
Using Room
// Using Room
fun someWorkNeedsToBeDone {
viewModelScope.launch {
suspendDAOFunction()
}
}
suspend fun suspendDAOFunction() {
// No need to specify the Dispatcher, Room uses Dispatchers.IO.
longrunningDatabaseWork()
}
Step 4: 데이터 띄우기
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)
}
}
Spanned는 HTML 형식의 문자열이다. Android의 TextView는 기본적인 HTML을 띄울 수 있기 때문에 이는 매우 편리하다.
strings.xml
<!-- Output to TextView styled with a little HTML -->
<string name="title"><![CDATA[<h3>HERE IS YOUR SLEEP DATA</h3>]]></string>
<string name="start_time"><![CDATA[<b>Start:</b>]]></string>
<string name="end_time"><![CDATA[<b>End:</b>]]></string>
<string name="quality"><![CDATA[<b>Quality:</b>]]></string>
<string name="hours_slept"><![CDATA[<b>Hours:Minutes:Seconds</b>]]></string>
문자열 리소스를 sleep 데이터를 띄우기 위해 형태를 바꾸는 CDATA의 사용에 주목하라.
SleepTrackerViewModel.kt
private var nights = database.getAllNights()
val nightsString = Transformations.map(nights) { nights ->
formatNights(nights, application.resources)
}
fragment_sleep_tracker.xml
<TextView
...
android:text="@{sleepTrackerViewModel.nightsString}" />
Step 5: Stop 버튼에 클릭 핸들러 추가하기
SleepTrackerViewModel.kt
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)
}
Kotlin에서 return@label 구문은 몇몇 내장된 함수들 중에서 이 statement가 리턴하는 것으로부터 함수를 명시한다.
fragment_sleep_tracker.xml
<Button
android:id="@+id/stop_button"
...
android:onClick="@{() -> sleepTrackerViewModel.onStopTracking()}"
Step 6: Clear 버튼에 클릭 핸들러 추가하기
생략
Homework
Answer these questions
Q1. 5 / Q2. 4 / Q3. 4
'안드로이드 Android > Android Study Jams' 카테고리의 다른 글
7. Databases and RecyclerView - RecyclerView Fundamentals (0) | 2021.01.21 |
---|---|
7. Databases and RecyclerView - Use LiveData to control Button states (0) | 2021.01.21 |
7. Databases and RecyclerView - Create a Room database (0) | 2021.01.18 |
6. Architecture components - LiveData transformations (0) | 2021.01.08 |
6. Architecture components - Data binding with ViewModel and LiveData (0) | 2021.01.08 |