12
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Retrofit × RxJava × Kotlin でイマドキのHttp通信をしよう(Android)

Last updated at Posted at 2019-09-28

実務でKotlinを使ったAndroidアプリの開発にかかわれそうなので、
この機会に、イマドキの通信処理について勉強しました。

今回は、その過程で作ったサンプルアプリについて、ご紹介します。

どういうアプリを作るのか

http://weather.livedoor.com/forecast/

上記URLのAPIに通信して、天気の情報をとってくるアプリを作ります。

取って来たデータをUIに反映する過程は省きますので、
「自分もこのアプリ作りたい」
と思った方は、UIを自作するか、
後ほど、このアプリのGitHubのURLを貼りますので、
そちらを参考にしてください。

必要なライブラリを取得する

アプリのbuild.gradleファイルに、必要な設定を追加します。

build.gradle
dependencies {
    apply plugin: 'kotlin-android'
    apply plugin: 'kotlin-kapt'

    ~~~~

    // Retrofit
    implementation 'com.squareup.retrofit2:retrofit:2.4.0'
    implementation "com.squareup.retrofit2:adapter-rxjava2:2.3.0"
    implementation 'com.squareup.retrofit2:converter-gson:2.3.0'
    implementation 'com.squareup.retrofit2:converter-simplexml:2.3.0'
    implementation 'io.reactivex.rxjava2:rxkotlin:2.0.0'
    implementation 'io.reactivex.rxjava2:rxandroid:2.0.1'
    implementation 'com.squareup.okhttp3:logging-interceptor:3.8.1'
}

まずは定数クラスを作成

通信に使用する定数を、一つのクラスにまとめます。

HttpConstants.kt
/**
 * 通信で使用する定数を定義する
 */
class HttpConstants {

    companion object {

        // ========== 通信に関する定数群 =========== //
        /** 通信のタイムアウト秒数 */
        const val CONNECT_TIMEOUT_MS = 120L
        /** 読み取りのタイムアウト秒数 */
        const val READ_TIMEOUT_MS = 120L
        /** 最大リトライ回数 */
        const val RETRY_COUNT = 3L

        // ========== リクエストヘッダー =========== //
        const val HEADER_USER_AGENT = "User-Agent"

        // ========== URLのリスト =========== //
        /** ドメイン */
        const val URL_BASE = "http://weather.livedoor.com/forecast/"
        /** RSSの取得用API */
        const val END_POINT_RSS = "rss/primary_area.xml"
        /** 都市の天気情報取得API */
        const val END_POINT_CITY_WEATHER = "webservice/json/v1"
    }
}

User-Agentは、今回使用するAPIでは必要ありませんが、
共通ヘッダーの設定の仕方をお見せしたので、
仮で設定しています。

ベースとなるHTTPクライアントを作成する

今回扱うデータには、JSONとXMLの2種類があるので、
それぞれについて、ベースとなるクライアントクラスを作ります。

作るベースクライアントは以下の3つです。

・ BaseClient
・ BaseJsonClient
・ BaseXmlClient

BaseClient.kt

import com.shunbou.retrofitsample.constant.HttpConstants
import com.shunbou.retrofitsample.util.DeviceUtils
import com.shunbou.retrofitsample.util.LogUtils
import io.reactivex.Observable
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit

/**
 * 通信クラスの親クラス
 */
abstract class BaseClient {

    protected fun getHttpClient(): OkHttpClient {

        return OkHttpClient().newBuilder().apply {

            connectTimeout(HttpConstants.CONNECT_TIMEOUT_MS, TimeUnit.SECONDS)
            readTimeout(HttpConstants.READ_TIMEOUT_MS, TimeUnit.SECONDS)
            addInterceptor(HttpLoggingInterceptor().apply {
                level = HttpLoggingInterceptor.Level.BODY
            })
            addInterceptor(HeaderInterceptor())
        }.build()
    }

    abstract fun getClient(): Retrofit

    /**
     * 非同期で通信する
     *
     * @param observable 通信ストリーム
     * @param onNext 通信成功後の処理
     * @param onError 通信失敗後の処理
     * @param onComplete 通信完了後の処理
     */
    fun <T> asyncRequest(
        observable: Observable<T>,
        onNext: ((T) -> Unit),
        onError: ((Throwable) -> Unit),
        onComplete: (() -> Unit)

    ): Disposable {

        return observable
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .retryWhen { observable ->
                observable.take(HttpConstants.RETRY_COUNT).flatMap {
                    return@flatMap Observable.timer(100, TimeUnit.MILLISECONDS)
                }

            }
            .subscribe({
                LogUtils.d(this::class.java.simpleName, "doOnNext : ${it.toString()}")
                onNext(it)
            }, {
                LogUtils.e(this::class.java.simpleName, "doOnError : ${it.message}")
                onError(it)
            }, {
                LogUtils.d(this::class.java.simpleName, "doOnComplete")
                onComplete()
            })

    }

    /**
     * 非同期で通信する
     *
     * @param single 通信ストリーム
     * @param onSuccess 通信成功後の処理
     * @param onError 通信失敗後の処理
     */
    fun <T> asyncSingleRequest(
        single: Single<T>,
        onSuccess: ((T) -> Unit),
        onError: ((Throwable) -> Unit)
    ): Disposable {

        return single
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .retry(HttpConstants.RETRY_COUNT)
            .subscribe({
                LogUtils.d(this::class.java.simpleName, "doOnSuccess : ${it.toString()}")
                onSuccess(it)
            }, {
                LogUtils.e(this::class.java.simpleName, "doOnError : ${it.message}")
                onError(it)
            })
    }

    class HeaderInterceptor : Interceptor {
        override fun intercept(chain: Interceptor.Chain): Response {
            return chain.run {
                proceed(
                    request()
                        .newBuilder()
                        .addHeader(HttpConstants.HEADER_USER_AGENT, DeviceUtils.getModel())
                        .build()
                )
            }
        }
    }
}

**getHttpClinet()**で、
RetrofitでラップするHttpClientを作成しています。

addInterceptorで、共通ヘッダーを設定することが可能です。
ここでは、User-Agentを設定しています。

続いて、Json用と、XML用のクライアントを作成します。

BaseJsonClient.kt

import com.google.gson.Gson
import com.shunbou.retrofitsample.http.RxCallAdapterWrapperFactory
import com.shunbou.retrofitsample.util.UrlUtils
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

abstract class BaseJsonClient : BaseClient() {

    /**
     * 共通のクライアントを取得する
     */
    override fun getClient(): Retrofit = Retrofit.Builder().apply {

        client(getHttpClient())
        baseUrl(UrlUtils.getDomain())
        addCallAdapterFactory(RxCallAdapterWrapperFactory.create())
        addConverterFactory(GsonConverterFactory.create(Gson()))
    }.build()
}

RxCallAdapterWrapperFactoryについては、
後ほど説明します。

BaseXmlClient.kt

import com.shunbou.retrofitsample.http.RxCallAdapterWrapperFactory
import com.shunbou.retrofitsample.util.UrlUtils
import retrofit2.Retrofit
import retrofit2.converter.simplexml.SimpleXmlConverterFactory

abstract class BaseXmlClient : BaseClient() {

    /**
     * 共通のクライアントを取得する
     */
    override fun getClient(): Retrofit = Retrofit.Builder().apply {

        client(getHttpClient())
        baseUrl(UrlUtils.getDomain())
        addCallAdapterFactory(RxCallAdapterWrapperFactory.create())
        addConverterFactory(SimpleXmlConverterFactory.create())
    }.build()
}

BaseJsonClientとの違いは、ConverterFactoryだけです。

CallAdapterを自作する

通信中に何かしらエラーが発生した場合、
onErrorに処理が入って来ます。

発生したエラーの種類に応じて処理分けしたいときなどは、
CallAdapterを自作して、onErrorが呼ばれる前に、
Throwableをラップしてしまうと便利です。

CallAdapterを自作するにあたって、
こちらを参考にしました。

How to make RxErrorHandlingCallAdapterFactory?
https://stackoverflow.com/questions/43225556/how-to-make-rxerrorhandlingcalladapterfactory/43252881

RxCallAdapterWrapperFactory.kt

import com.shunbou.retrofitsample.http.exception.RetrofitException.Companion.asRetrofitException
import io.reactivex.Completable
import io.reactivex.Observable
import io.reactivex.Single
import retrofit2.Call
import retrofit2.CallAdapter
import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
import java.lang.reflect.Type
import io.reactivex.functions.Function

class RxCallAdapterWrapperFactory private constructor() : CallAdapter.Factory() {

    companion object {

        fun create(): CallAdapter.Factory {
            return RxCallAdapterWrapperFactory()
        }
    }

    private val original = RxJava2CallAdapterFactory.create()

    override fun get(
        returnType: Type,
        annotations: Array<Annotation>,
        retrofit: Retrofit
    ): CallAdapter<*, *>? {
        return RxCallAdapterWrapper(original.get(returnType, annotations, retrofit) ?: return null)
    }

    inner class RxCallAdapterWrapper<R>(private val wrapped: CallAdapter<R, *>) :
        CallAdapter<R, Any> {

        override fun responseType(): Type {
            return wrapped.responseType()
        }

        override fun adapt(call: Call<R>): Any {
            return when (val result = wrapped.adapt(call)) {
                is Single<*> -> result.onErrorResumeNext(Function { throwable ->
                    Single.error(
                        asRetrofitException(throwable)
                    )
                })
                is Observable<*> -> result.onErrorResumeNext(Function { throwable ->
                    Observable.error(
                        asRetrofitException(throwable)
                    )
                })
                is Completable -> result.onErrorResumeNext(Function { throwable ->
                    Completable.error(
                        asRetrofitException(throwable)
                    )
                })
                else -> result
            }
        }
    }
}

onErrorResumeNextを使うと、
Errorの発生をキャッチして、任意のStreamを返すことができます。

この場合では、
throwableをRetrofitExceptionという自作したクラスでラップして、
onErrorに流しています。

RetrofitException.kt

import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException

class RetrofitException private constructor(

    /** エラーメッセージ */
    var mMessage: String?,
    /** 接続先のURL */
    val mUrl: String?,
    /** Httpのレスポンス */
    val mResponse: Response<*>?,
    /** エラーの種別 */
    val mErrorType: ErrorType,
    val mException: Throwable

) : RuntimeException(mMessage, mException) {

    override fun toString(): String {
        return "${super.toString()} : $mErrorType : $mUrl : ${mResponse?.errorBody()?.string()}"
    }

    enum class ErrorType {

        /** サーバー通信中に発生したエラー */
        NETWORK,
        /** サーバーから200番台以外の、ステータスコードを受け取った場合のエラー */
        HTTP,
        /** それ以外のエラー */
        UNEXPECTED
    }

    companion object {

        /**
         * ステータスコードに関するエラーを生成する
         */
        private fun httpError(
            url: String,
            response: Response<*>,
            httpException: HttpException
        ): RetrofitException {
            val message = "${response.code()} ${response.message()}"
            return RetrofitException(message, url, response, ErrorType.HTTP, httpException)
        }

        /**
         * サーバー通信に関するエラーを生成する
         */
        private fun networkError(exception: IOException): RetrofitException {
            return RetrofitException(exception.message, null, null, ErrorType.NETWORK, exception)
        }

        /**
         * それ以外のエラーを生成する
         */
        private fun unexpectedError(exception: Throwable): RetrofitException {
            return RetrofitException(exception.message, null, null, ErrorType.UNEXPECTED, exception)
        }

        /**
         * RetrofitExceptionに返還する
         *
         * @param throwable 例外
         */
        fun asRetrofitException(throwable: Throwable): RetrofitException =
            when (throwable) {
                is RetrofitException -> throwable
                is HttpException -> {
                    val response = throwable.response()
                    httpError(response.raw().request().url().toString(), response, throwable)
                }
                is IOException -> networkError(throwable)
                else -> unexpectedError(throwable)
            }
    }
}

実際にAPIと通信するクライアントを作成

ベースクライアントができあがったので、
実際にAPIと通信するクライアントを作ります。

作るのは以下の2つです。
・ 都市IDの一覧を取得するクライアント(xml)
・ 指定した都市IDの天気の情報を取得するクライアント(json)

WeatherRssClient.kt

import com.shunbou.retrofitsample.constant.HttpConstants
import com.shunbou.retrofitsample.data.Rss
import io.reactivex.Single
import io.reactivex.disposables.Disposable
import retrofit2.http.GET

/**
 * 全国地点定義表を取得する通信クライアント
 */
class WeatherRssClient : BaseXmlClient() {

    /**
     * 全国地点定義表を取得する
     *
     * @param onSuccess 通信成功後の処理
     * @param onError 通信失敗後の処理
     */
    fun getRss(
        onSuccess: ((Rss) -> Unit),
        onError: ((Throwable) -> Unit)
    ): Disposable {
        val single = getClient()
            .create(WeatherRssService::class.java)
            .getRss()

        return asyncSingleRequest(single, onSuccess, onError)
    }
}

/**
 * 全国地点定義表を取得するAPI
 */
interface WeatherRssService {

    @GET(HttpConstants.END_POINT_RSS)
    fun getRss(): Single<Rss>
}

WeatherRssClientで使用するデータクラスです。

Rss.kt

import android.text.TextUtils
import com.shunbou.retrofitsample.util.Diffable
import org.simpleframework.xml.*


@Root(name = "rss", strict = false)
class Rss {

    @set:Element(name = "channel")
    @get:Element(name = "channel")
    var channel: Channel? = null
}

@Root(name = "channel", strict = false)
class Channel {

    @set:Element(name = "copyright")
    @get:Element(name = "copyright")
    var copyRight: String? = null

    @set:Element(name = "source")
    @get:Element(name = "source")
    @Namespace(reference = "http://weather.livedoor.com/%5C/ns/rss/2.0", prefix = "ldWeather")
    var weatherSource: WeatherSource? = null
}

@Root(name = "source", strict = false)
class WeatherSource {

    @set:ElementList(name = "pref", inline = true)
    @get:ElementList(name = "pref", inline = true)
    var prefectureList: List<Prefecture>? = null
}

/**
 * 県の情報を格納するデータクラス
 */
@Root(name = "pref", strict = false)
class Prefecture {

    @set:ElementList(entry = "city", inline = true)
    @get:ElementList(entry = "city", inline = true)
    var cityList: List<City>? = null
}

/**
 * 都市の情報を格納するデータクラス
 */
@Root(name = "city", strict = false)
class City : Diffable {

    @set:Attribute(name = "title")
    @get:Attribute(name = "title")
    var title: String? = null

    @set:Attribute(name = "id")
    @get:Attribute(name = "id")
    var id: Int = 0

    @set:Attribute(name = "source")
    @get:Attribute(name = "source")
    var source: String? = null

    override fun isTheSame(other: Diffable): Boolean {
        return id == (other as? City)?.id
    }

    override fun isContentsTheSame(other: Diffable): Boolean {
        return TextUtils.equals(title, (other as? City)?.title)
    }
}
CityWeatherClient.kt

/**
 * 都市の天気情報を取得する通信クライアント
 */
class CityWeatherClient : BaseJsonClient() {


    /**
     * 都市の天気情報を取得する
     *
     * @param cityId 都市ID
     * @param onSuccess 通信成功後の処理
     * @param onError 通信失敗後の処理
     */
    fun getCityWeather(
        cityId: Int,
        onSuccess: ((CityWeather) -> Unit),
        onError: ((Throwable) -> Unit)
    ): Disposable {
        val single = getClient()
            .create(CityWeatherService::class.java)
            .getCityWeather(cityId)

        return asyncSingleRequest(single, onSuccess, onError)
    }
}

/**
 * 都市の天気情報を取得するAPI
 */
interface CityWeatherService {

    @GET(HttpConstants.END_POINT_CITY_WEATHER)
    fun getCityWeather(@Query("city") city: Int): Single<CityWeather>
}

CityWeatherClientで使用するデータクラスです。

CityWeather.kt

import com.shunbou.retrofitsample.util.Diffable

/**
 * 都市の天気情報を格納するデータクラス
 */
data class CityWeather(
    var publicTime: String? = null,
    var title: String? = null,
    var description: Description? = null,
    var forecasts: ArrayList<Forecast> = ArrayList()
)

data class Description(
    var text: String? = null
)

data class Forecast(
    var dateLabel: String? = null,
    var telop: String? = null,
    var date: String? = null,
    var temperature: Temperature? = null,
    var image: Image? = null
)

data class Temperature(
    var min: TemperatureDescription? = null,
    var max: TemperatureDescription? = null
)

data class TemperatureDescription(
    var celsius: Float = 0F,
    var fahrenheit: Float = 0F
)

data class Image(
    var title: String? = null,
    var url: String? = null
)

終わりに

サンプルアプリのコードは、Githubにあげていますので、
参考にしてください

12
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?