仕事で Android アプリを作成しているとよく 縦固定で と注文を受けることがある。画面回転させると、ある画面が横画面で見辛いレイアウトの場合は別にレイアウトを作成しなければならないかもしれないし、デフォルトでは画面回転のタイミングで Activity や Fragment が再生成されるのでそれに起因する問題に対応しなければならなくなるからだ。要するに工数の削減だ。
各画面を縦固定にするには AndroidManifest.xml の各 activity 要素に下記属性を指定してやればよい:
<activity android:screenOrientation="portrait"/> <!-- 縦固定 -->
<activity android:screenOrientation="landscape"/> <!-- 横固定 -->
でもやっぱり回転させたい
利用者をスマートフォンに限定している場合は構わないかもしれないが、タブレットを使っている人は意外と横で使う。横で持っている時に縦固定のアプリを起動するのは若干辛い。
「画面回転ありのアプリを後から縦固定にする」のと「縦固定のアプリを後から画面回転ありにする」のは 後者が圧倒的に辛い。 可能性があるのであれば、画面回転を考慮したつくりにしておくのがよい。画面回転時に考慮すべきことはある程度パターン化されるので、普段から画面回転ありで慣れておけばそれほどの負担は感じないはず。
一番楽な Activity 再生成させない方法
Activity を再生成させない指定をしてしまえば、実は縦画面固定で作っている時と同じ配慮で済む上に見た目上画面回転に対応しているようにみえる。しかもその指定は以下のように単純なものだ:
<!-- targetSdkVersion: 13 以上の場合は screenSize も併せて指定 -->
<activity android:configChanges="orientation|screenSize"/>
<!-- 今となってはあまり無いとは思うが targetSdkVersion: 12 以下の場合 -->
<activity android:configChanges="orientation"/>
Fragment は Activity のライフサイクルに依存するので Activity 側が再生成されない為 Fragment 側も再生成されなくなる。これだと Fragment#onCreateView() がコールされないので横画面で別レイアウトを inflate させることができない気がする。ただ、画面回転させたタイミングで Fragment#onConfigurationChanged() は呼ばれるので、そこでビューをいじる事はできるだろう。
※ Activity を固定していない場合は Fragment#onConfigurationChanged() は呼ばれない
普通に Fragment を使うと
Fragment は Layout XML に定義した場合と Java コードで生成した場合でライフサイクルで呼ばれるメソッドが微妙に異なるのだが、それは Master of Fragment という書籍に詳しく載っているので参考にされたい。
どちらでも共通するのは、画面回転時に Fragment の再生成 が行われるので Fragment#onCreate() から再度走るということだ。Fragment, DialogFragment もそうなのだが基本的に Fragment の状態は setArguments() されたものと状態を保持する機能を持ったビューしか保存されない。Fragment のフィールドに状態を保存している場合は要注意。その場合は Fragment 破棄時に onSaveInstanceState() で状態を保存し Fragment 復帰時に onActivityCreated() 等で保存した状態を復帰する処理を自分で書かなければならない。
バックグラウンド処理と Fragment 再生成は相性が悪い
よくあるのが以下のように AsyncTask を使った処理だ:
public class BlankFragment extends Fragment {
class MyTask extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... params) {
try {
TimeUnit.SECONDS.sleep(5); // 通信処理など
} catch (InterruptedException e) {
e.printStackTrace();
}
return null;
}
@Override
protected void onPostExecute(Void aVoid) {
// Fragment が破棄された時 getView() が null になる
((TextView) getView().findViewById(R.id.text)).setText("処理成功");
}
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
getView().findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
new MyTask().execute();
}
});
}
}
上記コードで MyTask#doInBackground() 中に画面回転させると onPostExecute() で getView() しても既に Fragment が破棄 (つまり destroyView() されている) ので null が返り失敗する。じゃあ onPostExecute() で再生成された Fragment を見つけにいってそこから対象のビューに対して処理をすればいいじゃないか、と思い以下のようなコードを書いてもダメだ:
class MyTask extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... params) {
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
return null;
}
@Override
protected void onPostExecute(Void aVoid) {
// getFragmentManager() も null になる
final Fragment fragment = getFragmentManager().findFragmentById(R.id.fragment);
((TextView) fragment.getView().findViewById(R.id.text)).setText("処理成功");
}
}
Fragment が破棄されると getActivity() も getFragmentManager() も getView() も null になる。Fragment のインスタンスは残っているがライフサイクル的には既にお亡くなりになっている。 Fragment の幽霊 である。かといって null チェックを入れると「画面回転するとビューに結果が反映されない」というバグになる。打開するにはイベントバスのライブラリを使うぐらいしか無いのではないだろうか。
Fragment#setRetainInstance()
画面回転しても Fragment を再生成させないようにするには Fragment#setRetainInstance() を使う。これで上記のコードで画面回転しても正しく動作する。但し Activity が destoy された場合はやはり Fragment も destroy され null が返るので実際には null チェックが必要で (注: Activity が終了していても何か処理をして欲しいというのであれば null チェックは不要だが、その時は内部クラスの AsyncTask に書くのはそもそも間違っている気がする) 以下のようになるだろう:
public class BlankFragment extends Fragment {
class MyTask extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... params) {
try {
TimeUnit.SECONDS.sleep(5); // 通信処理など
} catch (InterruptedException e) {
e.printStackTrace();
}
return null;
}
@Override
protected void onPostExecute(Void aVoid) {
// activity の終了を考慮. getActivity() のほうがいい? isVisible() もあるが
if (getView() == null) {
return;
}
((TextView) getView().findViewById(R.id.text)).setText("処理成功");
}
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setRetainInstance(true); // これで onCreate は 1 度しか呼ばれない
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
getView().findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
new MyTask().execute();
}
});
}
}
「Fragment が再生成されない」というところで勘違いされることがあるが onCreate(), onDestroy() は呼ばれなくなるが onDetach() や onAttach(), onCreateView() は依然として呼ばれる のに注意する。なので Fragment を固定化したからといって縦画面と横画面で別のレイアウトを指定できないなんてことはない。
あとこれもよくある落とし穴だが、あくまで Fragment が再生成されないだけで Activity は再生成される ので「Activity の onCreate() で Fragment をセットする」ようなコードを書いていた場合は注意。意図した通りにならないだろう:
public class MyActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// これはダメ. 画面回転で onCreate() が走りその都度 BlankFragment が add される
getFragmentManager().beginTransaction()
.add(android.R.id.content, new BlankFragment())
.commit();
}
}
※ android.R.id.content
はルート FrameLayout の id であり setContentView() しなくてもここに直接 View や Fragment を add することができる
まぁこれは savedInstanceState が初回のみ null が来るイディオムを利用すれば済む:
public class MyActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (savedInstanceState == null) {
getFragmentManager().beginTransaction()
.add(android.R.id.content, new BlankFragment())
.commit();
}
}
}
public class MyActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// この要件ならこれでもいいだろう
if (getFragmentManager().findFragmentByTag("blank") == null) {
getFragmentManager().beginTransaction()
.add(android.R.id.content, new BlankFragment(), "blank")
.commit();
}
}
}
この Fragment#setRetainInstance() は凄く便利なので積極的に使っていきたい所だ。通信処理をする場合はこれ以外にも IntentService を使って結果を otto 等のイベントバスライブラリを使って Fragment 側に通知するのがいいのではないかと思う。
レイアウトの縦横の出しわけ
これは Fragment 関係ないというか昔からある手法なのだが念のため。以下のイディオムでレイアウトファイルを inflate しているものとする:
public class BlankFragment extends Fragment {
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_blank, container, false);
}
上記のように書いたということは /res/layout(-なんたら)/fragment_blank.xml
が存在しないとビルドできないわけだが、縦と横に限って言えば以下のような挙動となる:
-
/res/layout/fragment_blank.xml
だけに置くと縦画面でも横画面でもこれが使われる -
/res/layout-port/fragment_blank.xml
と/res/layout-land/fragment_blank.xml
に置くと縦画面では layout-port のものが使われ横画面では layout-land のものが使われる -
/res/layout/fragment_blank.xml
と/res/layout-land/fragment_blank.xml
に置くと縦画面では layout-port に該当ファイルが無いので layout のものが使われ横画面では layout-land のものが使われる
大抵の場合必要なのは 3 番目の 普段は /res/layout
に定義し横画面で別レイアウトにしたい場合のみ /res/layout-land
に定義する なのではないだろうか。
なので Java コード側で現在の画面方向は getResources().getConfiguration().orientation
で取れるのでこれを Configuration.ORIENTATION_PORTRAIT
や Configuration.ORIENTATION_LANDSCAPE
と比較してレイアウトを出し分けるのは多くの場合冗長となる。