Develop/Kotlin

[kotlin] 코틀린 Android MVVM Retrofit(BE 연결) 구현

JunJangE 2022. 5. 1. 21:46

이전까지 모두 Room을 통해 MVVM 패턴을 구현해보았는데 이번에는 외부 백엔드와 연결하여 MVVM 패턴을 구현해보도록 하자.

Retrofit을 통해 BE와 연결 후 데이터를 가져오는 게시판 예제이다. 비동기 작업은 Corutine을 사용하였고 자세한 내용과 공부한 내용은 주석을 달아 놓았다.

Retrofit에 대해 잘 모른다면 다음 링크를 통해 알아보고 온 후에 아래 내용을 보면 이해가 더욱 쉬울 것으로 예상한다.

 

[kotlin] 코틀린 Android Retrofit 활용(기상청단기예보 오픈 API 사용)

Retofit이란? 이번에는 Retofit 라이브러리를 이용하여 기상청 단기예보 오픈 API와 통신하는 방법에 대해서 알아보도록 하자. Retofit이란 모바일에서 HTTP API 통신을 할 때 사용하는 라이브러리이다.

fre2-dom.tistory.com

폴더와 파일 위치

폴더와 파일에 위치는 위 이미지와 같이 구성했다.

라이브러리

    // data binding
    buildFeatures {
        dataBinding = true
    }
}

dependencies {

    // retrofit2
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
    implementation("com.squareup.okhttp3:logging-interceptor:4.8.1")

    // recyclerview
    implementation("androidx.recyclerview:recyclerview:1.2.1")
    implementation("androidx.recyclerview:recyclerview-selection:1.1.0")

    // Kotlin components
    def coroutines = '1.3.4'
    def kotlin_version = "1.3.72"
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines"
    api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines"


    // Lifecycle components
    def archLifecycleVersion = '2.2.0'
    implementation "androidx.lifecycle:lifecycle-extensions:$archLifecycleVersion"
    kapt "androidx.lifecycle:lifecycle-compiler:$archLifecycleVersion"
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$archLifecycleVersion"
    implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1'
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1'
    implementation 'androidx.navigation:navigation-fragment-ktx:2.4.1'
    implementation 'androidx.navigation:navigation-ui-ktx:2.4.1'
    implementation 'androidx.room:room-runtime:2.4.2'

    // floating button
    implementation 'com.google.android.material:material:<version>'

외부 BE와 연결하기 위해 Retrofit2 사용
리사이클러 뷰를 사용하기 위해 recyclerview 사용
ACC를 사용하기 위해 Kotlin componets와 Lifecylce componets 사용
floating button을 사용하기 위해 material 사용

다음으로 AndroidMainfest.xml에 들어가 인터넷 권한을 추가한다.

<!-- BE 연결을 위해 인터넷 연결 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>

<application
        android:usesCleartextTraffic="true"

android:usesCleartextTraffic="true"로 설정하는 이유로는

cleartext HTTP와 같은 cleartext 네트워크 트래픽을 사용할지 여부를 나타내는 flag로 이 플래그가 flase로 되어 있으면, 플랫폼 구성 요소 (예 : HTTP 및 FTP 스택, DownloadManager, MediaPlayer)는 일반 텍스트 트래픽 사용에 대한 앱의 요청을 거부하게 되기 때문이다. 이 flag를 설정하게 되면 모든 cleartext 트래픽은 허용 처리가 된다.

BoardRecyclerAdapter

/**
리사이클러 뷰를 데이터 바인딩으로 구현 : onCreateViewHolder()
ModelBoard에 있는 ModelBoardComponent를 리사이클러 뷰에 뿌려준다. : onBindViewHolder() --> setItem()
리사이클러 뷰 업데이트 => setData()

getItemViewType() =>  RecyclerView 재사용 item 오류/ position 오류 해결 방법[완벽한 해결]
각각의 viewType은 getItemViewType() 함수를 재정의하여 각 아이템 항목에 맞는 ViewType 값을 리턴하도록 재정의
이런식으로 하면 각각의 뷰들이 고유의 뷰 타입을 갖게 되어서 View 가 꼬이는 문제를 해결 할 수 있다.
실제로 구글에서 위 방법으로 문제를 해결하라고 하다고 한다..
 **/


class BoardRecyclerAdapter : RecyclerView.Adapter<BoardRecyclerAdapter.ViewHolder>() {
    private var items: ModelBoard = ModelBoard(ArrayList())

    // 뷰 홀더 만들어서 반환
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val binding = ItemRecyclerBoardBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return ViewHolder(binding)
    }


    // 전달받은 위치의 아이템 연결
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.setItem(items.board[position])
    }

    // 뷰 홀더 설정
    inner class ViewHolder(private val binding: ItemRecyclerBoardBinding) : RecyclerView.ViewHolder(binding.root) {
        fun setItem(item: ModelBoardComponent){
            binding.tvTitle.text =  item.title
            binding.tvContents.text =  item.contents
        }
    }

    override fun getItemViewType(position: Int): Int {
        return position
    }


    @SuppressLint("NotifyDataSetChanged")
    internal fun setData(newItems: ModelBoard ) {
        this.items = newItems
        notifyDataSetChanged()
    }

    // 아이템 갯수
    override fun getItemCount() = items.board.size
    
}

ModelBoard

/**
@SerializedName("result") 은 서버에서 가져온 객체의 'result' 값을 매핑된 변수에 넣겠다는 뜻
서버에서 가져온 변수명과 내가 쓰려는 변수명을 다르게 해도 된다.
 **/

// 리사이클러 뷰 아이템 클래스
data class ModelBoard(@SerializedName("result") val board: List<ModelBoardComponent>)

ModelBoardComponent

/**
ModelBoard 와 같다.
 **/
 
data class ModelBoardComponent(
    @SerializedName("title") val  title: String, // 제목
    @SerializedName("contents") val  contents: String // 설명
)

BoardInterface

/**
Rest API 서버와 통신하는 방법을 정의한 인터페이스

Call같은 경우는 명시적으로 Success / Fail을 나눠서 처리
Response 같은 경우는 서버에서 Status Code를 받아서 케이스를 나눠 처리
Callback Hell을 방지하려면 Response를 이용해서 하는 것이 더 좋다고 한다.
>>>>>>>>> 이 부분은 더 알아봐야할듯.
 **/

// 결과 xml 파일에 접근해서 정보 가져오기
interface BoardInterface {
    @GET("get_post_list.php")
    suspend fun getBoard(): Response<ModelBoard>

    @FormUrlEncoded
    @POST("add_post_2.php")
    suspend fun postBoard(
        @Field("title") title: String,
        @Field("contents") contents: String
    ): Response<JsonObject>
}

BoardObject

/**
OkHttp는 HTTP 기반의 request/response를 쉽게 할 수 있도록 해주는 라이브러리이다.
Square에서 제공하는 오픈소스 라이브러리로 HTTP 통신을 쉽게 할 수 있게 해준다.
안드로이드에서 Okhttp 없이 Http 통신을 하게 되면 예외처리, Buffer입출력, HttpURLConnection연결 등 할게 엄청 많아진다고 한다.

object로 싱글톤으로 객체를 생성한다.
여기서 getRetrofit와 getRetrofitService를 by lazy 로 늦은 초기화 해줌으로써,
api 변수가 사용될 때 초기화되고, 그 안에서 retrofit 변수를 사용하기 때문에 초기화 된다.
 **/
 
 
object BoardObject {
    // 서버 주소
    private const val BASE_URL = ""
    var token: String = ""

    private val okHttpClient = OkHttpClient.Builder().addInterceptor(HttpLoggingInterceptor().apply {
            level = HttpLoggingInterceptor.Level.NONE 
    }).addInterceptor {
            // Request
            val request = it.request()
                .newBuilder()
                .addHeader("Authorization", "Bearer $token")
                .build()

            // Response
            val response = it.proceed(request)
            response
        }.build()


    private val getRetrofit by lazy{
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    val getRetrofitService : BoardInterface by lazy{
        getRetrofit.create(BoardInterface::class.java)
    }
}

BoardRepository

/**
ViewMovel에서는 로컬데이터인지 원격데이터인지 신경쓰지않고 Repository를 사용할 수 있다.
result.isSuccessful : 통신에 성공했는지의 여부. 이때의 통신은 갔다 왔는가 그자체를 의미하는 것
result.body : 실질적으로 받게되는 데이터입니다. `as Type`으로 객체 타입을 명시. 여기서는 ModelBoard를 받음.

같은 시간에 여러 인스턴스가 하나의 Repository에 접근하는 것을 막기위해 싱글톤 패턴을 구현한다.
 **/


class BoardRepository(application : Application) {

    // Use Retrofit
    suspend fun retrofitSelectAllTodo(): ModelBoard {
        val response = BoardObject.getRetrofitService.getBoard()
        return if (response.isSuccessful) response.body() as ModelBoard else ModelBoard(ArrayList())

    }

    // singleton pattern
    companion object {
        private var instance: BoardRepository? = null

        fun getInstance(application : Application): BoardRepository? {
            if (instance == null) instance = BoardRepository(application)
            return instance
        }
    }

     // Insert
    suspend fun retrofitInsertTodo(modelBoardComponent: ModelBoardComponent) : Response<JsonObject> {
        return BoardObject.getRetrofitService.postBoard(modelBoardComponent.title, modelBoardComponent.contents)
    }
}

MainViewModel

/**
LiveData?
- LiveData는 observable 패턴을 사용하기에 데이터의 변화를 구독한 곳으로 통지하고, 업데이트한다.
- 메모리 누수 없는 사용을 보장한다.
- Lifecycle에 따라 LiveData의 이벤트를 제어한다.
- 항상 최신 데이터를 유지한다.
- 기기 회전이 일어나도 최신 데이터를 처리할 수 있도록 도와준다.(AAC-ViewModel과 함께 활용 시)
- LiveData의 확장도 지원한다.

MutableLiveData : 값의 get/set 모두를 할 수 있다.
LiveData : 값의 get()만을 할 수 있다.

factory를 응용하여 훨씬 적은 수고로 구상 클래스별 팩토리를 관리하여 응집성을 높이면서도 
실제 사용시의 코드에서 정말 깔끔하게 클래스명만 넘기는 것으로 복잡한 별도의 factory객체를 전달하지 않아도 된다.
 **/

class MainViewModel(private val repository: BoardRepository) : ViewModel(){
    private val _retrofitTodoList = MutableLiveData<ModelBoard>()
    val retrofitTodoList: MutableLiveData<ModelBoard>
    get() = _retrofitTodoList

    init { // 초기화 시 서버에서 데이터를 받아온다.
        viewModelScope.launch {
            _retrofitTodoList.value = repository.retrofitSelectAllTodo()
        }
    }

    // insert
    fun insertRetrofit(title : String, contents : String) = viewModelScope.launch {
        val response = repository.retrofitInsertTodo(ModelBoardComponent(title, contents))
        if (response.isSuccessful) _retrofitTodoList.value = repository.retrofitSelectAllTodo()
    }

    // 하나의 팩토리로 다양한 ViewModel 클래스를 관리할 수도 있고, 원치 않는 상황에 대해서 컨트롤 할 수 있다.
    class Factory(private val application : Application) : ViewModelProvider.Factory { // factory pattern
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            return MainViewModel(BoardRepository.getInstance(application)!!) as T
        }
    }

}

MainActivity

class MainActivity : AppCompatActivity() {
    private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
    private val viewModel by lazy { ViewModelProvider(this,MainViewModel.Factory(application))[MainViewModel::class.java] }
    private var isFabOpen = false // Fab 버튼 default는 닫혀있음
    private lateinit var retrofitAdapter: BoardRecyclerAdapter


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

        // 데이터 바인딩
        binding.viewModel = viewModel
        binding.lifecycleOwner = this

        setView() // 리사이클러 뷰 연결
        setObserver() // 뷰모델을 관찰합니다.

        // 플로팅 버튼
        binding.fabMain.setOnClickListener {
            // 플로팅 버튼 클릭시 애니메이션 동작 기능
            toggleFab()

            // 플로팅 버튼 클릭 이벤트 - 글 쓰기
            binding.fabEdit.setOnClickListener {
                val intent = Intent(this, WriteBoardActivity::class.java)
                startActivity(intent)

//              Toast.makeText(this, "글 쓰기 버튼 클릭", Toast.LENGTH_SHORT).show()
            }
        }

    }

    private fun setView(){
        retrofitAdapter =  BoardRecyclerAdapter().apply {
            setHasStableIds(true) // 리사이클러 뷰 업데이트 시 깜빡임 방지
        }
        binding.rvList.adapter = retrofitAdapter // 리사이클러 뷰 연결
    }

    private fun setObserver() {

        // 뷰모델 관찰
        viewModel.retrofitTodoList.observe(this, {
            viewModel.retrofitTodoList.value?.let { it1 -> retrofitAdapter.setData(it1) }
        })

    }

    private fun toggleFab() {

        // 플로팅 액션 버튼 닫기 - 열려있는 플로팅 버튼 집어넣는 애니메이션
        if (isFabOpen) {
            ObjectAnimator.ofFloat(binding.fabEdit, "translationY", 0f).apply { start() }
        } else { // 플로팅 액션 버튼 열기 - 닫혀있는 플로팅 버튼 꺼내는 애니메이션
            ObjectAnimator.ofFloat(binding.fabEdit, "translationY", -250f).apply { start() }
        }

        isFabOpen = !isFabOpen

    }
}

WriteBoardActivity

class WriteBoardActivity : AppCompatActivity() {
    private val binding by lazy { ActivityWriteBoardBinding.inflate(layoutInflater) }
    private val viewModel by lazy { ViewModelProvider(this,MainViewModel.Factory(application))[MainViewModel::class.java] }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        // 데이터 바인딩
        binding.viewModel = viewModel
        binding.lifecycleOwner = this

    }

    fun onClickAdd(view: View) {
        viewModel.insertRetrofit(binding.titleEdit.text.toString(), binding.contentEdit.text.toString())

        // 입력을 하고 MainActivity 이동
        val intent = Intent(this@WriteBoardActivity, MainActivity::class.java)
        startActivity(intent)
        finish()
    }
}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:tools="http://schemas.android.com/tools"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <data>
        <variable
            name="viewModel"
            type="com.twentyfour.bulletin_board.MainViewModel" />
    </data>


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

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rv_list"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/fabEdit"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_margin="16dp"
            android:src="@drawable/ic_baseline_create_24"
            app:fabSize="mini"
            app:layout_constraintTop_toTopOf="@id/fabMain"
            app:layout_constraintBottom_toBottomOf="@id/fabMain"
            app:layout_constraintStart_toStartOf="@id/fabMain"
            app:layout_constraintEnd_toEndOf="@id/fabMain"/>

        <com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/fabMain"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_margin="24dp"
            android:src="@drawable/ic_baseline_add_24"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

activity_write_board.xml

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

<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable
            name="viewModel"
            type="com.twentyfour.bulletin_board.MainViewModel" />
    </data>

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:backgroundTint="#FFFFFF"
    android:orientation="vertical"
    android:padding="20dp">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="25dp"
            android:text="제목"
            android:textStyle="bold" />

        <EditText
            android:id="@+id/title_edit"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="#eeeeee"
            android:hint="게시판 제목 입력"
            android:padding="16dp" />
    </LinearLayout>

    <EditText
        android:id="@+id/content_edit"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:background="#EEEEEE"
        android:hint="게시판 글 입력"
        android:padding="16dp" />

    <Button
        android:id="@+id/add_button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:backgroundTint="@color/teal_200"
        android:onClick="onClickAdd"
        android:text="추가"
        android:textColor="#FFFFFF" />


</LinearLayout>

</layout>

item_recycler_board.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">
    <data>
        <variable
            name="item"
            type="com.twentyfour.bulletin_board.data.ModelBoardComponent" />
    </data>

    <androidx.cardview.widget.CardView
        android:layout_margin="8dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:cardElevation="8dp"
        app:cardCornerRadius="8dp">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/linearLayout"

            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <TextView
                android:id="@+id/tv_title"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="16dp"
                android:layout_marginTop="8dp"
                android:text="TextView"
                android:textColor="?android:attr/textColorPrimary"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

            <TextView
                android:id="@+id/tv_contents"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="8dp"
                android:layout_marginBottom="8dp"
                android:text="TextView"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintStart_toStartOf="@+id/tv_title"
                app:layout_constraintTop_toBottomOf="@+id/tv_title" />
        </androidx.constraintlayout.widget.ConstraintLayout>
    </androidx.cardview.widget.CardView>

</layout>

 

위 코드를 모두 작성하고 실행하면 다음 결과 화면과 같이 나오는 것을 확인할 수 있다.

https://youtube.com/shorts/rBcusX1YWAA?feature=share 

참고

 

Overview - OkHttp

OkHttp HTTP is the way modern applications network. It’s how we exchange data & media. Doing HTTP efficiently makes your stuff load faster and saves bandwidth. OkHttp is an HTTP client that’s efficient by default: HTTP/2 support allows all requests to

square.github.io

 

LiveData 개요  |  Android 개발자  |  Android Developers

LiveData를 사용하여 수명 주기를 인식하는 방식으로 데이터를 처리합니다.

developer.android.com

 

[Android] AAC ViewModel 을 생성하는 6가지 방법 - ViewModelProvider

이 글은 이전 포스팅([Android] 화면 회전해도 데이터 유지하기 - AAC ViewModel)에 이어지는 글입니다. ViewModel 클래스를 상속하여 정의한 클래스는 개발자가 직접 생성자를 통하여서 인스턴스를 생성

readystory.tistory.com

 

[kotlin] compaion object를 활용한 factory - Bsidesoft co.

개요 자바에서 코틀린으로 건너오는 분들을 위해 static을 대체할 companion object를 소개하곤 합니다. 이렇게 소개되다보니 compaion object의 진짜 의미와 사용법에 대해 잘 알려지지 않는 면이 있는데

www.bsidesoft.com

GitHub

 

GitHub - junjange/Kotlin-Learning: 코틀린 학습📘

코틀린 학습📘. Contribute to junjange/Kotlin-Learning development by creating an account on GitHub.

github.com