🍀 ViewModel
🍎 안드로이드 젯팩이란
안드로이드 스튜디오 안드로이드 아키텍처 컴포넌트, 안드로이드 지원 라이브러리, 컴포즈 프레임워크와 안드로이드 앱 구조화 권고안을 통합한 일련의 가이드라인이다. 컴포넌트 중 많은 부분은 컴포즈에 내장된 기능으로 대체되었지만, Viewmodel 아키텍처 컴포넌트는 별도로 남아 있다.
구식 아키텍처 - 젯팩이 도입되기 전에는 대부분의 일반적인 아키텍처들이 하나의 액티비티가 사용자 인터페이스와 앱의 백엔드 로직을 함께 표현하고 관리하는 모든 코드를 포함하고 있었다.
모던 안드로이드 아키텍처 - 가이드라인들이 앱의 다양한 책임 부분을 완전히 별도의 모듈로 나누는 것을 권장한다. 이 접근 방식의 핵심 중 하나가 바로 ViewModel 컴포넌트다.
ViewModel 컴포넌트의 목적은 사용자 인터페이스와 관련된 데이터 모델과 앱의 로직으로 사용자 인터페이스를 표시 및 관리하고 운영체제와 상호작용하는 코드와 분리하는 것이다. 이런 방식으로 디자인된 앱은 하나 혹은 그 이상의 UI 컨트롤러와 이 컨트롤러들이 필요로 하는 데이터를 처리하는 ViewModel 인스턴스로 구성된다.
하나의 ViewModel은 별도의 클래스로 구현되며, 모델 데이터와 그 데이터를 관리하기 위해 호출될 수 있는 함수들을 포함한 상탯값을 포함한다.
ViewModel은 액티비티의 사용자 인터페이스에서 관찰할 수 있는 데이터를 저장하는 것을 주요 목표로 한다. 이를 통해 사용자 인터페이스는 ViewModel의 데이터가 변경되었을 때 반응할 수 있다. ViewModel 안에는 관찰 가능한 데이터를 두 가지 방법으로 선언할 수 있다.
- 컴포즈의 상태 메커니즘을 이용하는 것으로 이 방법은 이 책 전체에서 이용하고 있다.
- 젯팩 LiveData 컴포넌트를 이용하는 것으로 이 방법은 이번 장 후반부에서 설명한다.
컴포저블 안에서 선언되는 상태와 비슷하게 ViewModel 상태는 함수들의 mutableStateOf 그룹을 이용해 선언한다.
이 두 가지 기본 상태와 함수 빌딩 블록을 조합해서 사용한 것에 지나지 않음.
ViewModel은 사용자 인터페이스를 구성한 컴포저블 안에서 이용해야만 쓸모가 있다. 이를 위해서는 ViewModel 인스턴스를 컴포저블에 파라미터로 전달해, 컴포저블에서 상탯값과 함수에 접근할 수 있도록 해야 한다.
젯팩의 LiveData 컴포넌트는 컴포즈보다 먼저 도입되었으며 뷰 모델 안에서 데이터값을 감싸기 위한 목적으로 이용할 수 있다. LiveData 인스턴스 안에 포함된 변수는 액티비티 안의 컴포넌트에서 접근할 수 있게 된다.
LiveData를 다룰 때는 가장 먼저 컴포저블을 초기화하는 과정에서 뷰 모델의 인스턴스를 얻어야 한다. 그 다음 라이브 데이터 객체의 observeAsState()를 호출하면 라이브 데이터를 관측 가능하다.
프로젝트 - 섭씨와 화씨 온도를 변환하는 앱 구현
ViewModelDemo
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ViewModelDemoTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
ScreenSetup()
}
}
}
}
}
@Composable
fun ScreenSetup(viewModel: DemoViewModel = DemoViewModel()) {
MainScreen(
isFahrenheit = viewModel.isFahrenheit,
result = viewModel.result,
convertTemp = { viewModel.convertTemp(it) },
switchChange = { viewModel.switchChange() }
)
}
@Composable
fun MainScreen(
isFahrenheit: Boolean,
result: String,
convertTemp: (String) -> Unit,
switchChange: () -> Unit
) {
Column(horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize()) {
var textState by remember { mutableStateOf("") }
val onTextChange = { text : String ->
textState = text
}
Text("Temperature Converter",
modifier = Modifier.padding(20.dp),
style = MaterialTheme.typography.h4
)
InputRow(
isFahrenheit = isFahrenheit,
textState = textState,
switchChange = switchChange,
onTextChange = onTextChange
)
Text(result,
modifier = Modifier.padding(20.dp),
style = MaterialTheme.typography.h3
)
Button(
onClick = { convertTemp(textState) }
)
{
Text("Convert Temperature")
}
}
}
@Composable
fun InputRow(
isFahrenheit: Boolean,
textState: String,
switchChange: () -> Unit,
onTextChange: (String) -> Unit
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = isFahrenheit,
onCheckedChange = { switchChange() }
)
OutlinedTextField(
value = textState,
onValueChange = { onTextChange(it) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number
),
singleLine = true,
label = { Text("Enter temperature")},
modifier = Modifier.padding(10.dp),
textStyle = TextStyle(fontWeight = FontWeight.Bold,
fontSize = 30.sp),
trailingIcon = {
Icon(
painter = painterResource(R.drawable.ic_baseline_ac_unit_24),
contentDescription = "frost",
modifier = Modifier
.size(40.dp)
)
}
)
Crossfade(
targetState = isFahrenheit,
animationSpec = tween(2000)
) { visible ->
when (visible) {
true -> Text("\u2109", style = MaterialTheme.typography.h4)
false -> Text("\u2103", style = MaterialTheme.typography.h4)
}
}
}
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun DefaultPreview(model: DemoViewModel = DemoViewModel()) {
ViewModelDemoTheme {
MainScreen(
isFahrenheit = model.isFahrenheit,
result = model.result,
convertTemp = { model.convertTemp(it) },
switchChange = { model.switchChange() }
)
}
}
app -> java -> com.example.viewmodeldemo를 찾아서 마우스 오른쪽 버튼을 클릭한 뒤, New -> kotlin class/file 메뉴 옵션을 선택한다. 이후 나타나는 다이얼로그에서 클래스 이름을 DemoViewModel로 지정한 뒤 엔터 키를 입력.
ViewModel은 상태 변수들을 포함해야 하며, 이 변수들에는 변환 결과와 현재 스위치 위치가 저장된다.
뷰 모델 클래스를 선언했으므로 클래스의 인스턴스를 만들고 컴포저블과 통합해 MainActivity를 구성한다.
온도 입력 컴포저블 디자인 - 아이콘을 Tools를 이용해서 화면에 넣을 수 있다.
MainScreen 함수의 기능이 복잡해지는 것을 막기 위해 Switch, OutlinedTextField, 단위 표시 Text 컴포넌트는 InputRow라는 하나의 분리된 컴포넌트에 배치된다. MainActivity.kt 파일에 InputRow를 추가한다.
추가로 텍스트도 부여해서 넣어준다.
🍎 안드로이드 SQLite 데이터베이스 개요
대부분의 모바일 애플리케이션은 최소한의 영구적인 데이터를 저장한다. 데이터베이스 이용은 대부분의 애플리케이션에서 필수적으로 고려해야 한다.
-> 영구적인 데이터를 저장하는 것은 매우 중요하다.
- 데이터베이스 테이블은 데이터베이스에서 가장 기본 수준의 데이터 구조를 제공한다. 각 데이터베이스는 여러 테이블을 포함할 수 있고, 각 테이블은 특정한 타입의 정보를 저장한다.
- 데이터베이스의 각 테이블에는 해당 데이터베이스 안에서 고유한 이름이 할당되어 있다. 한 데이터베이스 안의 한 테이블에 할당된 테이블 이름은 다른 테이블 이름으로 이용할 수 없다.
- 데이터베이스 스키마는 데이터베이스 테이블에 저장되는 데이터의 특성을 정의한다. 또한 스키마를 이용하면 전체 데이터베이스와 각 데이터베이스에 포함된 다양한 테이블 사이의 관계도 정의할 수 있다.
- 데이터베이스 테이블은 데이터를 행과 열로 저장하는 스프레드시트와 유사하다고 생각하면 도움이 된다.
- 각 열은 해당 테이블의 데이터 필드를 나타낸다. (테이블의 이름, 주소, 전화번호 데이터 필드 등)
- 각 열은 계속해서 특정한 데이터 타입을 포함하도록 정의된다. 즉, 숫자를 저장하도록 디자인된 열은 숫자 데이터를 포함하는 것으로 정의된다.
- 테이블에 저장되는 새로운 각 레코드는 한 행에 저장된다. 각 행은 저장된 레코드와 관련된 데이터의 열로 구성된다.
- 행은 레코드 또는 항목이라 불리기도 하며 이 용어들은 일반적으로 함께 이용한다.
- 모든 데이터베이스 테이블은 각 행을 고유하게 식별할 수 있는 하나 이상의 열을 포함한다. 데이터베이스 용어에서는 이를 키본 키라 부른다. 고객 테이블은 고객의 주민등록번호를 기본 키로 이용할 수 있다.
🍎 SQLite
SQLite는 내장 관계형 데이터베이스 관리 시스템이다. 대부분의 관계형 데이터베이스는 데이터베이스 접근을 요구하는 애플리케이션에 독립적으로 실행되는 스탠드얼론 서버 프로세스다. SQL은 애플리케이션에 연결된 라이브러리 형태로 제공되기 때문에 내장형이라 불린다. 그렇기 때문에 백그라운드에서 스탠드얼론 데이터베이스 서버가 동작하지 않는다. 모든 데이터베이스 조작은 애플리케이션 안에서 SQLite 라이브러리에 포함된 함수를 호출해서 수행한다.
SQLite 데이터베이스에 저장된 데이터에는 구조화된 쿼리 언어라 불리는 고차원 언어를 이용해 접근한다. SQL은 대부분의 관계형 데이터베이스 관리 시스템에서 이용되는 표준 언어이며 SQL-92 표준 대부분을 준수한다.
AVD에서도 SQLite 이용할 수 있다.
🍎 안드로이드 Room 퍼시스턴스 라이브러리
SQLite는 C 프로그래밍 언어로 작성되었으며, 안드로이드 애플리케이션은 주로 자바 또는 코틀린 언어를 이용해 개발된다. 과거의 단점을 보완하기 위해서 안드로이드 젯팩 아키텍처 컴포넌트는 Room 퍼시스턴스 라이브러리를 제공한다. 이 라이브러리는 SQL 데이터베이스 시스템에 대한 고수준의 인터페이스를 제공하며, 이를 이용하면 최소한의 코딩만으로 현대 애플리케이션 아키텍처의 권고사항을 준수하면서 안드로이드 기긱에 국지적으로 데이터를 저장할 수 있다.
Room 데이터베이스 퍼시스턴스의 핵심 요소
저장소 - repository 모듈은 앱이 사용하는 모든 데이터 소스를 직접 조작하는 데 필요한 모든 코드를 포함한다. 이는 UI 컨트롤러나 ViewModel이 데이터베이스나 웹 서비스 같은 소스에 직접 접근하는 코드를 포함하는 것을 방지한다.
Room 데이터베이스 - room 데이터베이스 객체는 내부 SQLite 데이터베이스에 대한 인터페이스를 제공한다. 또한 데이터 접근 객체에 접근할 수 있는 저장소를 제공한다.
데이터 접근 객체 - DAO는 SQLite 데이터베이스 안에서 데이터를 삽입, 추출, 삭제하는 저장소가 필요로 하는 SQL 구문들을 포함한다. SQL 구문은 저장소 안에서 호출되는 메서드로 매핑되어 있으며, 이에 해당하는 쿼리들을 실행한다.
엔티티 - 엔티티는 데이터베이스 안의 테이블에 대한 스키마를 정의하는 클래스로 테이블 이름, 열 이름, 데이터 타입을 정의하고 어떤 열이 기본 키인지 식별한다. 엔티티 클래스는 테이블 스키마를 정의하고, 이 데이터 필드들에 접근하는 게터/세터 메서드를 포함한다.
SQLite 데이터베이스 - 데이터를 저장하고 데이터에 대한 접근을 제공한다. 저장소를 포함하는 앱 코드는 내부 데이터베이스에 직접 접근해서는 안 된다. 모든 데이터베이스 조작은 Room 데이터베이스, DAO, 엔티티를 조합해서 수행한다.
각 데이터베이스 테이블은 하나의 엔티티 클래스와 연관된다. 이 클래스는 테이블의 스키마를 정의하고 특별한 Room 애너테이션을 가진 표준 코틀린 클래스의 형태를 갖는다.
애너테이션은 엔티티 클래스 안에 포함시켜 다른 엔티티와의 관계를 만들 수 있다. 이는 관계형 데이터베이스에서 외부 키라 불린다. 외부 키를 이용하면 한 테이블에서 다른 테이블의 기본 키를 참조할 수 있다.
데이터 접근 객체를 이용하면 SQLite 데이터베이스 안에 저장된 데이터에 접근할 수 있다. DAO는 표준 코틀린 인터페이스로 선언된다. 추가 애너테이션을 이용해 특정한 SQL 구문과 저장소에서 호출되는 메서드들을 연결할 수 있다.
Room 데이터베이스 클래스는 RooomDatabase 클래스를 확장해서 생성하며, 안드로이드 운영체제 안에 내장된 실제 SQLite 데이터베이스의 최상위 레이어로 동작한다.
저장소는 데이터베이스 조작을 수행하는 DAO 메서드를 호출하는 코드를 포함한다. 저장소가 DAO에 접근한 뒤 데이터 접근 메서드를 호출할 수 있다.
인메모리 데이터베이스는 메모리 안에 존재하며 앱이 종료되면 사라진다. 이를 이용할 때는 Room.databaseBuilder() 대신 Room 데이터베이스 클래스의 Room.inMemoryDatabaseBuilder() 메서드만 호출하면 된다.
Database Inspector 를 이용하면 실행되는 앱과 관련된 Room 데이터베이스를 보고 검색하고 수정할 수 있다.
🍎 RoomDemo 프로젝트
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
RoomDemoTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
ScreenSetup()
}
}
}
}
}
@Composable
fun ScreenSetup(
viewModel: MainViewModel =
MainViewModel(LocalContext.current.applicationContext as Application)
) {
val allProducts by viewModel.allProducts.observeAsState(listOf())
val searchResults by viewModel.searchResults.observeAsState(listOf())
MainScreen(
allProducts = allProducts,
searchResults = searchResults,
viewModel = viewModel
)
}
@Composable
fun MainScreen(
allProducts: List<Product>,
searchResults: List<Product>,
viewModel: MainViewModel
) {
var productName by remember { mutableStateOf("") }
var productQuantity by remember { mutableStateOf("") }
var searching by remember { mutableStateOf(false) }
val onProductTextChange = { text : String ->
productName = text
}
val onQuantityTextChange = { text : String ->
productQuantity = text
}
Column(
horizontalAlignment = CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
) {
CustomTextField(
title = "Product Name",
textState = productName,
onTextChange = onProductTextChange,
keyboardType = KeyboardType.Text
)
CustomTextField(
title = "Quantity",
textState = productQuantity,
onTextChange = onQuantityTextChange,
keyboardType = KeyboardType.Number
)
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
) {
Button(onClick = {
viewModel.insertProduct(Product(productName,
productQuantity.toInt()))
searching = false
}) {
Text("Add")
}
Button(onClick = {
searching = true
viewModel.findProduct(productName)
}) {
Text("Search")
}
Button(onClick = {
searching = false
viewModel.deleteProduct(productName)
}) {
Text("Delete")
}
Button(onClick = {
searching = false
productName = ""
productQuantity = ""
}) {
Text("Clear")
}
}
LazyColumn(
Modifier
.fillMaxWidth()
.padding(10.dp)
) {
val list = if (searching) searchResults else allProducts
item {
TitleRow(head1 = "ID", head2 = "Product", head3 = "Quantity")
}
items(list) { product ->
ProductRow(id = product.id, name = product.productName,
quantity = product.quantity)
}
}
}
}
@Composable
fun TitleRow(head1: String, head2: String, head3: String) {
Row(
modifier = Modifier
.background(MaterialTheme.colors.primary)
.fillMaxWidth()
.padding(5.dp)
) {
Text(head1, color = Color.White,
modifier = Modifier
.weight(0.1f))
Text(head2, color = Color.White,
modifier = Modifier
.weight(0.2f))
Text(head3, color = Color.White,
modifier = Modifier.weight(0.2f))
}
}
@Composable
fun ProductRow(id: Int, name: String, quantity: Int) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(5.dp)
) {
Text(id.toString(), modifier = Modifier
.weight(0.1f))
Text(name, modifier = Modifier.weight(0.2f))
Text(quantity.toString(), modifier = Modifier.weight(0.2f))
}
}
@Composable
fun CustomTextField(
title: String,
textState: String,
onTextChange: (String) -> Unit,
keyboardType: KeyboardType
) {
OutlinedTextField(
value = textState,
onValueChange = { onTextChange(it) },
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType
),
singleLine = true,
label = { Text(title)},
modifier = Modifier.padding(10.dp),
textStyle = TextStyle(fontWeight = FontWeight.Bold,
fontSize = 30.sp)
)
}
package com.example.roomdemo
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
class MainViewModel(application: Application) {
val allProducts: LiveData<List<Product>>
private val repository: ProductRepository
val searchResults: MutableLiveData<List<Product>>
init {
val productDb = ProductRoomDatabase.getInstance(application)
val productDao = productDb.productDao()
repository = ProductRepository(productDao)
allProducts = repository.allProducts
searchResults = repository.searchResults
}
fun insertProduct(product: Product) {
repository.insertProduct(product)
}
fun findProduct(name: String) {
repository.findProduct(name)
}
fun deleteProduct(name: String) {
repository.deleteProduct(name)
}
}
엔티티 구축하는 과정을 통해서 데이터베이스 테이블 스키마를 정의하는 과정을 거쳐야 한다.
package com.example.roomdemo
import androidx.annotation.NonNull
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "products")
class Product {
@PrimaryKey(autoGenerate = true)
@NonNull
@ColumnInfo(name = "productId")
var id: Int = 0
@ColumnInfo(name = "productName")
var productName: String = ""
var quantity: Int = 0
constructor() {}
constructor(id: Int, productname: String, quantity: Int) {
this.productName = productname
this.quantity = quantity
}
constructor(productname: String, quantity: Int) {
this.productName = productname
this.quantity = quantity
}
}
위에 처럼 product 엔티티를 정의한 뒤에는 DAO 인터페이스를 만든다. -> ProductDao 파일을 생성해준다. (데이터 접근 객체 만들기)
package com.example.roomdemo
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@Dao
interface ProductDao {
@Insert
fun insertProduct(product: Product)
@Query("SELECT * FROM products WHERE productName = :name")
fun findProduct(name: String): List<Product>
@Query("DELETE FROM products WHERE productName = :name")
fun deleteProduct(name: String)
@Query("SELECT * FROM products")
fun getAllProducts(): LiveData<List<Product>>
}
그 다음, Room 데이터베이스를 추가해줘야 한다. -> room 데이터베이스 인스턴스를 구현한다.
package com.example.roomdemo
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [(Product::class)], version = 1)
abstract class ProductRoomDatabase: RoomDatabase() {
abstract fun productDao(): ProductDao
companion object {
private var INSTANCE: ProductRoomDatabase? = null
fun getInstance(context: Context): ProductRoomDatabase {
synchronized(this) {
var instance = INSTANCE
if (instance == null) {
instance = Room.databaseBuilder(
context.applicationContext,
ProductRoomDatabase::class.java,
"product_database"
).fallbackToDestructiveMigration()
.build()
INSTANCE = instance
}
return instance
}
}
}
}
그 다음, 저장소 추가해주면 된다. 이 클래스의 인스턴스가 만들어지면, 이 인스턴스의 참조를 ProductDao 객체에 전달해야 한다.
저장소 클래스는 ViewModel이 호출해 데이터베이스 조작을 초기화할 수 있도록 몇 가지 메서드를 제공해야 한다.
그 다음, ViewModel을 추가해주면 되는데 ViewModel은 데이터베이스, DAO, 저장소 인스턴스 생성과 UI 컨트롤러에서 이벤트를 다루기 위해 활용되는 메서드와 LiveData 객체를 제공한다.
이를 다 구현했으면 사용자 인터페이스를 디자인해주면 된다.
MainScreen 함수 안에 몇 가지 상태와 이벤트 핸들러를 추가해주면 된다.