본문 바로가기

책/코틀린 리액티브 프로그래밍

12. 리액티브 코틀린과 안드로이드

안드로이드에서 ToDoApp 개발

ToDoApp 해당 프로젝트 어디에서나 접근할 수 있는 전역 클래스이다.

class ToDoApp:Application() {
    override fun onCreate() {
        super.onCreate()
        instance = this
    }

    companion object {
        var instance:ToDoApp? = null
    }
}

다음과 같이 프로젝트의 매니피스트 파일에 ToDoApp를 application 으로 선언했다.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.rivuchk.todoapplication">

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

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme"
        android:name=".ToDoApp">
        ....
    </application>

</manifest>

BaseActivity 클래스는 onCreate 클래스를 숨기고 onCreateActivity를 제공한다. BaseActivity 를 상속 받으면 onCreateActivity를 재 정의 해야 한다. 

package com.rivuchk.todoapplication

import android.support.v7.app.AppCompatActivity
import android.os.Bundle

abstract class BaseActivity : AppCompatActivity() {

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

    abstract fun onCreateBaseActivity(savedInstanceState: Bundle?)

}

 레이아웃에서는 RecyclerView 선언에서 레이아웃 매니저를 LinearLayoutManager로 설정하고 레이아웃을 수직 방향으로 설정하였다. 

 새로운 todos를 추가하기 위해 FloatingActionButton을 사용했다. AppBarLayout을 사용해 액션바를 추가했다.

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout 
    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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.rivuchk.todoapplication.todolist.TodoListActivity">

    ....

    <android.support.v7.widget.RecyclerView
        android:id="@+id/rvToDoList"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="LinearLayoutManager"
        android:orientation="vertical"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"/>

    <android.support.design.widget.FloatingActionButton
        android:id="@+id/fabAddTodo"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|end"
        android:layout_margin="@dimen/fab_margin"
        app:srcCompat="@drawable/ic_add" />

</android.support.design.widget.CoordinatorLayout>

 

  • ToDoAdapter 인스턴스를 만들어 rvTodoList의 어댑터로 설정 했으며 RecycleView 에서는 todo 목록을 표시한다.
  • ToDoAdapter의 인스턴스를 생성하는 동안 람다를 전달했다. 이 람다는 rvToDoList의 항목을 클릭할 때 호출한다. 
  • onCreateBaseActivity 메서드의 끝에서 fetchTodoList() 함수를 호출한다. 이름에서 알수 있듯이 REST API에서 Todo목록을 가져온다. 
// TodoListActivity 클래스의 onCreateBaseActivity 메서드

lateinit var adapter: ToDoAdapter

private val INTENT_EDIT_TODO: Int = 100

private val INTENT_ADD_TODO: Int = 101

override fun onCreateBaseActivity(savedInstanceState: Bundle?) {
    setContentView(R.layout.activity_todo_list)
    setSupportActionBar(toolbar)


    fabAddTodo.setOnClickListener { _ ->
        startActivityForResult(intentFor<AddTodoActivity>(),INTENT_ADD_TODO)
    }

    adapter = ToDoAdapter(this,{
        todoItem->startActivityForResult(
            intentFor<TodoDetailsActivity>(Pair(Contents.INTENT_TODOITEM, todoItem))
            ,INTENT_ADD_TODO
        )
    })
    rvToDoList.adapter = adapter

    fetchTodoList()
}

 

아래는 ToDoAdapter 클래스의 모습이다.

  1. 주석 1 에서는 컨텍스트의 인스턴스를 생성자의 매개변수로 사용했다. 이 컨텍스트를 사용해 Inflater의 인스턴스를 가져왔으며, 이 인스턴스는 onCreateViewHolder 메서드 내부의 레이아웃을 확작하는데 사용됬다. 그리고 ToDoModel의 빈 ArrayList를 생성했다. 이 목록을 사용해 어댑터 getItemCount() 함수에서 아이템 수를 전달하고 onBindViewHolder 함수 내부에서 이를 ViewHolder 인스턴스로 전달했다.
  2. 주석 2의 TodoAdapter-onItemClick의 생성자 내에서 val 매개변수로 람다를 사용했다. 그 람다는 ToDoModel의 인스턴스를 매개변수로 입력받고 unit을 반환했다.
  3. 언급한 람다를 ToDoViewHolder의 bindView에서 itemView의 onClick 안에서 사용했다. 따라서 아이템을 클릭할 때마다 onItemClick 람다가 호출되며, 이는 TodoListActivity에서 전달된다.
  4. 주석 5에서는 setDataset() 메서드는 어댑터에 새 목록을 할당 하는데 사용된다. ArrayList-TodoList를 지우고 전달된 목록의 모든 아이템을 추가한다. 
  5. setDataset 메서드는 TodoListActivity의 fetchTodoList() 메서드에 의해 호출되야 한다. 그 fetchTodoList() 메서드는 REST API로 부터 목록을 가져오는 일을 담당하는 데 결과로 어뎁터를 전달할 것이다.
class ToDoAdapter(
        private val context:Context,                                   // (1)
        val onItemClick:(ToDoModel?)->Unit = {}                        // (2)
):RecyclerView.Adapter<ToDoAdapter.ToDoViewHolder>() {
    private val inflater:LayoutInflater = LayoutInflater.from(context) //(3)
    private val todoList:ArrayList<ToDoModel> = arrayListOf()          //(4)

    fun setDataset(list:List<ToDoModel>) {                             //(5)
        todoList.clear()
        todoList.addAll(list)
        notifyDataSetChanged()
    }

    override fun getItemCount(): Int = todoList.size

    override fun onBindViewHolder(holder: ToDoViewHolder?, position: Int) {
        holder?.bindView(todoList[position])
    }

    override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ToDoViewHolder {
        return ToDoViewHolder(inflater.inflate(R.layout.item_todo,parent,false))
    }

    inner class ToDoViewHolder(itemView:View):RecyclerView.ViewHolder(itemView) {
        fun bindView(todoItem:ToDoModel?) {
            with(itemView) {                                             // 6
                txtID.text = todoItem?.id?.toString()
                txtDesc.text = todoItem?.todoDescription
                txtStatus.text = todoItem?.status
                txtDate.text = todoItem?.todoTargetDate
                
                onClick {
                    this@TodoAdapter.onItemClick(todoItem)               // 7
                }
            }
        }
    }
}

 

레트로핏 2를 사용한 API 호출

 레트로핏은 네트워크를 호출하고 데이터를 파싱하는 작업을 메서드 호출과 같이 만들어 주는데, HTTP API를 자바 인터페이스로 변경해 준다. 또한 네트워크와 관련된 문제는 자체적으로 해결하지 않고 내부적으로는 OkHTTP에 위임한다. 

 

 레트로핏은 컨버터(JSON/XML파싱하는 헬퍼클래스)를 이용하여 클래스를 직렬화 또는 비직렬화 한다. 컨버터는 설정 가능한데, 다음과 같이 다양한 컨버터를 사용할 수 있다.

  • GSON
  • Jackson
  • Guava
  • Moshi
  • 자바8 컨버터
  • Wire
  • ProtoBuf
  • SimpleXML

 여기에서는 GSON을 사용한다. 레트로핏과 함께 사용하려면 다음 클래스가 필요하다.

  • Model 클래스(POJO 또는 데이터 클래스)
  • Retrofit.Builder()를 사용해 레트로핏 클라이언트 인스턴스를 생성해 줄 클래스
  • 요청 메서드(GET 또는 POST), 인자/요청본문/쿼리문자열, 응답 타입 등을 포함하는 HTTP 작업을 정의한 인터페이스

JSON의 응답 구조이다. 

  • error_code 는 오류 여부를 나타낸다. 0이 아니면 오류이고 0이면 오류가 없고 데이터 파싱을 진행할 수 있다.
  • error_message 는 error_code에 오류가 있을 경우 관련 메시지가 포함된다. 오류가 없을 경우 빈 값이다.
  • data 는 todo 목록을 위한 JSON 배열이 들어있다. 
  • error_code, error_message는 프로젝트의 모든 API 일관성을 갖고 사용되기 때문에 API의 기본 클래스를 만들고 기본 클래스를 확장하는 것이 좋다.
{
    "error_code": 0,
    "error_message": "",
    "data": [
        {
            "id": 1,
            "todoDescription": "Lorem ..... bibendum quam.",
            "totoTargetDate": "2017/11/18",
            "status": "complete"
        }
    ]
}

 

BaseAPIResponse 기본클래스이다.

  • @SerializedName 어노테이션은 GSON 에서 직렬화된 속성의 이름을 사용하는데 사용된다. 직렬화된 이름은 JSON 응답과 동일해야 한다. 
  • JSON 응답과 동일한 변수이름을 사용하면 @SerializedName 어노테이션을 사용하지 않아도 된다. 
package com.rivuchk.todoapplication.apis.apiresponse

import com.google.gson.annotations.SerializedName
import org.json.JSONObject
import java.io.Serializable

open class BaseAPIResponse (
        @SerializedName("error_code") val errorCode:Int
        , @SerializedName("error_message") val errorMessage:String
): Serializable

 

GetToDoListAPIResponse 클래스이다.

  • 속성 data는 JSON 응답과 동일한 이름을 사용하기 때문에 @SerializedName 어노테이션은 사용하지 않는다. 
  • 나머지 두개의 속성은 BaseAPIResponse 클래스에 의해 선언된다.
  • data는 ToDoModel의 ArrayList를 사용하는 GSON은 JSON 배열을 ArrayList로 변환해 준다.
open class GetToDoListAPIResponse(
        errorCode:Int,
        errorMessage:String,
        val data:ArrayList<ToDoModel>
):BaseAPIResponse(errorCode,errorMessage)

 

ToDoModel 클래스이다.

data class ToDoModel (
        val id:Int,
        var todoDescription:String,
        var todoTargetDate:String,
        var status:String
):Serializable

 

레트로핏을 위한 build 클래스이다. 

  • getClient() 함수는 레트로핏 클라이언트를 만들고 제공하는 일을 담당한다.
  • getAPIService() 함수는 레트로핏 클라이언트를 정의한 HTTP작업과 연결하고 인터페이스의 인스턴스를 만들어준다.
class APIClient {
    private var retrofit: Retrofit? = null

    fun getClient(logLevel: LogLevel): Retrofit {

        if(null == retrofit) {
           val client = OkHttpClient.Builder()
                         .connectTimeout(3, TimeUnit.MINUTES)
                         .writeTimeout(3, TimeUnit.MINUTES)
                         .readTimeout(3, TimeUnit.MINUTES)
                         .addInterceptor(interceptor)
                         .build()
           
           retrofit = Retrofit.Builder()
                         .baseUrl(Constants.BASE_URL)
                         .addConverterFactory(GsonConverterFactory.create())
                         .client(client)
                         .build()
        }
        return retrofit!!
    }
    fun getAPIService() = getClient().create(APIService::class.java)
}

 

HTTP 작업을 위한 인터페이스 APISevice 클래스이다.

  • Call 인스턴스는 웹 서버에 요청을 보내고 응답을 반환하는 레트로핏 메서드의 호출이다.
  • 각 호출은 고유한 HTTP 요청 및 응답 쌍을 생성한다.
  • Call<T> 인스턴스로 무엇을 해야할까? Callback<T> 인스턴스를 큐에 넣어야 한다. 
    • 여기에서 동일한 풀 메커니즘과 콜백지옥이 펼쳐진다. 리액티브 프로그래밍이 필요한 부분이다.
interface APIService {
    @POST(Constants.GET_TODO_LIST)
    fun getToDoList(): Call<BaseAPIResponse>

    @FormUrlEncoded
    @POST(Constants.EDIT_TODO)
    fun editTodo(
            @Field("todo_id") todoID:String
            ,@Field("todo") todo:String
    ): Call<BaseAPIResponse>

    @FormUrlEncoded
    @POST(Constants.ADD_TODO)
    fun addTodo(@Field("newtodo") todo:String): Call<BaseAPIResponse>
}

 

 

레트로핏과 RXKotlin 사용하기

안드로이드에서는 RxKotlin 외에도 RxAndroid를 사용해 안드로이드만의 장점을 살리 수 있다. 레트로핏도 RxAndroid를 지원한다. 

ReactiveX를 사용하기 위해 gradle에 다음 의존성을 추가한다. 

implementation 'com.squareup.retrofit2:adapter-rxjava2:2.3.0'
implementation 'io.reactivex.rxjava2:rxandroid:2.0.1'
implementation 'io.reactivex.rxjava2:rxkotlin:2.1.0'

 

Call 대신 Observable/Flowable을 변경해보자.

  • 여기에는 OkHTTP 로깅 인터셉터(HttpLoggingInterceptor)를 추가했다. OkHTTP 로깅 인터셉터는 요청과 응답을 기록하도록 도와준다. 
  • RxJava2CallAdapterFactory를 레트로핏 클라이언트의 CallAdapterFactory로 추가했다.
package com.rivuchk.todoapplication.apis

import com.rivuchk.todoapplication.utils.Constants
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.converter.scalars.ScalarsConverterFactory
import java.util.concurrent.TimeUnit

/**
 * Created by Rivu on 02-11-2017.
 */
class APIClient {
    private var retrofit: Retrofit? = null

    enum class LogLevel {
        LOG_NOT_NEEDED,
        LOG_REQ_RES,
        LOG_REQ_RES_BODY_HEADERS,
        LOG_REQ_RES_HEADERS_ONLY
    }

    /**
     * Returns Retrofit builder to create
     * @param logLevel - to print the log of Request-Response
     * @return retrofit
     */
    fun getClient(logLevel: LogLevel): Retrofit {

        val interceptor = HttpLoggingInterceptor()
        when(logLevel) {
            LogLevel.LOG_NOT_NEEDED ->
                interceptor.level = HttpLoggingInterceptor.Level.NONE
            LogLevel.LOG_REQ_RES ->
                interceptor.level = HttpLoggingInterceptor.Level.BASIC
            LogLevel.LOG_REQ_RES_BODY_HEADERS ->
                interceptor.level = HttpLoggingInterceptor.Level.BODY
            LogLevel.LOG_REQ_RES_HEADERS_ONLY ->
                interceptor.level = HttpLoggingInterceptor.Level.HEADERS

        }


        val client = OkHttpClient.Builder().connectTimeout(3, TimeUnit.MINUTES)
                .writeTimeout(3, TimeUnit.MINUTES)
                .readTimeout(3, TimeUnit.MINUTES).addInterceptor(interceptor).build()


        if(null == retrofit) {
            retrofit = Retrofit.Builder()
                    .baseUrl(Constants.BASE_URL)
                    .addConverterFactory(GsonConverterFactory.create())
                    .addConverterFactory(ScalarsConverterFactory.create())
                    .addCallAdapterFactory(RxJava2CallAdapterFactory.create())  // 1
                    .client(client)
                    .build()
        }

        return retrofit!!
    }

    fun getAPIService(logLevel: LogLevel = LogLevel.LOG_REQ_RES_BODY_HEADERS) 
                     = getClient(logLevel).create(APIService::class.java)
}

 

APIService 클래스에는 Call 대신 Observable 함수를 반환하도록 수정했다.

package com.rivuchk.todoapplication.apis

import com.rivuchk.todoapplication.apis.apiresponse.BaseAPIResponse
import com.rivuchk.todoapplication.apis.apiresponse.GetToDoListAPIResponse
import com.rivuchk.todoapplication.datamodels.ToDoModel
import com.rivuchk.todoapplication.utils.Constants
import io.reactivex.Observable
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST

/**
 * Created by Rivu on 01-11-2017.
 */
interface APIService {
    @POST(Constants.GET_TODO_LIST)
    fun getToDoList(): Observable<GetToDoListAPIResponse>

    @POST(Constants.EDIT_TODO)
    fun editTodo(
            @Body todo:String
    ): Observable<BaseAPIResponse>

    @POST(Constants.ADD_TODO)
    fun addTodo(@Body todo:String): Observable<BaseAPIResponse>
}

 

TodoListActivity 클래스의 fetchTodoList() 함수를 수정한다. 

  • API의 옵저버블을 구독하고 데이터가 도착하면 어뎁터에 데이터를 할당한다.
  • 데이터를 할당하기 전에 오류코드를 확인이 필요하다.
    //TodoListActivity
    
    private fun fetchTodoList() {
        APIClient()
                .getAPIService()
                .getToDoList()
                .subscribeOn(Schedulers.single())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribeBy(
                        onNext = { response ->
                            adapter.setDataset(response.data)
                        },
                        onError = {
                            e-> e.printStackTrace()
                        }
                )
    }

 

안드로이드 이벤트를 리액티브로 만들기

  • 생성자에 Subject 인스턴스가 있는데 itemView를 클릭하면 Subject의 onNext 이벤트를 호출하고 Pair를 사용해 itemView와 TodoModel 인스턴스를 전달한다. 
package com.rivuchk.todoapplication.todolist

import android.content.Context
import android.support.v7.widget.RecyclerView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.jakewharton.rxbinding2.view.RxView
import com.jakewharton.rxbinding2.view.clicks
import com.rivuchk.todoapplication.R
import com.rivuchk.todoapplication.datamodels.ToDoModel
import io.reactivex.rxkotlin.subscribeBy
import io.reactivex.subjects.PublishSubject
import io.reactivex.subjects.Subject
import kotlinx.android.synthetic.main.item_todo.view.*
import org.jetbrains.anko.intentFor
import org.jetbrains.anko.sdk25.coroutines.onClick

/**
 * Created by Rivu on 03-11-2017.
 */
class ToDoAdapter(
        private val context:Context, //(1)
        val onClickTodoSubject:Subject<Pair<View,ToDoModel?>>//(2)
):RecyclerView.Adapter<ToDoAdapter.ToDoViewHolder>() {
    private val inflater:LayoutInflater = LayoutInflater.from(context)//(3)
    private val todoList:ArrayList<ToDoModel> = arrayListOf()//(4)

    fun setDataset(list:List<ToDoModel>) {//(5)
        todoList.clear()
        todoList.addAll(list)
        notifyDataSetChanged()
    }

    override fun getItemCount(): Int = todoList.size

    override fun onBindViewHolder(holder: ToDoViewHolder?, position: Int) {
        holder?.bindView(todoList[position])
    }

    override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ToDoViewHolder {
        return ToDoViewHolder(inflater.inflate(R.layout.item_todo,parent,false))
    }

    inner class ToDoViewHolder(itemView:View):RecyclerView.ViewHolder(itemView) {
        fun bindView(todoItem:ToDoModel?) {
            with(itemView) {//(6)
                txtID.text = todoItem?.id?.toString()
                txtDesc.text = todoItem?.todoDescription
                txtStatus.text = todoItem?.status
                txtDate.text = todoItem?.todoTargetDate

                itemView.clicks()
                        .subscribeBy {
                            onClickTodoSubject.onNext(Pair(itemView,todoItem))
                        }
            }
        }
    }
}

 

안드로이드의 RxBinding 소개

RxBinding 라이브러리는 안드로이드 이벤트를 리액티브한 방법으로 얻을 수 있도록 도와준다. 

 

JakeWharton/RxBinding

RxJava binding APIs for Android's UI widgets. Contribute to JakeWharton/RxBinding development by creating an account on GitHub.

github.com

다음 의존성을 앱 수준의 build.gradle에 추가한다.

implementation 'com.jakewharton.rxbinding2:rxbinding-kotlin:2.0.0'

다음 코드를 사용해 ToDoViewHolder의 onClick 내부를 교체한다. 

// ToDoViewHolder 의 onClick 내부
itemView.clicks()
.subscribeBy {
    onClickTodoSubject.onNext(Pair(itemView, todoItem))
}

Rxbinding 사용하면 다음과 같은 이점이 있다.

  • 로직을 쉽게 분리할수 있어 특히 map 과 filter네 큰 도움이 된다.
  • 일관성을 제공한다. 예를 들어 EditText에서 변경 사항을 관찰해야 할 경우 일반적으로 TextWatcher 인스턴스에 코드 행을 작성하지만 RxBinding을 사용하면 다음과 같이 할 수있다.
textview.textChanges().subscribeBy {
    changeText->Log.d("Text Changed", changedText)
}

그 외에도 다른 장점에 대해 알아보려만 다음을 참고하면된다.

https://speakerdeck.com/lmller/kotlin-plus-rxbinding-equals 

 

Kotlin + RxBinding = ❤️

Lightning talk from GDG Devfest Hamburg 2016

speakerdeck.com

https://adavis.info/2017/07/using-rxbinding-with-kotlin-and-rxjava2.html 

 

Using RxBinding with Kotlin and RxJava2

RxBinding provides RxJava binding APIs for Android User Interface (UI) widgets. This allows us to easily translate Android UI events into Observable streams. Let’s look at a simple example of…

adavis.info

코틀린 익스텐션즈

View / ViewGroup 인스턴스를 사용해 비트맵을 확장하는 함수가 필요한 경우 다음과 같이 확장함수를 복사해서 붙여 넣을 수 있다.

fun View.getBitmap(): Bitmap {
    val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(bmp)
    draw(canvas)
    canvas.save()
    return bmp
}

 

아니면 조금 더 일반적인 케이스로 키보드를 숨기고 싶을 때 다음 확장 함수를 사용할 수 있다.

fun Activity.hideSoftKeyboard() {
    if( currentFocus != null ) {
        val inputMethodManager 
            = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
        
        inputMethodManager.hideSoftInputFromWindow(currentFocus!!.windowToken, 0)
    }
}

확장 함수가 필요할 때는 직접 작성하기 전에 다음 링크를 방문해 먼저 찾아보자

 

Kotlin Extensions

A handy collection of most commonly used Kotlin extensions to boost your productivity.

kotlinextensions.com