【Android 実装の罠 #1】 Fragment#setArguments()

Bad Operation Collection

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

IllegalStateException

Fragmentを利用する上で、避けて通れないのがIllegalStateExceptionです。Androidのアプリを作る上でFragmentを利用してる場合は、みなさんほぼ遭遇したことがあるのではないでしょうか。
今回はあえて、IllegalStateExceptionを起こすようなコードを作成し、実行してみます
「何故そのような動作が起こったのか」、「どうしてそのタイミングでそのメソッドを呼んではいけないのか」をPlatform内部のソースコードを見ながら考察してみます

Fragment#setArguments(Bundle bundle)

yanzmさんのブログ等々でも随分前から指摘されていることですが、FragmentはOSによって自動的に初期化されたりコンストラクタが呼び出されたりするので、基本的にコンストラクタの引数によるFragmentへの情報の受け渡しは推奨されていません。

そこで利用するのが、Bundleにラップして情報をFragmentクラスへ受け渡すsetArguments()です。

このsetArguments()、publicなインスタンスメソッド故に、別に初期化時でなくても呼び出すことは可能です
初期化時以外のタイミングで呼び出してみましょう。どうなるのでしょうか

サンプルコード

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context=".MainActivity" >
    
    <!-- Bottom Menu layout -->
    <LinearLayout 
	    android:layout_width="match_parent"
	    android:layout_height="wrap_content"
	    android:orientation="horizontal"
	    android:id="@+id/bottom_menu_buttons"
	    android:padding="5dip"
	    android:background="@android:color/holo_orange_light"
	    android:layout_alignParentBottom="true">
	    
        <!-- Show Fragment -->
	    <Button 
	        android:layout_width="0dip"
	        android:layout_height="wrap_content"
	        android:layout_weight="1"
	        android:layout_gravity="center_vertical"
	        android:layout_margin="5dip"
	        android:id="@+id/change_frag_01"
	        android:text="Change Frag01"/>
	    </LinearLayout>
	    
    <!-- Fragment Container Layout -->
    <FrameLayout 
        android:id="@+id/fragment_container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#ff0000"
        android:layout_above="@id/bottom_menu_buttons">
    </FrameLayout>
</RelativeLayout>

Java - BadOperationCollection_01reslayoutactivity_main

fragment_01.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical" >
    <LinearLayout 
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:weightSum="5"
        >
        <TextView 
            android:layout_width="0dip"
            android:layout_height="wrap_content"
            android:text="項目1:"
            android:layout_weight="1"
            />
        <EditText 
            android:layout_width="0dip"
            android:layout_height="wrap_content"
            android:layout_weight="4"
            android:hint="項目1"
            android:id="@+id/category_01_value"
            />
    </LinearLayout>
    <LinearLayout 
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:weightSum="5"
        >
        <TextView 
            android:layout_width="0dip"
            android:layout_height="wrap_content"
            android:text="項目2:"
            android:layout_weight="1"
            />
        <EditText 
            android:layout_width="0dip"
            android:layout_height="wrap_content"
            android:layout_weight="4"
            android:hint="項目2"
            android:id="@+id/category_02_value"
            />
    </LinearLayout>
    
    <LinearLayout 
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:weightSum="5"
        >
        <Button 
            android:layout_width="0dip"
            android:layout_height="wrap_content"
            android:layout_weight="5"
            android:layout_margin="10dip"
            android:id="@+id/call_method"
            android:text="Call setArguments()"
            />
	</LinearLayout>
</LinearLayout>

Java - BadOperationCollection_01reslayoutfragment_01

MainActivity.java

package jp.classmethod.android.sample.badoperationcollection_01;

import android.app.Activity;
import android.app.FragmentTransaction;
import android.os.Bundle;
import android.view.Menu;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;

public class MainActivity extends Activity {
	
	private Button changeFragment01;

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_main);
		
		changeFragment01 = (Button) findViewById(R.id.change_frag_01);
		changeFragment01.setOnClickListener(new OnClickListener() {
			
			@Override
			public void onClick(View v) {
				
				// Fragmentを生成
				FirstFragment fragment = new FirstFragment();
				// 設定のBundleを設定
				Bundle bundle = new Bundle();
				bundle.putString("category_01", "タンゴ");
				bundle.putString("category_02", "イエーガー");
				// Bundleを設定してFragmentの初期値を渡す
				fragment.setArguments(bundle);
				
				// Fragmentを表示
				FragmentTransaction ft = getFragmentManager().beginTransaction();
				ft.replace(R.id.fragment_container, fragment);
				ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE);
				ft.commit();
			}
		});
	}

	@Override
	public boolean onCreateOptionsMenu(Menu menu) {
		// Inflate the menu; this adds items to the action bar if it is present.
		getMenuInflater().inflate(R.menu.main, menu);
		return true;
	}
}

FirstFragment.java

package jp.classmethod.android.sample.badoperationcollection_01;

import android.app.Fragment;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;

public class FirstFragment extends Fragment {
	
	private Button callSetArgument;
	
	private EditText category01;
	
	private EditText category02;

	@Override
	public void onActivityCreated(Bundle savedInstanceState) {
		super.onActivityCreated(savedInstanceState);
		
		// 渡ってきたBundleから初期値を設定
		Bundle bundle = getArguments();
		String category01Val = bundle.getString("category_01");
		String category02Val = bundle.getString("category_02");
		
		category01.setText(category01Val);
		category02.setText(category02Val);
	}

	@Override
	public void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
	}

	@Override
	public View onCreateView(LayoutInflater inflater, ViewGroup container,
			Bundle savedInstanceState) {
		View view = inflater.inflate(R.layout.fragment_01, container, false);
		
		callSetArgument = (Button) view.findViewById(R.id.call_method);
		callSetArgument.setOnClickListener(new OnClickListener() {
			
			@Override
			public void onClick(View v) {
				// 現在入力されてる状態をBundleに格納する
				Bundle bundle = newFragmentSetting();
				setArguments(bundle);
			}
		});
		
		category01 = (EditText) view.findViewById(R.id.category_01_value);
		category02 = (EditText) view.findViewById(R.id.category_02_value);
		
		return view;
	}

	/**
	 * 今の情報のBundleを新規作成
	 * @return
	 */
	private Bundle newFragmentSetting() {
		Bundle bundle = new Bundle();
		bundle.putString("category_01", category01.getText().toString());
		bundle.putString("category_02", category02.getText().toString());
		
		return bundle;
	}	
}

Fragmentに遷移し、ボタンをタップしてみます。この時点でFragmentは既にActive化しているため、通常setArguments()を呼び出すことはしないはずです。
ボタンをタップして、強制的に呼び出してみます

実行結果

10-03 11:41:22.041: E/AndroidRuntime(6749): FATAL EXCEPTION: main
10-03 11:41:22.041: E/AndroidRuntime(6749): java.lang.IllegalStateException: Fragment already active
10-03 11:41:22.041: E/AndroidRuntime(6749): 	at android.app.Fragment.setArguments(Fragment.java:673)
10-03 11:41:22.041: E/AndroidRuntime(6749): 	at jp.classmethod.android.sample.badoperationcollection_01.FirstFragment$1.onClick(FirstFragment.java:50)
10-03 11:41:22.041: E/AndroidRuntime(6749): 	at android.view.View.performClick(View.java:4203)
10-03 11:41:22.041: E/AndroidRuntime(6749): 	at android.view.View$PerformClick.run(View.java:17189)
10-03 11:41:22.041: E/AndroidRuntime(6749): 	at android.os.Handler.handleCallback(Handler.java:615)
10-03 11:41:22.041: E/AndroidRuntime(6749): 	at android.os.Handler.dispatchMessage(Handler.java:92)
10-03 11:41:22.041: E/AndroidRuntime(6749): 	at android.os.Looper.loop(Looper.java:137)
10-03 11:41:22.041: E/AndroidRuntime(6749): 	at android.app.ActivityThread.main(ActivityThread.java:4961)
10-03 11:41:22.041: E/AndroidRuntime(6749): 	at java.lang.reflect.Method.invokeNative(Native Method)
10-03 11:41:22.041: E/AndroidRuntime(6749): 	at java.lang.reflect.Method.invoke(Method.java:511)
10-03 11:41:22.041: E/AndroidRuntime(6749): 	at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:1004)
10-03 11:41:22.041: E/AndroidRuntime(6749): 	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:771)
10-03 11:41:22.041: E/AndroidRuntime(6749): 	at dalvik.system.NativeStart.main(Native Method)

IllegalStateException が発生しました。なぜここで呼んではいけないのか。Platform内のソースを見てみます

android.app.Fragment

Fragmentのソースは、{ANDROID-SDK}/sources/android-18/android/app 以下に格納されています。今回は、APIレベル18のFragmentのソースを参照します
まずはsetArguments()を探してみます。

一部抜粋。android/app/Fragment.java

/**
* Supply the construction arguments for this fragment.  This can only
* be called before the fragment has been attached to its activity; that
* is, you should call it immediately after constructing the fragment.  The
* arguments supplied here will be retained across fragment destroy and
* creation.
*/
public void setArguments(Bundle args) {
	if (mIndex >= 0) {
	    throw new IllegalStateException("Fragment already active");
	}
	mArguments = args;
}

mIndexという値がカギを握っているようです。0以上であれば、「Fragmentは既にActive化されているため、この操作はできません」というメッセージを含めて例外を放るようです
mIndexを探索してみます。Fragment内の宣言は371行目にありました

// Index into active fragment array.
int mIndex = -1;

初期値は「-1」なので、このままであればsetArguments()の呼び出しは可能です

また、FragmentManagerによって呼び出される初期化メソッドも発見しました。こちらでも値の初期化を行っているようです

 /**
* Called by the fragment manager once this fragment has been removed,
* so that we don't have any left-over state if the application decides
* to re-use the instance.  This only clears state that the framework
* internally manages, not things the application sets.
*/
void initState() {
	mIndex = -1;
	mWho = null;
	mAdded = false;
	mRemoving = false;
	mResumed = false;
	mFromLayout = false;
	mInLayout = false;
	mRestored = false;
	mBackStackNesting = 0;
	mFragmentManager = null;
	mActivity = null;
	mFragmentId = 0;
	mContainerId = 0;
	mTag = null;
	mHidden = false;
	mDetached = false;
	mRetaining = false;
	mLoaderManager = null;
	mLoadersStarted = false;
	mCheckedForLoaderManager = false;
}

では、このmIndexの値をどこで書き換えているのでしょうか
不正なsetArguments()呼び出し時に出力されたメッセージから見ると、「FragmentがActive化」された際に書き換わる、タイミングでしょうか
値を書き換えている箇所は619行目にありました

final void setIndex(int index, Fragment parent) {
	mIndex = index;
	if (parent != null) {
	    mWho = parent.mWho + ":" + mIndex;
	} else {
	    mWho = "android:fragment:" + mIndex;
	}
}

このメソッドが呼ばれている場所を探してみます。先ほど考察したようにFragmentがActive化されたタイミングのようなので、FragmentManager.javaから探索してみましょう

android.app.FragmentManager.java

ご存知の通り、Fragmentを利用する場合は、FragmentManagerを利用して入れかえや追加・削除を行います。そこでFragmentの追加のタイミングを探してみます
1120行目にそれらしい実装がありました

一部抜粋。android/app/FragmentManager.java

public void addFragment(Fragment fragment, boolean moveToStateNow) {
	if (mAdded == null) {
	    mAdded = new ArrayList<Fragment>();
	}
	if (DEBUG) Log.v(TAG, "add: " + fragment);
	makeActive(fragment);
	if (!fragment.mDetached) {
	    if (mAdded.contains(fragment)) {
	        throw new IllegalStateException("Fragment already added: " + fragment);
	    }
	    mAdded.add(fragment);
	    fragment.mAdded = true;
	    fragment.mRemoving = false;
	    if (fragment.mHasMenu && fragment.mMenuVisible) {
	        mNeedMenuInvalidate = true;
	    }
	    if (moveToStateNow) {
	        moveToState(fragment);
	    }
	}
}

1125行目にmakeActive()というメソッドがありました。どうやらここでFragmentのActive化処理を行っているようです

void makeActive(Fragment f) {
	if (f.mIndex >= 0) {
	    return;
	}

	if (mAvailIndices == null || mAvailIndices.size() <= 0) {
	    if (mActive == null) {
	        mActive = new ArrayList<Fragment>();
	    }
	    f.setIndex(mActive.size(), mParent);
	    mActive.add(f);
	    
	} else {
	    f.setIndex(mAvailIndices.remove(mAvailIndices.size()-1), mParent);
	    mActive.set(f.mIndex, f);
	}
	if (DEBUG) Log.v(TAG, "Allocated fragment index " + f);
}

ありました。f.setIndexにActive化されたFragmentのListサイズを入力しています。ここでFragmentのmIndexが入力されているようです。
つまりこのメソッド以後はFragmentはActive化されたとみなされ、setArguments()を呼び出すとIllegalStateExceptionがスローされます

ちなみに、すぐ近くにmakeInactive(Fragment f)もありました。こちらは逆にFragmentの状態をリセットするようです。

void makeInactive(Fragment f) {
	if (f.mIndex < 0) {
	    return;
	}

	if (DEBUG) Log.v(TAG, "Freeing fragment index " + f);
	mActive.set(f.mIndex, null);
	if (mAvailIndices == null) {
	    mAvailIndices = new ArrayList<Integer>();
	}
	mAvailIndices.add(f.mIndex);
	mActivity.invalidateFragment(f.mWho);
	f.initState();
}

f.initState();で初期化しています

考察・まとめ

  • setArguments()は、FragmentManagerによってAddされた以降は呼んではいけない
  • setArguments()呼び出し時に、Fragment#mIndexの評価を行っている。0以上であればIllegalStateExceptionをスローする
  • mIndexは、FragmentManager#makeActive()呼び出し時に入力される

Fragmentの状態がActiveになるトリガは、FragmentManager#makeActive()のようです
これ以降はActive化された証としてFragmentのmIndexが0以上になるため、setArguments()を呼ぶとIllegalStateExceptionがスローされるというのが、今回の動作の「解」でしょうか

Fragment周りのライフサイクルや状態変化、IllegalStateExceptionによる例外発生は、実装するうえで避けては通れない道です
IllegalStateExceptionに限った話ではないですが、例外が発生した場合は、何故その例外が発生したのかを立ち止まって考え、時にPlatformのソース内部まで探索し自身で納得したうえで、安易にtry-catchで囲んだりしないようにしましょう(自戒をこめて)。意外と新しい発見があるかもしれません

それではみなさまごきげんよう