2016年3月16日水曜日

Migrate from Retrofit to Retrofit2 (Retrofit から Retrofit2 に移行する)

retrofit2 が正式リリースされました。

retrofit/CHANGELOG.md at master · square/retrofit

retrofit との後方互換性はありません。そのため maven の group id が com.squareup.retrofit2 になっています。 compile 'com.squareup.retrofit2:retrofit:2.0.0'
Converter

GSON などの Converter は別のモジュールに分割されました。

retrofit new RestAdapter.Builder() .setConverter(new GsonConverter(gson)) ... retrofit2 compile 'com.squareup.retrofit2:converter-gson:2.0.0' new Retrofit.Builder() .addConverterFactory(GsonConverterFactory.create(gson)) ... gson 以外の Converter については square.github.io/retrofit/ の CONVERTERS 部分に記載があります。


RxJava

retrofit で RxJava を使うためのクラスは別のモジュールに分割されました。

retrofit2 compile 'com.squareup.retrofit2:adapter-rxjava:2.0.0' new Retrofit.Builder() .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) ...
AndroidLog

retrofit.android.AndroidLog はなくなりました。 https://github.com/square/okhttp/tree/master/okhttp-logging-interceptor
を使って okhttp3 の interceptor で行います。

retrofit AndroidLog logger = new AndroidLog(MyService.class.getSimpleName()); new RestAdapter.Builder() .setLogLevel(RestAdapter.LogLevel.FULL) .setLog(logger) ... retrofit2 compile 'com.squareup.okhttp3:logging-interceptor:3.0.0' HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); logging.setLevel(HttpLoggingInterceptor.Level.BODY); new OkHttpClient.Builder(); .addInterceptor(logging) ...
ErrorHandler, RetrofitError

retrofit.ErrorHandler, retrofit.RetrofitError はなくなりました。
okhttp3 の interceptor で行うとか https://github.com/square/retrofit/issues/1102
CallAdapterFactory を作るとか https://github.com/square/retrofit/pull/1277/files
いくつか方法があります。

adapter-rxjava を使っているなら HttpException が onError() で返されるので、ここから status code, message, response が取れます。

retrofit new RestAdapter.Builder() .setErrorHandler(new MyErrorHandler()) ... public class MyErrorHandler implements ErrorHandler { @Override public Throwable handleError(RetrofitError cause) { if (cause.getKind() == RetrofitError.Kind.NETWORK) { // network error ... } else if (cause.getResponse() != null) { final int statusCode = cause.getResponse().getStatus(); try { MyErrorResponse errorResponse = (MyErrorResponse) cause.getBodyAs(MyErrorResponse.class); } catch (Exception e) { ... } } else { ... } } } retrofit2 @Override public void onError(Throwable e) { if (e instanceof IOException) { // network error ... } else if (e instanceof HttpException) { HttpException he = (HttpException) e; final int statusCode = he.code(); final ResponseBody errorBody = e.response().errorBody(); if (errorBody != null) { try { MyErrorResponse errorResponse = (MyErrorResponse) GsonConverterFactory .create() .responseBodyConverter(MyErrorResponse.class, new Annotation[0], null) .convert(errorBody); // retrofit インスタンスが利用できる場合 // MyErrorResponse errorResponse = retrofit // .responseConverter(MyErrorResponse.class, new Annotation[0]) // .convert(errorBody); } catch (Exception re) { ... } } } else { ... } }
RequestInterceptor

retrofit.RequestInterceptor はなくなりました。
okhttp3 の interceptor を使います。https://github.com/square/retrofit/issues/1082

retrofit public class MyRequestInterceptor implements RequestInterceptor { @Override public void intercept(RequestFacade request) { request.addHeader(HEADER_KEY, headerValue); } } new RestAdapter.Builder() .setRequestInterceptor(new MyRequestInterceptor()) ... retrofit2 public class MyRequestInterceptor implements Interceptor { @Override public Response intercept(Chain chain) throws IOException { final Request.Builder builder = chain.request().newBuilder(); builder.addHeader(HEADER_KEY, headerValue); return chain.proceed(builder.build()); } } new OkHttpClient.Builder() .addInterceptor(new MyRequestInterceptor()) ...
Response

import 先を変更する必要があります。

retrofit import retrofit.client.Response retrofit2 import retrofit2.Response
Observable<Response>

ResponseBody を指定する必要があります。

retrofit @DELETE("/delete") Observable<Response> delete(); retrofit2 @DELETE("/delete") Observable<Response<ResponseBody>> delete();
@Body, @GET, @Post など

import 先を変更する必要があります。

retrofit import retrofit.http.Body retrofit2 import retrofit2.http.Body Query の encodeValue は encoded に名前が変わっています。

retrofit @Query(value = "categories", encodeValue = false) @Nullable String categories retrofit2 @Query(value = "categories", encoded = false) @Nullable String categories
TypedFile

TypedFile はなくなりました。
MultipartBody.Part を使います。

retrofit @Multipart @POST("/upload") Observable<Response> uploadImage( @Part("image") TypedFile image ); final TypedFile image = new TypedFile("image/jpeg", file); retrofit2 @Multipart @PATCH("/upload") Observable<Response> uploadImage( @Part() MultipartBody.Part image ); MultipartBody.Part のときは @Part に value(上記だと "image")を設定してはいけません。設定すると実行時に落ちます。 final MultipartBody.Part image = MultipartBody.Part.createFormData("image", file.getName(), RequestBody.create(MediaType.parse("image/jpeg"), file)); もともと @Part に指定していた "image" は MultipartBody.Part.createFormData() の第1引数に指定します。


Client

retrofit.client.Client はなくなりました。

retrofit側でモック化したい場合は retrofit-mock があります。 compile 'com.squareup.retrofit2:retrofit-mock:2.0.0' 使い方は
https://github.com/square/retrofit/blob/master/samples/src/main/java/com/example/retrofit/SimpleMockService.java が参考になります。

okhttp側でモック化したい場合は mockwebserverがあります。 compile 'com.squareup.okhttp3:mockwebserver:3.0.0'


okhttp

okhttp3 を使います。

retrofit import com.squareup.okhttp.Interceptor; import com.squareup.okhttp.Request; import com.squareup.okhttp.Response; retrofit2 import okhttp3.Interceptor; import okhttp3.Request; import okhttp3.Response;
stetho-okhttp

stetho-okhttp3 を使います。https://github.com/facebook/stetho/issues/327

retrofit2 compile 'com.facebook.stetho:stetho-okhttp3:1.3.1'


java.io.InterruptedIOException: thread interrupted

Retrofit2 (というか okhttp3)にして thread interrupted java.io.InterruptedIOException: thread interrupted at okio.Timeout.throwIfReached(Timeout.java:145) at okio.Okio$1.write(Okio.java:77) ... というようなエラーが起きたら、RxJava の subscribeOn() や observeOn() の使い方があやしいので見直しましょう。
例えば以下のような、subscribeOn(Schedulers.newThread()) を指定した複数の Observable を merge して takeLast すると InterruptedIOExceptionになります。 zip や combineLatest だと問題ないです。

NG final List<Observable<ImageResponse>> observables = new ArrayList<>(); for (...) { final MultipartBody.Part image = ...; final Observable<ImageResponse> o = service.uploadImage(image) .subscribeOn(Schedulers.newThread()); observables.add(o); } Observable .merge(observables) .takeLast(1) .subscribeOn(Schedulers.newThread()); .observeOn(AndroidSchedulers.mainThread()) .subscribe(new Subscriber<ImageResponse>() { ... }); OK final List<Observable<ImageResponse>> observables = new ArrayList<>(); for (...) { final MultipartBody.Part image = ...; final Observable<ImageResponse> o = service.uploadImage(image) .subscribeOn(Schedulers.newThread()); observables.add(o); } Observable .combineLatest(observables, new FuncN<ImageResponse>() { @Override public ImageResponse call(Object... args) { return ...; } }) .subscribeOn(Schedulers.newThread()); .observeOn(AndroidSchedulers.mainThread()) .subscribe(new Subscriber<ImageResponse>() { ... });


2016年3月6日日曜日

RecyclerView で IndexOutOfBoundsException が起こる話

注意:以下の現象は support library v23.2.0 では起こりません。

support library v23.1.1 で以下のコードを実行すると落ちます。

java.lang.IndexOutOfBoundsException: Inconsistency detected. Invalid item position 0(offset:20).state:20 at android.support.v7.widget.RecyclerView$Recycler.getViewForPosition(RecyclerView.java:4405) ... public class RecyclerViewGoneActivity extends AppCompatActivity { private TextAdapter textAdapter; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_recycler_view_gone); final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view); recyclerView.setLayoutManager(new LinearLayoutManager(this)); // 1. 初期状態がGONE recyclerView.setVisibility(View.GONE); // 2. setHasFixedSize(true); recyclerView.setHasFixedSize(true); textAdapter = new TextAdapter(); recyclerView.setAdapter(textAdapter); new Handler().postDelayed(new Runnable() { @Override public void run() { ArrayList<String> data = new ArrayList<>(); for (int i = 0; i < 20; i++) { data.add("data : " + i); } // 3. notifyItemRangeInserted(); を呼ぶ textAdapter.addAll(data); // 4. visibility を VISIBLE にする recyclerView.setVisibility(View.VISIBLE); } }, 1000); } private static class TextAdapter extends RecyclerView.Adapter<ViewHolder> { private final ArrayList<String> data = new ArrayList<>(); @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { LayoutInflater inflater = LayoutInflater.from(parent.getContext()); return new ViewHolder(inflater.inflate(ViewHolder.LAYOUT_ID, parent, false)); } @Override public void onBindViewHolder(ViewHolder holder, int position) { holder.textView.setText(data.get(position)); } @Override public int getItemCount() { return data.size(); } public void add(String s) { final int positionStart = getItemCount(); data.add(s); notifyItemInserted(positionStart); } public void addAll(ArrayList<String> d) { final int positionStart = getItemCount(); final int itemCount = d.size(); data.addAll(d); notifyItemRangeInserted(positionStart, itemCount); } public void clear() { final int itemCount = getItemCount(); data.clear(); notifyItemRangeRemoved(0, itemCount); } } private static class ViewHolder extends RecyclerView.ViewHolder { public static final int LAYOUT_ID = android.R.layout.simple_list_item_1; final TextView textView; public ViewHolder(View itemView) { super(itemView); this.textView = (TextView) itemView.findViewById(android.R.id.text1); } } }


落ちなくする方法がいくつかあります。
  • 1. setHasFixedSize(true); を呼ばないようにする(ただしその分パフォーマンスに影響する)
  • 2. あらかじめデータを入れておいて VISIBLE にする前に消す
  • textAdapter = new TextAdapter(); recyclerView.setAdapter(textAdapter); textAdapter.add("dummy"); // これを追加 new Handler().postDelayed(new Runnable() { @Override public void run() { textAdapter.clear(); // これを追加 ArrayList<String> data = new ArrayList<>(); for (int i = 0; i < 20; i++) { data.add("data : " + i); } textAdapter.addAll(data); recyclerView.setVisibility(View.VISIBLE); } }, 1000);
関連


2016年3月5日土曜日

CollapsingToolbarLayout で status bar を透明にする方法

注意:以下は support library v23.2.0 での動作をもとにしています。

追記
AppBarLayout と CollapsingToolbarLayout にも android:fitsSystemWindows="true" の指定を追加するようにしました。 これにより、v25.0.1, v25.0,0, v24.2.1, v24.2.0, v24.1.1, v24.1.0, v24.0.0, v23.4.0, v23.3.0, v23.2.1 でも動作することを確認してあります。



わかりやすいように

colorPrimary : #ff0000(赤)
colorPrimaryDark : #99ff00ff(マゼンダ)

contentScrim : #990000ff(青)
statusBarScrim : #9900ffff(シアン)

Toolbar の background : #9900ff00(緑)

にしてあります。

普通に実装するとこんな感じになります。 <?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"> <android.support.design.widget.AppBarLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:fitsSystemWindows="true" android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"> <android.support.design.widget.CollapsingToolbarLayout android:id="@+id/toolbar_layout" android:layout_width="match_parent" android:layout_height="wrap_content" android:fitsSystemWindows="true" app:contentScrim="#990000ff" app:layout_scrollFlags="scroll|exitUntilCollapsed" app:statusBarScrim="#9900ffff"> <ImageView android:layout_width="match_parent" android:layout_height="360dp" android:scaleType="centerCrop" android:src="@drawable/sample" tools:ignore="ContentDescription" /> <android.support.v7.widget.Toolbar android:id="@id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" app:layout_collapseMode="pin" tools:background="#9900ff00" /> </android.support.design.widget.CollapsingToolbarLayout> </android.support.design.widget.AppBarLayout> <android.support.v4.widget.NestedScrollView android:layout_width="match_parent" android:layout_height="match_parent" android:layout_gravity="fill_vertical" app:layout_behavior="@string/appbar_scrolling_view_behavior"> ... </android.support.v4.widget.NestedScrollView> </android.support.design.widget.CoordinatorLayout> これを実行するとこうなります。



マゼンダ色の status bar が表示されています。この status bar を透明にして ImageView をその分上にあげるにはどうすればいいか。

そのためにまずテーマに <item name="android:statusBarColor">@android:color/transparent</item> を指定して、Activity の onCreate() に以下の処理を追加します。 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { findViewById(android.R.id.content).setSystemUiVisibility( View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); } すると次のようになります。



マゼンダから赤色に変わりました。これはステータスバーの色が見えているのではなく CollapsingToolbar の領域が見えています。その証拠にスクロールするとこの領域も移動します。

この領域はどこで確保されているかというと CollapsingToolbarLayout の onLayout() です。 @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); ... // Update our child view offset helpers for (int i = 0, z = getChildCount(); i < z; i++) { final View child = getChildAt(i); if (mLastInsets != null && !ViewCompat.getFitsSystemWindows(child)) { final int insetTop = mLastInsets.getSystemWindowInsetTop(); if (child.getTop() < insetTop) { // If the child isn't set to fit system windows but is drawing within the inset // offset it down ViewCompat.offsetTopAndBottom(child, insetTop); } } getViewOffsetHelper(child).onViewLayout(); } ... } CollapsingToolbarLayout の直接の子ビューで fitsSystemWindows が false のものは、ビューの配置位置を insetTop(ステータスバーの高さ)分だけ下にずらすようになっています。
つまり、fitsSystemWindows = true を子ビューにセットすれば、この処理が行われないということです。

そこで ImageView に android:fitsSystemWindows="true" を追加すると <android.support.design.widget.CollapsingToolbarLayout ... > <ImageView ... android:fitsSystemWindows="true" /> <android.support.v7.widget.Toolbar ... /> </android.support.design.widget.CollapsingToolbarLayout> 次のようになります。




この方法を取る場合、4.4 で注意が必要です。 「 StatusBar 透明化の正しい方法」で書いているように、v19 では android:windowTranslucentStatus をセットして、v21 では android:statusBarColor をセットしている場合、上記の対応を入れると次のように 4.4 で余分な領域が確保されてしまいます。



そのため、v21以降だけ android:fitsSystemWindows="true" が指定されるようにします。

values/bools.xml <?xml version="1.0" encoding="utf-8"?> <resources> <bool name="fitsSystemWindowForImage">false</bool> </resources> values-v21/bools.xml <?xml version="1.0" encoding="utf-8"?> <resources> <bool name="fitsSystemWindowForImage">true</bool> </resources> <android.support.design.widget.CollapsingToolbarLayout ... > <ImageView ... android:fitsSystemWindows="@bool/fitsSystemWindowForImage" /> <android.support.v7.widget.Toolbar ... /> </android.support.design.widget.CollapsingToolbarLayout> こうすると、4.4 でも次のような結果になります。




4.4 での実行結果をよく見ると、閉じたときに status bar 分の領域が確保されずに Toolbar が status bar の下にきてしまうことがわかります。



残念ながら 4.4 で閉じた時に status bar 分を確保するシンプルな方法はありません。 以下のように inset を margin に付け替える Toolbar を用意するとこれが解決できます。 public class CustomToolbar extends Toolbar { public CustomToolbar(Context context) { super(context); } public CustomToolbar(Context context, AttributeSet attrs) { super(context, attrs); } public CustomToolbar(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override protected boolean fitSystemWindows(Rect insets) { final ViewGroup.LayoutParams params = getLayoutParams(); if (params instanceof MarginLayoutParams) { ((MarginLayoutParams) params).topMargin = insets.top; } return true; } }