안드로이드/개발 관련

[Kotlin] 안드로이드 AAC ViewModel을 MVVM패턴을 적용해서 Retrofit2 / Okhttp3 사용해서 Solved.ac API 호출해보기. (백준 정보 가져오기) (+@ Glide를 View Binding에서 사용해보기)

kimyunseok 2021. 11. 26. 01:08
 

@solvedac/unofficial-documentation

이 프로젝트는 solved.ac API를 문서화하는 커뮤니티 프로젝트입니다. 이 저장소는 원작자의 요청에 따라 언제든 지워질 수 있으며, 현재 API와 일치하지 않을 수도 있는 점 양해 부탁드립니다.

solvedac.github.io

이번에 사용해 볼 API는 위에 기록되어 있다.

solved.ac에서 사용하는 API이며, user에 있는 사용자 정보 가져오기 API를 호출해 볼 것이다.

2021년 11월 25일 기준으로 작동하는 API이며, 원작자의 요청에 따라 언제든 지워질 수 있고, 수정될 수 있다.

사용자 정보와 이미지를 불러와서 띄워볼 것이다.

 

위와같은 정보들이 불러와진다.

 

AAC ViewModel이란?

MVVM에서의 ViewModel은 View가 ViewModel에서의 값을 Observe하여 Update하고,

ViewModel은 Model을 Update하는 역할을 담당한다. 

 

AAC ViewModel은 안드로이드에서 자체적으로 만든 ViewModel로,

MVVM에서의 ViewModel과는 전혀 다른 개념이다.

나중에 디자인 패턴을 정리한 후에 따로 또 정리할 예정이지만, 간략하게 설명해서

  • 안드로이드 생명주기를 고려해서 만들어진 ViewModel이다. Activity당 하나의 ViewModel만 생성 가능하다.
  • UI 관련 요소를 저장하고 관리한다.

AAC ViewModel을 사용한다 해서 MVVM 패턴이 되는 것은 아니다.

(위에서 말했듯이 MVVM 개념에서의 ViewModel과 AAC 개념에서의 ViewModel은 상관이 없는 용어이다.)

그러나 AAC ViewModel을 MVVM 패턴으로 구현할 수 있기 때문에(LiveData, Flow 등 사용)

AAC ViewModel로 MVVM 패턴을 구현하겠다.

이렇게 하면, 생명주기를 고려한 MVVM 패턴을 만들 수 있게 된다.

 

의존성 추가

App 단계의 의존성에 Kapt 플러그인, 뷰바인딩과 데이터 바인딩,

Retrofit2와 Okhttp3, Glide에 대한 의존성을 추가해준다.

 

매니페스트 인터넷 권한 설정

 

레이아웃 코드

메인액티비티의 container에 메인 프래그먼트를 띄우고,

메인 프래그먼트 안에 있는 container에 유저의 정보를 띄우는 프래그먼트를 띄우는 방식으로 만들었다.

 

메인 액티비티

메인액티비티 레이아웃 xml

 

메인 프래그먼트

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

    <data>
        <variable
            name="handle"
            type="java.lang.String" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:paddingHorizontal="12dp"
        android:background="@color/white">

        <TextView
            android:id="@+id/text_view1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="10dp"
            android:text="@string/introduce_search_comment"
            android:textColor="@color/black"
            android:textSize="21sp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"/>

        <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/search_layout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="20dp"
            app:layout_constraintTop_toBottomOf="@+id/text_view1">

            <EditText
                android:id="@+id/search_edit_text"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginEnd="10dp"
                android:padding="6dp"
                android:background="@drawable/square_background"
                android:text="@={handle}"
                android:textSize="18sp"
                android:textColor="@color/black"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toStartOf="@id/search_btn"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintBottom_toBottomOf="parent"/>

            <Button
                android:id="@+id/search_btn"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="@string/search"
                android:textSize="18sp"
                android:textColor="@color/white"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintStart_toEndOf="@id/search_edit_text"
                app:layout_constraintEnd_toEndOf="parent"/>

        </androidx.constraintlayout.widget.ConstraintLayout>

        <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/user_profile_container"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_marginTop="20dp"
            app:layout_constraintTop_toBottomOf="@id/search_layout"
            app:layout_constraintBottom_toBottomOf="parent"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

메인프래그먼트 xml파일이다. 

  • EditText에 양방향 데이터를 사용해서, handle의 값을 EditText의 값이 바뀔때마다 바뀌도록 한다.
  • user_profile_container 레이아웃 안에 childFragmentManager를 이용해서 받아온 유저의 정보를 나타내주는
    프래그먼트를 띄울 것이다.

 

유저 프로필 프래그먼트

<?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">

    <data>
        <import type="android.view.View"/>

        <variable
            name="model"
            type="com.khs.retrofitandokhttpexampleproject.model.SolveAcGetUserDataModel" />
    </data>

    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/white">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:paddingHorizontal="16dp">

            <androidx.constraintlayout.widget.ConstraintLayout
                android:id="@+id/profile_layout"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:visibility="@{model.code == 200 ? View.VISIBLE : View.GONE}"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintBottom_toBottomOf="parent">

                <ImageView
                    android:id="@+id/background_img"
                    android:layout_width="match_parent"
                    android:layout_height="0dp"
                    app:imageFromUrl="@{model.background.backgroundImageUrl}"
                    app:layout_constraintTop_toTopOf="@+id/profile_img"
                    app:layout_constraintBottom_toBottomOf="@+id/profile_img"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintEnd_toEndOf="parent"/>

                <ImageView
                    android:id="@+id/profile_img"
                    android:layout_width="210dp"
                    android:layout_height="210dp"
                    android:layout_marginTop="20dp"
                    app:imageFromUrl="@{model.profileImageUrl}"
                    app:layout_constraintTop_toTopOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintEnd_toEndOf="parent"/>

                <TextView
                    android:id="@+id/handle_and_organization_tv"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="10dp"
                    android:text="@{model.code == 200 ? model.handle + '\n' + model.organizations.get(0).name : String.valueOf(' ')}"
                    android:textColor="@color/black"
                    android:textSize="21sp"
                    android:textAlignment="center"
                    app:layout_constraintTop_toBottomOf="@id/profile_img"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintEnd_toEndOf="parent"/>

                <TextView
                    android:id="@+id/profile_tv"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="10dp"
                    android:text="@{model.bio}"
                    android:textColor="@color/black"
                    android:textSize="17sp"
                    app:layout_constraintTop_toBottomOf="@id/handle_and_organization_tv"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintEnd_toEndOf="parent"/>

                <androidx.constraintlayout.widget.ConstraintLayout
                    android:id="@+id/solved_layout"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="10dp"
                    app:layout_constraintTop_toBottomOf="@+id/profile_tv">

                    <TextView
                        android:id="@+id/solved_count_kor_tv"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:text="@string/solved_count"
                        android:textColor="@color/black"
                        android:textSize="17sp"
                        app:layout_constraintHorizontal_chainStyle="packed"
                        app:layout_constraintTop_toTopOf="parent"
                        app:layout_constraintBottom_toBottomOf="parent"
                        app:layout_constraintStart_toStartOf="parent"
                        app:layout_constraintEnd_toStartOf="@+id/solved_count_tv"/>

                    <TextView
                        android:id="@+id/solved_count_tv"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:text="@{' ' + String.valueOf(model.solvedCount) + '개'}"
                        android:textColor="@color/black"
                        android:textSize="17sp"
                        app:layout_constraintTop_toTopOf="parent"
                        app:layout_constraintBottom_toBottomOf="parent"
                        app:layout_constraintStart_toEndOf="@+id/solved_count_kor_tv"
                        app:layout_constraintEnd_toEndOf="parent"/>

                </androidx.constraintlayout.widget.ConstraintLayout>


                <androidx.constraintlayout.widget.ConstraintLayout
                    android:id="@+id/tier_layout"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="10dp"
                    app:layout_constraintTop_toBottomOf="@+id/solved_layout">

                    <TextView
                        android:id="@+id/tier_kor_tv"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:text="@string/tier"
                        android:textColor="@color/black"
                        android:textSize="17sp"
                        app:layout_constraintHorizontal_chainStyle="packed"
                        app:layout_constraintTop_toTopOf="parent"
                        app:layout_constraintBottom_toBottomOf="parent"
                        app:layout_constraintStart_toStartOf="parent"
                        app:layout_constraintEnd_toStartOf="@+id/tier_tv"/>

                    <TextView
                        android:id="@+id/tier_tv"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:text="@{model.tierText}"
                        android:textColor="@color/black"
                        android:textSize="17sp"
                        app:layout_constraintTop_toTopOf="parent"
                        app:layout_constraintBottom_toBottomOf="parent"
                        app:layout_constraintStart_toEndOf="@+id/tier_kor_tv"
                        app:layout_constraintEnd_toEndOf="parent"/>

                </androidx.constraintlayout.widget.ConstraintLayout>


                <TextView
                    android:id="@+id/description_1_tv"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="10dp"
                    android:text="@{model.background.displayName}"
                    android:textColor="@color/black"
                    android:textSize="17sp"
                    app:layout_constraintTop_toBottomOf="@id/tier_layout"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintEnd_toEndOf="parent"/>

                <TextView
                    android:id="@+id/description_2_tv"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="10dp"
                    android:text="@{model.background.displayDescription}"
                    android:textColor="@color/black"
                    android:textSize="17sp"
                    app:layout_constraintTop_toBottomOf="@id/description_1_tv"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintEnd_toEndOf="parent"/>

            </androidx.constraintlayout.widget.ConstraintLayout>

        </androidx.constraintlayout.widget.ConstraintLayout>

    </androidx.core.widget.NestedScrollView>
</layout>

유저 프로필 xml파일.

받아온 유저의 정보를 레이아웃의 model에 넣은 후에 띄운다.

DataBinding을 이용해서 레이아웃을 짰다.

MVVM 패턴으로 프로젝트를 만들었기 때문에, DataBinding을 사용하면 MVVM 패턴의 효율을 높일 수 있다.

 

소스코드

 

메인 액티비티 소스 코드

메인 액티비티에서는 메인 프래그먼트만 띄우면 된다.

 

베이스 프래그먼트 소스 코드

베이스 프래그먼트 코드.

베이스 프래그먼트를 사용하면 ViewBinding 객체를 초기화하는 코드의 중복을 줄일 수가 있다.

후에, init() 메서드를 구현한 후에 원래라면 onCreateView()에 들어갈 내용들을 넣어주면 된다.

 

메인 프래그먼트 소스 코드

메인 프래그먼트에서는 베이스 프래그먼트를 상속받고, 검색 버튼의 OnClickListener를 구현해준다.

버튼의 로직은 다음과 같다.

  1. childFragmentManager를 통해서,
    현재 user_profile_container 레이아웃에 존재하는 프래그먼트 객체를 생성한다.
    타입은 UserProfileFragment?로 만들어서 null Check이 가능하도록 만든다.

  2. 프래그먼트 객체가 null인 경우, 한 번도 검색하지 않았다는 뜻이므로,
    childFragmentManager를 통해서 해당 레이아웃에 유저 정보를 띄우는 프래그먼트
    UserProfileFragment()를 띄운다.
    Bundle 객체를 통해서 입력한 handle(id)를 넘겨주도록 한다.

  3. 프래그먼트 객체가  null이 아닌 경우, 이미 검색했다는 뜻이고 프래그먼트 객체가 존재하므로,
    뒤에 자세히 나올 내용이지만, 뷰 모델에서 getUserData 메서드를 호출해서, 
    유저의 정보를 받아온 후에 UI가 갱신되도록 한다.

 

유저의 정보를 받아오는 뷰 모델을 살펴보기 위해서, API 통신이 어떻게 이뤄지는지 살펴본 후에

유저 프로필 프래그먼트를 살펴보겠다.

 

package com.khs.retrofitandokhttpexampleproject.common

import android.app.Application
import android.widget.ImageView
import androidx.core.content.ContextCompat
import androidx.databinding.BindingAdapter
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.khs.retrofitandokhttpexampleproject.R
import com.khs.retrofitandokhttpexampleproject.api.InterceptorForHeader
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager

class GlobalApplication: Application() {
    companion object {
        lateinit var prefs: MySharedPreferences

        //Retrofit2
        lateinit var baseService: Retrofit
            private set

        //Glide URL -> ImageView 데이터바인딩에서 사용하기 위한 메서드
        @BindingAdapter("imageFromUrl")
        @JvmStatic
        fun bindImageFromUrl(view: ImageView, imageUrl: String?) {
            if(!imageUrl.isNullOrEmpty()) {
                Glide.with(view.context)
                    .load(imageUrl)
                    .transition(DrawableTransitionOptions.withCrossFade())
                    .into(view)
            } else {
                view.setImageDrawable(ContextCompat.getDrawable(view.context, R.drawable.ic_no_image))
            }
        }
    }

    override fun onCreate() {
        super.onCreate()
        prefs = MySharedPreferences(applicationContext)

        baseService = initRetrofitBuilder()
    }

    private fun initRetrofitBuilder(): Retrofit {
        val BASE_URL = "https://solved.ac/"
        // 기본 URL에는 기본 주소만 넣어주도록 한다. /api/v3/도 넣어주면 URL에서 인식이 불가능하다.

        //OKHttp 클라이언트
        //private val okHttpClient = OkHttpClient.Builder() 만일 SSL인증이 필요없다면 이렇게 구현.
        val okHttpClient = getUnsafeOkHttpClient() // SSL인증서 허용을 위한 OkHttpClient.Builder
            .connectTimeout(20, TimeUnit.SECONDS)
            .readTimeout(20, TimeUnit.SECONDS)
            //.writeTimeout(20, TimeUnit.SECONDS) //쓰는 기능은 현재 프로젝트에 없음.
            //.addNetworkInterceptor(InterceptorForHeader()) 헤더가 필요 없음.
            .build()

        //리턴하는 레트로핏
        return Retrofit.Builder().baseUrl(BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    // SSL을 우회하는 OKHttpClient Builder를 생성한다. 모든 SSL 인증서를 허용하게 된다.
    private fun getUnsafeOkHttpClient(): OkHttpClient.Builder {
        val trustAllCerts = arrayOf<TrustManager>(object : X509TrustManager {
            override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {

            }

            override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {

            }

            override fun getAcceptedIssuers(): Array<X509Certificate> {
                return arrayOf()
            }
        })

        val sslContext = SSLContext.getInstance("SSL")
        sslContext.init(null, trustAllCerts, SecureRandom())

        val sslSocketFactory = sslContext.socketFactory

        val builder = OkHttpClient.Builder()
        builder.sslSocketFactory(sslSocketFactory, trustAllCerts[0] as X509TrustManager)
        builder.hostnameVerifier { hostname, session -> true }

        return builder
    }
}

전역 어플리케이션 객체를 하나 생성해준다. 내용들은 다음과 같다.

  • MySharedPreferences 객체 prefs를 lateinit var 형태로 선언한다.
    이 프로젝트의 기능에서 사용하지는 않지만,
    실제 서버와 통신 시에는 access_token 같은 가벼운 토큰 값을 기록해 둘 필요가 있다.
    이 기능을 위해 만들어놓은 것이다.
    onCreate() 메서드에서 prefs를 초기화한다.
    MySharedPreferences 내용은 본 프로젝트 기능에서 다루지 않으므로 넣기만 하고 설명은 생략하겠다.

  • BASE_URL로 호출할 api의 기본 URL을 설정한다.
    원래는 solved.ac/api/v3/ 라고 넣었는데 자꾸 호출이 안됐었다.
    로그를 찍어보니 BASE_URL에서는 /api/v3/를 인식하지 않고 solved.ac까지만 인식됐었다.
    따라서 위처럼 solved.ac까지만 입력해준다.

  • okHttpClient의 Builder를 생성해준다.
    이 프로젝트를 생성하기 전까지는 SSL 인증이 필요없는 서버 통신만 구현했었다.
    하지만, solved.ac의 API 호출을 위해서는 SSL 인증이 필요하다는 에러가 나왔었다.
    Chain validation failed 에러 로그가 나왔는데, 이는 SSL 인증이 필요하다는 뜻이다.
    이 프로젝트에서는 모든 SSL 인증을 무시하는 형태의 OkHttpClient의 Builder를 생성했다.
    이를 생성해주는 메서드가 getUnsafeOkHttpClient() 메서드이다.
    만일 SSL 인증이 필요없는 OkHttpClient Builder가 필요하다면 주석처리 해놓은 부분을 사용하면 된다.

  • Retrofit의 Builder를 생성해준다.
    baseUrl 메서드로 BASE_URL을 지정하고, client 메서드로 생성해뒀던 okHttpClient Builder를 지정한다.
    Gson 형태로 데이터를 받을 것이므로 addConvertFactory 메서드로 GsonConvertFactory.create()를 지정해준다.
    ScalarsConverterFactory.create()로 지정하면 response를 String 형태로 받을 수 있다.


  • DataBinding에서 String(image URL) -> ImageView로 바로 전환해주는 bindImageFromUrl 메서드를 추가한다.
    이 메서드는 @BindingAdapter로, imageFromUrl이라는 이름으로 layout xml파일에서 호출 가능하다.
    만일 imageUrl이 null이 아니라면 Glide 라이브러리를 통해 imageView에 이미지를 넣어준다.
    null이거나 빈 값이라면 drawable에 있는 no_image라는 Drawable로 이미지를 설정한다.

 

전역 어플리케이션을 만들면 Manifest에 위처럼 android:name 속성에 넣어줘야 사용이 가능하다.

 

유저 프로필 프래그먼트

AAC 뷰모델을 사용하는 프래그먼트이다.

이 프래그먼트에서는 ViewModelFactory를 초기화하고,

유저의 정보를 불러오는 UserDataViewModel을 초기화한다.

 

Model 클래스

MVVM 중에서 Model에 해당하는 부분이다.

우리는 위에서 설명했듯이 Gson 형태로 받아올 것이므로, @SerializedName()을 통해 서버와 변수명을
같게 맞춰주면 된다.
넘겨주는 데이터가 상당히 많지만, 내가 필요한 부분만 받아와서 쓸 것이다.

각 변수가 무엇을 의미하는지는 solved.ac의 비공식 api 링크에 자세하게 설명되어 있다.

code는 Http Response Code를 담을 변수이고,

tierText는 Solved.ac API에서 tier를 int형으로 넘어오는데, 이걸 String (ex. Bronze 3, Silver 2)로 바꿔준 값을 담을 변수이다.

 

API 인터페이스

GET Request를 위한 메서드를 만들어 놓는다.

@GET({API 위치})로 Annotation을 달아놓고 그 아래에 메서드를 구현해주면 된다.

동기식으로 받을 것이라면 위 메서드에서 suspend를 빼고, Response를 Call 형태로 바꿔주면 된다.

그리고 사용할 액티비티 / 프래그먼트에서 baseService와 SolvedAcAPI 인터페이스를 사용한

레트로핏 객체를 생성한 후에 위 메서드를 호출해서 enqueue(object: Callback<SolveAcGetUserDataModel>{구현})

을 해주면 된다.

 

하지만 이 프로젝트에서는 비동기식으로(CoroutineScope) 데이터를 받고 처리할 것이므로

suspend 메서드와 Response 반응을 사용해서 구현한다.

 

Repository Class

안드로이드 권장 아키텍처에 따른 Repository Class.

solvedAcClient라는 Retrofit 객체를 생성해준다.

권장 아키텍처는 View -> ViewModel -> Repository -> Retrofit의 형태이다.

getUserData라는 같은 이름의 메서드를 생성하고, Retrofit의 getUserData 메서드를 호출하도록 한다.

 

ViewModel Class

뷰모델에서는 매개변수로 Repository를 받는다.

getUserDataRepositories는 LiveData의 일종으로, 이 값을 Observe하면서 UI를 갱신할 것이다.

 

CoroutineScope의 coroutineContext를 Dispatchers.IO로 설정한다.

Dispatchers.IO는 기본 스레드 외부에서 디스크 또는 네트워크 I/O를 실행하도록 최적화되어 있다고 한다.

그리고 repository에서 getUserData()메서드를 호출한 후에 response를 받는다.

response code가 200번이라면 잘 받아온 값이므로 받아온 response.body()를 바로 라이브 데이터에 postValue 해준다.

200번이 아니라면 값을 제대로 받아오지 못했다는 뜻이다.

임시로 만든 값들을 넣어서 만든 Model을 라이브 데이터에 postValue 해준다.

정수형 티어를 문자열로 바꿔주는 메서드이다.

 

ViewModelFactory Class

ViewModelFactory를 왜 사용하냐면,

AAC ViewModel에서는 각 Activity마다 ViewModel을 하나씩만 사용하기를 권장한다.

(이미 액티비티에 생성된 AAC ViewModel이 있다면, 생성됐었던 AAC ViewModel이 다시 호출된다.)

AAC ViewModel을 생성할 때, ViewModelProvider를 사용하는데, 이렇게 생성할 경우 매개변수를 넘겨줄 수 없다.

따라서 매개변수가 있는 AAC ViewModel은 ViewModelFactory를 사용해야 한다.

 

생성할 ViewModelFactory 클래스는 ViewModelProvider.Factory를 상속하고, create() 메서드를 오버라이딩 한다.

그리고 위처럼 구현해주면 된다.

매개변수가 여러개일 경우에는 newInstance에 (A, B, ...)의 형태로 구현해주면 된다.

 

유저의 정보를 보여주는 프래그먼트

이제, 유저의 정보를 보여주는 UserProfileFragment를 설명할 수 있게 됐다.

이 글에서 정확하고 깊게는 아니지만 간략하게 나마 AAC ViewModel / MVVM ViewModel에 대해서 설명했다.

코드의 로직들은 다음과 같다.

  • initViewModel() 메서드 : viewModelFactory를 초기화한다.
    후에, 액티비티마다 한 개의 AAC 뷰모델만 있어야 하므로 ViewModelProvider를 통해 viewModel을 초기화한다.
    뷰모델은 매개변수가 필요하기 때문에 ViewModelFactory를 사용했다.

  • setUpObserver() 메서드 : userDataViewModel의 getUserDataRepositories를 Observe한다.
    값이 바뀔 때 마다, DataBinding의 model을 API 통신 결과로 나온 결과 모델로 수정한다.
    Http Response Code가 200번이 아니라면 토스트 메시지를 띄운다.

  • getUserData() 메서드 : 맨 처음에 프래그먼트가 생성될 때만 이 방법으로 유저의 정보를 불러오는 API를 호출한다.

이 유저 프로필 프래그먼트에서 userDataViewModel을 생성했고, Owner를 requireActivity() 즉 MainActivity로 설정했다.

따라서 메인 프래그먼트에서 userDataViewModel을 같은 방식으로 생성해도 똑같은 ViewModel이 생성된다.

 

코드의 로직에 대한 설명은 끝났다.

 

결과화면

 

+@ 만일 권장 아키텍처 형식으로 API를 호출하지 않는다면?

위에서 설명했었던, Call 방식으로 API를 호출해보겠다.

API Interface파일에 위와 같은 코드를 추가한다.

 

그리고 유저 프로필 프래그먼트에 위와 같은 코드를 추가해준다.

Call 방식으로 API의 Response를 받는 방식이다.

이렇게 만들 경우, 프래그먼트에서 Model을 Update시키므로, MVVM이 아니게 된다.

또한 ViewModel, Repository를 사용하지 않으므로 안드로이드 권장 아키텍처도 아니게 된다.

작동은 잘 하지만, 이런 식으로 코드를 프래그먼트에 많이 짜게 될 경우 나중에 유지 및 보수가 힘들어 질 것이다.

 

클래스를 분할해서 만들면 되지 않냐? 라고 의문이 들긴 했지만

이 방법이 바로 ViewModel을 사용한 MVVM이라는 생각이 들었다.

 

이 글을 정리하면서 AAC ViewModel, MVVM ViewModel의 차이 등 앱 아키텍처 디자인에 관해서 

많이 공부할 필요가 있다고 깨달았다.

 

조만간

  • Android Design Pattern(MVC, MVP, MVVM),
  • 안드로이드 아키텍처 권장사항,
  • AAC ViewModel

에 대해서 정리를 해야겠다.

 

 

GitHub - kimyunseok/android-study

Contribute to kimyunseok/android-study development by creating an account on GitHub.

github.com

예시 프로젝트 파일은 위에서 확인이 가능하다.