実務でKotlinを使ったAndroidアプリの開発にかかわれそうなので、
この機会に、イマドキの通信処理について勉強しました。
今回は、その過程で作ったサンプルアプリについて、ご紹介します。
どういうアプリを作るのか
http://weather.livedoor.com/forecast/
上記URLのAPIに通信して、天気の情報をとってくるアプリを作ります。
取って来たデータをUIに反映する過程は省きますので、
「自分もこのアプリ作りたい」
と思った方は、UIを自作するか、
後ほど、このアプリのGitHubのURLを貼りますので、
そちらを参考にしてください。
必要なライブラリを取得する
アプリの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'
}
まずは定数クラスを作成
通信に使用する定数を、一つのクラスにまとめます。
/**
* 通信で使用する定数を定義する
*/
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
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用のクライアントを作成します。
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については、
後ほど説明します。
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
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に流しています。
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)
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で使用するデータクラスです。
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)
}
}
/**
* 都市の天気情報を取得する通信クライアント
*/
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で使用するデータクラスです。
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にあげていますので、
参考にしてください