TEKHIT ANDROID SCHOOL

[TEKHIT] 오늘의 멘토링

빨주노초잠만보 2024. 2. 13. 15:13

멋쟁이 사자처럼에서 진행하는 안드로이드 앱 스쿨에서 멘토링을 받고 배운 지식들을 정리하는 글입니다. 

👩‍💻 오늘의 할 말

요즘 프로젝트를 하느라 바빠서 오랜만에 글을 쓰네요.  빨리 프로젝트를 마무리하고 싶습니다. 다음 계획으론 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는 데이터 클래스 생성 시 필요한 파라미터들의 이름을 지정해 주어 파라미터를 입력 순서와 상관없이 전달할 수 있고 가독성 또한 향상할 수 있습니다. 

 

제가 모르는 코틀린의 유용한 기능들이 정말 많은 것 같습니다. 왜 코틀린이 좋은 언어인지 새삼 깨닫게 되네용... 아직 내용이 한참 남았지만 글이 길어져는 것 같아 다음 글에서 작성하겠슴당 👼