Support PackageとMapView
この記事は「Android Advent Calendar 2011 」の裏エントリとして書いています。
16日の表のエントリは@RKisatoさんです。前の日の記事は@LuckOfWiseさんによる「きっとお金が欲しくてAndroidを始めた僕たちへ | LuckOfWise.com」です。
こんにちは、@9reです。Androidアプリ開発に携わってそろそろやっと1年です。恐縮ですが、ごく普通のAndroidアプリ開発に関する記事を書きます。
Support Package
というと、ご存知の通り、Android 4.0用のコード等をそれらよりもっと前のAndroid 1.6とかでも使えるようにという素敵プロジェクトです。個人の感想を言うと、当初の設計ミスや使い辛さを半ば認めているようなプロジェクトだとも思います。という意味では新規プロジェクトなんかにはかなりオススメしたいライブラリではあると思っています。・・・が
サンプル・コードがそもそも動かない
まず、1.6で動かないのはわりと仕方ないし、これから導入する方にとってはあまり関係ないかも知れませんが、2.2でもサンプルコードが動かないので導入を躊躇ってしまわれた方もいるかもしれません。quick & dirty fixではありますが、1.6でも動くサンプル作っておきましたので是非。
MapViewをSupport PackageのFragment内で使うには?
ということでやっと本題に入りました。これはアチラこちらで質問がされていてFAQだと思います。android.support.v4.app.FragmentManagerという android.app.FragmentManager相当のクラスがあるのですが、これを使うには、FragmentActivityを継承する必要があります。
一方MapViewを利用するにはMapActivityを継承する必要があります。そうです。よくある多重継承の問題です。
多重継承
個人的には必要は感じませんが、やはり困ったとき(フレームワークに設計を強制される時)の最終手段があるというのはいいですね。
このケースでは、FragmentActivityとMapActivityのうちFragmentActivityはオープンソースですので、そちらに手を入れます。
一つの方法は、単純にFragmentActivityのsuper classをMapActivityにすることです。これは、android-support-v4-googlemapsというプロジェクトでの方針です。diffが少ない分メンテしやすいし、安全だと思います。しかし一方で、FragmentActivityまでもがMapActivityの制約を受けることになってしまいます。
MapActivityの制約
これを無視すると、予期せぬことが起きるらしいです。少なくとも次のようなエラーメッセージが出ている人は、うっかりこの制約を冒してしまっているように思います。
E/ActivityThread( 1543): android.app.IntentReceiverLeaked: Activity com.example.android.apis.view.MapViewDemo has leaked IntentReceiver com.google.android.maps.NetworkConnectivityListener$ConnectivityBroadcastReceiver@333aa670 that was originally registered here. Are you missing a call to unregisterReceiver()?
E/ActivityThread( 1543): at android.app.ActivityThread$PackageInfo$ReceiverDispatcher.
E/ActivityThread( 1543): at android.app.ActivityThread$PackageInfo.getReceiverDispatcher(ActivityThread.java:748)
E/ActivityThread( 1543): at android.app.ContextImpl.registerReceiverInternal(ContextImpl.java:791)
E/ActivityThread( 1543): at android.app.ContextImpl.registerReceiver(ContextImpl.java:778)
E/ActivityThread( 1543): at android.app.ContextImpl.registerReceiver(ContextImpl.java:772)
E/ActivityThread( 1543): at android.content.ContextWrapper.registerReceiver(ContextWrapper.java:318)
E/ActivityThread( 1543): at com.google.android.maps.NetworkConnectivityListener.startListening(MapActivity.java:163)
E/ActivityThread( 1543): at com.google.android.maps.MapActivity.onResume(MapActivity.java:431)
E/ActivityThread( 1543): at android.app.Instrumentation.callActivityOnResume(Instrumentation.java:1149)
E/ActivityThread( 1543): at android.app.Activity.performResume(Activity.java:3823)
E/ActivityThread( 1543): at android.app.ActivityThread.performResumeActivity(ActivityThread.java:3211)
E/ActivityThread( 1543): at android.app.ActivityThread.handleResumeActivity(ActivityThread.java:3236)
E/ActivityThread( 1543): at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2093)
E/ActivityThread( 1543): at android.os.Handler.dispatchMessage(Handler.java:99)
E/ActivityThread( 1543): at android.os.Looper.loop(Looper.java:123)
E/ActivityThread( 1543): at android.app.ActivityThread.main(ActivityThread.java:4735)
E/ActivityThread( 1543): at java.lang.reflect.Method.invokeNative(Native Method)
E/ActivityThread( 1543): at java.lang.reflect.Method.invoke(Method.java:521)
E/ActivityThread( 1543): at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:867)
E/ActivityThread( 1543): at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:625)
E/ActivityThread( 1543): at dalvik.system.NativeStart.main(Native Method)
この部分はオープンソースではないため詳細は分かりませんが、「ドキュメントにやってはいけない」と明記されていることをするのは、あまり得策ではないでしょう。
この場合でも、Activityが一つしか存在しない作りであれば、全く問題はないと思います。
しかし、既存のプロジェクトからの移行等そのようなことができない場合はmixin風のクラスを書くというような解決策になってくると思います。
mixinで書きなおしてみた
取り敢えず、forkしてブランチ作っておきました。experimental-fragment-activity-feature-impl
実装にあたっての指針
まず、本家のFragmentActivityで定義されているメソッドを2つに分けます。Activityのメソッドを上書きしているものと、独自のメソッドです。特に独自のメソッド群は新しくFragmentActivityFeatureとしてインターフェイスを定義します。また、ライブラリ内で直接アクセスされているフィールドのgetterやsetterもこれに加えます。
/*** * FragmentActivity独自のメソッド ***/ Object onRetainCustomNonConfigurationInstance(); Object getLastCustomNonConfigurationInstance(); void supportInvalidateOptionsMenu(); void onAttachFragment(Fragment fragment); FragmentManager getSupportFragmentManager(); void startActivityFromFragment(Fragment fragment, Intent intent, int requestCode); void invalidateSupportFragmentIndex(int index); LoaderManager getSupportLoaderManager(); LoaderManagerImpl getLoaderManager(int index, boolean started, boolean create); /*** * ライブラリ内でアクセスされているフィールド用のgetter ***/ FragmentManagerImpl<?> getFragmentManagerImpl(); boolean isRetaining(); Handler getHandler();
あとは、FragmentActivityとして定義されている変数を新しいインターフェイスの型FragmentActivityFeatureとして定義し直します。一部Activityとしての性質を要求されている箇所では取り敢えず、<FragmentActivityImpl extends Activity & FragmentActivityFeature>というパラメータ付きの型として定義します。
しかしながら、これではFragmentクラスようなパブリックなクラスまでもがパラメータ付きのクラスになってしまいます。これではもはや、android.app.Fragmentと同じようには使うことが出来ません。
Fragment<?> fragment = new Fragment<?>();
勿論ここでraw typeを使うことも可能ですが、コンパイラにも怒られてしまいますし、あまり気持ちの良い物ではありません。
そこで<FragmentActivityImpl extends Activity & FragmentActivityFeature>として定義していたフィールドmActivityをただのActivityとして定義しなおしました。そしてこのフィールドをprivateにし、setterのシグネチャを以下のように定義することで、このフィールドに対する代入の型チェックを行うことにしました。
<FragmentActivityImpl extends Activity & FragmentActivityFeature> void setActivity(FragmentActivityImpl activity) { mActivity = activity; }
なので、このフィールドに対して
((FragmentActivityFeature)mActivity).getSupportLoaderManager();
というキャストは安全です。
JDK 6のバグ
このフィールドのgetterを以下のように定義しました。
@SuppressWarnings("unchecked") final public <FragmentActivityImpl extends Activity & FragmentActivityFeature> FragmentActivityImpl getActivity() { return (FragmentActivityImpl) mActivity; }
しかし、ここに書かれている通り、このコードはeclipseのコンパイラではコンパイルが通りますが、JDK 6の古いバージョンではコンパイルが通りません。
また、JDK 7ではこのコードは問題なくコンパイルすることが出来ます。ということでバグなのではないか?というように思っています。
ということでjdk6用のパッチも書きました。単純にActivityを返します。
Activity getActivity() { return mActivity; }
FragmentActivityFeatureの機能を使いたい場合は、内部で行なっているのと同様の
((FragmentActivityFeature)getActivity()).getSupportLoaderManager();
というキャストを行います。
勿論、パッチを当てたバージョンをdefaultとして、パラメータ付きの型を返す方をパッチとするべき、という批判があるかもしれません。
困りがちな事例を幾つか
MapActivityにはMapViewは一つしか作れない
FragmentのonCreateViewでMapViewを生成するとこの制約にかかってしまいます。ActivityのonCreateで生成し、mMapViewのようなフィールドとしてインスタンスの参照を保持し、Fragment側で利用する際には、
MyMapActivity activity = (MyMapActivity) getActivity(); MapView mapView = activity.getMapView();
のようなgetterを通してインスタンスを利用すると良いでしょう。
ViewPagerの中でMapViewのスクロールが出来ない
ViewPagerは子要素のTouchEventsをViewGroup.onInterceptTouchEventをoverrideすることで、子要素から奪っています。この挙動を変えるにはこのメソッドを上書きしてください。
@Override public boolean onInterceptTouchEvent(MotionEvent event) { if (shouldDispatchTouchEventsToTheMapView()) { // 子要素にイベントを送りたい! return false; } return super.onInterceptTouchEvent(event); }
ListFragmentのちょっとしたハマり所
以下のようなコードは、エラー等は一切起きませんが、その後このFragmentの挙動がおかしくなる可能性があります。
@Override public void onDestroyView() { super.onDestroyView(); if (getView() != null) { // 何か終了処理のようなもの getListView().setOnScrollListener(null); } }
以下のようにメソッドの呼び出し順序をかえてください。
@Override public void onDestroyView() { if (getView() != null) { // 何か終了処理のようなもの getListView().setOnScrollListener(null); } super.onDestroyView(); }
これはsuper.onDestroyView()を呼んだ後に、getListView()を呼んでしまうと、内部のフィールドmListがonDestroyViewの後は本来はnullとなっている筈であるのに対し、getListView()を呼ぶことで、mListが古いListViewの参照を持つこととなってしまいます。結果再びonCreateView()が呼ばれても内部変数は、ensureList()の
private void ensureList() { if (mList != null) { return; } }
という実装により、古いListViewと古いListViewに対するOnItemClickListenerという組み合わせが残ってしまい、コールバックvoid onListItemClick (ListView l, View v, int position, long id)は呼ばれなくなってしまいます。
最近出たrev.5になっても未だに修正されない明らかなバグ
結び
少々バグもありますが、Fragment apiやLocalBroadcastReceiverやModernAsyncTask等、便利なapiも結構ありますので、 何か困ったことがあったら、ソースにパッチを当てたり等しながら導入するといいのではないでしょうか。
長々とありがとうございました。少し翻訳調なのは、焦りつつ原稿を日本語に直していたからです。
以上、日本語も英語も不自由な@9reでした!