天天看点

学徒浅析Android——PreferenceActivity在Android 8.0和 7.0上细微变化

    日前在利用PrepertyActivity配置设置页面时,出现了一个IllegalArgumentException崩溃,这个崩溃只在8.0系统的手机上出现了,在7.0及以下的系统中不会出现,后来经过追根溯源,发现原因是Android8.0的API变更导致的,先把分析过程分享一下,希望能帮助到有同样问题的同学,当时触发的崩溃如下:

java.lang.RuntimeException: Unable to start activity ComponentInfo{XXX/XXX.activity.CustomePreferenceActivity}: java.lang.IllegalArgumentException: No view found for id 0x1020372 (android:id/prefs) for fragment PropertyTopFragment{2004b3d #0 id=0x1020372}
     at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2828)
     at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2909)
     at android.app.ActivityThread.-wrap11(Unknown Source:0)
     at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1639)
     at android.os.Handler.dispatchMessage(Handler.java:106)
     at android.os.Looper.loop(Looper.java:164)
     at android.app.ActivityThread.main(ActivityThread.java:6631)
     at java.lang.reflect.Method.invoke(Native Method)
     at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:467)
     at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:821)
  Caused by: java.lang.IllegalArgumentException: No view found for id 0x1020372 (android:id/prefs) for fragment CustomePreferenceFragment{2004b3d #0 id=0x1020372}
     at android.app.FragmentManagerImpl.moveToState(FragmentManager.java:1271)
     at android.app.FragmentManagerImpl.addAddedFragments(FragmentManager.java:2407)
     at android.app.FragmentManagerImpl.executeOpsTogether(FragmentManager.java:2186)
     at android.app.FragmentManagerImpl.removeRedundantOperationsAndExecute(FragmentManager.java:2142)
     at android.app.FragmentManagerImpl.execPendingActions(FragmentManager.java:2043)
     at android.app.FragmentManagerImpl.dispatchMoveToState(FragmentManager.java:3032)
     at android.app.FragmentManagerImpl.dispatchActivityCreated(FragmentManager.java:2979)
     at android.app.FragmentController.dispatchActivityCreated(FragmentController.java:178)
     at android.app.Activity.performCreate(Activity.java:7166)
     at android.app.Activity.performCreate(Activity.java:7151)
     at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1218)
     at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2781)
     at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2909) 
     at android.app.ActivityThread.-wrap11(Unknown Source:0) 
     at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1639) 
     at android.os.Handler.dispatchMessage(Handler.java:106) 
     at android.os.Looper.loop(Looper.java:164) 
     at android.app.ActivityThread.main(ActivityThread.java:6631) 
     at java.lang.reflect.Method.invoke(Native Method) 
     at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:467) 
     at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:821) 
           

根据日志定位到崩溃位置是在CustomePreferenceActivity.onCreateImpl()中执行startPreferencePanel(fragmentName, args, 0, null, null, 0)方法。PreferenceActivity.startPreferencePanel(fragmentName, args, 0, null, null, 0)方法用来加载一个设置页面,该方法在Android7.0上的实现逻辑如下:

public void startPreferencePanel(String fragmentClass, Bundle args, @StringRes int titleRes, CharSequence titleText, Fragment resultTo, int resultRequestCode) {
        if (mSinglePane) {
            startWithFragment(fragmentClass, args, resultTo, resultRequestCode, titleRes, 0);
        } else {
            Fragment f = Fragment.instantiate(this, fragmentClass, args);
            if (resultTo != null) {
                f.setTargetFragment(resultTo, resultRequestCode);
            }
            FragmentTransaction transaction = getFragmentManager().beginTransaction();
            transaction.replace(com.android.internal.R.id.prefs, f);//实际出错位置,找不到R.id.prefs所在的布局
            if (titleRes != 0) {
                transaction.setBreadCrumbTitle(titleRes);
            } else if (titleText != null) {
                transaction.setBreadCrumbTitle(titleText);
            }
            transaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN);
            transaction.addToBackStack(BACK_STACK_PREFS);
            transaction.commitAllowingStateLoss();
        }
    }
           

可以看到方法内部会先判断当前是否处于多窗口模式,毕竟Androiod7.0的的特色就是多窗口。如果是传统的单窗口就执行startWithFragment()去加载,如果是多窗口,就直接替换当前的Fragment。结合崩溃日志提示的id,可以在这段处理逻辑中很明显的找到问题根源。就是红色标记处。这说明当前的PreferenceActivity并没有加载布局。重新比对了下Android8.0中的处理逻辑,该方法在Android8.0上的实现逻辑如下:

public void startPreferencePanel(String fragmentClass, Bundle args, @StringRes int titleRes, CharSequence titleText, Fragment resultTo, int resultRequestCode) {
        Fragment f = Fragment.instantiate(this, fragmentClass, args);
        if (resultTo != null) {
            f.setTargetFragment(resultTo, resultRequestCode);
        }
        FragmentTransaction transaction = getFragmentManager().beginTransaction();
        transaction.replace(com.android.internal.R.id.prefs, f);
        if (titleRes != 0) {
            transaction.setBreadCrumbTitle(titleRes);
        } else if (titleText != null) {
            transaction.setBreadCrumbTitle(titleText);
        }
        transaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN);
        transaction.addToBackStack(BACK_STACK_PREFS);
        transaction.commitAllowingStateLoss();
    }
           

可以看到直接取消了对于单窗口的判断。也就是说通过PreferenceActivity加载PreferenceFragment在8.0上是需要确保当前布局必须加载完毕才行,这个布局不是我们自定义的布局,而是PreferenceActivity其本身的布局。PreferenceActivty是如何加载含有R.id.prefs控件的布局呢,可以看下它的onCreate()方法,在onCreate()方法中会执行以下重点操作:

final int layoutResId = sa.getResourceId(
                com.android.internal.R.styleable.PreferenceActivity_layout,
                com.android.internal.R.layout.preference_list_content);
setContentView(layoutResId);		
mSinglePane = hidingHeaders || !onIsMultiPane();
String initialFragment = getIntent().getStringExtra(EXTRA_SHOW_FRAGMENT);
Bundle initialArguments = getIntent().getBundleExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS);
           

布局R.layout.preference_list_content是含有R.id.prefs的,但是两个参数initialFragment和initialArguments确实我们不曾传递的。7.0和8.0在后续针对这两个参数的处理出现了变化,先看7.0的处理:

if (savedInstanceState != null) {
	}else{
	     if (initialFragment != null && mSinglePane) {
	        switchToHeader(initialFragment, initialArguments);
	     }
	}
	if (initialFragment != null && mSinglePane) {
          // Single pane, showing just a prefs fragment.
          findViewById(com.android.internal.R.id.headers).setVisibility(View.GONE);
          mPrefsContainer.setVisibility(View.VISIBLE);
          if (initialTitle != 0) {
              CharSequence initialTitleStr = getText(initialTitle);
              CharSequence initialShortTitleStr = initialShortTitle != 0
                      ? getText(initialShortTitle) : null;
              showBreadCrumbs(initialTitleStr, initialShortTitleStr);
          }
 } else if (mHeaders.size() > 0) {
          setListAdapter(new HeaderAdapter(this, mHeaders, mPreferenceHeaderItemResId,
                  mPreferenceHeaderRemoveEmptyIcon));
          if (!mSinglePane) {
              // Multi-pane.
              getListView().setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
              if (mCurHeader != null) {
                  setSelectedHeader(mCurHeader);
              }
              mPrefsContainer.setVisibility(View.VISIBLE);
          }
 } else {//初次加载PreferenceActivty注定会走进此处逻辑处理中
          // If there are no headers, we are in the old "just show a screen
          // of preferences" mode.
          setContentView(com.android.internal.R.layout.preference_list_content_single);切换布局导致的
          mListFooter = (FrameLayout) findViewById(com.android.internal.R.id.list_footer);
          mPrefsContainer = (ViewGroup) findViewById(com.android.internal.R.id.prefs);
          mPreferenceManager = new PreferenceManager(this, FIRST_REQUEST_CODE);
          mPreferenceManager.setOnPreferenceTreeClickListener(this);
} 
           

在来看8.0在onCreate()中的处理:

if (savedInstanceState != null) {
 }else{
      if (initialFragment != null) {
            switchToHeader(initialFragment, initialArguments);
	}
 }
if (mSinglePane && initialFragment != null && initialTitle != 0) {}
if (mHeaders.size() == 0 && initialFragment == null) {
  // If there are no headers, we are in the old "just show a screen
  // of preferences" mode.
  setContentView(com.android.internal.R.layout.preference_list_content_single);
}
           

我们在调用一个PreferenceActivty时,注定saveInstanceState是null的,参数initialFragment和initialArguments是空值,mHeaders初始化后也是空的,并且只有在saveInstanceState !=null时才会添加数据,所以此时一定会走进setContentView(com.android.internal.R.layout.preference_list_content_single)中,更换后的布局R.layout.preferencelist_content_single是没有R.id.prefs的。那么不论是Android8.0还是Android.7.0的处理流程直接调用replace(com.android.internal.R.id.prefs, fragment),都会出现IllegalArgumentException。Android7.0之所以能够避开崩溃,是因为判断为传统单窗口时调用了startWithFragment(),这个方法的作用就是重新启动PreferenceActivity,并传递一部分参数,确保在onCreate()中saveInstanceState、initialFragment和initialArguments不是空值。可以看下startWithFragment()的处理逻辑:

public void startWithFragment(String fragmentName, Bundle args,
            Fragment resultTo, int resultRequestCode, @StringRes int titleRes,
            @StringRes int shortTitleRes) {
        Intent intent = onBuildStartFragmentIntent(fragmentName, args, titleRes, shortTitleRes);
        if (resultTo == null) {
            startActivity(intent);
        } else {
            resultTo.startActivityForResult(intent, resultRequestCode);
        }
    }
           
public Intent onBuildStartFragmentIntent(String fragmentName, Bundle args,
            @StringRes int titleRes, int shortTitleRes) {
        Intent intent = new Intent(Intent.ACTION_MAIN);
        intent.setClass(this, getClass());实际上就是重启自己,重新调用了onCreate()方法
        intent.putExtra(EXTRA_SHOW_FRAGMENT, fragmentName);表明此时要加载的fragment,initialFragment
        intent.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, args);就是saveInstanceState
        intent.putExtra(EXTRA_SHOW_FRAGMENT_TITLE, titleRes);
        intent.putExtra(EXTRA_SHOW_FRAGMENT_SHORT_TITLE, shortTitleRes);
        intent.putExtra(EXTRA_NO_HEADERS, true);
        return intent;
    }
           

可以看出startWithFragment就是在利用传入的数据通过Intent再次调起PreferenceActivty自身。这样再次执行onCreate是就不会出现布局替换的现象。通过比较我们可以看到,在代码中可以直接调用startWithFragment()可以更好的实现原有功能。Android8.0针对PreferenceActivty的修改目的不得而知,Google自身建议是直接使用PreferenceFragment,不再维护PreferenceActivty,可能这就导致此处变更并没有出现在变更说明上。如果你的项目中还在继续使用PreferenceActivty,那么就要注意把startPreferencePanel()方法替换成startWithFragment(),规避这个崩溃异常。

延伸:

在设置PreferenceScreen时。同样存在触发startPreferencePanel()方法的场景,当PreferenceFragment加载的是PreferenceScreen构成的xml文件时,页面点击事件会触发onPreferenceTreeClick(),该方法是OnPreferenceTreeClickListener的回调方法。该方法会去执行PreferenceActivity的onPreferenceStartFragment()方法。onPreferenceTreeClick()的实现方法在Android7.0和8.0上并无变动,具体代码构成如下:

public boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen,
            Preference preference) {
      if (preference.getFragment() != null &&
                getActivity() instanceof OnPreferenceStartFragmentCallback) {
            return ((OnPreferenceStartFragmentCallback)getActivity()).onPreferenceStartFragment(
                    this, preference);
        }
        return false;
    }
           

可以看到实际的执行内容是onPreferenceStartFragment(this, preference)。onPreferenceStartFragment又是执行的startPreferencePanel(),具体代码构成如下:

@Override
public boolean onPreferenceStartFragment(PreferenceFragment caller, Preference pref) {
    startPreferencePanel(pref.getFragment(), pref.getExtras(), pref.getTitleRes(),
            pref.getTitle(), null, 0);
    return true;
}
           

综上分析,为了彻底避免startPreferencePanel()内部逻辑变化带来的影响,需要对继承于PreferneceFragment的子类重写onPreferenceTreeClick(),确保不再调用startPreferencePanel(),而是替换为startWithFragment(),替换后的写法如下:

public boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen, Preference preference) {
    if (preference.getFragment() != null &&
            getActivity() instanceof OnPreferenceStartFragmentCallback) {
    		((OnPreferenceStartFragmentCallback)getActivity()).startWithFragment(pref.getFragment(), pref.getExtras(), null, 0, pref.getTitleRes(), 0);
        return true;
    }
    return false;
 }
           

继续阅读