실습 프로젝트
- 이름 : TEA(Time Elapsed After..)
- package : com.example.tea
- 음식을 먹을 때마다 기록하면, 마지막으로 음식을 먹은 후 경과 시간을 볼 수 있다.
- View Binding 적용
Android Application Local Data
- 중요한 데이터는 Back-end 서버에 저장하는 것이 맞지만 각 개인의 앱 설정 데이터, 서버 데이터의 캐시 등은 Local Data로 저장하는 것이 좋다.
- Shared Preference: key-value 형식의 간단한 데이터를 파일에 저장.
- 앱이 종료되어도 데이터가 유지된다.
- 참고) 설정 값 만을 저장하는 것은 Preference
- ViewModel: UI가 유지될 동안 유지되는 데이터
안드로이드에서는 파일형 DB인 SQLite를 각 어플리케이션에 지원하고 있다.
Context(문맥)
- Context(문맥) = 생략되어 있어도 무엇을 대상으로 하고 있는지?
- Abstract class - 구현은 안드로이드 시스템에서 제공
- 앱 환경에 대한 전역 정보를 가지는 interface - resource, database, classes등 앱 레벨 정보에 접근할 수 있게 해 줌
- 주요 subclass들
- Activity, Application, Service 등
- Application Context: 싱글 인스턴스. 앱의 라이프사이클과 연결
- Activity Context: Activity의 라이프라이클과 연결
val str = String(””) ← 금방 사라지기 때문에 Context가 아니다.
LifeCycle을 가지는 것들 :
Application —————————>
→ MainActivity → CourseActivity
- Application이 LifeCycle이 가장 길다(오랫동안 살아있다)
- CourseActivity가 화면으로 가도 MainActivity가 꺼진것은 아니다.
Application, Activity ← 둘 다 Context(문맥)임
val intent = Intent(this, ChatRoomActivity::class.java)
여기에서 Intent의 생성자의 this 부분이 Context이다.
Context : 나는 누구와 함께 LifeCycle을 함께 하는 것인가? 라고 생각하면 됨
SharedPreference
- Key-value 형태의 파일로 저장 - 앱이 재시작되어도 이전 기록이 남는다.
- 파일 생성 및 파일 내용을 불러오기
- getSharedPreferences(파일이름, Context.MODE_PRIVATE)
- 앱의 모든 Context*들이 불러 쓸 수 있는 파일을 이름을 지정하여 생성
- getPreferences(Context.MODE_PRIVATE): 특정 Activity에 속한 파일 생성
- getSharedPreferences(파일이름, Context.MODE_PRIVATE)
현재 보안상의 문제로 PUBLIC 등 다른 모드들이 없어져 Context.MODE_PRIVATE밖에 사용하지 못함
- 값 읽기
- sharedPreference 객체에서 getInt, getString과 같은 함수를 호출한다.
- 값 쓰기
- sharedPreference.edit()을 통해 editor 객체를 받고
- editor.putInt(), editor.putString()등의 함수로 객체에 새 값을 저장한 다음
- editor.apply() 를 통해 최종 파일에 저장한다.
- editor.commit도 있지만 동기식이라서 저장중에는 UI가 멈춘다. 그래서 비동기식인 apply를 개발자가 자주 사용함
getSharedPreferences(파일이름, Context.MODE_PRIVATE)를 하면 해당 파일을 읽어서 메모리 상에 올려서 사용을 하는 것이기 때문에 해당 파일을 수정을 하면 메모리 상에서만 수정이 된것이기 때문에 꼭 apply로 저장을 해주어야 함
- 값 읽기와 값 쓰기 예
edit.apply() - 수정된 값을 객체에 즉시 반영하고 파일 저장은 비동기로 수행한다.
edit.commit() - 수정된 값을 객체에 즉시 반영하고 파일 저장을 동기로 수행한다.
String 정의
res/values/strings.xml
<resources>
<string name="app_name">TEA</string>
<string name="elapsed_time_default">00:00:00</string>
<string name="elapsed_time">%02d:%02d:%02d</string>
<string name="no_time_saved">저장된 시간이 없습니다.</string>
<string name="no_food_saved">저장된 음식 정보가 없습니다.</string>
<string name="enter_food_name">섭취하신 음식을 입력하세요.</string>
<string name="save">저장</string>
</resources>
activity_main.xml 작성
-
더보기
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/main" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <TextView android:id="@+id/textViewLastTime" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/no_time_saved" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintVertical_bias="0.15" /> <TextView android:id="@+id/textViewLastFood" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/no_food_saved" app:layout_constraintEnd_toEndOf="@+id/textViewLastTime" app:layout_constraintStart_toStartOf="@+id/textViewLastTime" app:layout_constraintTop_toBottomOf="@+id/textViewLastTime" /> <TextView android:id="@+id/textViewElapsedTime" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="32dp" android:text="@string/elapsed_time_default" android:textSize="30sp" android:textStyle="bold" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textViewLastFood" /> <EditText android:id="@+id/editTextFood" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="32dp" android:autofillHints="" android:ems="12" android:gravity="center" android:hint="@string/enter_food_name" android:inputType="text" android:minHeight="48dp" android:textColorHint="#546E7A" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textViewElapsedTime" /> <Button android:id="@+id/buttonSave" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/save" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/editTextFood" /> </androidx.constraintlayout.widget.ConstraintLayout>
ElapsedTime Data Class 추가
data class ElapsedTime (
val hours:Long = 0,
val minutes:Long = 0,
val seconds:Long = 0
)
MainActivity
- getElapstedTime, saveRecord 함수를 직접 구현해본다.
class MainActivity : AppCompatActivity() {
private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) } // binding 생성
private lateinit var preference:SharedPreferences // SharedPreferences 변수 생성
overri @de fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(binding.root) // binding.root를 화면에 표시
ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
preference = getSharedPreferences("food_record", MODE_PRIVATE) // SharedPreferences 객체 생성
val lastFood = preference.getString("food", null) // food_record.xml 파일에서 food 키의 값을 가져옴
val lastTime = preference.getString("time", null) // food_record.xml 파일에서 time 키의 값을 가져옴
updateWidgets(lastFood, lastTime) // updateWidgets 함수 호출
binding.buttonSave.setOnClickListener{ saveRecord() }
}
private fun saveRecord() {
val time = LocalDateTime.now().toString() // 현재 시간을 문자열로 변환
val food = binding.editTextFood.text.toString() // editTextFood의 텍스트를 가져와 food 변수에 저장
if(food.isNotEmpty()) {
preference.edit().run {
putString("food", food)
putString("time", time)
apply()
}
updateWidgets(food, time)
}
}
private fun getElapsedTime(time:String?):ElapsedTime {
if(time == null)
return ElapsedTime()
val now = LocalDateTime.now() // 현재 시간을 LocalDateTime 객체로 변환
val before = LocalDateTime.parse(time) // 문자열로 된 시간을 LocalDateTime 객체로 변환
val hours = ChronoUnit.HOURS.between(before, now) // 시간 차이를 시간 단위로 계산
val minutes = ChronoUnit.MINUTES.between(before, now) % 60 // 시간 차이를 분 단위로 계산
val seconds = ChronoUnit.SECONDS.between(before, now) % 60 // 시간 차이를 초 단위로 계산
return ElapsedTime(hours, minutes, seconds) // ElapsedTime 객체 반환
}
private fun updateWidgets(lastFood:String?, lastTime:String?) {
val elapsedTime = getElapsedTime(lastTime)
binding.textViewLastFood.text = lastFood?:getString(R.string.no_food_saved)
binding.textViewLastTime.text = lastTime?:getString(R.string.no_time_saved)
binding.textViewElapsedTime.text =
getString(R.string.elapsed_time, elapsedTime.hours, elapsedTime.minutes, elapsedTime.seconds)
}
}

time을 DateTime으로 저장안한 이유 SharedPreferences는 DateTime을 저장하지 못함
문제점
- 경과 시간이 업데이트가 안된다.
- Timer를 사용하면?
- 안드로이드에서는 Original Thread(MainThread) 에서만 UI를 업데이트 할 수 있도록 허용한다.
- 타이머를 돌리는 것은 가능하지만 해당 정보로 UI를 업데이트하는데 제약이 있다.
Activity ——————————————————————————> (계속유지)
onCreate → onStart → onResume → 가로/세로 변경 → onCreate부터 다시 호출
문제점 : Activity를 새로 만드는건 아니지만 레이아웃이 다시 그려지면서 작성된 데이터가 날아갈 수도 있다.
Activity가 살아 있는 동안 유지되는 데이터를 만들어주세요. (가로, 세로 변경되어도 데이터는 유지되도록 해주세요 → onCreate가 다시 불려도 초기화 되지 않는 데이터) == ViewModel
ViewModel
내 앱이 켜져서 꺼질 때까지는 유지되는 데이터
- ViewModel: Activity, Fragment 등의 요소의 수명 주기를 고려한 UI 관련 데이터 저장소. -> 화면 회전 등의 경우에 데이터를 보존할 수 있다.
- LiveData: 자신의 정보가 변경되면 Observer 객체에게 이를 알리는 데이터.
- Observer: ViewModel을 관찰하는 클래스. Observer가 START 또는 RESUMED 상태 일때 관찰자가 활성화 되었다고 판단. -> 활성화된(엑티비티가 화면에 떠있는 상태) 관찰자에게만 업데이트를 알림.

LiveData → 관찰자에게 데이터의 업데이트를 알려줌
Activity가 Observe로 LiveData를 구독한다고 생각하면 됨
안드로이드에서는 평시적으로 Background Thread에서는 UI를 새로 고치는걸 못하는데 ViewModel을 사용하면 Background Thread에서도 UI를 업데이트를 할 수 있다.
- 기존 구조
- 데이터를 변경한 함수에서 UI 업데이트까지 수행해야 함
- MainThread 에서만 UI 변경
- ViewModel을 사용하면:
- (실행되는 Thread 상관 없이)함수에서는 데이터만 변경.
- 관련된 observer가 UI를 업데이트한다
ViewModel의 수명 주기

- Activity의 회전 등에 의해 onCreate 가 다시 실행되어도 ViewModel은 유지된다.
- 서버로 전송할 영구 데이터가 아닌 UI를 그리는데 필요한 데이터를 적절히 보관한다
ViewModel과 LiveData를 위한 Dependency
- libs.versions.toml
[versions]
...
livedata = "2.7.0"
[libraries]
androidx-viewmodel = {group="androidx.lifecycle", name="lifecycle-viewmodel-ktx", version.ref="livedata"}
androidx-livedata = {group="androidx.lifecycle", name="lifecycle-livedata-ktx", version.ref="livedata"}
- build.gradle.kts(Module:app)
dependencies {
...
implementation(libs.androidx.livedata)
implementation(libs.androidx.viewmodel)
}
ViewModel 사용하기
- ViewModel은 일반 객체처럼 생성해서는 안된다. → 처음에만 생성하고 그 이후 onCreate 될 때 다시 생성되면 안되기 때문에
- → 처음에 ViewModel 생성 후 다른 곳에서는 이미 만들어진 ViewModel을 참조하는 형식으로 사용
- ViewModel 객체를 생성할 때 ViewModelProvider를 사용한다.
val model:MyViewModel = ViewModelProvider(this)[MyViewModel::class.java]
- libs.version.toml, build.gradle(Module:app) 에서 각각 다음 확인
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
implementation(libs.androidx.activity)
- Delegation → by viewModels() 를 사용한다.
- val model:MyViewModel by viewModels() 또는 val viewModel by viewModels<MainViewModel>()
- Delegation → by viewModels() 를 사용한다.
MainActivity에서 불러 쓰기
- 변수 선언부
import android.content.SharedPreferences
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.lunadev.tea.ElapsedTime
import com.lunadev.tea.MainViewModel
import com.lunadev.tea.R
import com.lunadev.tea.databinding.ActivityMainBinding
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import java.util.Timer
class MainActivity : AppCompatActivity() {
private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) } // binding 생성
private lateinit var preference:SharedPreferences // SharedPreferences 변수 생성
private val timer = Timer()
private val viewModel: MainViewModel by viewModels() // ViewModel 객체 생성
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(binding.root) // binding.root를 화면에 표시
ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
preference = getSharedPreferences("food_record", MODE_PRIVATE) // SharedPreferences 객체 생성
val lastFood = preference.getString("food", null) // food_record.xml 파일에서 food 키의 값을 가져옴
val lastTime = preference.getString("time", null) // food_record.xml 파일에서 time 키의 값을 가져옴
updateWidgets(lastFood, lastTime) // updateWidgets 함수 호출
binding.buttonSave.setOnClickListener{ saveRecord() }
viewModel.lastFood.value = lastFood // ViewModel의 lastFood 변수를 변경함
viewModel.lastTime.value = lastTime // ViewModel의 lastTime 변수를 변경함
// LiveData observe값이 수정될 경우 람다식이 동작하여 UI가 업데이트 됨
viewModel.lastFood.observe(this) { binding.textViewLastFood.text = it }
viewModel.lastTime.observe(this) { binding.textViewLastTime.text = it }
viewModel.updateElapsedTime.observe(this){
val elapsedTime = getElapsedTime(viewModel.lastTime.value)
binding.textViewElapsedTime.text = getString(
R.string.elapsed_time,
elapsedTime.hours,
elapsedTime.minutes,
elapsedTime.seconds)
}
viewModel.startTimer() // BackgroundThread에서 LiveData 값 수정
}
private fun saveRecord() {
val time = LocalDateTime.now().toString() // 현재 시간을 문자열로 변환
val food = binding.editTextFood.text.toString() // editTextFood의 텍스트를 가져와 food 변수에 저장
if(food.isNotEmpty()) {
preference.edit().run {
putString("food", food)
putString("time", time)
apply()
}
viewModel.lastTime.value = time
viewModel.lastFood.value = food
// MainThread에서 LiveData 값 수정 값만 수정하고 UI 업데이트는 신경 쓰지 않아도 됨
}
}
private fun getElapsedTime(time:String?): ElapsedTime {
if(time == null)
return ElapsedTime()
val now = LocalDateTime.now() // 현재 시간을 LocalDateTime 객체로 변환
val before = LocalDateTime.parse(time) // 문자열로 된 시간을 LocalDateTime 객체로 변환
val hours = ChronoUnit.HOURS.between(before, now) // 시간 차이를 시간 단위로 계산
val minutes = ChronoUnit.MINUTES.between(before, now) % 60 // 시간 차이를 분 단위로 계산
val seconds = ChronoUnit.SECONDS.between(before, now) % 60 // 시간 차이를 초 단위로 계산
return ElapsedTime(hours, minutes, seconds) // ElapsedTime 객체 반환
}
private fun updateWidgets(lastFood:String?, lastTime:String?) {
val elapsedTime = getElapsedTime(lastTime)
binding.textViewLastFood.text = lastFood?:getString(R.string.no_food_saved)
binding.textViewLastTime.text = lastTime?:getString(R.string.no_time_saved)
binding.textViewElapsedTime.text =
getString(R.string.elapsed_time, elapsedTime.hours, elapsedTime.minutes, elapsedTime.seconds)
}
}
ViewModel 코드
package com.lunadev.tea
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import java.util.Timer
import kotlin.concurrent.schedule
class MainViewModel: ViewModel() {
val lastTime = MutableLiveData<String>().apply { value="00" }
val lastFood = MutableLiveData<String>().apply { value="00" }
val updateElapsedTime = MutableLiveData<Boolean>()
private val timer = Timer()
fun startTimer(){
timer.schedule(1000, 1000) {
// Background Thread 에서는 postValue 를 사용해 값을 업데이트한다.
updateElapsedTime.postValue(true)
}
}
override fun onCleared() {
super.onCleared()
timer.cancel()
}
}
ViewModel 코드
package com.lunadev.tea
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import java.util.Timer
import kotlin.concurrent.schedule
class MainViewModel: ViewModel() {
val lastTime = MutableLiveData<String>().apply { value="00" }
val lastFood = MutableLiveData<String>().apply { value="00" }
val updateElapsedTime = MutableLiveData<Boolean>()
private val timer = Timer()
fun startTimer(){
timer.schedule(1000, 1000) {
// Background Thread 에서는 postValue 를 사용해 값을 업데이트한다.
updateElapsedTime.postValue(true)
}
}
override fun onCleared() {
super.onCleared()
timer.cancel()
}
}
실습 프로젝트
- 이름 : TEA(Time Elapsed After..)
- package : com.example.tea
- 음식을 먹을 때마다 기록하면, 마지막으로 음식을 먹은 후 경과 시간을 볼 수 있다.
- View Binding 적용
Android Application Local Data
- 중요한 데이터는 Back-end 서버에 저장하는 것이 맞지만 각 개인의 앱 설정 데이터, 서버 데이터의 캐시 등은 Local Data로 저장하는 것이 좋다.
- Shared Preference: key-value 형식의 간단한 데이터를 파일에 저장.
- 앱이 종료되어도 데이터가 유지된다.
- 참고) 설정 값 만을 저장하는 것은 Preference
- ViewModel: UI가 유지될 동안 유지되는 데이터
안드로이드에서는 파일형 DB인 SQLite를 각 어플리케이션에 지원하고 있다.
Context(문맥)
- Context(문맥) = 생략되어 있어도 무엇을 대상으로 하고 있는지?
- Abstract class - 구현은 안드로이드 시스템에서 제공
- 앱 환경에 대한 전역 정보를 가지는 interface - resource, database, classes등 앱 레벨 정보에 접근할 수 있게 해 줌
- 주요 subclass들
- Activity, Application, Service 등
- Application Context: 싱글 인스턴스. 앱의 라이프사이클과 연결
- Activity Context: Activity의 라이프라이클과 연결
val str = String(””) ← 금방 사라지기 때문에 Context가 아니다.
LifeCycle을 가지는 것들 :
Application —————————>
→ MainActivity → CourseActivity
- Application이 LifeCycle이 가장 길다(오랫동안 살아있다)
- CourseActivity가 화면으로 가도 MainActivity가 꺼진것은 아니다.
Application, Activity ← 둘 다 Context(문맥)임
val intent = Intent(this, ChatRoomActivity::class.java)
여기에서 Intent의 생성자의 this 부분이 Context이다.
Context : 나는 누구와 함께 LifeCycle을 함께 하는 것인가? 라고 생각하면 됨
SharedPreference
- Key-value 형태의 파일로 저장 - 앱이 재시작되어도 이전 기록이 남는다.
- 파일 생성 및 파일 내용을 불러오기
- getSharedPreferences(파일이름, Context.MODE_PRIVATE)
- 앱의 모든 Context*들이 불러 쓸 수 있는 파일을 이름을 지정하여 생성
- getPreferences(Context.MODE_PRIVATE): 특정 Activity에 속한 파일 생성
- getSharedPreferences(파일이름, Context.MODE_PRIVATE)
현재 보안상의 문제로 PUBLIC 등 다른 모드들이 없어져 Context.MODE_PRIVATE밖에 사용하지 못함
- 값 읽기
- sharedPreference 객체에서 getInt, getString과 같은 함수를 호출한다.
- 값 쓰기
- sharedPreference.edit()을 통해 editor 객체를 받고
- editor.putInt(), editor.putString()등의 함수로 객체에 새 값을 저장한 다음
- editor.apply() 를 통해 최종 파일에 저장한다.
- editor.commit도 있지만 동기식이라서 저장중에는 UI가 멈춘다. 그래서 비동기식인 apply를 개발자가 자주 사용함
getSharedPreferences(파일이름, Context.MODE_PRIVATE)를 하면 해당 파일을 읽어서 메모리 상에 올려서 사용을 하는 것이기 때문에 해당 파일을 수정을 하면 메모리 상에서만 수정이 된것이기 때문에 꼭 apply로 저장을 해주어야 함
- 값 읽기와 값 쓰기 예
edit.apply() - 수정된 값을 객체에 즉시 반영하고 파일 저장은 비동기로 수행한다.
edit.commit() - 수정된 값을 객체에 즉시 반영하고 파일 저장을 동기로 수행한다.
String 정의
res/values/strings.xml
<resources>
<string name="app_name">TEA</string>
<string name="elapsed_time_default">00:00:00</string>
<string name="elapsed_time">%02d:%02d:%02d</string>
<string name="no_time_saved">저장된 시간이 없습니다.</string>
<string name="no_food_saved">저장된 음식 정보가 없습니다.</string>
<string name="enter_food_name">섭취하신 음식을 입력하세요.</string>
<string name="save">저장</string>
</resources>
activity_main.xml 작성
-
더보기
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/main" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <TextView android:id="@+id/textViewLastTime" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/no_time_saved" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintVertical_bias="0.15" /> <TextView android:id="@+id/textViewLastFood" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/no_food_saved" app:layout_constraintEnd_toEndOf="@+id/textViewLastTime" app:layout_constraintStart_toStartOf="@+id/textViewLastTime" app:layout_constraintTop_toBottomOf="@+id/textViewLastTime" /> <TextView android:id="@+id/textViewElapsedTime" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="32dp" android:text="@string/elapsed_time_default" android:textSize="30sp" android:textStyle="bold" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textViewLastFood" /> <EditText android:id="@+id/editTextFood" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="32dp" android:autofillHints="" android:ems="12" android:gravity="center" android:hint="@string/enter_food_name" android:inputType="text" android:minHeight="48dp" android:textColorHint="#546E7A" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textViewElapsedTime" /> <Button android:id="@+id/buttonSave" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/save" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/editTextFood" /> </androidx.constraintlayout.widget.ConstraintLayout>
ElapsedTime Data Class 추가
data class ElapsedTime (
val hours:Long = 0,
val minutes:Long = 0,
val seconds:Long = 0
)
MainActivity
- getElapstedTime, saveRecord 함수를 직접 구현해본다.
class MainActivity : AppCompatActivity() {
private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) } // binding 생성
private lateinit var preference:SharedPreferences // SharedPreferences 변수 생성
overri @de fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(binding.root) // binding.root를 화면에 표시
ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
preference = getSharedPreferences("food_record", MODE_PRIVATE) // SharedPreferences 객체 생성
val lastFood = preference.getString("food", null) // food_record.xml 파일에서 food 키의 값을 가져옴
val lastTime = preference.getString("time", null) // food_record.xml 파일에서 time 키의 값을 가져옴
updateWidgets(lastFood, lastTime) // updateWidgets 함수 호출
binding.buttonSave.setOnClickListener{ saveRecord() }
}
private fun saveRecord() {
val time = LocalDateTime.now().toString() // 현재 시간을 문자열로 변환
val food = binding.editTextFood.text.toString() // editTextFood의 텍스트를 가져와 food 변수에 저장
if(food.isNotEmpty()) {
preference.edit().run {
putString("food", food)
putString("time", time)
apply()
}
updateWidgets(food, time)
}
}
private fun getElapsedTime(time:String?):ElapsedTime {
if(time == null)
return ElapsedTime()
val now = LocalDateTime.now() // 현재 시간을 LocalDateTime 객체로 변환
val before = LocalDateTime.parse(time) // 문자열로 된 시간을 LocalDateTime 객체로 변환
val hours = ChronoUnit.HOURS.between(before, now) // 시간 차이를 시간 단위로 계산
val minutes = ChronoUnit.MINUTES.between(before, now) % 60 // 시간 차이를 분 단위로 계산
val seconds = ChronoUnit.SECONDS.between(before, now) % 60 // 시간 차이를 초 단위로 계산
return ElapsedTime(hours, minutes, seconds) // ElapsedTime 객체 반환
}
private fun updateWidgets(lastFood:String?, lastTime:String?) {
val elapsedTime = getElapsedTime(lastTime)
binding.textViewLastFood.text = lastFood?:getString(R.string.no_food_saved)
binding.textViewLastTime.text = lastTime?:getString(R.string.no_time_saved)
binding.textViewElapsedTime.text =
getString(R.string.elapsed_time, elapsedTime.hours, elapsedTime.minutes, elapsedTime.seconds)
}
}

time을 DateTime으로 저장안한 이유 SharedPreferences는 DateTime을 저장하지 못함
문제점
- 경과 시간이 업데이트가 안된다.
- Timer를 사용하면?
- 안드로이드에서는 Original Thread(MainThread) 에서만 UI를 업데이트 할 수 있도록 허용한다.
- 타이머를 돌리는 것은 가능하지만 해당 정보로 UI를 업데이트하는데 제약이 있다.
Activity ——————————————————————————> (계속유지)
onCreate → onStart → onResume → 가로/세로 변경 → onCreate부터 다시 호출
문제점 : Activity를 새로 만드는건 아니지만 레이아웃이 다시 그려지면서 작성된 데이터가 날아갈 수도 있다.
Activity가 살아 있는 동안 유지되는 데이터를 만들어주세요. (가로, 세로 변경되어도 데이터는 유지되도록 해주세요 → onCreate가 다시 불려도 초기화 되지 않는 데이터) == ViewModel
ViewModel
내 앱이 켜져서 꺼질 때까지는 유지되는 데이터
- ViewModel: Activity, Fragment 등의 요소의 수명 주기를 고려한 UI 관련 데이터 저장소. -> 화면 회전 등의 경우에 데이터를 보존할 수 있다.
- LiveData: 자신의 정보가 변경되면 Observer 객체에게 이를 알리는 데이터.
- Observer: ViewModel을 관찰하는 클래스. Observer가 START 또는 RESUMED 상태 일때 관찰자가 활성화 되었다고 판단. -> 활성화된(엑티비티가 화면에 떠있는 상태) 관찰자에게만 업데이트를 알림.

LiveData → 관찰자에게 데이터의 업데이트를 알려줌
Activity가 Observe로 LiveData를 구독한다고 생각하면 됨
안드로이드에서는 평시적으로 Background Thread에서는 UI를 새로 고치는걸 못하는데 ViewModel을 사용하면 Background Thread에서도 UI를 업데이트를 할 수 있다.
- 기존 구조
- 데이터를 변경한 함수에서 UI 업데이트까지 수행해야 함
- MainThread 에서만 UI 변경
- ViewModel을 사용하면:
- (실행되는 Thread 상관 없이)함수에서는 데이터만 변경.
- 관련된 observer가 UI를 업데이트한다
ViewModel의 수명 주기

- Activity의 회전 등에 의해 onCreate 가 다시 실행되어도 ViewModel은 유지된다.
- 서버로 전송할 영구 데이터가 아닌 UI를 그리는데 필요한 데이터를 적절히 보관한다
ViewModel과 LiveData를 위한 Dependency
- libs.versions.toml
[versions]
...
livedata = "2.7.0"
[libraries]
androidx-viewmodel = {group="androidx.lifecycle", name="lifecycle-viewmodel-ktx", version.ref="livedata"}
androidx-livedata = {group="androidx.lifecycle", name="lifecycle-livedata-ktx", version.ref="livedata"}
- build.gradle.kts(Module:app)
dependencies {
...
implementation(libs.androidx.livedata)
implementation(libs.androidx.viewmodel)
}
ViewModel 사용하기
- ViewModel은 일반 객체처럼 생성해서는 안된다. → 처음에만 생성하고 그 이후 onCreate 될 때 다시 생성되면 안되기 때문에
- → 처음에 ViewModel 생성 후 다른 곳에서는 이미 만들어진 ViewModel을 참조하는 형식으로 사용
- ViewModel 객체를 생성할 때 ViewModelProvider를 사용한다.
val model:MyViewModel = ViewModelProvider(this)[MyViewModel::class.java]
- libs.version.toml, build.gradle(Module:app) 에서 각각 다음 확인
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
implementation(libs.androidx.activity)
- Delegation → by viewModels() 를 사용한다.
- val model:MyViewModel by viewModels() 또는 val viewModel by viewModels<MainViewModel>()
- Delegation → by viewModels() 를 사용한다.
MainActivity에서 불러 쓰기
- 변수 선언부
import android.content.SharedPreferences
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.lunadev.tea.ElapsedTime
import com.lunadev.tea.MainViewModel
import com.lunadev.tea.R
import com.lunadev.tea.databinding.ActivityMainBinding
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import java.util.Timer
class MainActivity : AppCompatActivity() {
private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) } // binding 생성
private lateinit var preference:SharedPreferences // SharedPreferences 변수 생성
private val timer = Timer()
private val viewModel: MainViewModel by viewModels() // ViewModel 객체 생성
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(binding.root) // binding.root를 화면에 표시
ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
preference = getSharedPreferences("food_record", MODE_PRIVATE) // SharedPreferences 객체 생성
val lastFood = preference.getString("food", null) // food_record.xml 파일에서 food 키의 값을 가져옴
val lastTime = preference.getString("time", null) // food_record.xml 파일에서 time 키의 값을 가져옴
updateWidgets(lastFood, lastTime) // updateWidgets 함수 호출
binding.buttonSave.setOnClickListener{ saveRecord() }
viewModel.lastFood.value = lastFood // ViewModel의 lastFood 변수를 변경함
viewModel.lastTime.value = lastTime // ViewModel의 lastTime 변수를 변경함
// LiveData observe값이 수정될 경우 람다식이 동작하여 UI가 업데이트 됨
viewModel.lastFood.observe(this) { binding.textViewLastFood.text = it }
viewModel.lastTime.observe(this) { binding.textViewLastTime.text = it }
viewModel.updateElapsedTime.observe(this){
val elapsedTime = getElapsedTime(viewModel.lastTime.value)
binding.textViewElapsedTime.text = getString(
R.string.elapsed_time,
elapsedTime.hours,
elapsedTime.minutes,
elapsedTime.seconds)
}
viewModel.startTimer() // BackgroundThread에서 LiveData 값 수정
}
private fun saveRecord() {
val time = LocalDateTime.now().toString() // 현재 시간을 문자열로 변환
val food = binding.editTextFood.text.toString() // editTextFood의 텍스트를 가져와 food 변수에 저장
if(food.isNotEmpty()) {
preference.edit().run {
putString("food", food)
putString("time", time)
apply()
}
viewModel.lastTime.value = time
viewModel.lastFood.value = food
// MainThread에서 LiveData 값 수정 값만 수정하고 UI 업데이트는 신경 쓰지 않아도 됨
}
}
private fun getElapsedTime(time:String?): ElapsedTime {
if(time == null)
return ElapsedTime()
val now = LocalDateTime.now() // 현재 시간을 LocalDateTime 객체로 변환
val before = LocalDateTime.parse(time) // 문자열로 된 시간을 LocalDateTime 객체로 변환
val hours = ChronoUnit.HOURS.between(before, now) // 시간 차이를 시간 단위로 계산
val minutes = ChronoUnit.MINUTES.between(before, now) % 60 // 시간 차이를 분 단위로 계산
val seconds = ChronoUnit.SECONDS.between(before, now) % 60 // 시간 차이를 초 단위로 계산
return ElapsedTime(hours, minutes, seconds) // ElapsedTime 객체 반환
}
private fun updateWidgets(lastFood:String?, lastTime:String?) {
val elapsedTime = getElapsedTime(lastTime)
binding.textViewLastFood.text = lastFood?:getString(R.string.no_food_saved)
binding.textViewLastTime.text = lastTime?:getString(R.string.no_time_saved)
binding.textViewElapsedTime.text =
getString(R.string.elapsed_time, elapsedTime.hours, elapsedTime.minutes, elapsedTime.seconds)
}
}
ViewModel 코드
package com.lunadev.tea
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import java.util.Timer
import kotlin.concurrent.schedule
class MainViewModel: ViewModel() {
val lastTime = MutableLiveData<String>().apply { value="00" }
val lastFood = MutableLiveData<String>().apply { value="00" }
val updateElapsedTime = MutableLiveData<Boolean>()
private val timer = Timer()
fun startTimer(){
timer.schedule(1000, 1000) {
// Background Thread 에서는 postValue 를 사용해 값을 업데이트한다.
updateElapsedTime.postValue(true)
}
}
override fun onCleared() {
super.onCleared()
timer.cancel()
}
}
ViewModel 코드
package com.lunadev.tea
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import java.util.Timer
import kotlin.concurrent.schedule
class MainViewModel: ViewModel() {
val lastTime = MutableLiveData<String>().apply { value="00" }
val lastFood = MutableLiveData<String>().apply { value="00" }
val updateElapsedTime = MutableLiveData<Boolean>()
private val timer = Timer()
fun startTimer(){
timer.schedule(1000, 1000) {
// Background Thread 에서는 postValue 를 사용해 값을 업데이트한다.
updateElapsedTime.postValue(true)
}
}
override fun onCleared() {
super.onCleared()
timer.cancel()
}
}