이 프로젝트는 기본적인 인벤토리 앱으로 제품의 이름과 수량을 저장한다. 앱은 데이터베이스 항목 추가, 삭제, 검색할 수 있으며 데이터베이스에 현재 저장된 모든 제품을 스크롤할 수 있는 리스트로 표시한다. 이 제품 리스트는 데이터베이스 항목이 추가 또는 삭제되면 자동으로 업데이트된다.
데이터베이스 스키마를 정의하는 엔티티를 만든다. 엔티티는 제품 ID, 제품 이름, 수량을 갖는다. 제품 ID가 기본 키로 사용되고 자동으로 생성되도록 한다.
@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(productName: String, quantity: Int) {
this.productName = productName
this.quantity = quantity
}
}
위 코드에서 SQL 쿼리에서 quantity 열을 참조할 필요는 없으므로, quantity 변수에는 열 이름을 할당하지 않는다.
@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>>
}
위 Dao에서는 제품 데이터베이스에 레코드를 추가, 검색, 삭제하는 메서드를 구현했다. 삽입 메서드는 저장할 데이터를 포함하는 Product 엔티티 객체를 전달하고, 검색 및 삭제 메서드에는 조작 대상 제품의 이름을 포함한 문자열을 전달한다. getAllProducts() 메서드는 데이터베이스의 모든 레코드를 포함하는 하나의 LiveData 객체를 반환하며, 이 객체를 이용해 사용자 인터페이스 레이아웃의 제품 리스트를 데이터베이스와 동기화한다.
@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 = context.applicationContext,
ProductRoomDatabase::class.java,
"product_database"
).fallbackToDestructiveMigration()
.build()
INSTANCE = instance
}
return instance
}
}
}
}
저장소 클래스는 ViewModel을 대신해 Room 데이터베이스와 상호작용하는 책임을 지며, DAO를 이용해 제품 레코드를 추가, 삭제, 질의하는 메서드를 제공해야 한다. getAllProducts() DAO 메서드(이 메서드는 LiveData를 반환한다)를 제외하고 데이터베이스 조작은 메인 스레드와 분리된 스레드에서 실행해야 한다.
class ProductRepository(private val productDao: ProductDao) {
val searchResults = MutableLiveData<List<Product>> ()
}
위 코드는 searchResults라는 이름의 변수를 선언하고 비동기 검색 태스크가 완료될 때마다 검색 조작 결과를 저장한다(이후 viewModel 안의 옵저버가 이 라이브 데이터 객체를 모니터링하게 한다). 이 인스턴스의 참조를 ProductDao 객체에 전달해야 한다.
이 저장소 클래스는 viewModel이 호출해 데이터베이스 조작을 초기화할 수 있도록 몇 가지 메서드를 제공해야 한다. 메인 스레드에서 데이터베이스 조작을 수행하는 것을 방지하기 위해 저장소는 코루틴을 이용한다.
class ProductRepository(private val productDao: ProductDao) {
val searchResults = MutableLiveData<List<Product>> ()
private val coroutineScope = CoroutineScope(Dispatchers.Main)
fun insertProduct(newProduct: Product) {
coroutineScope.launch(Dispatchers.IO) {
productDao.insertProduct(newProduct)
}
}
fun deleteProduct(name: String) {
coroutineScope.launch(Dispatchers.IO) {
productDao.deleteProduct(name)
}
}
fun findProduct(name: String) {
coroutineScope.launch(Dispatchers.Main) {
searchResults.value = asyncFind(name).await()
}
}
private fun asyncFind(name: String): Deferred<List<Product>?> =
coroutineScope.async(Dispatchers.IO) {
return@async productDao.findProduct(name)
}
}
검색 조작 시 asyncFind() 메서드는 지연된 값을 이용해 검색 결과를 findProduct() 메서드로 반환한다. findProduct() 메서드는 searchResults 변수에 접근해야 하므로 asyncFind() 메서드에 대한 호출은 메인 스레드로 전달되고, 메인 스레드는 차례로 IO 디스패처를 이용해 데이터베이스 조작을 수행한다.
사용자 인터페이스 레이아웃에 추가될 LazyColumn은 데이터베이스에 저장된 현재 제품 리스트의 최신 상태를 유지해야 한다. ProductDao 클래스는 이미 getAllProducts() 메서드를 가지고 있으며, 이 메서드는 SQL 뭐리를 이용해 모든 데이터베이스 레코드를 선택하고 이들을 하나의 LiveData 객체로 감싸서 반환할 수 있다. 저장소는 초기화 단계에서 이 메서드를 한 번 호출하고 그 결과를 LiveData 객체 안에 저장한다. 저장된 결과는 viewModel과 액티비티에 의해 관찰된다. 이 설정을 마치고 나면 데이터베이스 테이블에 변경이 발생할 때마다, 액티비티 옵저버가 이를 감지하고 LazyColumn이 최신 제품 리스트를 재구성한다. 이에 따라 ProductRepository 파일에 LiveData 변수와 DAO의 getAllProducts() 메서드 호출을 추가한다.
class ProductRepository(private val productDao: ProductDao) {
val allProducts: LiveData<List<Product>> = productDao.getAllProducts()
val searchResults = MutableLiveData<List<Product>> ()
private val coroutineScope = CoroutineScope(Dispatchers.Main)
.
.
.
}
viewModel은 데이터베이스, DAO, 저장소 인스턴스 생성과 UI 컨트롤러에서 이벤트를 다루기 위해 활용되는 메서드와 LiveData 객체를 제공한다.
viewModel 파일이 애플리케이션 인스턴스와 몇몇 프로퍼티, 초기화 블록을 받을 수 있도록 다음과 같이 코드를 작성한다. 안드로이드 Context 클래스로 표현되는 애플리케이션 컨텍스트는 애플리케이션 코드 안에서 이용되며, 런타임에 애플리케이션 리소스에 접근한다. 또한 애플리케이션 컨텍스트에 대해 다양한 메서드를 호출함으로써 애플리케이션 환경에 대한 정보를 얻거나 변경할 수 있다. 여기서 애플리케이션 컨텍스트는 데이터베이스를 만들 때 필요하며 액티비티 안에서 뷰모델로 전달해야 한다.
class MainViewModel(application: Application): ViewModel() {
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
}
}
viewModel에서의 마지막 작업으로 버튼을 클릭했을 때 액티비티 안에서 호출될 메서드를 구현한다.
class MainViewModel(application: Application): ViewModel() {
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)
}
}
RoomDemoActivity의 코드를 다음과 같이 작성한다.
@Composable
fun TitleRow(head1: String, head2: String, head3: String) {
Row(
modifier = Modifier
.background(MaterialTheme.colorScheme.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
)
)
}
이 프로젝트에서 생성한 뷰모델은 애플리케이션 인스턴스에 대한 참조를 전달해야 한다. 이 경우 viewModel() 함수를 사용하여 뷰모델을 생성하면 애플리케이션 인스턴스에 대한 참조를 전달할 수 없다. 따라서 커스텀 ViewModelProvider Factory 클래스를 함수에 전달해야 한다. 이 클래스는 애플리케이션 참조를 받아 초기화된 MainViewModel 인스턴스를 반환한다.
RoomDemoActivity 파일에 팩토리 클래스를 추가한다.
class RoomDemoActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
MainScreen()
}
}
}
}
}
// 새로 추가한 Factory 클래스
class MainViewModelFactory(val application: Application) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return MainViewModel(application) as T
}
}
viewModel() 함수는 팩토리 및 현재 ViewModelStoreOwner에 대한 참조를 필요로 한다. 뷰모델 저장소는 일종의 컨테이너로 간주할 수 있으며, 그 안에는 현재 활성화된 뷰모델들이 각 모델에 대한 식별 문자열(viewModel() 호출 시 함께 전달되어야 한다)과 함께 저장된다.
RoomDemoActivity의 onCreate() 메서드를 다음과 같이 수정한다.
class RoomDemoActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
val owner = LocalViewModelStoreOwner.current
owner?.let {
val viewModel: MainViewModel = viewModel(
it,
"MainViewModel",
MainViewModelFactory(
LocalContext.current.applicationContext as Application
)
)
RoomDemoScreenSetup(viewModel)
}
}
}
}
}
}
추가된 코드는 가장 먼저 현재 로컬 뷰모델 저장 소유자에 대한 참조를 얻는다. 소유자가 null이 아님을 확인한 뒤 viewModel() 함수를 호출하고 소유자, 하나의 식별 문자열, 뷰모델 팩토리(애플리케이션 참조를 전달하는)를 전달한다. viewModel() 함수 호출로 반환된 뷰모델은 RoomDemoScreenSetup() 함수로 전달된다.
다음으로 RoomDemoScreenSetup 함수에서 viewModel 인스턴스를 만들고 이를 이용해 allProducts와 searchResults 라이브 데이터 객체를 상탯값으로 변경하고 빈 리스트로 초기화한다. 이 상태들은 뷰모델과 함께 MainScreen 컴포저블에 전달된다.
@Composable
fun MainScreen(
allProducts: List<Product>,
searchResults: List<Product>,
viewModel: MainViewModel
) {
}
@Composable
fun RoomDemoScreenSetup(viewModel: MainViewModel) {
val allProducts by viewModel.allProducts.observeAsState(listOf())
val searchResults by viewModel.searchResults.observeAsState(listOf())
MainScreen(
allProducts = allProducts,
searchResults = searchResults,
viewModel = viewModel
)
}
지금까지의 과정에서 ViewModel 인스턴스를 만들 때 LocalContext 객체를 이용해 애플리케이션 컨텍스트에 대한 참조를 얻어, 이를 뷰모델에 전달해 데이터베이스를 만들 때 이용할 수 있도록 한 점에 유의한다.
MainScreen 함수 안에 몇 가지 상태와 이벤트 핸들러를 추가한다.
@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 = Alignment.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 = {
if (productQuantity.isNotEmpty()) {
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)
}
}
}
}
이제 프로젝트를 실행하면 기능이 정상 작동하는 것을 볼 수 있다.
