こわくない! Fragment

  • 47
    いいね
  • 0
    コメント

この記事は Androidその2 Advent Calendar 2016 の6日目の記事です。


Fragment怖いですか? 怖いですね。
興味本位で、あるいは必要にかられてFragmentを使って痛い目を見たことのあるAndroidエンジニアがほとんどなのではないかと思います。
みんなのトラウマFragment。

そのあまりのややこしさから、SquareなんかはFragmentとの決別を宣言していますし実際にFragmentを捨てるためのライブラリも書いていたりします。
みんなFragmentには煮え湯を飲まされてきているんですね。

しかし、Fragmentが有用である場合やFragmentの使用が強く推奨されるものもあったりしますよね。

  • ViewPagerの中でインタラクティブなページを表示したい
  • ダイアログを表示したい
  • 画面を構成するパーツの見た目とロジックを再利用したい
  • etc...

そもそもCustomeViewだけで全てを行うのもつらかったり…

諸般の事情でFragmentを使わないといけない場合に、うまくFragmentと付き合う方法を、SupportFragmentの実装を見ながら考えていきましょう。

実装がわかれば、罠も罠でなくなります!

コンストラクタからパラメータを渡す -> 画面回転後にパラメータを取得できない

Fragment使い初めのときによくハマる罠です。

// Activity or 親Fragment側
HogeFragment f = new HogeFragment();
f.hoge = "hoge";
// commit


// HogeFragmentの実装
public void onSomeCallback() {
    String s = this.hoge;     // -> Fragmentの再生成が1度でも発生するとnull
    textView.setText(s);
}

標準のFragmentは、画面回転時にインスタンスが再生成されます。(Don't keep Activities.がOFFであっても!)
Fragmentに何らかのパラメータを渡す際には、 setArguments()を使いましょう。

Bundle arg = new Bundle();
arg.putString("ARG1", "hoge");
arg.putString("ARG2", "fuga");
HogeFragment f = new HogeFragment();
f.setArguments(arg);

// こうした操作はHogeFragmentクラス側に持たせるのがお行儀の良い実装なので、
// HogeFragment f = HogeFragment.newInstance("hoge", "fuga");
// とできるようなメソッド newInstance を用意したほうが良いですね。

とはよく言われるのですが…

渡されたBundleはどこへ行くのでしょう。
Fragmentの実装を少し覗いて見ましょう。

Fragmentのメタな状態を保持するFragmentStateがいる

SupportLibraryのFragmentのファイルを見ますと、 setArguments() の実装はこのようになっています。

android/support/v4/app/Fragment.java
    public void setArguments(Bundle args) {
        if (mIndex >= 0) {
            throw new IllegalStateException("Fragment already active");
        }
        mArguments = args;
    }

mArguments にBundleを代入しているだけのようです。
逆に getArgumentsmArguments の内容を返すだけ。

ライフサイクルの都合でインスタンスが消えても状態を保持するためにはParcelableに値を保存しなければならないはず。
実際の保存は、同じファイル内の FragmentState で行われているようです。

FragmentState はPercelableを継承しておりCREATORクラスを抱えているなど、典型的なPercelableになっています。
savedInstanceState として開発者が扱えるBundleの他に、Fragmentがレイアウトファイルからinflateされたものなのかどうか、inflate先のViewGroupのidなど、Fragmentのメタな状態を取り扱うのが、このクラスの役割です。

その中の一つとして、Argumentsの保持も行っています。

writeToParcel() でParcelに書き込んでおり、Parcelからの復元時に読み込んで、 さらにFragmentの復元をする際にFragmentへbundleを書き戻しています。

android/support/v4/app/Fragment.java
final class FragmentState implements Parcelable {

    ...

    final Bundle mArguments;

    ...

    public FragmentState(Parcel in) {

        ...
        // Parcelからargumentsを復元
        mArguments = in.readBundle();

        ...
    }

    // Fragmentを生成し直す
    public Fragment instantiate(FragmentHostCallback host, Fragment parent,
            FragmentManagerNonConfig childNonConfig) {
        if (mInstance == null) {
            final Context context = host.getContext();
            if (mArguments != null) {
                mArguments.setClassLoader(context.getClassLoader());
            }
            mInstance = Fragment.instantiate(context, mClassName, mArguments);

            ...

        return mInstance;
    }

    public void writeToParcel(Parcel dest, int flags) {

        ...
        // Parcelへ保存
        dest.writeBundle(mArguments);

        ...
    }

このようにして、setArgumentsで渡された値は保存されていました。

これによって、Fragmentが破棄されてもArgumentsが保持される仕組みがわかりましたね。
ArgumentsをBundleで取り扱う理由もなんとなくわかる気がします。

これで次からは忘れずにsetArgumentsを使えるようになりましたね!!

requestCodeを65535より大きな数にしてstartActivityForResult() -> 例外

Fragmentからも startActivityForResult() を使用でき、Activityの結果をFragmentの onActivityResult() でハンドリングすることができます。

その際に requestCode を指定します。
これに適当にキーボードを叩いた値などを使っているとIlligalStateExceptionに遭遇することがあります。

MyFragment.java
startActivityForResult(new Intent(getContext(), NextActivity.class), 65536);

// -> java.lang.IllegalArgumentException: Can only use lower 16 bits for requestCode

16ビット以下の値を使えとのことです。
これもよく言われますね。

しかしJavaのintは32ビット整数のはず…なぜ16ビットなのか…

FragmentActivityが startActivityForResult を発行する手前にトリックがある

Fragmentの startActivityForResult からコードを追っていくと、こんなコードに出くわします。

android/support/v4/app/FragmentActivity.java
    public void startActivityFromFragment(Fragment fragment, Intent intent,
            int requestCode, @Nullable Bundle options) {

        // FragmentからのstartActivityForResultかどうかのフラグ
        mStartedActivityFromFragment = true;
        try {
            if (requestCode == -1) {
                ActivityCompat.startActivityForResult(this, intent, -1, options);
                return;
            }

            // checkForValidRequestCodeが16ビット以下かどうか調べて例外を投げている
            checkForValidRequestCode(requestCode);

       // Activityが抱えているFragmentのうち、
             // どのFragmentがstartActivityForResultを呼び出したのかを記録している
             // テーブルがあり、そこにFragment(を一意に特定する名前)を追加している
            int requestIndex = allocateRequestIndex(fragment);

            // 上位16ビットにテーブルのindexを詰め、
            // 下位16ビットに元々のrequestCodeを詰めてActivityを呼び出す
            ActivityCompat.startActivityForResult(
                    this, intent, ((requestIndex + 1) << 16) + (requestCode & 0xffff), options);
        } finally {
            // 呼び出しが済んだらフラグを折る
            mStartedActivityFromFragment = false;
        }
    }

32ビットあるうち、上下16ビットずつを別々の用途につかっているから、16ビットに抑えろ、と言われるわけですね。

ちなみに、FragmentActivityから startActivityForResult() を呼び出す際にも、 requestCodeが16ビット以下かどうかチェックされます

android/support/v4/app/FragmentActivity.java
    public void startActivityForResult(Intent intent, int requestCode) {
        // Fragmentからの呼び出しの場合は既に16ビット以下かどうかチェック済み。
        // 上位16ビットが0x0000だった場合にはActivityからの呼び出しと判断するため、
        // ActivityからrequestCodeも16ビット以下であることを確かめる。
        if (!mStartedActivityFromFragment) {
            if (requestCode != -1) {
                checkForValidRequestCode(requestCode);
            }
        }
        super.startActivityForResult(intent, requestCode);
    }

startActivityForResult() する際には、Fragmentからでなくとも16ビット以下のrequestCodeにした方が良さそうですね。

FragmentTransaction#commit() の直後にFragment内でcontextを使う -> NPE

場合によってはActivityからFragmentに何かしらの操作を依頼したいときがありますよね。

getSupportFragmentManager().
        beginTransaction().
        add(R.id.placeholder, HogeFragment.newInstance()).
        commit();
HogeFragment h = (HogeFragment) getSupportFragmentManager().
        findFragmentById(R.id.placeholder);


h.doHoge();   // -> NullPointerException

Fragmentは神隠しにあったようです :innocent:
ならこれはどうだ?

// Fragmentのインスタンスを保持しておく
HogeFragment h = HogeFragment.newInstance();
getSupportFragmentManager().
        beginTransaction().
        add(R.id.placeholder, h).
        commit();

h.doHoge();   // -> 中で getString() などcontextを使う操作があると IllegalStateException

そう、 commit() は非同期に実行されるのです!
Activityにattachされるのはしばらく後!
するとcontextを使えるのもしばらく後!

これもよく言われる落とし穴。
executePendingTransactions() を使えば解決できます。

しかし、 executePendingTransactions() を呼び出すほど急いではいないので、Fragmentがattachされた後くらいにしたい処理がある…
Activityに何かしらinterfaceを実装して、 Fragment#onAttach() 辺りでActivityを呼び出せば良いかもしれないけれど、そこまで面倒くさいことはしたくない…
(業務なら面倒臭がらずに実装しましょう)

commitの非同期処理はLooperが担当している

FragmentTransaction#commit() から処理を追っていくと、 FragmentManager#enqueueAction() にたどり着きます。

android/support/v4/app/FragmentManager.java
    // actionはBackStackRecord(Runnableを実装している)が入る。
    public void enqueueAction(Runnable action, boolean allowStateLoss) {
            ...

            // mPendingActionsはactionを格納しておくList
            mPendingActions.add(action);

            // 実行待ちのものが今追加したactionしかなければ、
            // Activityと同じスレッドのLooperにメッセージを投げるHandlerにpost
            if (mPendingActions.size() == 1) {
                mHost.getHandler().removeCallbacks(mExecCommit);
                mHost.getHandler().post(mExecCommit);
            }
        }
    }

mExecCommitFragmentManager#execPendingActions() を呼び出すだけなので、 execPendingActions() を見てみましょう。

android/support/v4/app/FragmentManager.java
    public boolean execPendingActions() {
        ...

        while (true) {
            int numActions;

            synchronized (this) {
                ...

                // mPendingActionsからmTmpActionsへRunnableを移し替え。
                // おそらくsynchronizedし続ける時間を短くするため。
                numActions = mPendingActions.size();
                if (mTmpActions == null || mTmpActions.length < numActions) {
                    mTmpActions = new Runnable[numActions];
                }
                mPendingActions.toArray(mTmpActions);
                mPendingActions.clear();
                mHost.getHandler().removeCallbacks(mExecCommit);
            }

            // 溜まっていたcommit処理を実行
            mExecutingActions = true;
            for (int i=0; i<numActions; i++) {
                mTmpActions[i].run();
                mTmpActions[i] = null;
            }
            mExecutingActions = false;
            didSomething = true;
        }

        // Fragmentの状態を遷移させる?
        doPendingDeferredStart();

        return didSomething;
    }

非同期処理と言えばLooperとHandlerですね。
Fragmentのcommitは重たい処理のようなので、非同期に、メインスレッドで処理できるタイミングになったときに処理されるようです。

とすると、 FragmentTransaction#commit() の直後にメインスレッドのHandlerにpostすると、Fragmentがattachされた直後に処理が実行されるはず…!
(メインスレッド以外でFragmentManagerをいじらなければ)

getSupportFragmentManager().
        beginTransaction().
        add(R.id.placeholder, HogeFragment.newInstance()).
        commit();
new Handler(Looper.getMainLooper()).post(new Runnable() {
    @Override
    public void run() {
        HogeFragment h = (HogeFragment) getSupportFragmentManager().
            findFragmentById(R.id.placeholder);

        h.doHoge();   // hはnon-null!!
    }
});

こんなこともできちゃいました :hugging:

まとめ

frameworksやSupportLibraryのコードを読むと色々なことがわかって楽しいですよ!!

Javaでの上手い書き方や設計の仕方、異なるバージョンへの対応の仕方を学ぶこともできますし、たまに反面教師にしようと思うような部分も見つかったりします。
普段からAndroid開発をしている方ならAndoridの挙動がわかっていますから、挙動をイメージしながらコードを読むこともできるでしょう。

私もこのあたりのコードを読むようになったのはつい最近のことなので、わかったことがあれば随時発信していきたいと思います。

ライセンス

Support Libraryのソースコードは、Apache License 2.0でライセンスされています。
この記事では一部のコードを抜粋・改変して掲載しています。

この投稿は Androidその2 Advent Calendar 20166日目の記事です。