概要
Lollipop が発表されてから時間も立ち、Android Auto、Android Wear、Android TV と、多様性を見せ始めた Android ですが、今後とも多種多様なデバイス向けに様々なアプリを作っていく流れがあるなか、新しくアプリを作るなら抑えておきたい要所をまとめました。
TL;DR
抑えるところは 3 つ。
- 画面とライフサイクル
- 非同期処理
- 互換性
かなり端的にいうと、Activity や Service などのライフサイクルとうまく付き合いながら、コードの構成のレイヤー化を行い、非同期処理を簡潔に記述できる準備をしておくことと、非同期処理とあわせてマルチスレッドプログラミングの基本を抑えておくこと、互換性への準備を最初にしておきましょう、という話です。
画面とライフサイクル
- MVC を基本として、データ構造を定義して Activity や Fragment からロジックを追い出す
- View を継承したカスタムクラスを Fragment の代わりに使う
- ライフサイクルごと適切にオブジェクトを管理する
MVC を基本として、データ構造を定義して Activity や Fragment からロジックを追い出す
事の始まり
Square 社としてのポリシーの表明 に始まって、こんな記事やこんな記事を書いたりしました。
Android で画面を作る場合、Activity や Fragment、View を使って作ることになります(ゲーム等では例外的に OpenGL などの他の枠組みを使うことが多くなるかと思いますが)。そこでなぜ上記のような議論の盛り上がりがあるかというと、以下に列挙した点で問題があるからです。
- Activity や Fragment にすべてを詰め込んだ結果、テストができない/メンテナンスが困難なコードが出来上がる
- Fragment の取り扱いが複雑で面倒くさく、デバッグもしづらい
- View を使っても Fragment と同じことができ、View のほうが歴史があり扱いが簡単だから Fragment の優位性が殆ど無い
Activity や Fragment にロジックを詰め込み過ぎないためのポイント
iOS でも共通する悩みとして、yimajo さんの記事にまとまっているような話です。
要約すると、いわゆる MVC をちゃんとやろう、という話ですが、付随して、MVC のやりとりの中で使われるデータ構造もキチンと定義しましょう、という話になります。
モデルについては、この記事が詳しいです。
Activity や Fragment が太りがちになることについては、Activity から詰め込んだロジックを外部に委譲することで解消します。ロジック上 Context が必要な場合は、Inject するなりメソッドの引数で渡すなりで解決できます。問題はその委譲先ですが、Fragment は良い選択肢とはいえません。なぜなら Fragment は Activity の部分をパーツとして切り出すためのコンポーネントなので、Fragment もまた Activity と同じコントローラとして扱うほうがよいでしょう。そうなると、別のオブジェクトを自分で作るか、あるいは View にロジックを移すかのどちらかになります。
別のオブジェクトを自分で作る場合、特に、Activity のライフサイクルに合わせて管理されるべきオブジェクトを作る場合(リスナの登録と解除をしなければいけないようなもの等)には、必ず、ライフサイクルの開始と終了を意味するメソッドを用意すべきです。例えば、Activity のライフサイクルメソッドと同じ命名規則で、onCreate() や onDestroy() と言った具合のメソッドを用意します。そうすることで、そのオブジェクトの利用者にとって、ライフサイクルに合わせてそれらのメソッドを呼び出し、明示的な管理をすることが求められていることがわかりやすくなります。
public class ActivityViewLogicDelegate implements TextWatcher {
private final Activity activity;
@InjectView(R.id.text) TextView text;
public Something(Activity activity) {
this.activity = activity;
}
public void onCreate() {
Butterknife.inject(this, activity):
this.text.addTextChangedListener(this);
}
public void onDestroy() {
this.text.removeTextChangedListener(this);
Butterknife.reset(this);
}
}
public class MyActivity extends Activity {
private ActivityViewLogicDelegate delegate = new ActivityViewLogicDelegate(this);
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
delegate.onCreate();
}
@Override
public void onDestroy() {
delegate.onDestroy();
super.onDestroy();
}
}
View にロジックを移す場合、Context のライフサイクルに同期して呼び出されるメソッドがはじめから用意されているので、それらを適宜オーバライドして、適切なタイミングで適切な処理を行えば良いことになります。
public class MyCustomView extends View {
public MyCustomView(Context context) {
super(context);
}
public MyCustomView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public MyCustomView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
@Override
public void onFinishInflate() {
super.onFinishInflate();
// do something
}
@Override
public void onDetachedFromWindow() {
// do something
super.onDetachedFromWindow();
}
}
ここで、画面の中で取り扱うデータの構造を定義し、そのオブジェクトを引数として各所で利用可能にしておくと、コントローラとその処理の委譲先のクラスの設計の理解が早まります。例えば、ユーザ情報を取り扱うための User クラスを定義しておけば、いくつかの入力からユーザ情報を構築し、それをモデルに渡して使うこともできますし、別の画面にユーザ情報を渡して表示することもできます。
public class User implements Parcelable {
public static final Creator<User> CREATOR = new Creator<>() { /* snip */ };
private String id;
private String nickname;
private String givenName;
private String familyName;
// ...
}
入力からデータ構造を生成する部分は、モデルなり委譲先のオブジェクトなりに隠蔽されていることが望ましいでしょう。View にそのロジックを書いておけば、Activity や Fragment は View のメソッドを呼び出してデータ構造を取り出すだけで済みます。
public class InputFormContainer extends LinearLayout {
// ...
@InjectView(R.id.nickname) EditText nickname;
@InjectView(R.id.givenName) EditText givenName;
@InjectView(R.id.familyName) EditText familyName;
// ...
public User getUserInfo() {
return new User.Builder()
.setNickname(nickname.getText().toString())
.setGivenName(givenName.getText().toString())
.setFamilyName(familyName.getText().toString())
.build();
}
}
public class MyActivity extends Activity {
@InjectView(R.id.inputForm) InputFormContainer form;
private final UserModel model;
// ...
public void onSubmitClick(View view) {
model.createUser(form.getUserInfo());
}
}
設計のより深い理解には、この記事を御覧ください。
View を継承したカスタムクラスを Fragment の代わりに使う
たとえば、Progress を表示するのであれば、単純に ProgressBar を他の View に被せるか差し換えるかで表示すれば、ProgressDialog を使う代替になるので、ProgressDialogFragment のようなものを作る必要がなくなります。
また、CustomView を作って、それにある程度の役割をもたせれば、Fragment と同じことができます。
あるいは、ViewPager と共に Fragment を使う場合も有り得ますが、CustomView でも代替可能です。Fragment のページごと独立して AsyncTaskLoader を使いたければ Fragment を使う選択をすることになりますが、今や Promise など他の機構で非同期処理を書くこともでき、通常の View との親和性も高いので、CustomView でなんとかなる場面はかなり多いと言えるでしょう。
レイアウトさえ多様なデバイスに対応できれば、コードは Fragment でも CustomView でもさほど変わらないように作ってしまえば、Fragment が活躍するのは、ユーザにとって大事な操作の確認を促すための Alert だけになります。
Alert にも幾つかパターンが有り、単純な操作の報告であれば Toast や Toast をカスタマイズしたもの、あるいは通常の View で十分になります。
そうすると、このまま操作を進めてよいかどうかの確認のために Alert を出す目的で、AlertDialogFragment を作ることになるでしょう。
YES/NO/CANCEL の選択肢のどれを選んだかをコールバックで受ける場合、AlertDialogFragment にコールバックインタフェースを定義し、Activity とのやりとりはこのインタフェースでのみ行うようにします。
public class AlertDialogFragment extends DialogFragment {
private AlertCallbacks callback;
@Override
public void onAttach(Activity activity) {
super.onAttach();
callback = (AlertCallbacks) activity;
}
public Dialog onCreateDialog(Bundle savedInstanceState) {
return new AlertDialog.Builder(getActivity())
.setPositiveButton("Accept", new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
callback.accept();
}
}).setNegativeButton("Decline", new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
callback.decline();
}
}).create();
}
public static interface AlertCallbacks {
void accept();
void decline();
}
}
この AlertDialogFragment を使おうとする Activity は AlertCallbacks の実装を強いられますが、これは Fragment が Activity に判断を委ねているだけで、かつインタフェースでのみ結合するので、それほど強く結合するようなことはありません。むしろ、Activity から、アタッチされた Fragment の public メソッドを叩きに行くほうが結合度が高く見えます。Fragment#onAttach(Activity) の引数の Activity や、Fragment#getActivity() の返り値の Activity を、具体的な Activity へキャストすることがバッドプラクティスなのです。
ライフサイクルごと適切にオブジェクトを管理する
これまでも折にふれて書いてきましたが、Android の標準的なコンポーネント(Activity, Service, BroadcastReceiver)にはライフサイクルがあります。自分で何らかのクラスを設計する際には、各種コンポーネントで使うときにどのように使ってもらうことを想定しているかを明確にしておくことが重要になります。
ライフサイクルのあるオブジェクトと共にオブジェクトを管理するには、以下に挙げることを抑えておくとよいでしょう。
- ライフサイクルメソッドの対応ごとに、オブジェクトの管理をする
- ライフサイクルメソッドのタイミングごとに、オブジェクトの管理をする
ライフサイクルメソッドの対応ごとに、オブジェクトの管理をする
例えば、Activity#onStart()
でオブジェクトの準備をした場合、オブジェクトの後始末をつけるのはActivity#onStop()
が望ましいでしょう。これをActivity#onDestroy()
まで先延ばしにすると、ライフサイクルの循環で後始末が実行されなくことがあります。
後始末を早めるぶんにはあまり重大な問題は起こらないかもしれませんが、特に理由がなければ、対応するライフサイクルメソッドで管理を完結させるほうが、他の人がコードを読んだ時に余計な勘ぐりをしてしまったり、変なバグを踏んだりしなくなる分健康的なコードになるでしょう。
public class MyActivity extends Activity {
private ActivityViewLogicDelegate delegate = new ActivityViewLogicDelegate(this);
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
delegate.onCreate(); // call after super.onCreate()
}
@Override
public void onDestroy() {
delegate.onDestroy(); // call before super.onDestroy()
super.onDestroy();
}
}
ちなみに、ライフサイクルの開始にあるときは、親クラスのメソッドを呼んでから自分の処理を実行し、ライフサイクルの終了にあるときは、親クラスのメソッドを呼ぶ前に自分の処理を実行しておきます。
ライフサイクルメソッドのタイミングごとに、オブジェクトの管理をする
ライフサイクルメソッドには、それぞれどのタイミングで呼ばれるかが決まっています。最初にレイアウトを読み込むとき、画面に表示するとき、インタラクションが開始されるとき、など、用途は様々です。
オブジェクトごと、どのタイミングで初期化し、どのタイミングで後始末をするかは異なります。
たとえば、otto や EventBus のようなイベントバスの仕組みの場合、画面が前面で表示されている間はイベントをハンドリングしますが、バックグラウンドに回っている間はイベントをハンドリングさせないようにします(そうでないと、意図せずイベントをハンドリングしてしまい仕様にない動作をしてしまう場合があるかもしれない為。)。
このような場合には、Activity#onStart()
で Activity を登録し、Activity#onStop()
で Activity の登録を解除するのが望ましいでしょう。
public class MyActivity extends Activity {
private Bus eventBus = Utils.getApplicationEventBus();
@Override
public void onStart() {
super.onStart();
eventBus.register(this);
}
@Override
public void onStop() {
eventBus.unregister(this);
super.onStop();
}
}
BroadcastReceiver についての注意点
BroadcastReceiver は、BroadcastReceiver#onReceive()
のメソッド呼び出しの間がライフタイムです。つまり、このメソッドの処理が終わる(メソッドのブロックを抜ける)とライフサイクルは終了したものと見なされます。必ず、メソッドのブロックを抜けるまでに、確実にオブジェクトの後始末が完了するようにしなければならないことに気をつけてください。
非同期処理
非同期処理のフレームワーク
Android には標準で幾つかの非同期処理のフレームワークがあります。それぞれ使い方に差異があります。
AsyncTask
古くからある非同期処理のフレームワークです。このクラスは Activity や Fragment と共に使われる場面が多いかと思いますが、ライフサイクルが Activity や Fragment とは独立して動くため、それらへの参照を持っているとメモリリークの原因となります。Java の仕様上、static でない内部クラスとして AsyncTask を定義すると、暗に外側のクラスのインスタンスへの参照を持ってしまうので、必ず static な内部クラスとして定義します。
また、AsyncTask#cancel(boolean)
で処理をキャンセルすることができますが、実際のところ、必ず別スレッドで動いている処理が即座に停止するわけではない点に注意します。つまり、結果を受け取った時にも、キャンセルされたかどうかや、Activity・Fragment が生存しているかどうかに注意してハンドリングする必要があります。
最後に、同じ AsyncTask のインスタンスを使いまわすことはできません。必ず、非同期処理をするたびに新しいインスタンスを作る必要があります。
public class MyActivity extends Activity {
private MyTask mTask;
public void onSubmitClick(View view) {
if (mTask != null && mTask.getStatus() != AsyncTask.Status.FINISHED) {
Log.i("MyActivity", "task is in progress");
return;
}
mTask = new MyTask(this);
mTask.execute(null);
}
static class MyTask extends AsyncTask<Void, Void, Void> {
private final WeakReference<Context> mContext;
public MyTask(Context context) {
mContext = new WeakReference<>(context);
}
@Override
public Void doInBackground(Void... params) {
// 別のスレッドで何かする
return null;
}
@Override
public void onPostExecute(Void result) {
Context context = mContext.get();
if (context == null) {
return;
}
// activity context で何かする
}
}
}
言うまでもなく、static な内部クラスにしても Activity を強い参照で持っていたら意味が無い点に注意します。
AsyncTaskLoader
AsyncTask が、頻繁に内部クラスとして定義されるため、どうしても非同期処理と画面構成のための処理が結合してしまうパターンに嵌ってしまうことが多くなったので、これをきちんと分離するためのフレームワークとして新しく導入されたものです。内部的には AsyncTask をラップしているものですが、画面のライフサイクルに応じた AsyncTask の管理をフレームワークが持ってくれるようになっています。
少しクセがありますが、AsyncTaskLoader のインスタンスは、Activity や Fragment ごと別に管理されるため、LoaderManager#initLoader(int, Bundle, LoaderCallbacks)
の第一引数は、同じ Activity でない限り同じ数字を渡しても問題ありません。
AsyncTaskLoader のインスタンスは明示的に開放しない限り再利用されます。
IntentService
Service の特別な実装で、バックグラウンドで非同期処理を行うのによく用いられます。実際は AsyncTask でも(結果のコールバックが画面に依存しないならば)同じように扱えますが、その違いは、Handler に処理の依頼がキューイングされるか、ThreadPool から取り出したスレッド上で処理が実行されるか、というところになります。
IntentService の場合は、Intent を投げると Handler に post され、順番に処理が実行されます。簡易的なジョブキューのような仕組みが実現できます(端末の再起動などによって揮発しますが)。
AsyncTask の場合は、ThreadPool にあるスレッドを使いまわしますが、並列実行か逐次実行かは Executor によるので、自分でどちらにするかを選択することができます。
その他の非同期処理フレームワーク
例えば、Bolts や Android-Promise は、継続の考え方や Promise の考え方を用いて、非同期処理とそのコールバックからの処理を記述することができます。ライフサイクルへの同期とオブジェクトの管理もある程度面倒を見てくれるので、個人的な印象としては AsyncTask や AsyncTaskLoader よりもクセがなく記述できるように感じています。
あるいは、Android Annotations のように、アノテーションプロセッサを活用してコードの自動生成をすることにより、コードを書くときにはどのメソッドが非同期で、どのメソッドがメインスレッド上かを宣言的に書くだけでよしとするライブラリも存在します。
最近は、コールバックの受け取り方も様々になりました。古き良きコールバックインタフェースから、イベントバスのような仕組み、あるいはリアクティブほげほげの仕組みだったり、いろいろあります。
ライブラリによって特徴が異なりますが、イベントバスのように、根本的に異なるライフサイクルのもの同士を切り離すための仕組みを用いるのもよい手立てとなるでしょう。
同期化
synchronized
マルチスレッドプログラミングをするときには必ずついて回るのが同期の問題です。スレッドごと固有のオブジェクトがスレッド内で完結するように操作されるならば大きな問題は起こりませんが、いろいろなスレッドから同一のオブジェクトを操作するとき、そのオブジェクトの状態がどうなるかを考慮しないと、思わぬバグを作りこんだり、デバッグも再現も困難な動作を引き起こしたりします。
不変なオブジェクト(一度状態を決めるとあとから変えられないもの)であれば、状態は一つに定まるので、他のスレッドが変な状態へ変えてしまうおそれがありません。状態を変えられる場合、状態を変える操作をするスレッドの順番をうまくしないと、全く同時に状態を変更しようとして変な動きになることがあります。このようなときに同期化の仕組み(排他制御など)を使うことになります。
シングルトンなオブジェクトでも、同期化の仕組みが必要なことがあります。複数のスレッドからシングルトンなオブジェクトを使う場合には、初期化処理が全く同時に複数スレッドから呼ばれると困ったことになるおそれがあるので、以下のように同期化します。
public final class Singleton {
private static Singleton sSingleton;
private final Context context;
Singleton(Context context) {
this.context = context;
}
public static synchronized Singleton getInstance(Context context) {
if (sSingleton == null) {
sSingleton = new Singleton(context);
}
return sSingleton;
}
}
毎回 getInstance(Context) でロックを取るのがコストに見合わなかったり、不都合な時は、以下のようにするとコストが抑えられます。
public final class Singleton {
private static volatile Singleton sSingleton;
private final Context context;
Singleton(Context context) {
this.context = context;
}
public static Singleton getInstance(Context context) {
if (sSingleton == null) {
synchronized(Singleton.class) {
if (sSingleton == null) {
sSingleton = new Singleton(context);
}
}
}
return sSingleton;
}
}
互換性
Android は、アップデートが出来ない端末があったり、アップデートの配信が遅い端末があったりして、古いバージョンでも長く使われる傾向があります。ですので、できるだけ多くの端末をサポートするのであれば、互換性への準備は重要です。
サポートライブラリ
Android 標準の API の互換性を保つため、最新の API Level のものをバックポートしているライブラリです。基本的にはこちらをデフォルトにしておくと、最新を追いかけ続けやすくなります。
ただし、Fragment 等が標準 API とサポートライブラリ版とで動作が微妙に違う時があるので注意します。
また、最新の API のバックポートとは言え、古いバージョンではサポートしきれない機能もある為、そういったものは基本的に実装の中身が無いことが多いです。つまり、インタフェースのみがバックポートされており、その実装は何もないので、古いバージョンでは同じように動作しないことになります。返り値のあるものは null が返される事が多いため、何らかのオブジェクトが得られることを前提にしてコードを書いていると、予期せず NullPointerException を得ることになります。本当にまれによくある話です。
リソース
端末のハードウェアスペックや設定の違いを吸収するために、リソースの Qualifier を細かに設定しておきます。そのほうが圧倒的に楽です。
最近は特に画像リソースについて巨大化しつつあるので、可能ならば VectorDrawable などを用いたり、自分で xml を組むほうが apk サイズに対して優しい設計となるでしょう。
また、Context を適切に渡してコードを書けるような設計も重要です。Context が無いことにはリソースが解決できません。
解析
たとえば、Fabric や ACRA、Splunk Mint、New Relic、Crittercism など、遠隔地にある端末の状態やクラッシュレポートなど、ことこまかにレポートしてくれるサービスを活用すると、特定の端末でしか置きない問題、という難題が解決しやすくなるかもしれません。スピーディにアップデートを配信できるようにする体制を整えておきましょう。
まとめ
今やライブラリも世の中に溢れていて、先人たちの知恵の詰まった汗と涙の結晶を使えば、爆速で望んだアプリが作れそうな気がします。
ただ、同じ機能を実現するライブラリでも、方法によってはどちらが良いかが変わることもあります。例えば、アノテーションを使ったライブラリが人気でよくもてはやされますが、アノテーションがコンパイル時に使われるのか、実行時に使われるのかでも違いがあります。コンパイル時にアノテーションを扱う話はこのスライドが詳しいです。
また、これら以外にも細かい話(コーディング規約みたいなもの)も役に立ちます。このスライドにまとめてあります。
やることが多いようにも見えますが、良いアプリを作る上で、フレームワークにはどんな特徴があって、それとうまく付き合うにはどうしたらいいかというノウハウはどんなアプリでも生きてくるものです。ここに挙げた以外にも沢山のノウハウが有るはずですので、そういったものをもっとたくさん知る機会があると自分としても嬉しいです。
合わせて読みたい
- 新人プログラマに知っておいてもらいたい人類がオブジェクト指向を手に入れるまでの軌跡
- 2015年に備えて知っておきたいリアクティブアーキテクチャの潮流
- 「オブジェクト指向プログラミング」と「関数型プログラミング」のたった一つのシンプルな違い
- MVCの流れを簡単にまとめてみる
- Fragments vs. CustomViews に一つの結論を出してみた
- Fragment は本当に多様なデバイスへ対応する唯一の方法なのか
- 非同期処理でよく使う IntentService と AsyncTask は何が違って何が同じなのか
- コールバックと上手に付き合う
- 今さら聞けない Activity と Fragment の使い分け
- EventBus はどこでつかうべきか
- やさしい設計 〜 Android 編
- イマドキなイカした Android のオープンソースライブラリ集
(´-`).。oO(なげーよ…)