멋쟁이 사자처럼에서 진행하는 안드로이드 앱 스쿨에서 멘토링을 받고 배운 지식들을 정리하는 글입니다.
👩💻 오늘의 할 말
요즘 프로젝트를 하느라 바빠서 오랜만에 글을 쓰네요. 빨리 프로젝트를 마무리하고 싶습니다. 다음 계획으론 MVVM과 GoF의 디자인 패턴을 공부할 건데 재미있어 보여서 얼렁 빨리 하고 싶어요😭 공부할 책이랑 강의도 미리 정해놔서 빨리 하고싶습니다...!🔥
📕 질문
내가 등록한 매장 정보를 보여주는 프래그먼트에서 정보를 수정 후, 사진첩을 열고 돌아오면 기존의 수정 데이터가 저장되지 않고 수정 이전의 데이터를 보여주며 사진첩에서 이미지를 선택하고 변경해도 이미지가 변경되지 않습니다.
📌Layout.xml
Layout은 매장 정보는 Local Database인 Room에서 가져온 정보를 데이터 바인딩을 사용하여 렌더링 하며 이미지는 바인딩 어댑터를 사용해서 보여주도록 구현하였습니다.
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:bind="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="vm"
type="com.myproject.cloudbridge.viewModel.StoreManagementViewModel" />
</data>
(생략...)
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/storeName_Layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
app:boxStrokeColor="@color/blue_grey"
app:errorEnabled="true"
app:helperTextTextColor="@color/orange_red"
app:startIconDrawable="@drawable/baseline_store_24"
app:startIconTint="#000">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/storeName_edit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{vm.myStore.storeName}"
android:fontFamily="@font/rubik_bold"
/>
</com.google.android.material.textfield.TextInputLayout>
(생략...)
📌ViewModel - Model
- 데이터 바인딩을 위해 사용된 ViewModel Class와 BindingAdapter, Model Class입니다.
- DataStore에 저장된 나의 사업자 등록번호를 Primary Key로 Room에서 데이터를 가져왔습니다.
- Room에서 꺼내온 데이터는 Flow Type입니다. 그래서 StateFlow Type으로 변환하기 위해 stateIn 함수를 사용하였습니다.
class StoreManagementViewModel: ViewModel() {
private lateinit var myStoreInfoRequestModel: MyStoreInfoRequestModel
private val networkRepository = NetworkRepository()
private val dbRepository = DBRepository()
// 나의 매장 정보
private var _myStore: MutableStateFlow<StoreEntity> = MutableStateFlow(createFirstData())
val myStore: StateFlow<StoreEntity> get() = _myStore
fun getMyStoreInfo() = viewModelScope.launch(Dispatchers.IO) {
try {
// 1. Datastore에 저장된 사업자 등록번호를 가지고
MainDataStore.getCrn().collect{ crn->
// 2. Room에서 나의 매장 정보를 가져와
val response = dbRepository.getMyStoreInfo(crn)
// 3. flow로 반환된 데이터를 StateFlow로 변환해 저장
response.stateIn(viewModelScope).collect{ myStore->
_myStore.value = myStore
}
}
}catch (e: Exception){
Log.d("MyPageViewModel","MyPageViewModel: $e")
}
}
}
###################################################################################
object MyStoreBindingAdapter {
// 1) 이미지뷰에 새로운 xml 속성 만들기
// [속성명 : myStoreImage ]
@JvmStatic
@BindingAdapter("myStoreImage") //어노테이션 해독기 필요 - 빌드 그래이들에 기능 추가 필요!(kapt)
fun imageBindingAdapter(view: ImageView, image: Bitmap?){
if (image != null) {
Glide.with(view.context).load(image).into(view)
}
}
}
###################################################################################
@Entity(tableName = "store_table")
data class StoreEntity (
@PrimaryKey
@ColumnInfo(name = "company_registration_number")
val crn: String,
@ColumnInfo(name = "store_image")
val image: Bitmap,
@ColumnInfo(name = "store_name")
val storeName: String,
@ColumnInfo(name = "ceo_name")
val ceoName: String,
val contact: String,
val address: String,
val latitude: String,
val longitude: String,
val kind: String
)
기본적인 나의 매장 정보를 보여주는 로직은 위와 같이 구현해 주었습니다. 이제 저는 View의 수정된 데이터를 받기 위해 새로운 데이터 모델을 만들었습니다.
🤔 왜 새로운 모델을 만들었나요?
viewModel에서는 사용자에게 보여줄 나의 매장 정보를 backing Property로 선언했습니다.
backing Property를 사용한 한 이유는 MVVM에서 다양한 아키텍처적 관점에 대한 장점이 있기 때문입니다.
우선 _myStore는 외부에서 관찰할 수 없지만 변경 가능한 타입입니다. myStore는 불변 타입이지만 외부에서 관찰할 수 있으며 _myStore를 통해 데이터를 가져옵니다. 이렇게 하면 ViewModel에서는 데이터 관련한 처리를 하고, UI에서는 데이터를 보여 주기만 할 수 있어 UI와 비즈니스 로직을 분리하고 캡슐화를 제공할 수 있습니다.
또한 이를 통해 View와 ViewModel의 역할을 완전히 분리함으로써 단일 책임 원칙을 준수할 수 있습니다.
이외에도 클린 아키텍처적인 장점이 더 많을 것 같은데 아직 제가 클린 아키텍처에 대한 지식이 부족해서 좀 더 공부해야겠습니다😭
그래서 저는 UI에서 사용자의 입력값으로 기존 ViewModel에 있던 데이터를 변경하는 것이 불가능하다고 생각해서 새로운 데이터 모델을 만들었지만 이후 이게 문제의 원인이 되었습니다.
data class ModifyStoreStateSaveModel (
var storeName: String, // 매장명
var ceoName: String, // 점주명
var contact: String, // 전화번호
var address: String, // 주소
var kind: String // 업종
)
class StoreManagementViewModel: ViewModel() {
private lateinit var myStoreInfoRequestModel: MyStoreInfoRequestModel
private val networkRepository = NetworkRepository()
private val dbRepository = DBRepository()
// 나의 매장 수정 정보
private var _myModifyStoreInfo: MutableStateFlow<ModifyStoreStateSaveModel> = MutableStateFlow(
ModifyStoreStateSaveModel("", "", "", "", "",))
val myModifyStoreInfo: StateFlow<ModifyStoreStateSaveModel> get() = _myModifyStoreInfo
fun updateSavedData(modifyData: ModifyStoreStateSaveModel) {
_myModifyStoreInfo.value = modifyData
}
}
액티비티에선 이미지 등록 버튼을 눌러 갤러리로 화면이 전환되면 입력되어 있던 매장 정보들을 viewModel에 업데이트합니다. 이후 다시 프래프먼트로 돌아와 LifeCycle이 onstart( ) 상태 일 때, 저장돼있던 데이터들을 화면에 보여주려 했습니다.
이렇게 생각한 이유는 Fragment → 갤러리의 화면 전환에서 프래그먼트는 백그라운드 상태가 되며 Fragment LifeCycle은 onStop( ) 상태가 됩니다. 그리고 viewModel은 연결된 Activity나 Fragment가 완전히 제거 (onDestroy) 된 후에 클리어(onCleared) 된다는 점을 이용해서 ViewModel에 데이터를 저장하려 했습니다.
class UpdateFragment2 : Fragment(), View.OnClickListener{
(생략...)
override fun onStart() {
super.onStart()
viewLifecycleOwner.lifecycleScope.launch {
viewModel.myModifyStoreInfo.collectLatest{
if (it.storeName.isNotEmpty()){
setSavedStateInstance(it)
}
}
}
}
override fun onClick(v: View?) {
when(v?.id){
(생략 ...)
R.id.img_load_button -> {
if (isAllPermissionsGranted()){
getSavedStateInstance()
accessGallery()
}
else{
launcherForPermission.launch(REQUEST_STORAGE_PERMISSIONS)
}
}
}
}
private fun getSavedStateInstance(){
with(binding){
val modifyData = ModifyStoreStateSaveModel(
storeNameEdit.text.toString(),
ceoNameEdit.text.toString(),
phoneEdit.text.toString(),
addrEdit.text.toString(),
kindEdit.text.toString()
)
viewModel.updateSavedData(modifyData)
}
}
}
private fun setSavedStateInstance(state: ModifyStoreStateSaveModel) {
with(binding){
storeNameEdit.setText(state.storeName)
ceoNameEdit.setText(state.ceoName)
phoneEdit.setText(state.contact)
addrEdit.setText(state.address)
}
}
문제는 여전히 화면 전환 후 데이터가 소실되는 현상이 발생하였습니다. 제가 생각했을 때, 데이터 바인딩에 바인딩된 ViewModel 인스턴스 때문에 새로운 모델에 저장했던 데이터를 보여줄 수 없다고 생각을 했지만 해결 방법을 찾지 못해 멘토링을 신청하게 되었습니다.
📖 이에 대해 멘토링에선 다음과 같은 답변을 얻을 수 있었습니다.
override fun onStart() {
super.onStart()
viewLifecycleOwner.lifecycleScope.launch {
viewModel.myModifyStoreInfo.collectLatest{
if (it.storeName.isNotEmpty()){
setSavedStateInstance(it)
}
}
}
}
1️⃣ onStart에서 코루틴을 실행할 필요가 없었습니다.
프래그먼트가 화면 전환 후 다시 포그라운드로 전환되면 LifeCycle이 onStart( )부터 동작하기 때문에 이 타이밍에 맞춰서 저장했던 데이터를 렌더링 하려 했습니다. 하지만 제가 위에서 언급했던 것처럼 viewModel은 Fragment가 onDestroy( ) 되기 전까지 살아있기 때문에 굳이 onStart( ) 에서 수행할 필요가 없었습니다.
2️⃣ 데이터 바인딩에 사용된 Model Class를 변경하면 안 됩니다.
- 이 부분도 제가 생각했던 문젠데 왜 하나만 알고 둘은 몰랐을까요 😭😭😭😭😭😭😭😭
3️⃣ 자자 오늘의 하이라이트 사용자의 입력값으로 ViewModel에서 데이터를 변경하는 방법을 보겠습니다.
class StoreManagementViewModel: ViewModel() {
// 나의 매장 정보
private var _myStore: MutableStateFlow<StoreEntity> = MutableStateFlow(createFirstData())
val myStore: StateFlow<StoreEntity> get() = _myStore
// 사용자 수정 데이터 업데이트
fun updateSavedData(storeName: String, ceoName: String, contact: String, address: String, kind: String) {
// 바뀌지 않은 값은 유지
_myStore.value = _myStore.value.copy(
storeName = storeName,
contact = contact,
ceoName = ceoName,
address = address,
kind = kind
)
}
}
copy( ) 함수는 얕은 복사(shallow copy)를 수행하며 이 함수는 새로운 객체를 만들어 기존 객체의 필드를 변경하거나 유지하는 데 사용됩니다. 이를 통해 기존의 저장되어 있던 데이터는 유지하면서 사용자가 수정한 값은 변경이 가능해집니다.
깨알 팁으로 Named Argument라는 문법도 배울 수 있었습니다. Named Argument는 데이터 클래스 생성 시 필요한 파라미터들의 이름을 지정해 주어 파라미터를 입력 순서와 상관없이 전달할 수 있고 가독성 또한 향상할 수 있습니다.
제가 모르는 코틀린의 유용한 기능들이 정말 많은 것 같습니다. 왜 코틀린이 좋은 언어인지 새삼 깨닫게 되네용... 아직 내용이 한참 남았지만 글이 길어져는 것 같아 다음 글에서 작성하겠슴당 👼
'TEKHIT ANDROID SCHOOL' 카테고리의 다른 글
[TEKHIT] 오늘의 멘토링 (1) | 2024.02.16 |
---|---|
[TEKHIT] 오늘의 멘토링 (1) | 2024.02.14 |
[TEKHIT] 오늘의 멘토링 (1) | 2024.02.07 |
[TECKHIT] Material3 Text fields (0) | 2024.01.22 |
[TECKHIT] Material3 Button (1) | 2024.01.21 |