티스토리 뷰

728x90

2022.05.11 - [안드로이드/코드] - 도로명 주소 개발센터 API 사용해 보기 3: Coroutine 사용해 보기

 

도로명 주소 개발센터 API 사용해 보기 3: Coroutine 사용해 보기

 2022.05.07 - [분류 전체보기] - 도로명 주소 개발센터 API 사용해 보기 2:DI + Hilt 사용해 보기 도로명 주소 개발센터 API 사용해 보기 2:DI + Hilt 사용해 보기 2022.04.28 - [안드로이드] - 도로명 주소 개..

alanboyce.tistory.com

이번 포스팅의 메인은 AAC입니다.

우선 그전에 디자인 패턴에 대하여 알아야 할 필요가 있습니다.

 

1. 디자인 패턴

디자인 패턴의 종류는 MVC, MVP, MVVM 등등 여러 가지가 존재합니다.

(글 주제가 디자인 패턴도 아니고 글을 잘 쓰신 분이 있어서 링크 첨부합니다!)

https://beomy.tistory.com/43

 

[디자인패턴] MVC, MVP, MVVM 비교

웹 개발자로 일을 하면서 가장 먼저 접한 디자인패턴이 바로 MVC 패턴이었습니다. 그만큼 유명하고 많이 쓰이는 디자인패턴인 MVC 패턴과 MVC 패턴에서 파생되어져 나온 MVP 패턴과 MVVM 패턴을 이야

beomy.tistory.com

디자인 패턴은 이걸 지켜야 개발이 가능하건 아닙니다.
또한 어떤 패턴을 적용했다고 해서 반드시 성능이 올라간다고 말할 수도 없습니다.

다만, 여러 개발자들이 시행착오를 겪으면서 문제를 해결해 나갔고
그에 대한 해결 방안으로 디자인 패턴을 적용해 왔습니다.
최근 제일 많이 사용되고 있는 패턴은 MVVM 패턴이라고 하네요

기존 안드로이드 개발의 경우 MVC의 패턴에 가깝게 개발이 되고 있었습니다.

대부분의 개발 로직이 Activity 또는 Fragment에 집중되어 있어서 유지보수 측면에서 어려움이 있습니다.
저도 최근 예전에 개발한 앱을 수정 중인데 위와 같이 개발을 해 놓은 상태여서 코드 분석, 수정하는데 오랜 시간을 쓰고 있습니다.

최근 앱 개발도 MVVM 패턴을 위주로 개발 진행을 많이 한다고 합니다.

근데 여기서 주의할 점은 안드로이드 개발 시에는 ViewModel이 MVVM의 ViewModel이 아니라 AAC의 ViewModel입니다.

역시 이거 관련해서도 이번 주제가 아니므로 링크 첨부합니다.

https://leveloper.tistory.com/216

 

[Android] MVVM 패턴과 AAC에서의 ViewModel

 안드로이드 오픈 톡방을 보다 보면 주기적으로 올라오는 질문이 몇 가지 있습니다. 그중 하나가 MVVM 패턴에서의 ViewModel과 AAC(Android Architecture Components)에서 제공하는 ViewModel이 다른 것인가에 대

leveloper.tistory.com

 

요약하자면 안드로이드 개발시에는 MVVM 패턴을 적용하기 위해 AAC의 ViewModel에 ObservableField나 LiveData 등을 이용하여 데이터 바인딩 기능을 추가한다라고 이해하시면 편할 것 같습니다!

제가 사용해보면서 느꼈던 점은

  1. 처음에는 파일의 갯수가 많이 증가 하기 때문에 기반을 잡는데 시간이 걸리고 좀 어렵게 느껴졌습니다.
  2. 하지만 역할에 따라 코드가 분리가 되었기 때문에 코드 복잡도는 오히려 내려갔습니다. (뭣보다 코드가 이전에 비해 깔끔해 진거 같아 뿌듯합니다 ㅎㅎ)
  3. 특정 기능을 수정, 추가, 삭제할 때 여러 파일을 거쳐야 해서 복잡할 수도 있지만 적응만 되면 오히려 Activity나 Fragment 단에서는 최소한의 수정으로 개발 할 수 있어서 좋습니다.

 

2. AAC

AAC(Android Architecture Components)는 테스트와 유지보수가 쉬운 앱을 디자인할 수 있도록 돕는 라이브러리 모음 입니다.

여러가지 Component들이 있습니다. 위에서 언급된 ViewModel을 포함하여 추후에 다룰 workmanger, databinding, liveData, paging등이 있습니다.

다 다루기에는 글이 안끝날듯 하니 ViewModel에 대해서만 알아보겠습니다.

ViewModel 클래스는 수명 주기를 고려하여 UI 관련 데이터를 저장하고 관리하도록 설계되었습니다. 
ViewModel 클래스를 사용하면 화면 회전과 같이 구성을 변경할 때도 데이터를 유지할 수 있습니다.

공식문서에는 위와 같이 설명하고 있습니다.

위의 사진과 같이 ViewModel의 경우 Activity 또는 Fragment와 별도로 생명주기를 가지고 있습니다.

Activity나 Fragment가 생성될때 생성하고 Destory될 때 제거 됩니다.

그러므로 Activity나 Fragment의 생명주기와는 별도로 데이터를 보관할 수 있습니다.

또한 Observer 객체를 생성하여 데이터가 변경될 때 Activity나 Fragment에서 해당 조건에 맞는 이벤트 효과를 줄 수 있습니다.

사용 방법은 아래에서 다루겠습니다.

 

이제 간략한 이론?은 알았고 이제 AAC를 활용하여 MVVM패턴 적용을 할 예정입니다.
물론 앞에서 다룬 Retrofit과 Hilt도 적용해서 말이죠!

다만 또 한가지 알아야 할것이 있습니다.

위와 같은 양식을 지켜서 개발을 진행 할 것입니다.

화살표 방향을 잘 보시면 위에서 아래로 내려가지만 위로 올라가는 화살표가 없다는 것을 확인할 수 있습니다.
또한 바로 아래로만 접근하지 다른 곳을 접근하지 않습니다.

즉, Activity에서 직접적으로 DB, 서버관련 로직을 접근 하는 것이 아닌 ViewModel->Repository를 통해서 접근을 하게 되는 것입니다.

Activity가 하는 것은 Observer 객체를 구독함으로써 데이터 변화를 감지하고 있다가 UI를 업데이트 해주는게 끝입니다.
ViewModel은 Activity에서 요청이 들어오면 Repository 등을 거쳐 데이터를 업데이트 역할을 합니다.

 

3. AAC 적용하기

ViewModel 생성 및 Hilt적용하기

import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel

@HiltViewModel
class AacViewModel @Inject constructor() : ViewModel() {

}
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.example.standardstudy.databinding.ActivityAacBinding
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class AacActivity : AppCompatActivity() {

    private val binding: ActivityAacBinding by lazy { ActivityAacBinding.inflate(layoutInflater) }
    private val viewModel: AacViewModel by viewModels()

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

    }

 

우선 기본 준비는 이렇게 하시면 끝입니다.

여기서 데이터바인딩을 이용하여 코드양을 줄이는 방법도 있긴 한데.. 그건 우선 코드 로직을 알고 있으면 응용하는 파트이니 넘어가겠습니다!

만든 ViewModel에 이제 Observe 객체를 만들어줍니다. 이번 예제에서는 LiveData를 사용할 것입니다.

@HiltViewModel
class AacViewModel @Inject constructor() : ViewModel() {

    private val _stateLiveData = MutableLiveData<AddressState>(AddressState.Uninitialized)
    val stateLiveData : LiveData<AddressState>
        get() = _stateLiveData


    sealed class AddressState {

        object Uninitialized : AddressState()

        data class Address(
            val list : List<SearchAddressResult>
        ) : AddressState()
        
        data class Error(
            val msg : String
        ) : AddressState()

    }
}

_stateLiveData는 private로 viewModel에서 수정을 하며 상용하는 변수이고,

stateLiveData는 액티비티에서 구독할 변수입니다.

상태 변화를 액티비티에 어떻게 알릴 것인가는 여러가지 방법이 있겠지만 위와 같이 seald class를 활용하는 방법을 사용하였습니다.

Uninitalized는 viewModel 생성시에 처음 _stateLiveData가 초기 값으로 가지는 상태 값으로
액티비티에서 화면 초기화 및 이전 기록으로 검색하게 하기 위해 사용 예정입니다.

Address는 실제 조회한 값을 액티비티에 전달해주기 위한 상태 값입니다.

@AndroidEntryPoint
class AacActivity : AppCompatActivity() {

...

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

        observeData()

    }

    private fun observeData() {
        viewModel.stateLiveData.observe(this) {
            when(it) {
                is AacViewModel.AddressState.Uninitialized -> {
                    initViews()
                }

                is AacViewModel.AddressState.Address -> {

                }
                is AacViewModel.AddressState.Error -> {

                }
            }
        }
    }

}

액티비티에서는 stateLiveData의 상태가 변화하는 것을 감지하다가 변화가 발생했을 때

상태 값에 따라 어떻게 작업을 진행할지 구현해주면 됩니다.

이번 예제의 마지막 글이니 좀 길어지겠지만 한 단계씩 진행을 해볼게요~

fun searchAddress() = viewModelScope.launch {

}

우선 viewModel에 위의 함수를 준비해줍니다. 

코루틴을 이용하여 서버 조회를 할 예정이므로 코루틴 빌더를 만들 필요가 있는데 viewModel에서는 ViewModelScope를 지원해 줍니다.

이제 searchAddress 함수 안에서 코루틴을 사용할 수 있는 상태가 되었습니다.

우선 준비만 해두고 넘어 갑시다

@Module
@InstallIn(SingletonComponent::class)
object PreferenceModule {

    @Provides
    @Singleton
    fun providePreference(@ApplicationContext context: Context) : SharedPreferences =
        context.getSharedPreferences("Standard Study", Context.MODE_PRIVATE)

}

그 다음 이전 Hilt 글에서 알아보았던 방식을 이용하여 preferenceModule을 만들어 줍니다.

간단한 기능이여서 이렇게까지 할 필요는 없을 순 있으나 연습이니까요! ㅎㅎ

<?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"
    tools:context=".aac.AacActivity">

    <ScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:padding="20dp">

            <EditText
                android:id="@+id/editText"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:hint="검색할 주소 입력"
                android:layout_marginEnd="10dp"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toStartOf="@id/button"/>

            <Button
                android:id="@+id/button"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="검색"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintEnd_toEndOf="parent"/>

            <TextView
                android:id="@+id/textView"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginTop="10dp"
                android:textSize="22sp"
                android:textColor="@color/black"
                android:lineSpacingExtra="1.2sp"
                app:layout_constraintTop_toBottomOf="@id/editText"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent"/>

        </androidx.constraintlayout.widget.ConstraintLayout>

    </ScrollView>

</layout>

UI 는 이전과 동일하게 간단하게 구현되어 있습니다.

@AndroidEntryPoint
class AacActivity : AppCompatActivity() {

	...
    @Inject lateinit var pref : SharedPreferences

	...

    private fun observeData() {
        viewModel.stateLiveData.observe(this) {
            when(it) {
                is AacViewModel.AddressState.Uninitialized -> {
                    initViews()
                }

                is AacViewModel.AddressState.Address -> {

                }
            }
        }
    }

    private fun initViews() = with(binding) {
    
        button.setOnClickListener {
            viewModel.searchAddress()
        }

        val history = getPreference(SEARCH_HISTORY, "") ?: ""

        if (history.isNotEmpty()) {
            editText.setText(history)
            button.performClick()
        }

    }

    private fun Activity.setPreference(key: String, value : String) {
        pref.edit().putString(key, value).apply()
    }

    private fun Activity.getPreference(key: String, defaultValue: String) =
        pref.getString(key, defaultValue)

    companion object {
        const val SEARCH_HISTORY = "search_history"
    }


}

위에서도 언급했듯이 StateLiveData의 경우 초기 값으로 Uninitialized 상태이므로

해당 상태일 때 UI를 초기화 해줍니다.

초기화 하면서 이전에 검색기록이 있는지 SharedPreference에서 조회를 하여 

editText에 채워주고 검색을 하여 리스트를 호출합니다

검색 버튼 클릭 시 아까 ViewModel에서 만든 searchAddress() 함수를 호출해 줍니다.

이제 Retrofit을 이용해서 도로명 주소를 검색해야하는데

위에서 봤던 이 그림을 보면

ViewModel은 Repository를 거쳐서 Retrofit을 호출 하는 것을 볼 수 있습니다.

저희도 역시 Repository를 만들겁니다.

그전에 이전 글에서 만든 서비스, Retrofit 코드를 가져오겠습니다.

package com.example.standardstudy.network.address.data

import com.example.standardstudy.BuildConfig
import com.google.gson.annotations.SerializedName

// https://www.juso.go.kr/addrlink/devAddrLinkRequestGuide.do?menu=roadApi

/**
 * 주소 검색 API 호출을 위한
 * @param confmKey : (필수) 신청시 발급받은 승인키
 * @param currentPage : (필수) 현재 페이지 번호, 기본 값 1
 * @param countPerPage : (필수) 페이지당 출력할 결과 Row 수, 기본 값 10
 * @param keyword : (필수) 주소 검색어
 * @param resultType : 검색결과형식 설정(xml, json), 기본 값 xml
 * @param hstryYn : [2020년12월8일 추가된 항목] 변동된 주소정보 포함 여부, 기본 값 N
 * @param firstSort : [2020년12월8일 추가된 항목] 정확도순 정렬(none)
 *                    우선정렬(road: 도로명 포함, location: 지번 포함), 기본 값 none
 *                    ※ keyword(검색어)가 우선정렬 항목에 포함된 결과 우선 표출
 * @param addInfoYn : [2020년12월8일 추가된 항목]
 *                    출력결과에 추가된 항목(hstryYn, relJibun, hemdNm) 제공여부, 기본 값 N
 *                    ※ 해당 옵션으로 추가제공되는 항목의 경우, 추후 특정항목이 제거되거나 추가될 수 있으니 적용 시 고려해주시기 바랍니다.
 * */
data class AddressParam(
    val confmKey : String? = BuildConfig.ADDRESS_API_KEY,
    val currentPage : Int?,
    val countPerPage : Int? = 10,
    val keyword : String?,
    val resultType : String? = "json",
    val hstryYn : String? = "N",
    val firstSort : String? = "road",
    val addInfoYn : String? = "N"
)

/**
 * 주소 검색 api 결과
 * */
data class AddressResult(
    val results : AddressData
)

data class AddressData(
    val common : AddressCommon,
    @SerializedName("juso")
    val address : List<Address>?
) {
    fun toSearchResult() : SearchResult =
        SearchResult(
            list = address?.map { it.toSearchAddressResult() }
        )
}

data class AddressCommon(
    val totalCount : String?, // 총 검색 데이터수
    val currentPage : String?, // 페이지 번호
    val countPerPage : Int?, // 페이지당 출력할 결과 Row 수
    val errorCode : String?, // 에러 코드
    val errorMessage : String? // 에러 메시지
)

data class Address(
    val roadAddr : String?, // 전체 도로명주소
    val roadAddrPart1 : String?, // 도로명주소(참고항목 제외)
    val roadAddrPart2 : String?, // 도로명주소 참고항목
    val jibunAddr : String?, // 지번주소
    val engAddr : String?, // 도로명주소(영문)
    val zipNo : String?, // 우편번호
    val admCd : String?, // 행정구역코드
    val rnMgtSn : String?, // 도로명코드
    val bdMgtSn : String?, // 건물관리번호
    val detBdNmList : String?, // 상세건물명
    val bdNm : String?, // 건물명
    val bdKdcd : String?, // 공동주택여부(1 : 공동주택, 0 : 비공동주택)
    val siNm : String?, // 시도명
    val sggNm : String?, // 시군구명
    val emdNm : String?, // 읍면동명
    val liNm : String?, // 법정리명
    val rn : String?, // 도로명
    val udrtYn : String?, // 지하여부(0 : 지상, 1 : 지하)
    val buldMnnm : Long?, // 건물본번
    val buldSlno : Long?, // 건물부번
    val mtYn : String?, // 산여부(0 : 대지, 1 : 산)
    val lnbrMnnm : Long?, // 지번본번(번지)
    val lnbrSlno : Long?, // 지번부번(호)
    val emdNo : String?, // 읍면동일련번호
    val hstryYn : String?, // [2020년12월8일 추가된 항목] 변동이력여부(0: 현행 주소정보, 1: 요청변수의 keyword(검색어)가 변동된 주소정보에서 검색된 정보)
    val relJibun : String?, // [2020년12월8일 추가된 항목] 관련지번
    val hemdNm : String? // [2020년12월8일 추가된 항목] 관할주민센터 ※ 참고정보이며, 실제와 다를 수 있습니다.
) {
    fun toSearchAddressResult() : SearchAddressResult =
        SearchAddressResult(
            roadAddr = roadAddr,
            jibunAddr = jibunAddr
        )
}

data class SearchAddressResult(
    val roadAddr : String?,
    val jibunAddr : String?
)

data class SearchResult(
    val list : List<SearchAddressResult>?
)

datd의 경우는 조금 바뀌었습니다!

주된 내용은 SearchAddressResult, SearchResult 추가 및 이전 data class 에서 함수들이 추가되었어요

interface AddressService {
    @GET("addrlink/addrLinkApi.do")
    suspend fun getAddress(
        @Query("confmKey") confmKey : String? = BuildConfig.ADDRESS_API_KEY,
        @Query("currentPage") currentPage : Int? = 1,
        @Query("countPerPage") countPerPage : Int? = 10,
        @Query("keyword") keyword : String,
        @Query("resultType") resultType : String? = "json",
        @Query("firstSort") firstSort : String? = "road"
    ): Response<AddressResult>
}
class AddressClient @Inject constructor(
    private val addressService: AddressService
) {

    suspend fun getAddress(
        keyword : String,
        currentPage : Int?
    ) : Response<AddressResult> =
        addressService.getAddress(
            keyword = keyword,
            currentPage = currentPage
        )

}
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides
    @Singleton
    fun provideOkHttpClient(
        loggingInterceptor: HttpLoggingInterceptor
    ) : OkHttpClient =
        OkHttpClient.Builder()
            .readTimeout(10, TimeUnit.SECONDS)
            .connectTimeout(10, TimeUnit.SECONDS)
            .writeTimeout(15, TimeUnit.SECONDS)
            .addInterceptor(loggingInterceptor)
            .build()

    @Provides
    @Singleton
    fun provideGsonConvertFactory() : GsonConverterFactory = GsonConverterFactory.create()

    @Provides
    @Singleton
    fun provideRetrofit(
        okHttpClient: OkHttpClient,
        gsonConverterFactory: GsonConverterFactory
    ) : Retrofit =
        Retrofit.Builder()
            .baseUrl(Constants.ADDRESS_API_BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(gsonConverterFactory)
            .build()

    @Provides
    @Singleton
    fun provideLoggingInterceptor() : HttpLoggingInterceptor =
        HttpLoggingInterceptor().apply {
            level = if(BuildConfig.DEBUG) {
                HttpLoggingInterceptor.Level.BODY
            } else {
                HttpLoggingInterceptor.Level.NONE
            }
        }

    @Provides
    @Singleton
    fun provideAddressService(retrofit: Retrofit) : AddressService =
        retrofit.create(AddressService::class.java)

    @Provides
    @Singleton
    fun provideAddressClient(addressService: AddressService) : AddressClient =
        AddressClient(addressService)
}

이렇게 까지 준비가 되었다면 진짜로 Repository를 만들어 봅시다.

class CoroutineRepository @Inject constructor(
    private val client : AddressClient
) {

   suspend fun selectAddress(keyword : String, currentPage: Int) : List<SearchAddressResult>? =
       try {
           val result = client.getAddress(keyword, currentPage)
           if (result.isSuccessful) {
               result.body()?.results?.toSearchResult()?.list
           } else {
               null
           }
       } catch (e: Exception) {
           e.printStackTrace()
           null
       }

}

Repository를 사용하는 목적에 대하여 먼저 설명하겠습니다.

viewModel이 액티비티에서 사용하는 데이터들을 Observe한 객체로 저장하는 역할을 한다면

Repository는 그 데이터를 로컬 데이터베이스, API 통신 등을 통해 데이터를 수집하여 얻은 데이터를 캡슐화 하여 ViewMode에 데이터를 전달해 주는 역할을 수행합니다.

2가지 예시로 설명을 하자면

1. Repository가 DB에서 10가지의 데이터를 가져왔는데 해당 ViewModel에서 알고 싶은 데이터가 5개만 필요하다고 하면
원본 데이터를 그대로 주는 것이 아닌 매핑이나 다른 방법을 이용하여 5개의 데이터만 전달 해 줍니다.
이로써 ViewModel이 필요한 5개를 제외한 데이터가 변경된다하더라고 ViewModel은 영향을 받지 않습니다.

2. ViewModel에서 원하는 5가지 데이터가 있었는데 처음에는 A Api를 이용하여 데이터를 가져왔는데 B Api로 변경하라는 지시가 들어왔습니다.
그렇다고 하더라도 ViewModel은 수정할 필요 없이 Repository에서 Api를 변경하여 ViewModel이 원하는 데이터를 넘겨주면 수정이 끝이 납니다.

다시 코드로 돌아와서 코루틴+Retrofit을 이용하여 통신을 한 뒤
mapper를 위해 구현한 toSearchResult를 이용하여 원하는 데이터를 viewModel에게 전달 합니다.

@HiltViewModel
class AacViewModel @Inject constructor(
    private val repository: CoroutineRepository
) : ViewModel() {

    ...
    
    fun searchAddress(keyword: String) = viewModelScope.launch {
        val result = repository.selectAddress(keyword = keyword, currentPage = 1)
        if (result.isNullOrEmpty()) {
            _stateLiveData.postValue(AddressState.Error("조회 결과가 없습니다."))
        } else {
            _stateLiveData.postValue(AddressState.Address(result))
        }
    }

    ...

}

ViewModel에서 위에서 만든 repository를 주입받고, searchAddress() 함수에서 selectAddress() 함수를 호출해서 데이터를 받아옵니다.

이제 원하는 방식에 맞게  repository에서 오류가 발생할때나 결과 값이 비어있을 때에는 조회 결과가 없다고 출력할 것이고

데이터가 있다면 그 데이터를 뿌려줄 것입니다.

상태값에 맞게 postValue를 이용하여 데이터를 전달해줍니다.

그러면 감지하고 있던 Activity에서 데이터가 변한것을 인지합니다.

...

private fun observeData() {
    viewModel.stateLiveData.observe(this) {
        when(it) {
            is AacViewModel.AddressState.Uninitialized -> {
                initViews()
            }

            is AacViewModel.AddressState.Address -> {
                setSearchResult(it)
            }
            is AacViewModel.AddressState.Error -> {
                binding.textView.text = it.msg
            }
        }
    }
}

private fun setSearchResult(addressState: AacViewModel.AddressState.Address) = with(binding) {
    var road = ""
    addressState.list.forEach { result ->
        road += "[${result.roadAddr}] ${result.roadAddr}\n"
    }
    binding.textView.text = road

    setPreference(SEARCH_HISTORY, binding.editText.text.toString())
}

...

이제 받은 데이터들을 활용하여 UI를 꾸며주면 이번 예제는 완료됩니다~

 

제가 글제주가 부족하여 참고했던 링크는 첨부하고 있으니 보시는 것들은 권장합니다!

 

 

 

 

 

 

 

참고 자료

공식 사이트 :
블로그 1(AAC) : https://velog.io/@hwi_chance/Android-%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-AAC
블로그 2(MVVM) : https://velog.io/@k7120792/Model-View-ViewModel-Pattern
블로그 3(MVC, MVP, MVVM) : https://beomy.tistory.com/43
블로그 4(Repository) : https://vagabond95.me/posts/android-repository-pattern/
블로그 5(Ropository) : https://hs5555.tistory.com/112

 

 

728x90
댓글