2013/12/08

PreferenceActivity で isValidFragment を override していないと RuntimeException が発生する

Eclipse の ADT ではいくつかのテンプレートが用意されていて、お決まりの class を作成する場合とても重宝しますNew → Other → Android → Android Object で選択できます。
Settings Activity もそのひとつ。
このテンプレートを使用すると、multi-pane layout の設定画面を手軽に作成することが出来ます。

multi-pane layout というのは、以下のように小型スマートフォンと大型タブレットで表示形式を変えるレイアウトのことです。
スマートフォンの設定画面
タブレットの設定画面

ところが、このタブレット用の画面を Android 4.4 (Kitkat) のタブレットで表示すると以下のような RuntimeException が発生し、アプリが落ちるようになってしまいました。
テンプレートを使用するとアプリが落ちてしまうというは、ちょっとダサい状況ですね。

FATAL EXCEPTION: main
Process: com.kokufu.android.apps.sqliteviewer, PID: 32372
java.lang.RuntimeException: Unable to start activity ComponentInfo{com.kokufu.android.apps.sqliteviewer/com.kokufu.android.apps.sqliteviewer.base.SettingsActivity}: java.lang.RuntimeException: Subclasses of PreferenceActivity must override isValidFragment(String) to verify that the Fragment class is valid! com.kokufu.android.apps.sqliteviewer.base.SettingsActivity has not checked if fragment com.kokufu.android.apps.sqliteviewer.base.SettingsActivity$DisplayPreferenceFragment is valid.
 at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2176)
 at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2226)
 at android.app.ActivityThread.access$700(ActivityThread.java:135)
 at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1397)
 at android.os.Handler.dispatchMessage(Handler.java:102)
 at android.os.Looper.loop(Looper.java:137)
 at android.app.ActivityThread.main(ActivityThread.java:4998)
 at java.lang.reflect.Method.invokeNative(Native Method)
 at java.lang.reflect.Method.invoke(Method.java:515)
 at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:777)
 at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:593)
 at dalvik.system.NativeStart.main(Native Method)
Caused by: java.lang.RuntimeException: Subclasses of PreferenceActivity must override isValidFragment(String) to verify that the Fragment class is valid! com.kokufu.android.apps.sqliteviewer.base.SettingsActivity has not checked if fragment com.kokufu.android.apps.sqliteviewer.base.SettingsActivity$DisplayPreferenceFragment is valid.
 at android.preference.PreferenceActivity.isValidFragment(PreferenceActivity.java:898)
 at android.preference.PreferenceActivity.switchToHeaderInner(PreferenceActivity.java:1179)
 at android.preference.PreferenceActivity.switchToHeader(PreferenceActivity.java:1219)
 at android.preference.PreferenceActivity.onCreate(PreferenceActivity.java:564)
 at android.app.Activity.performCreate(Activity.java:5243)
 at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1087)
 at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2140)
 ... 11 more

原因

RuntimeException で落ちる直接的な原因は、エラーメッセージにもあるように、isValidFragment(String) を override していないからです。
では、isValidFragment(String) って何なのでしょうか?

isValidFragment(String) の JavaDoc を読んでみると、

Subclasses should override this method and verify that the given fragment is a valid type to be attached to this activity. The default implementation returns true for apps built for android:targetSdkVersion older than KITKAT. For later versions, it will throw an exception.

(意訳)
子クラスはこのメソッドを override して、引数で与えられた fragment がこの activity で使用してよいものか確認しなけれければならない。デフォルトでは android:targetSdkVersion が KITKAT より古いものであった場合は、必ず true を返すが、KITKAT 以降を指定した場合は exception を投げる。

とあります。
また、PreferenceActivity には EXTRA_SHOW_FRAGMENT という定数定義があり、以下のように書かれています。

When starting this activity, the invoking Intent can contain this extra string to specify which fragment should be initially displayed. Starting from Key Lime Pie, when this argument is passed in, the PreferenceActivity will call isValidFragment() to confirm that the fragment class name is valid for this activity.

(意訳)
この activity を呼び出す Intent にこの extra string を含めることができ、この文字列で指定した fragment が最初に表示される。 Key Lime Pie 以降でこの設定値が指定されたとき、PreferenceActivity は isValidFragment() を呼び出し、class name が正しいものかどうかを判断する。


つまり、外からデフォルトで表示させる fragment の名前を文字列で指定できるようにしてあるけど、何でもかんでも呼べると危険なので、ちゃんと確認しないとダメ。
ということのようです。

ちなみに、Key Lime Pie って書いてあるのですが、KITKAT のことだと思われます。
KITKAT に正式決定する前は Key Lime Pie で暫定決定していたらしいですね。そのころの名残でしょう。


対策

一番手っ取り早い方法は、android:targetSdkVersion を 18 以前にすることです。
これにより、isValidFragment(String) を override しなくても RuntimeException が発生することはなくなります。

とはいえ、いつまでも targetSdkVersion を固定しておくわけにはいきません。
その場合の正攻法は、isValidFragment(String) を override することです。
以下のような感じでしょうか。
public class SettingsActivity extends PreferenceActivity {

    // 略

    @TargetApi(Build.VERSION_CODES.HONEYCOMB)
    public static class FragmentA extends PreferenceFragment {
        // 略
    }

    @TargetApi(Build.VERSION_CODES.HONEYCOMB)
    public static class FragmentB extends PreferenceFragment {
        // 略
    }

    @TargetApi(Build.VERSION_CODES.HONEYCOMB)
    public static class FragmentC extends PreferenceFragment {
        // 略
    }

    @Override
    protected boolean isValidFragment(String fragmentName) {
        // 使用できる Fragment か確認する
        if (FragmentA.class.getName().equals(fragmentName) ||
            FragmentB.class.getName().equals(fragmentName) ||
            FragmentC.class.getName().equals(fragmentName)) {
            return true;
        }
        return false;
    }
}

リフレクションを利用するなど、愚直に全部書かない方法もありそうですが、そんなに Fragment の数がない場合は、直接書いてしまった方が良いかと思いますFragment の数が多い場合は、仕様に問題があるような気もします

難読化をしている場合は要注意

Proguard などで難読化を行なっている場合、上記の実装ではうまくいきません。
引数に渡される文字列は難読化前のクラス名だからです。
なので、以下のように、PreferenceFragment を継承しているクラスを難読化対象から外しましょう。

proguard-project.txt
-keep class * extends android.preference.PreferenceFragment

もしくは、難読化前のクラス名(パッケージ名込の正式名)を文字列で定義し、その文字列と比較するのもアリかもしれません。

0 件のコメント: