Development/Android

Android MVVM with Retrofit2

Jamie 2022. 1. 30. 11:57
반응형

디자인패턴? MVVM?

안드로이드 및 어플리케이션 개발에서 디자인 패턴은 앱의 수정 및 유지보수를 용이하게 해주는 방법론이 있습니다.

앱을 개발할때, 구조에 대해서 신경을 쓰지않고 코드를 작성하게 된다면, 다음과 같은 문제사항이 생깁니다.

  1. 버그가 발생했을때 수정 해야하는 부분을 명확하게 알 수 없음.
  2. 버그가 발생했을때 문제가 발생한곳 외에 엉뚱한곳에서 사이드이펙트가 발생할 수 있음.
  3. 앱 특성상 수정사항이 빈번할 경우, 여러곳을 수정해야할 수 있음. (비용이 많이 발생)

MVC, MVP, MVVM등 다양한 방법을 적용하여, View와 Controller, Model등을 분리하여 대응하기 편하게 고안된것이 디자인 패턴이라고 합니다.

 

디자인패턴의 장점?

디자인패턴은 다음과 같은 장점을 제공합니다.

  1. 디자인패턴을 학습한 개발자간의 의사소통이 편리해집니다.
  2. 디자인패턴을 학습한 개발자는 구조파악이 쉬워집니다.
  3. 재사용을 할 수 있어 개발 기간이 단축됩니다.
  4. 변경에 대해서 유연하게 대처할 수 있습니다.

안드로이드에서는 MVC, MVP, MVVM등 다양한 패턴들이 있지만, 여기서는 최근 가장 많이 사용하고 있는 MVVM 패턴에 대해서 설명하도록 하겠습니다.

 

MVVM 설명 (MVVM (Model - View - ViewModel)

데이터를 저장하는 Model, 사용자에게 화면을 표현하는 View, View에서 사용하는 데이터를 저장하는 ViewModel등으로 구분합니다.

 

출처: http://kyubid.com/blog/mvvm-android-tutorial-01-mvc-vs-mvp-vs-mvvm/

안드로이드에서는 아래와 같은 그림으로 설명하면 이해가 빠를것 같습니다.

출처: https://jjjoonngg.github.io/android%20architecture/MVVM/

위의 그림대로 다음과 같이 예제 프로젝트를 작성해볼까합니다.

  1. ViewModel, LiveData, Observer를 이용한 MVVM 구조 반영
  2. Repository에 Retrofit2, Okhttp3를 이용하여 API 서버에서 데이터를 수신합니다.
  3. Remote Data Source는 공공데이터포털의 REST API를 이용합니다. (JSON 파일)
  4. 공공데이터포털의 부산키즈카페 목록을 불러와서 화면에 표시해주겠습니다.
  5. 시작하기에 앞서, 공공데이터포털의 API Key를 신청하는 절차를 미리 해두면 편리합니다.
    (https://www.data.go.kr/tcs/dss/selectApiDataDetailView.do?publicDataPk=15088938)

※ 예제코드에는 API Key를 포함하지 않습니다.

 

최종결과물은 아래 스크린샷과 같으며, 예제코드를 업로드한 github주소는 글 하단에 기재하였습니다.

진행 방법

1. 안드로이드 프로젝트를 생성합니다. Kotlin으로 생성하였습니다.

2. Retrofit을 이용하여 API 서버와 통신을 할것이므로, AndroidManifest 파일에 인터넷 권한을 추가해줍니다.

<uses-permission android:name="android.permission.INTERNET" />

 

3. 키즈카페의 데이터를 표시할 뷰를 먼저 작성해줍니다. API의 결과를 있는 그대로 표시해줄것이므로, 간단하게 레이아웃을 작성해주겠습니다. fragment_kidscafe_search.xml로 작성하였습니다.

버튼을 클릭하면 하단의 스크롤뷰의 텍스트뷰에 수신한 JSON 데이터가 그대로 표출되는 방식입니다. 

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <Button
        android:id="@+id/search_btn"
        android:text="@string/search"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:src="?attr/selectableItemBackgroundBorderless"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent">
    </Button>

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

        <TextView
            android:id="@+id/kids_cafe_result_text_view"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
        </TextView>
    </ScrollView>

</androidx.constraintlayout.widget.ConstraintLayout>

 

4. 다음은 Fragment에서 사용 할 ViewModel을 작성해야하는데, ViewModel의 Repository에서 Retrofit으로 통신을 할것이므로, 실제 API와 통신하는 Repository를 먼저 작성하겠습니다. 

Retrofit을 사용하기위해 API의 명세대로 구현한 interface를 먼저 작성해야합니다. 저는 그전에 Postman을 활용하여 API가 스펙대로 작동하고 있는지 먼저 테스트를 진행했습니다.

 

 

5. 정상적으로 API가 작동했음을 확인했고, 이제 interface를 작성해주겠습니다.

import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query

interface KidsCafeSearchService {
    @GET(value = "BusanKidsCafeInfoService/getKidsCafeInfo")
    fun searchKidsCafe(
        @Query(value = "ServiceKey", encoded = true) serviceKey: String,
        @Query(value = "numOfRows", encoded = true) numOfRows: String,
        @Query(value = "pageNo", encoded = true) pageNo: String,
        @Query(value = "resultType", encoded = true) resultType: String
    ): Call<KidsCafeInfoResponse>
}

 

6. Call<KidsCafeInfoResponse>는 API와의 통신후 수신받을 데이터 모델입니다. API 스펙을 참고하여 다음과 같이 작성했습니다.

data class KidsCafeInfoResponse(
    @Expose
    @SerializedName(value = "getKidsCafeInfo")
    val kidsCafeInfo: KidsCafeInfo
)

data class KidsCafeInfo(
    @Expose
    @SerializedName(value = "header")
    val header: HeaderModel,

    @Expose
    @SerializedName(value = "item")
    val itemModelList: ArrayList<ItemModel>,

    @Expose
    @SerializedName(value = "numOfRows")
    val numOfRows: Int,

    @Expose
    @SerializedName(value = "pageNo")
    val pageNo: Int,

    @Expose
    @SerializedName(value = "totalCount")
    val totalCount: Int
)

 

7. 내부의 모델(ItemModel, HeaderModel)들도 모두 정의합니다.

data class ItemModel(

    @SerializedName(value = "gugun")
    @Expose
    val city: String,

    @SerializedName(value = "cafe_nm")
    @Expose
    val cafeName: String,

    @SerializedName(value = "road_nm")
    @Expose
    val roadName: String,

    @SerializedName(value = "tel_no")
    @Expose
    val telNumber: String,

    @SerializedName(value = "lat")
    @Expose
    val latitude: String,

    @SerializedName(value = "lng")
    @Expose
    val longitude: String,

    @SerializedName(value = "data_date")
    @Expose
    val updateDate: String
)

data class HeaderModel(
    @SerializedName(value = "code")
    @Expose
    val code: String,

    @SerializedName(value = "message")
    @Expose
    val message: String
)

 

8. 이제 Retrofit을 이용하는 Repository를 작성하겠습니다.
Repository 클래스안에는 Retrofit을 이용하여 실제 통신하는 부분과 이것을 ViewModel의 Observer (LiveData로 반환)으로 전달해주는 부분이 있습니다. 

import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.cplaygr.sample.busankidscafe.model.KidsCafeInfoResponse
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

class KidsCafeRepository {

    companion object {
        private const val TAG = "KidsCafeRepository"
        private const val BASE_URL = "http://apis.data.go.kr/6260000/"
    }

    private lateinit var kidsCafeSearchService: KidsCafeSearchService
    private var kidsCafeInfoMutableLiveData: MutableLiveData<KidsCafeInfoResponse> = MutableLiveData()

    init {
        val interceptor = HttpLoggingInterceptor()
        interceptor.level = HttpLoggingInterceptor.Level.BODY
        val client: OkHttpClient = OkHttpClient.Builder()
                .addInterceptor(interceptor)
                .build()

        val gson: Gson = GsonBuilder().setLenient().create()

        kidsCafeSearchService = Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(client)
            .addConverterFactory(GsonConverterFactory.create(gson))
            .build()
            .create(KidsCafeSearchService::class.java)
    }

    fun searchKidsCafe(serviceKey: String, numOfRows: String, pageNo: String, resultType: String) {
        kidsCafeSearchService.searchKidsCafe(serviceKey, numOfRows, pageNo, resultType).enqueue(object : Callback<KidsCafeInfoResponse?> {
                override fun onResponse(
                    call: Call<KidsCafeInfoResponse?>,
                    response: Response<KidsCafeInfoResponse?>
                ) {
                    Log.d(TAG, "onResponse: ${GsonBuilder().setPrettyPrinting().create().toJson(response.body())}")
                    kidsCafeInfoMutableLiveData.postValue(response.body())
                }

                override fun onFailure(call: Call<KidsCafeInfoResponse?>, t: Throwable) {
                    Log.e(TAG, "onFailure: error. cause: ${t.message}")
                    kidsCafeInfoMutableLiveData.postValue(null)
                }
            })
    }

    fun getKidsCafeResponseLiveData(): LiveData<KidsCafeInfoResponse> {
        return this.kidsCafeInfoMutableLiveData
    }
}

 

9. KidsCafeSearchFragment에서 사용할 KidsCafeSearchViewModel을 작성해줍니다. ViewModel에서 Repository의 메서드를 호출해서 통신결과를 얻고 LiveData를 회신해줄겁니다.

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import com.cplaygr.sample.busankidscafe.const.KidsCafeConst
import com.cplaygr.sample.busankidscafe.model.KidsCafeInfoResponse
import com.cplaygr.sample.busankidscafe.network.KidsCafeRepository

class KidsCafeSearchViewModel(application: Application) : AndroidViewModel(application) {

    private var kidsCafeSearchRepository: KidsCafeRepository
    private var kidsCafeInfoResponseLiveData: LiveData<KidsCafeInfoResponse>

    init {
        kidsCafeSearchRepository = KidsCafeRepository()
        kidsCafeInfoResponseLiveData = kidsCafeSearchRepository.getKidsCafeResponseLiveData()
    }

    fun searchKidsCafe() {
        kidsCafeSearchRepository.searchKidsCafe(KidsCafeConst.serviceKey, KidsCafeConst.numOfRows.toString(), KidsCafeConst.pageNo.toString(), KidsCafeConst.resultType)
    }

    fun getKidsCafeInfoResponseLiveData(): LiveData<KidsCafeInfoResponse> {
        return kidsCafeInfoResponseLiveData
    }
}

 

10. KidsCafeSearchFragment에 ViewModel을 연결하고, ViewModelProvider, Observer를 설치하여 통신결과를 하단의 스크롤뷰에 표시해주겠습니다.

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import com.cplaygr.sample.busankidscafe.databinding.FragmentKidscafeSearchBinding
import com.cplaygr.sample.busankidscafe.viewmodel.KidsCafeSearchViewModel
import com.google.gson.GsonBuilder

class KidsCafeSearchFragment : Fragment() {

    companion object {
        private const val TAG = "KidsCafeSearchFragment"
    }

    private var _binding: FragmentKidscafeSearchBinding? = null
    private val binding get() = _binding!!

    private lateinit var viewModel: KidsCafeSearchViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        viewModel = ViewModelProvider(this).get(KidsCafeSearchViewModel::class.java)
        viewModel.getKidsCafeInfoResponseLiveData().observe(this, Observer { response ->
            if (response != null) {
                binding.kidsCafeResultTextView.text = GsonBuilder().setPrettyPrinting().create().toJson(response)
            }
        })
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentKidscafeSearchBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        initListeners()
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

    private fun initListeners() {
        binding.searchBtn.setOnClickListener {
            viewModel.searchKidsCafe()
        }
    }
}

 

11. 실제 통신해보면 에러가 발생하는데, 공공데이터포털의 API가 https를 지원하지 않아 발생하는 문제가 있습니다. 프로젝트안에 xml 폴더를 생성하고, network-security-config를 작성해주어야합니다.

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">apis.data.go.kr</domain>
    </domain-config>
</network-security-config>

 

12. 인터페이스 작성시 @Query에 encoded 옵션을 주지 않으면, 작성한 API Key와 다르게 나가는 현상이 있습니다. 꼭 옵션 지정해주세요.

 

※ MainActivity에서 navigation을 이용하여 KidsCafeSearchFragment로 첫 페이지를 설정하도록 구성했습니다. 자세한건 샘플코드를 참고해주세요.

 

 

참고한 사이트

 

예제 github 주소 (API Key는 첨부하지 않았습니다.)

 

감사합니다.

반응형