[TIL][내일배움캠프]

[내일배움캠프][TIL] 24.03.11 (월) - 최종 프로젝트 4주차 : MVVM 적용하기

kimlaurant 2024. 3. 11. 21:32
1. 최종 프로젝트 4주차

 

저번주차까지 MVP를 구현하는 데에 시간을 쏟아부었다.

이번주차부터는 여기에 추가적으로 구현해야 하는 기능들을 구현시키고, 테스트를 통해 문제점들을 보완하면서 최종적으로 배포를 완료하는 것이 목표이다.

 

우선, 그 첫번째로 한 것은 바로바로…!

 

 

MVVM

 

바로 MVVM (Model - View - Viewmodel) 이다.

 

이 MVVM 패턴을 굳이 사용하지 않아도 되지만, 이걸 사용하는 가장 주요한 이유는 단연 유지보수가 용이하기 때문이다.

특히나 이렇게 하나하나씩 추가기능을 만드는 중인 지금같은 상황에서는 기능을 추가할 때마다 코드가 점점 무거워지게 되고, 그러다보면 MVC 패턴으로는 관리하는 데에 한계가 있다는 것.

 

…이라고 팀원들에게 가스라이팅을 당하여 MVVM에 도전하게 되었다.

 

어디까지나, 이 MVVM 패턴은 결국 코드 재구조화라 봐야하기 때문에 실질적으로 기능이 추가되거나 하는 건 없다. 하지만 기존의 코드를 재구조화한다는 것이 그 어느때보다 험난한 길이다. 코딩에서 제일 힘든 부분이 코드를 새로 만드는 것이 아니라 기존의 코드를 수정하는 것이라는 말이 괜히 있는 것이 아니다.

 

아무튼, MVVM을 시작해보았다.

 

 

MVVM의 기본 구조를 그림으로 요약하면 다음과 같다.

MVVM에는 데이터를 저장하는 Model, 화면에 나타나는 View, 그리고 그 View에 필요한 데이터를 Model에 받아와서 처리하는 기능을 맡은 ViewModel이 있다.

 

이 ViewModel을 사용하기 위해서는 LiveData와 Observer가 필요하다. 보통 LiveData는 ViewModel에서 설정하고, Observer는 View에서 설정한다.

 

이 LiveData는 우리가 필요로 하는 data class를 연결시키면 된다.

현재 SanDetailActivity(MountainDetailActivity)에는 SanDetailDTO(SanDetailUiState, ViewModel로 바꾸면서 이것도 이름을 저렇게 바꾸었다)를 data class로 받아오기 때문에 이걸 LiveData에 연결한다.

 

class SanDetailViewModel(sanDetailRepository: SanDetailRepository) : ViewModel() {
    private val repo : SanDetailRepository = sanDetailRepository

    val info : LiveData<SanDetailDTO> = repo.info


    fun getSelectedSan(sanName: String?) {
        repo.getSelectedItemFromRepository(sanName)
        Log.d(TAG, "getSelectedSan: $sanName")
    }

}

 

참고로, ViewModel을 설정할 때에는 어느 정도의 공식이 있는데 ViewModel을 만들었으면 ViewModelFactory 역시 만들어야 한다. ViewModelFactory는 Parameter가 있는 ViewModel을 객체화 시키는 역할을 한다.

class SanDetailViewModelFactory(private val sandetailRepository: SanDetailRepository) : ViewModelProvider.Factory {

    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if(modelClass.isAssignableFrom(SanDetailViewModel::class.java)){
            @Suppress("UNCHECKED_CAST")
            return SanDetailViewModel(sandetailRepository) as T
        }
        throw IllegalArgumentException("gg")
    }
}

 

이 다음으로는 Data를 받아오는 Repository를 만들어봤다. (참고로 이건 필수가 아니다. 이 MVVM을 가르쳐주는 팀원 분이 이런 방식으로 사용하셨기에 나 역시 이 방법을 사용했다)

private const val TAG = "SanDetailRepository"
class SanDetailRepository {
    private val firestore = FirebaseFirestore.getInstance()

    private var _info: MutableLiveData<SanDetailDTO> = MutableLiveData()
    val info: LiveData<SanDetailDTO> get() = _info


    private fun initSanData(sanName: String?) {
        firestore.collection("sanlist")
            .get()
            .addOnSuccessListener { documents ->
                documents.forEach { document ->
                    val sanList = SanDetailDTO(
                        document.getString("name") ?: "none",
                        document.getString("address") ?: "none",
                        document.getLong("difficulty") ?: 0,
                        document.getDouble("height") ?: 0.0,
                        document.getLong("time_uphill") ?: 0,
                        document.getLong("time_downhill") ?: 0,
                        document.getString("summary") ?: "none",
                        document.getString("recommend") ?: "none",
                        document["images"] as ArrayList<String>,
                        document.getBoolean("isLiked") ?: false
                    )
                    if (sanList.mountain == sanName) {
                        _info.value = sanList
                        Log.d(TAG, "initSanData: $sanList -> ${info.value}")
                    }
                }

            }
            .addOnFailureListener { exception ->
                Log.d("fireTest", "Firebase Error", exception)
            }
    }

    fun getSelectedItemFromRepository(sanName: String?) {
        initSanData(sanName)
        Log.d(TAG, "getSelectedItemFromRepository: $sanName")
    }

}

 

그리고 이 Repository를 만들면서 생긴 가장 큰 차이점은 바로 이미지 역시 하나의 ArrayList에서 받아와서 처리하도록 변경했다. 즉, 다시 말해 이전처럼 직접 url을 때려넣는 무식한 방법을 쓰지 않아도 된다는 것. (물론 아직까지는 API로 받아오는 것이 아니기 때문에 데이터를 따로따로 넣어야 하는 건 변함이 없다)

 

이렇게 ViewModel을 완성시켰으니, 이제 대망의 View를 재설정해야지.

private const val TAG = "SanDetailActivity"

class SanDetailActivity : AppCompatActivity() {
    private var _binding: ActivitySanDetailBinding? = null
    private val binding get() = _binding!!

    // 자동 스크롤
    private val slideImageHandler: Handler = Handler()
    private val slideImageRunnable =
        Runnable { binding.vpMountain.currentItem = binding.vpMountain.currentItem + 1 }

    private lateinit var imageAdapter: SanImageAdapter
    private val sanDetailViewModel: SanDetailViewModel by viewModels { SanDetailViewModelFactory((application as MyApplication).sanDetailRepository) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        _binding = ActivitySanDetailBinding.inflate(layoutInflater)
        setContentView(binding.root)

        window.statusBarColor = ContextCompat.getColor(this, R.color.transparent)
        //전체화면으로 설정하면 상단 parent 아이콘 배치 margin 주어야 함 안그러면 상태바 아래로 기어드감
        window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
        //보고 필요하면 상태바 아이콘 어둡게
        //window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR


        sanDetailViewModel.getSelectedSan(getSanName())
//        Log.d(TAG, "산이름 : ${sanName}")


        initBackButton()
        initObserver()
    }

    private fun initObserver() {

        sanDetailViewModel.info.observe(this) {
            initView(it)
        }
    }

    // 액티비티가 다시 시작될 때 자동스크롤도 다시 시작
    override fun onResume() {
        super.onResume()
        slideImageHandler.postDelayed(slideImageRunnable, 5000)
    }

    // 액티비티가 멈출 때 자동스크롤도 같이 멈춤
    override fun onPause() {
        super.onPause()
        slideImageHandler.removeCallbacks(slideImageRunnable)
    }


    @SuppressLint("SetTextI18n")
    private fun initView(sanlist: SanDetailDTO) {
        // 산 정보 표시
        setSanInfoView(sanlist)
        // 자세히 보기 클릭 시 텍스트 전부 출력
        setMoreView(sanlist)
        // 숫자에 따라 난이도 부여 & 색상 부여
        setDifficultyView(sanlist)
        //상행시간, 하행시간, 총 등산시간
        setHikingTimeView(sanlist)
        // 뷰페이저 어댑터 기본 설정
        initImage(sanlist)
    }
    // 자동 스크롤되는 ViewPager2 이미지
    private fun initImage(sanlist : SanDetailDTO) {

        // 뷰페이저 어댑터 기본 설정
        setImageAdapter(sanlist)
        //자동 스크롤 콜백 설정
        setImageCallBack()

    }

    private fun setImageCallBack() {
        binding.vpMountain.registerOnPageChangeCallback(object :
            ViewPager2.OnPageChangeCallback() {
            override fun onPageSelected(position: Int) {
                super.onPageSelected(position)
                slideImageHandler.removeCallbacks(slideImageRunnable)
                slideImageHandler.postDelayed(slideImageRunnable, 5000)
            }
        })
    }

    private fun setImageAdapter(sanlist: SanDetailDTO) {

        binding.vpMountain.adapter = getImageAdapter(sanlist)
        binding.vpMountain.orientation = ViewPager2.ORIENTATION_HORIZONTAL
    }

    private fun getImageAdapter(sanlist: SanDetailDTO) = SanImageAdapter(sanlist.img, binding.vpMountain)
    // 인텐트로 넘오는 산 이름 받아줌.
    private fun getSanName() = intent.getStringExtra("name")


    private fun setSanInfoView(sanlist: SanDetailDTO) = with(binding){
        tvMountain.text = sanlist.mountain
        tvAddress.text = sanlist.address

        val dec = DecimalFormat("#,###")
        val height = sanlist.height
        tvHeightInfo.text = "${dec.format(height)}m"
    }

    private fun setMoreView(sanlist: SanDetailDTO) = with(binding){
        tvIntroInfo.text = sanlist.summary
        tvRecommendInfo.text = sanlist.recommend
        viewMoreText(tvIntroInfo, tvIntroPlus, tvIntroShort)
        viewMoreText(tvRecommendInfo, tvRecommendPlus, tvRecommendShort)
    }

    private fun setDifficultyView(sanlist: SanDetailDTO) = with(binding){
        val difficulty = sanlist.difficulty
        tvDifficultyInfo.text = when (difficulty) {
            1L -> "하"
            2L -> "중"
            else -> "상"
        }

        when (tvDifficultyInfo.text) {
            "하" -> tvDifficultyInfo.setTextColor(
                ContextCompat.getColor(
                    applicationContext,
                    R.color.offroader_blue
                )
            )

            "중" -> tvDifficultyInfo.setTextColor(
                ContextCompat.getColor(
                    applicationContext,
                    R.color.offroader_orange
                )
            )

            else -> tvDifficultyInfo.setTextColor(
                ContextCompat.getColor(
                    applicationContext,
                    R.color.offroader_red
                )
            )
        }
    }

    private fun setHikingTimeView(sanlist: SanDetailDTO) = with(binding) {
        val uphillTime = sanlist.uphillTime
        val downhillTime = sanlist.downhillTime
        val totalTime = uphillTime + downhillTime

        viewHillTime(uphillTime, tvUptimeInfo)
        viewHillTime(downhillTime, tvDowntimeInfo)
        viewHillTime(totalTime, tvTimeInfo)    }

    // 자세히 보기 클릭 시 텍스트 전부 출력하는 함수
    private fun viewMoreText(info: TextView, plus: TextView, short: TextView) {
        info.post {
            val lineCount = info.layout.lineCount
            if (lineCount > 0) {
                if (info.layout.getEllipsisCount(lineCount - 1) > 0) {
                    plus.visibility = View.VISIBLE

                    plus.setOnClickListener {
                        info.maxLines = Int.MAX_VALUE
                        plus.visibility = View.GONE
                        short.visibility = View.VISIBLE
                    }

                    short.setOnClickListener {
                        info.maxLines = 5
                        plus.visibility = View.VISIBLE
                        short.visibility = View.GONE
                    }
                }
            }
        }
    }

    // 등산 시간을 출력시켜주는 함수
    @SuppressLint("SetTextI18n")
    private fun viewHillTime(time: Long, info: TextView) {
        if (time % 60 == 0L) {
            info.text =
                "${time / 60}시간"
        } else {
            info.text =
                "${time / 60}시간 ${time % 60}분"
        }
    }


    // 뒤로가기 버튼
    private fun initBackButton() {
        binding.ivBack.setOnClickListener {
            finish()
        }
    }
}

 

MVVM으로 변환하면서 하나하나 이동시키기 편하게 함수로 변환시킬 수 있는 부분은 전부 함수 형태로 만드려고 했다.

그 덕분에 보기에는 굉장히 깔끔해보인다.

 

이렇게 만든 다음에 에뮬레이터를 돌려보니 실행하는 데에 아무런 문제가 없었다.

 

하지만, 이게 MVVM의 끝은 아니고 다시 고칠 부분을 고쳐봐야지.