깨알 개념/Android

[Android] Retrofit - MVVM 구조

interfacer_han 2024. 6. 5. 18:15

#1 이전 글

 

[Android] Retrofit - 기초

#1 이전 글 [Android] Retrofit - 배경과 구조#1 Restrofit의 배경#1-1 REST API REST API (REpresentational State Transfer Application Programming Interface)#1 무엇(What)에 대한 API인가?#1-1 개요REST(REpresentational State Transfer) 또는

kenel.tistory.com

이전 게시글에서는 userId를 하드 코딩했지만, 여기에서는 사용자가 원하는 userId를 요청하고 그에 응답할 수 있게 만들어본다. 하는 김에 MVVM적인 구조로 만든다.

#2 MVVM 패턴

 

[Android] MVVM 구조 한눈에 보기

#1 안드로이드 앱의 '전통적인' 방식 vs MVVM 패턴#1-1 도식도와 종속성화살표는 클래스 간의 종속성을 나타낸다. 예를 들어, View는 ViewModel에 종속된다. 종속의 사전적 의미는 '자주성이 없이 주가

kenel.tistory.com

이전 게시글의 완성된 앱(소스 코드)를에 지금까지 공부했던 MVVM 패턴을 접목시켰다.
 

#3 코드 수정

#3-1 개요

수정한 전체 소스 코드는 #5에서 확인하자. 여기서는 간략한 설명만을 곁들인다.
 

#3-2 프로젝트 수준 build.gradle 수정

// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
    ...
    id("com.google.devtools.ksp") version "1.9.0-1.0.13" apply false
}

 

#3-3 모듈 수준 build.gradle 수정

plugins {
    ...
    id("com.google.devtools.ksp")
    id("org.jetbrains.kotlin.kapt")
}

android {
    ...
    buildFeatures {
        dataBinding = true
    }
}

dependencies {

    ...

    // Retrofit
    ...

    // Coroutines
    ...

    // LiveData, ViewModel
    ...
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion")
}

 

#3-4 Repository 클래스 만들기

// package com.example.mvvmbasics

import com.example.mvvmbasics.retrofit.AlbumService
import com.example.mvvmbasics.retrofit.Albums
import kotlinx.coroutines.delay
import retrofit2.Response

class MyRepository(private val retService: AlbumService) {

    suspend fun getAlbums(): Response<Albums> {
        delay(1500) // 통신 딜레이를 재현하기 위한 delay
        return retService.getAlbums()
    }

    suspend fun getSortedAlbums(userId: Int): Response<Albums> {
        delay(1500) // 통신 딜레이를 재현하기 위한 delay
        return retService.getSortedAlbums(userId)
    }
}

우선, Repository 클래스를 만들고, 생성자에 Retofit Service를 요구하게 만들었다. 이 Repository Class는 나중에 본 앱을 더 업그레이드해서 Room까지 사용하게 되는 경우, 생성자에 Room의 DAO도 요구하게 될 것이다. 즉, Repository는 원격(Retrofit)이든 로컬(Room)이든 데이터 저장소를 한 데 모아 통합하고 쉽게 관리하는 역할을 한다.
 

#3-5 ViewModel

// package com.example.mvvmbasics

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.mvvmbasics.retrofit.Albums
import kotlinx.coroutines.launch
import retrofit2.Response

class MainViewModel(private val repository: MyRepository) : ViewModel() {

    private var _userId: MutableLiveData<Int> = MutableLiveData()
    val userId: MutableLiveData<Int> get() = _userId

    private var _albums: MutableLiveData<Response<Albums>> = MutableLiveData()
    val albums: LiveData<Response<Albums>> get() = _albums

    init {
        _userId.value = 0
        _albums.value = Response.success(Albums()) // 빈 Albums 넣기
    }

    fun onSubmitClicked(newUserId: Int) {
        _userId.value = newUserId
        updateAlbums()
    }

    private fun updateAlbums() {
        viewModelScope.launch {
            val response = repository.getSortedAlbums(_userId.value!!)
            _albums.value = response
        }
    }
}

Model이 '재료'고 View가 '요리'라면, View Model은 '필요한 재료가 올라가있는 도마'다. 따라서, Model에 해당하는 Repository 이후 만들 것은 View Model이다. 생성자로 Repository를 요구하게 만든다 (따라서 이후에 ViewModelFactory도 만들어주어야 한다). 그리고 ViewModel가 가지고 있을 프로퍼티인 userId와 albums를 정의한다. 아래 쪽에 있는 함수는 이 프로퍼티와 관련된 로직을 처리하는 함수들이다.
 

#3-6 activity_main.xml

<?xml version="1.0" encoding="utf-8"?>

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <import type="com.example.mvvmbasics.MyConverter" />

        <variable
            name="viewModel"
            type="com.example.mvvmbasics.MainViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/constraintLayout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent">

            <EditText
                android:id="@+id/editText"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:hint="User id"
                android:textSize="30dp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toStartOf="@id/button"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

            <Button
                android:id="@+id/button"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:hint="User id"
                android:onClick="@{() -> viewModel.onSubmitClicked(MyConverter.stringToInt(editText, editText.getText().toString()))}"
                android:text="Submit"
                android:textSize="30dp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toEndOf="@+id/editText"
                app:layout_constraintTop_toTopOf="parent" />
        </androidx.constraintlayout.widget.ConstraintLayout>

        <ScrollView
            android:id="@+id/scrollView"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/constraintLayout">

            <TextView
                android:id="@+id/textView"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:text=""
                android:textSize="30dp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />
        </ScrollView>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

데이터 바인딩을 사용할 것이므로, <layout> 태그로 코드를 감싼다. 위 코드를 처음부터 만든 것은 아니고 틀만 잡아놓은 채로 이후 다른 작업을 하면서 동시에 다듬어나갔다. String형인 EditText의 데이터와  Int형인 ViewModel.userId 간 데이터형 차이를 메꿔주기 위해서 Converter를 사용했다 (양방향 데이터 바인딩이 아니어도 그냥 사용할 수 있다).
 

#3-7 액티비티 수정

// package com.example.mvvmbasics

import android.os.Bundle
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import com.example.mvvmbasics.databinding.ActivityMainBinding
import com.example.mvvmbasics.retrofit.AlbumService
import com.example.mvvmbasics.retrofit.Albums
import com.example.mvvmbasics.retrofit.RetrofitInstance
import retrofit2.Response

class MainActivity : AppCompatActivity() {
    private lateinit var repository: MyRepository
    private lateinit var viewModelFactory: MainViewModelFactory
    private lateinit var viewModel: MainViewModel
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        repository = MyRepository(RetrofitInstance.getRetrofitInstance().create(AlbumService::class.java))
        viewModelFactory = MainViewModelFactory(repository)
        viewModel = ViewModelProvider(this, viewModelFactory).get(MainViewModel::class.java)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.viewModel = viewModel
        binding.lifecycleOwner = this

        viewModel.userId.observe(this, Observer {
            binding.textView.text = ""
        })

        viewModel.albums.observe(this, Observer {
            appendListOnTextView(binding.textView, it)
        })
    }

    private fun appendListOnTextView(textView: TextView, list: Response<Albums>) {
        val albumsList = list.body()?.listIterator()
        if (albumsList != null) {

            while (albumsList.hasNext()) {
                val albumsItem = albumsList.next()
                val result =
                    "User id : ${albumsItem.userId}" + "\n" +
                    "Album id : ${albumsItem.id}" + "\n" +
                    "Album Title : ${albumsItem.title}" + "\n\n\n"

                textView.append(result)
            }
        }
    }
}

UI를 직접적으로 다루는 코드를 Activity가 아닌 ViewModel에 넣지 않게 주의해야 한다. ViewModel은 #3-5에서 말했듯 Repository에서 가져온 재료들 중에서 필요한 것들만 챙겨놓은 도마에 불과하다.
 

#4 작동 확인

 

#5 완성된 앱

 

android-practice/retrofit/MVVMBasics at master · Kanmanemone/android-practice

Contribute to Kanmanemone/android-practice development by creating an account on GitHub.

github.com