【Android】【Retrofit】Retrofit 2.0.1使い方メモとハマりどころメモ

2018/11/29 追記

  • UTF-8 以外の文字コードで POST / PUT を行う方法を追記しました。
  • RxJava 2.x 用の CallAdapter を追記しました。

前書き

最近Retrofitを使うことがあったんですが、イントロダクションをちょろっと読んだぐらいだと「え、そーなの?」と思うような事象に何度も見舞われたので、メモしておきます。

API の設定

HTTP メソッドやパス、クエリやパラメータなどを適当に作ったインターフェースとアノテーションで表現します。

public interface GitHubService {
  @GET("users/{user}/repos")
  Call<List<Repo>> listRepos(@Path("user") String user);
}

ベースとなる URL はRetrofit.Builder#baseUrlで渡すので、相対パスを書いておけば OK です。

アノテーションだけでどうにでもなってしまうので、色んなサービスの API を叩きたい時に共通の型がなくて却って使いにくいこともたまーにありますが、その辺は自分で共通の型を作ればいいだけの話です。

HTTP Methods

HTTP のメソッドに対応するアノテーションは以下のものが用意されています。必要十分って感じですね。

PATCHってDELETE以上に見たことないけど、意識の高い API 設計者は使うんでしょうか。

独自のメソッドを用意している意識が高すぎる API に対してはHTTP#methodに渡しましょう。

あと、微妙にハマりポイントなんですが、HEADを指定したメソッドの返り値の型は必ずCall<Void>にする必要があります。*1

普通にヘッダだけ欲しいんだけどどうしたらええの?と言う質問が Issue にあるんですが、ガン無視されています。私はもう面倒なので GET メソッド投げるようにしてしまいました。(怒)

ルーティング

@GET("users/{user}/repos"){user}部分をメソッドの引数で渡すことができます。

対応する引数にPathアノテーションをつけてあげれば OK です。

マルチバイト文字とか記号とかをうにゃうにゃしたい時はPath#encodedtrueを渡してあげましょう。

クエリ

Queryアノテーションを指定してあげればよしなにやってくれます。

@GET("group/{id}/users")
Call<List<User>> groupList(@Path("id") int groupId, @Query("sort") String sort);

optional なパラメータが多い時はQueryMapアノテーションを使うと便利です。

@GET("group/{id}/users")
Call<List<User>> groupList(@Path("id") int groupId, @QueryMap Map<String, String> options);

@Query@QueryMapencodedtrueを指定することで URL エンコードできるので、特に理由がないならやっておいた方が色々安全だと思います。

また、うっかりミスでこういうことをやってしまうことがあるんですが、これは怒られます。

// クエリ部分にPathで値を渡すのは無理
// 渡せるようにしても良い気がするけどね。
@GET("group/{id}/users?sort={sort}")
Call<List<User>> groupList(@Path("id") int groupId, @Path("sort") String sort);

クエリ応用編

@Queryを指定する引数を配列 / 可変長引数にすることで、同じクエリを複数指定することができます。

@GET("/list")
Call<ResponseBody> list(@Query("category") String... category);

例えば、service.list("hoge", "fuga")と呼び出したら、/list?category=hoge&category=&fugaになります。*2

また、特定のクエリを固定値で指定しておく場合はこのように書けば OK です。うまいこと後ろにクエリを足してくれます。

@GET("group/{id}/users?sort=desc")
Call<List<User>> groupList(@Path("id") int groupId, @Query("filter") String filter);

クエリ更に応用編(外法)

GETだけでなくどのメソッドでもいいんですが、Urlアノテーションを引数として渡すことで API のパス自体を動的に変更できます。

public interface GitHubService {
  @GET
  Call<List<Repo>> listRepos(@Url String url);
}

極端な話、この@Urlにパスもクエリもぶち込んでしまえば動いてくれます。型安全とは何だったのか。

POST のパラメータ

多分、PUTでも同じだと思います。

パラメータとして渡すにはBodyField(もしくはFieldMapアノテーションを使います。

application/x-www-form-urlencoded形式での POST

application/x-www-form-urlencoded (key=value) 形式に自動で変換して欲しい場合は@FormUrlEncoded@Filedを使います。

@FormUrlEncoded
@POST("user/edit")
Call<User> updateUser(@Field("first_name") String first, @Field("last_name") String last);

デフォルト値を設定する方法は現在開発中なので、@FieldMapと適当なデフォルト値を持つMap<String, String>を作るメソッドを併用するぐらいしか対処法がありません。

@FormUrlEncoded
@POST("user/edit")
// Call<User> updateUser(@Field("first_name") String first, @Field("last_name") String last);
Call<User> updateUser(@FieldMap Map<String, String> names);
public static Map<String, String> createFullName(String first) {
    final Map<String, String> param = new HashMap<>();
    param.put("first_name", first);
    param.put("last_name", "hogehoge");
    return param;
}

それ以外の形式での POST

@Bodyを使うとそのまんま Body 部に値をぶちこみます。application/jsonなどを渡したい場合はこちらを使いましょう。

ちなみにConverter(後述)を指定することで独自の型でもいい感じに変換してくれます。*3

@POST("users/new")
Call<User> createUser(@Body User user);

UTF-8 以外でエンコードしたapplication/x-www-form-urlencoded

@FormUrlEncodedはパラメータを強制的に UTF-8エンコードしてしまいます。この文字コードを変えることはできません。*4

どうしても他の文字コードで POST する必要がある場合も@Bodyを使う必要があります。

Converterを自作してもいいですが、スカラー値を扱う Converterが公式で用意されているので、自分でapplication/x-www-form-urlencoded形式のStringを作ってしまうのが早いです。*5

// API定義
@POST
//@FormUrlEncoded
//Call<User> updateUser(@Field("first_name") String first, @Field("last_name") String last);
Call<User> updateUser(@Body String body);
// 呼び出し方
String firstName = "山田";
String lastName = "太郎";

Function<String, String> encode = (s) -> {
  try {
    // java.net.URLEncoderでUTF-8以外の文字コードに変換
    return URLEncoder.encode(s, "Shift-JIS");
  } catch (UnsupportedEncodingException e) {
    throw new RuntimeException(e);
  }
};

// 自分でapplication/x-www-form-urlencoded形式のStringを作る
String body = String.format("%s=%s&%s=%s",
    encode.apply("first_name"),
    encode.apply(firstName),
    encode.apply("last_name"),
    encode.apply(lastName));

// StringをRequestBodyとして投げる
Call<User> user = service.updateUser(body);

Multipart なデータを POST

ちゃんとMultipartと言うアノテーションがあります。

渡す値はPart(もしくはPartMapアノテーションで指定します。

@Multipart
@PUT("user/photo")
Call<User> updateUser(@Part("photo") RequestBody photo, @Part("description") RequestBody description);

渡せる型はデフォルトだとokhttp3.MultipartBody.Partokhttp3.RequestBodyで固定されているようです。もちろん対応するConverterがあれば独自の型でも OK です。*6

なお、@Multipart@FormUrlEncodedは併用できません。Content-Typeが矛盾するからです。*7

ヘッダの指定

Headersアノテーションを指定しておくことで、事前にヘッダを作っておくことができます。

@Headers("Cache-Control: max-age=640000")
@GET("widget/list")
Call<List<Widget>> widgetList();
@Headers({
    "Accept: application/vnd.github.v3.full+json",
    "User-Agent: Retrofit-Sample-App"
})
@GET("users/{username}")
Call<User> getUser(@Path("username") String username);

また、Headerアノテーションを引数に指定することで、動的にヘッダ部を変更することができます。

@GET("user")
Call<User> getUser(@Header("Authorization") String authorization)

ただ、API 共通で指定したいヘッダはokhttp3.Interceptorを作ったほうがいいです。(後述)

実際の通信部分の設定

Retrofit.Builder#clientで指定するokhttp3.OkHttpClientが実際に HTTP 通信を行うクライアントになります。

どんな設定ができるかはokhttp3.OkHttpClient.Builderのメソッドを見れば大体わかると思います。また、Wiki の Recipesもかなり参考になると思います。

Proxy や Timeout、Cookie / キャッシュの管理もこれでやることになるので、Retrofit を使う場合は必ず一読しておいたほうがいいです。

ちなみにOkHttp3 ではOkHttpClientのコネクションプールを各インスタンス毎に持っているのでシングルトンで持っておくことが推奨されています。Interceptorタイムアウト値、リトライ回数なんかを API 別に設定したい場合はokhttp3.OkHttpClient#newBuilderを使いましょう

okhttp3.Interceptorの設定

下手なコードを読むよりWikiを読んだほうがわかりやすいです。

okhttp3.Interceptorを実装するにあたって覚えておくことは以下の 3 つだけです。

okhttp3.Responseも色々さわりたい*8場合はproceedで取得した内容をごにょごにょしましょう。

単純に共通のヘッダを指定したいだけならこんな感じになります。複数のヘッダを指定したい場合はokhttp3.Headersを使ったりしましょう。

public class CommonInterceptor implements Interceptor {
    @Override public Response intercept(Interceptor.Chain chain) throws IOException {
        return chain.proceed(
            chain.request().newBuilder()
                .header("User-Agent", "OkHttp Example")
                .build()
        );
    }
}

Cookie の設定

okhttp3.CookieJarを適当に実装してあげましょう。

一ミリも頭を使わない実装をするとこんな感じになります。ただ、あまりにも Cookie の仕様をガン無視しているのでおすすめできません。

public class NoHeadCookieJar implements CookieJar {
    private final List<Cookie> saveCookies = new ArrayList<>();

    @Override
    public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
        for(Cookie c : cookies) {
           if(!saveCookies.contains(c)) saveCookies.add(c);
        }
    }

    @Override
    public List<Cookie> loadForRequest(HttpUrl url) {
        return saveCookies;
    }
}

きちんと Cookie を管理するのであればJavaNetCookieJarを使用するのが安全です。

JavaNetCookieJarCookieCookieHandler(=CookieManager)で管理するので、他の HTTP クライアントや、Android であればWebViewCookie を共有しやすくもなります。

ちなみにokhttp本体とは別モジュール*9なので、依存関係に追記が必要なことを忘れないでおきましょう。

dependencies {
    compile "com.squareup.okhttp3:okhttp-urlconnection:3.2.0"
}

今回は詳しく説明しませんが、同じくokhttp-urlconnection内にあるJavaNetAuthenticatorも中々便利そうなので、Retrofit or okhttp でプロキシ認証が必要になったら参考にしたり流用したりしましょう。

レスポンスの Call Adapter

Retrofit 2.x は、デフォルトのままだとレスポンスを必ずCall<T>でラップするようになっています。*10

Call<T>には同期的に実行するexecuteと非同期的に実行するenqueueの 2 つのメソッドが用意されています。

enqueueで渡すCallback<T>は確かに必要最低限の機能が用意されていますが、Promise パターンが使えないので、複数の API を組み合わせて実行しようとすると JavaScript もびっくりなコールバック地獄になります。

それは面倒だよね、ってことで、もっと便利なものにラップできるよう、Call Adapterが用意されています。

ライブラリ ラップする型 モジュール
RxJava 1.x Observable / Single com.squareup.retrofit2:adapter-rxjava
RxJava 2.x Observable / Flowable / Single / Maybe / Completable com.squareup.retrofit2:adapter-rxjava2
Guava ListenableFuture com.squareup.retrofit2:adapter-guava
Java 8 CompleteableFuture com.squareup.retrofit2:adapter-java8

実際にどの Call Adapter を使うかはRetrofit.Builder#addCallAdapterFactoryで指定します。

RxJava 1.x の Adapter を指定するならこんな感じです。

Retrofit retrofit = new Retrofit.Builder()
    .baseUrl("https://api.example.com")
    .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
    .build();

なお、RxJavaCallAdapterFactoryにはRxJavaCallAdapterFactory#createWithScheduler(Scheduler)と言うメソッドも用意されています。ここで指定しておけば生成されるObservableに対して自動でsubscribeOnしてくれるので、上手く活用しましょう。*11

リクエスト・レスポンスの自動型変換

HTTP 通信によって取得した内容(Entity)は事前にConverterを指定しておくことで自動で型変換を行うことができます。*12

公式に用意されているものは以下の通りです。

ライブラリ モジュール
Gson com.squareup.retrofit2:converter-gson
Jackson com.squareup.retrofit2:converter-jackson
Moshi com.squareup.retrofit2:converter-moshi
Protobuf com.squareup.retrofit2:converter-protobuf
Wire com.squareup.retrofit2:converter-wire
Simple XML com.squareup.retrofit2:converter-simplexml
Scalars (primitives, boxed, and String) com.squareup.retrofit2:converter-scalars

使用する Converter はRetrofit.Builder#addConverterFactoryで指定することができます。Call Adapter と一緒ですね。

Gson の Converter を指定する場合はこんな感じになります。

Retrofit retrofit = new Retrofit.Builder()
    .baseUrl("https://api.github.com")
    .addConverterFactory(GsonConverterFactory.create())
    .build();

HTTP 通信結果の取得

ステータスコードやヘッダといった情報をコールバックで取得したい場合は、返り値の型をResponse<T>でラップしましょう。(ex. Call<Response<String>>

ちなみにResponse<T>は Converter に一切依存しないので、どんな Converter を指定していても使用できます*13

また、戻り値の型をokhttp3.ResponseBodyにすることで、生の Entity や Body を取得することもできます。(ex. Call<ResponseBody>

こちらもConverter に一切依存しないので、「欲しい Converter が用意されてないけど自力で実装するのダルい」「型変換のところでエラーが出るから値を確認しつつデバッグしたい」などの場合に使いましょう。*14

まとめ

またなんか無駄に濃密な記事になってしまいました。

*1:それ以外の型を指定すると「HEAD method must use Void as response type」と怒られる

*2:@QueryMap で同じようなことができるかどうかは未検証。Key が同じになっちゃうから難しいような…?

*3:Retrofit 1.x では勝手に JSON 形式にしていたようだが、今はちゃんと Converter を指定してやる必要がある。

*4:現在の application/x-www-form-urlencoded の仕様的に仕方ないんですが…。

*5:Converter を自作しても結局同じようなことをする必要がある。

*6:Introductionで紹介されている Converter にはそれ用のものがないので、余程のことがないなら MultipartBody.Part か RequestBody でいい気がする。

*7:と、そんな回答がされている。まぁ、サーバ側の実装が悪いとしか言いようがないっちゃないよね。

*8:本来返されるレスポンスの内容をここで書き換えたりするのは行儀が悪い(と言うか、Retrofit においてそれは Converter のお仕事である)ので、各情報の取得だけにとどめておいた方が無難。

*9:なんでやねん。

*10:1.x では違ったらしいけど割愛。

*11:特に Android では「呼び出し元で subscribeOn し忘れた」のようなしょうもない事故を防止するためになるべくこっちを使うべき。

*12:レスポンスだけでなく、リクエストで使う引数の型も Converter で変換できる。

*13:もちろん一つも Converter を指定していなくても使用可能。

*14:Converter.Factoryの実装はどうしても「どんな型が指定されても大丈夫!」って感じにせざるを得ないので、しょうもない Converter を大量生産するよりはコールバック内でそれぞれ型変換した方がまだマシな気がする。(個人の感想です。)