Jared的Android之路

不让岁月磨去心中的热爱

Android Fragment Transactions & Activity State Loss(译)

本文翻译自Fragment Transactions & Activity State Loss,原作者为Alex Lockwood, 译者为Jared Luo

自从Honeycomb版本发布以后,下面这一块异常消息就成了StackOverflow上问题的常客:

java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState
at android.support.v4.app.FragmentManagerImpl.checkStateLoss(FragmentManager.java:1341)
at android.support.v4.app.FragmentManagerImpl.enqueueAction(FragmentManager.java:1352)
at android.support.v4.app.BackStackRecord.commitInternal(BackStackRecord.java:595)
at android.support.v4.app.BackStackRecord.commit(BackStackRecord.java:574)

这篇文章就来给大家解释一下为什么这个异常会被抛出,并且会总结出一些建议,让您的APP再也不受其导致崩溃的困扰。

异常产生的原因?

这个异常会被抛出的原因是:在Activity的状态被保存之后,你才尝试去commit一个FragmentTransaction,这样就会导致Activity的状态丢失。在深入了解这句话真正的意思之前,让我们先来看看当onSaveInstanceState()被调用时,其内部发生了什么。正如我上一篇文章Binders & Death Recipients所说,Android应用程序在Android运行时当中对其自身的命运,几乎没有什么话语权。Android系统有权利随时杀掉任何一个进程来解放内存空间,这样就导致后台运行的Activity们随时都有可能,在没有一点警告的情况下被杀掉。为了保证这种不可预测的动作不被用户所感受到,系统框架给了Activity一个机会,在可能被杀掉之前,通过调用onSaveInstanceState()方法来保存状态。这样当保存的状态被恢复的时候,无论Activity是否被杀掉,用户都会感觉到好像前后台的Activity进行的是无缝的切换。

当框架调用onSaveInstanceState()的时候,它会向这个方法传递一个Bundle参数,Activity可以用这个参数来保存页面、对话框、Fragments和视图的状态。当onSaveInstanceState返回时,会将一个Bundle对象序列化之后通过Binder接口传递给系统服务进程,并安全的保存起来。当系统晚一点想要重启Activity的时候, 它会把之前的Bundle对象传递回应用,并用来恢复Activity之前的状态。

所以为什么会抛出之前的异常呢?问题的根源在于Bundle这个对象仅仅是Activity在onSaveInstanceState()方法被调用那一刻的快照。这就意味着当你如果在onSaveInstanceState()之后再调用FragmentTransaction.commit()的话,由于这次Transaction没有被作为Activity状态的一部分来保存,自然也就丢失掉了。从用户的角度来说,Transaction的遗失就导致意外的UI状态丢失。那么为了维护良好的用户体验,Android系统会不惜一切代价的避免页面状态的丢失,所以在这种情况发生的时候就直接抛出了IllegalStateException。

异常产生的时机?

如果你曾经遇见过这个异常,你可能会注意到在各个版本的Android平台上,这个异常抛出的时机略有不同。举个栗子,你可能会发现相对老的设备上这个异常可能会抛出的没有那么频繁,另外如果你的应用使用了support library的话,可能会比使用官方框架更容易崩溃一点。这个不同的现象可能会让我们假设support library是不是有bug,不能让我们信任呢?然而,并不是这样。

这种各个版本之间的现象不一致源于,在Honeycomb这个Android版本中,Activity的生命周期做了相当的调整。在Honeycomb之前的版本中,Activity在pause以前都是不能被杀掉的,所以onSaveInstanceState() 就会在onPause()之前被立刻调用。但是从Honeycomb开始,Activity变成了在stop之前不能被杀掉。所以自然onSaveInstanceState()就会变成在onStop()之前被立刻调用。下面的表格总结了这个区别:

上面的针对Activity生命周期的改变就导致了,有时候support library需要根据系统版本的不同, 来调整自己的行为。举个栗子,在Honeycomb和其以上版本的设备上,只要在onSaveInstanceState()以后调用commit(), 就会抛出异常来提示开发者页面状态丢失了。但是在Honeycomb之前的版本,因为onSaveInstanceState()被调用的时机提前了很多,导致状态的丢失更容易出现,所以每次都抛出异常就显得有点太严格了。Android开发团队不得不被迫做了一个妥协:为了更好的配合老版本的系统,Honeycomb之前的版本就必须忍受onPause()和onStop()之间可能会发生的状态丢失(而不会抛出异常,译者注)。 如下表格总结了Support library在两类版本的表现:

怎样避免异常出现?

一旦您了解了真正的原理,避免Activity状态丢失就会变得容易许多。如果您已经读到了这里,我希望您对support library对这类问题的处理方式,以及避免页面状态丢失的重要性,有了更深入的了解。如果您是来查找怎样能够快速修复这个问题,下面有一些关于使用FragmentTransactions的建议:

  • 谨慎的在Activity的生命周期方法中调用transaction的commit方法。大多数应用只会在第一次调用onCreate()或者响应用户输入的时候去commit transaction, 这样不会有什么问题。但是, 当您把transaction放到其他Activity的生命周期方法中时,比如onActivityResult(), onStart() 和onResume(),事情就会变得有点复杂。举个栗子,您不应该在FragmentActivity#onResume()中去commit transaction,因为在某些情况下,这个方法可能会在Activity状态恢复之前被调用(看这里))。如果您的应用需要在onCreate()之外的其他生命周期方法中去commit transaction,那就请在 FragmentActivity#onResumeFragments()或者Activity#onPostResume()中去commit。这两个方法保证会在Activity恢复状态之后才会被调用,所以就能完全避免状态丢失的可能性。(作为例子,可以查看我在StackOverflow上,关于怎样在Activity#onActivityResult()中commit FragmentTransactions 的回答)。
  • 避免在异步回调中去处理transaction。包括使用AsyncTask#onPostExecute()和 LoaderManager.LoaderCallbacks#onLoadFinished()方法等。这样做的问题在于,您不会知道这些异步方法在调用的时候,当前的Activity究竟处于生命周期的哪一个状态。比如说,下面这一系列事件:

  • Activity执行一个AsyncTask。

  • 当用户按下Home键,导致Activity的onSaveInstanceState()和onStop()执行。
  • AsyncTask执行完成并且onPostExecute()被调用,其不知道Activity这个时候已经被stopped。
  • FragmentTransaction在onPostExecute()中被调用,导致异常被抛出。

总体来说,在这种情况下,最好避免异常的方法是不要在异步回调中去commit transaction。Google的工程师们看起来也同意这个观点。根据Android开发者团队邮件组的这篇文章,Android团队认为在异步方法中去调用commit transaction而产生的UI切换,会导致不好的用户体验。 如果您的应用确实需要在异步方法中处理transaction,并且没有简单的方法可以保证回调在onSaveInstanceState()之前被调用的话,你可能只有使用commitAllowingStateLoss(),并且自己处理可能发生的状态丢失了(请参考StackOverflow,这里还有这里)。

  • 只在不得已的情况下,才使用commitAllowingStateLoss()。commit()和commitAllowingStateLoss()唯一的不同是,后者当状态丢失时,不会抛出异常。通常由于存在状态丢失的可能性,您不会希望使用这个方法。更好的解决方法是,在您的应用中使用commit(),并保证在Activity的状态保存之前调用它,来获取更好的用户体验。除非状态丢失的可能性不能被避免,否则您不应该使用commitAllowingStateLoss()。

希望这些提示能够帮您解决这个异常带来的相关问题。如果您仍然有没有解决的疑问,请到StackOverflow上发帖子,并且把地址发布到下面的评论中,我可以帮您看看:)

Android中FragmentPagerAdapter对Fragment的缓存(二)

上一篇我们谈到了,当应用程序恢复时,由于FragmentPagerAdapter对Fragment进行了缓存的读取,导致其并未使用在Activity中新创建的Fragment实例。今天我们来看如何解决这种情况。

 根据上篇Blog的描述,我们不难发现,目前需要解决的问题有以下两个:

 1. 缓存Fragment内部成员变量缺失的问题。

 2. 新Fragment的创建和缓存Fragment使用之间的矛盾。

 下面先来解决第一个问题,缓存Fragment内部成员变量缺失。上篇Blog中,Fragment当中,有一个成员变量mText,是通过setter的方式在创建Fragment之初设置进去的。但是在经历了一系列的存储和恢复操作过后,其值在最终却为空,导致了程序展示的异常。那么能不能让mText也在Fragment中同步缓存和恢复呢?

 最先能想到的方法,就是通过Fragment的onSaveInstanceState方法在进程被杀掉时存储,当恢复时通过onCreateView的savedInstanceState参数取出;代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 @Override
 public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
    ...
    if (savedInstanceState != null) {
        mText = savedInstanceState.getString(SAVED_KEY_TEXT);
    }
    ...
}


@Override
public void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);
    outState.putString(SAVED_KEY_TEXT, mText);
}

 这种Activity和Fragment通用的方法,无疑是应用被杀掉时我们存储数据比较好的选择。不过还有其他方式吗?

 目前,mText是通过setter向Fragment设置的,这样做从实现来讲没有问题,不过其实并不是Android官方文档推荐的最佳实践; 官方文档上不推荐使用setter或者重写默认构造器的方式来传递参数:

It is strongly recommended that subclasses do not have other constructors with parameters, since these constructors will not be called when the fragment is re-instantiated; instead, arguments can be supplied by the caller with setArguments(Bundle) and later retrieved by the Fragment with getArguments().

 原因是,当Fragment重新被恢复时,不会去重新调用这些setter/有参构造方法; 而是会调用onCreateView,我们却可以在其中重新调用getArguments去获取这些参数。这就保证了在恢复过后,我们需要传入的参数可以重新被设置。一番改造之后如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    TestFragment fragmentOne = new TestFragment();
    Bundle bundleOne = new Bundle();
    bundleOne.putString(TestFragment.PARAM_KEY_TEXT, "One");
    fragmentOne.setArguments(bundleOne);

    TestFragment fragmentTwo = new TestFragment();
    Bundle bundleTwo = new Bundle();
    bundleTwo.putString(TestFragment.PARAM_KEY_TEXT, "Two");
    fragmentTwo.setArguments(bundleTwo);

    TestFragment fragmentThree = new TestFragment();
    Bundle bundleThree = new Bundle();
    bundleThree.putString(TestFragment.PARAM_KEY_TEXT, "Three");
    fragmentThree.setArguments(bundleThree);

 这样传入的参数,就不需要在onSaveInstanceState里面去手动保存了。

1
2
3
4
5
6
7
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_test, container, false);
        TextView textView = (TextView) view.findViewById(R.id.center_text_view);
        mText = (getArguments() != null) ? getArguments().getString(PARAM_KEY_TEXT) : "";
        textView.setText(mText);
        return view;
    }

 第一个问题到这里就处理好了,接下来看看第二个问题:怎样解决onCreate中新实例化的Fragment,与Adapter中FragmentManager中取出的Fragment不一致的冲突。

 虽然mText找回来了,但是如果我们需要对Activity中实例化的Fragment做一些进一步的操作,比如传入一些Listener之类的事情,就会遇到一些麻烦,因为毕竟我们处理的这些Fragment,实际上并不是当前展示在屏幕上的Fragment。

上篇Blog中讲到,FragmentPagerAdapter使用container.getId()与getItemId拼接的字符串作为FragmentManager中缓存的Key,FragmentPagerAdapter代码如下:

1
2
3
4
5
6
7
8
  String name = makeFragmentName(container.getId(), itemId);
  Fragment fragment = mFragmentManager.findFragmentByTag(name);

  ...

  private static String makeFragmentName(int viewId, long id) {
        return "android:switcher:" + viewId + ":" + id;
    }

 从上面的代码来看,其实要避免缓存和新创建的Fragment不一致,最简单的方式是,通过重写getItemId()方法,让每次打开应用返回不同的值(比如随机数之内的),让FragmentPagerAdapter找不到之前的缓存,就会使用我们新传入的实例了。

 不过这样做,看起来既不优雅,也不靠谱。毕竟Android官方给我们提供了这样一种缓存机制,那我们还是应该考虑怎样利用才好。

 1. 既然有缓存,那我们不必在Activity中每次都去新创建Fragment实例了。从源码中可以看出,每次如果FragmentPagerAdapter需要新实例化Fragment的话,都回去调用getItem方法,所以,可以考虑把Fragment的实例化工作放到getItem当中去。

 2. 考虑到后面我们会使用到这些Fragment实例,可以考虑在instantiateItem当中去获取并存放在数组当中。这里选择到instantiateItem,而不是getItem方法中去取的原因是:如果一旦出现有缓存的情况,FragmentPagerAdapter并不会调用getItem方法,如下:

1
2
3
4
5
6
7
8
9
10
11
    String name = makeFragmentName(container.getId(), itemId);
    Fragment fragment = mFragmentManager.findFragmentByTag(name);
    if (fragment != null) {
        if (DEBUG) Log.v(TAG, "Attaching item #" + itemId + ": f=" + fragment);
        mCurTransaction.attach(fragment);
    } else {
        fragment = getItem(position);
        if (DEBUG) Log.v(TAG, "Adding item #" + itemId + ": f=" + fragment);
        mCurTransaction.add(container.getId(), fragment,
                makeFragmentName(container.getId(), itemId));
    }

 按照上面两点想法,经过改造的Adapter的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class CustomPagerAdapter extends FragmentPagerAdapter {
    private static final int COUNT = 3;

    private Fragment[] mFragments;
    private Context mContext;

    public CustomPagerAdapter(Context context, FragmentManager fm) {
        super(fm);
        this.mContext = context;
        this.mFragments = new Fragment[COUNT];
    }

    @Override
    public Fragment getItem(int position) {
        String text;
        switch (position) {
            case 0:
                text = "One";
                break;
            case 1:
                text = "Two";
                break;
            case 2:
                text = "Three";
                break;
            default:
                text = "";
        }
        Bundle bundle = new Bundle();
        bundle.putString(TestFragment.PARAM_KEY_TEXT, text);
        return Fragment.instantiate(mContext, TestFragment.class.getName(), bundle);
    }

    @Override
    public int getCount() {
        return COUNT;
    }

    @Override
    public long getItemId(int position) {
        return position;
    }

    @Override
    public Object instantiateItem(ViewGroup container, int position) {
        Fragment fragment = (Fragment) super.instantiateItem(container, position);
        mFragments[position] = fragment;
        return fragment;
    }

    public Fragment[] getFragments() {
        return mFragments;
    }
}

有一点需要注意的是,mFragment数组需要在每个页面都实例化好了之后才会填充完成,需要注意调用的时机。

FragmentPagerAdapter对Fragment缓存的分析就是这么多了,欢迎指正。

Android中FragmentPagerAdapter对Fragment的缓存(一)

ViewPager + FragmentPagerAdapter,时我们经常使用的一对搭档,其实际应用的代码也非常简单,但是也有一些容易被忽略的地方,这次我们就来讨论下FragmentPagerAdapter对Fragment的缓存应用。

 我们可以先看看最简单的实现,自定义Adapter如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class CustomPagerAdapter extends FragmentPagerAdapter{

    private List<Fragment> mFragments;

    public CustomPagerAdapter(FragmentManager fm, List<Fragment> fragments) {
        super(fm);
        this.mFragments = fragments;
        fm.beginTransaction().commitAllowingStateLoss();
    }

    @Override
    public Fragment getItem(int position) {
        return this.mFragments.get(position);
    }

    @Override
    public int getCount() {
        return this.mFragments.size();
    }

    @Override
    public long getItemId(int position) {
        return position;
    }
}

 代码比较简单,就不解释了,接着在Activity中使用这个Adapter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ViewPager pager = (ViewPager) findViewById(R.id.view_pager);

List<Fragment> fragmentList = new ArrayList<>()
TestFragment fragmentOne = new TestFragment();
fragmentOne.setText("One");
TestFragment fragmentTwo = new TestFragment();
fragmentTwo.setText("Two");
TestFragment fragmentThree = new TestFragment();
fragmentThree.setText("Three");

fragmentList.add(fragmentOne);
fragmentList.add(fragmentTwo);
fragmentList.add(fragmentThree);

CustomPagerAdapter adapter = new CustomPagerAdapter(getSupportFragmentManager(), fragmentList);
pager.setAdapter(adapter);

 这样就完成了一个FragmentPagerAdapter最基本的应用。现在,看上去一切都如我们所愿,但是真的没有任何问题了吗?

 接下来,我们来模拟一下程序运行在后台时,Android系统由于内存紧张,杀掉我们程序进程的情况:

  1. 首先运行程序至前台
  2. 接下来,点击Home键,返回桌面,同时我们的程序退回至后台运行。
  3. 进入Android Studio中,点击Android Monitor这个tab,并选择当前Device,并选择我们程序的进程名。
  4. 点击Terminal Application这个小红叉按钮,如下图:
  5. 这个时候后台进程已经被杀掉了,但是应用程序历史里我们的应用还在,所以长按Home键,并选择我们的程序,让其恢复到前台。
  6. 这时会看到,程序的确恢复到之前的页面。但奇怪的是,页面上却只有Hello,我们之前传入的Two到哪里去了?

 Fragment代码也比较简单,通过日志,我们发现恢复时,mText字段为空。所以页面上对应的TextView无法显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class TestFragment extends Fragment {

    private String mText;

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_test, container, false);
        TextView textView = (TextView) view.findViewById(R.id.center_text_view);
        textView.setText(mText);
        return view;
    }

    public void setText(String text) {
        this.mText = text;
    }
}

 我们知道,以上是模拟Android系统内存紧张时,杀掉后台应用的流程。另外,当用户安装了类似360安全管家等应用,选择清理内存时,也会触发以上情况。

 那么当上面的流程发生时,Activity的onSaveInstanceState会被调用,以便我们可以保存当前的用户数据/页面状态等。当恢复时,在onCreate时,我们通过savedInstanceState参数,可以取到之前存储的数据,然后重新绑定到View上。

 这个过程都可以理解,可是回到我们的Activity代码当中:

1
2
3
4
5
6
7
8
9
10
11
12
TestFragment fragmentOne = new TestFragment();
fragmentOne.setText("One");
TestFragment fragmentTwo = new TestFragment();
fragmentTwo.setText("Two");
TestFragment fragmentThree = new TestFragment();
fragmentThree.setText("Three");
fragmentList.add(fragmentOne);
fragmentList.add(fragmentTwo);
fragmentList.add(fragmentThree);

CustomPagerAdapter adapter = new CustomPagerAdapter(getSupportFragmentManager(), fragmentList);
        pager.setAdapter(adapter);

 这段代码,是在onCreate方法中调用的。应用从后台恢复的时候,这段代码是被完整的执行过的。既然这样,三个Fragment都被重新创建过,并设置过对应的Text值,那么为什么Fragment中mText字段仍然为空呢?

 难道说,呈现在屏幕上的Fragment,和我们在onCreate中实例化的Fragment,已然不是同一个实例?

 为了验证这个想法,在OnCreate中加入下面的日志:

1
2
3
 TestFragment fragmentOne = new TestFragment();
 fragmentOne.setText("One");
 Log.i("test", "++++fragmentOne++++:" + fragmentOne.toString());

 同时在TestFragment的onCreateView方法中也记下日志:

1
Log.i("test", "++++current fragment++++:" + this.toString());

 第一次运行,恩,没有问题。创建和运行的都是同一个实例 534ed278

1
2
I/test: ++++fragmentOne++++:TestFragment{534ed278}
I/test: ++++current fragment++++:TestFragment{534ed278 #0 id=0x7f0c0066 android:switcher:2131492966:0}

 接下来,我们再次进行杀进程并恢复的过程。日志输出为:

1
2
I/test: ++++fragmentOne++++:TestFragment{534c5c30}
I/test: ++++current fragment++++:TestFragment{534d10d4 #0 id=0x7f0c0066 android:switcher:2131492966:0}

 额。。果然,这次我们创建的Fragment,和实际经过onCreateView的Fragment。并不是同一个(534c5c30/534d10d4)。

 看来,还是要从源码中寻求真相,打开FragmentPagerAdapter的源码,在instantiateItem方法中发现了下面这一段:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Do we already have this fragment?
String name = makeFragmentName(container.getId(), itemId);
Fragment fragment = mFragmentManager.findFragmentByTag(name);

if (fragment != null) {
    if (DEBUG) Log.v(TAG, "Attaching item #" + itemId + ": f=" + fragment);
    mCurTransaction.attach(fragment);
} else {
    fragment = getItem(position);
    if (DEBUG) Log.v(TAG, "Adding item #" + itemId + ": f=" + fragment);
    mCurTransaction.add(container.getId(), fragment,
            makeFragmentName(container.getId(), itemId));
}

 makeFragmentName方法如下:

1
2
3
private static String makeFragmentName(int viewId, long id) {
    return "android:switcher:" + viewId + ":" + id;
}

 原来,在实例化Fragment的时候,FragmentPagerAdapter会先通过makeFragmentName返回的tag,到FragmentManager当中进行查找是否有当前Fragment的缓存,如果有的话,就直接将之前的Fragment恢复回来并使用;反之,才会使用我们传入的新实例。

 而makeFragmentName产生的tag,只受我们重写的getItemId()方法返回值,和当前容器View的Id,container.getId()的影响。

 到这里,问题就清楚了,由于FragmentPagerAdapter会主动的去取缓存当中的Fragment,所以导致恢复回来之后,Fragment的实例不一样的问题。

 至于为什么mText字段为空,以及怎样解决这个情况,我们下一篇再来讨论^_^。

Android ListView中使用多种Type时,getItemViewType返回值不能为负数,特别是-2

背景

大家在Android开发中使用,常常由于业务的需求,会重写ListView的getItemViewType方法,来展示不同种类的Items;比如微博客户端,就有很多类型的Item(比如,普通微博,转发,推荐…)。 在公司项目的开发中,我们也采用了这种手段。但是没想到却意外的遇到了问题……

  其实遇到的问题也很简单, 假设我们有6种类型的item需要展示在ListView当中,我们会在Adapter中这样去写

1
2
3
4
5
6
    public static final int VIEW_TYPE_NEWS_SPORT = 1;
    public static final int VIEW_TYPE_NEWS_SOCIAL = 2;
    public static final int VIEW_TYPE_NEWS_POLITICS = 3;
    public static final int VIEW_TYPE_NEWS_SIGNIFICANT = -1;
    public static final int VIEW_TYPE_NEWS_LATEST = -2;
    public static final int VIEW_TYPE_NEWS_SUGGESTION = -3;

  上面我们在Adapter中定义好了6个常量,分别对应我们要展示的6种type。前三种type是我们和服务端约定好的,后面三种是客户端自己加入的,为了良好的扩展性,我们将后三种定义为负数,以免和以后服务端增加的type有所冲突。

1
2
3
4
5
6
7
8
9
    @Override
    public int getItemViewType(int position) {
        return getCurrentTypeByPosition([position]);
    }

    @Override
    public int getViewTypeCount() {
        return 6;
    }

  然后就是重写这两个方法,根据数据结构的特征,返回不同的type。

1
2
3
4
5
6
7
    @Override
    getView(View convertView){
       switch(type){
             case:
                .....
        }
    }

  接下来,当然是在getView中根据不同的类型,返回不同的View。然后代码基本就是这样,APP运行起来也符合我们的预期。目前看起来一切ok。

  不过,当ListView数据量大一点过后,我们发现了一个现象:每当ListView滚动到VIEW_TYPE_NEWS_POLITICS这种type的Item的时候,会有一点卡顿掉帧的现象。另外,我们的图片异步加载出来后,有一个渐变的动画,每当这种Item呈现出来时,只要手指放在屏幕上,轻轻移动,这个Item中的图片,就会频繁闪动。

  起初遇到这个Bug时,我的心情还是很淡定的。猜想是因为这个item的getView被频繁调用了多次呗,于是在getView方法中每种Type里都记下了日志,一运行,输出是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
==== VIEW_TYPE_NEWS_SPORT ====
==== VIEW_TYPE_NEWS_POLITICS ====
==== VIEW_TYPE_NEWS_LATEST ====
==== VIEW_TYPE_NEWS_LATEST ====
==== VIEW_TYPE_NEWS_LATEST ====
==== VIEW_TYPE_NEWS_LATEST ====
==== VIEW_TYPE_NEWS_LATEST ====
....
==== VIEW_TYPE_NEWS_LATEST ====
==== VIEW_TYPE_NEWS_SIGNIFICANT ====
==== VIEW_TYPE_NEWS_SOCIAL ====
==== VIEW_TYPE_NEWS_SUGGESTION ====

 果然!当Item为VIEW_TYPE_NEWS_LATEST这种type时,手指一放上屏幕,getView就会被调用了很多次!

 getView这种异常频繁的调用在之前也遇到过,不过通常是因为ListView的android:layout_height被设置成了wrap_content, 导致其需要在Layout时调用getView去计算自身高度导致的。改成match_parent或者固定值就好了。具体可以参考StackOverFlow上这个问题

  不过,这次似乎有点不同,我检查了ListView的android:layout_height,确实是match_parent。并且这次只有这一个Item类型出现这种状况,另外5种却完全正常。

  接下来,我分别尝试了改换VIEW_TYPE_NEWS_LATEST的布局文件,更换这种Item在列表中的位置。可这个问题却和这种Type如影随行,不管怎么改动,只要当前Item的Type为VIEW_TYPE_NEWS_LATEST,getView便会被调用个不停。

  此刻,我的心情是崩溃的TT

  冷静下来想了想,这个现象,既然和位置,布局都没有关系,唯一的关系大概也就只剩下VIEW_TYPE_NEWS_LATEST这个Type的值了吧?不管了,试一下,把换VIEW_TYPE_NEWS_LATEST改成了4。

  运行…

  …

  …

  居然好了!!!当手指放到这个type上的时候,一大堆getView的日志居然消失了!!!

  于是马上想到,这个特别的值 -2,一定在源码中有所应用吧。

 于是,马上打开ListView源码,搜索使用getItemViewType的地方,果然发现了如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
p.viewType = mAdapter.getItemViewType(position);

if ((recycled && !p.forceAdd) || (p.recycledHeaderFooter
        && p.viewType == AdapterView.ITEM_VIEW_TYPE_HEADER_OR_FOOTER)) {
    attachViewToParent(child, flowDown ? -1 : 0, p);
} else {
    p.forceAdd = false;
    if (p.viewType == AdapterView.ITEM_VIEW_TYPE_HEADER_OR_FOOTER) {
        p.recycledHeaderFooter = true;
    }
    addViewInLayout(child, flowDown ? -1 : 0, p, true);
}

 注意,AdapterView.ITEM_VIEW_TYPE_HEADER_OR_FOOTER 的值为-2,很简单,当item type为-2时,就把当前这种type当成了Header或者Footer。

 另外,看看ListView是怎么处理这种情况的,当其为Header或者Footer时,会调用attachViewToParent方法;而当前View为普通Item时,则调用addViewInLayout方法,并传入最后一个参数preventRequestLayout为true:

@param preventRequestLayout if true, calling this method will not trigger a layout request on child

 这里对Header/Footer与普通View处理方式的不同造成了这种情况。

 另外,除了-2之外,使用其他负数貌似也会使View无法成功的被缓存,源码如下:

1
2
3
4
5
6
7
8
    if (recycleOnMeasure() && mRecycler.shouldRecycleViewType(
                ((LayoutParams) child.getLayoutParams()).viewType)) {
            mRecycler.addScrapView(child, 0);
    }

    public boolean shouldRecycleViewType(int viewType) {
        return viewType >= 0;
    }

 最后,去看看文档上对getItemViewType的return写的什么:

Note: Integers must be in the range 0 to getViewTypeCount() - 1. IGNORE_ITEM_VIEW_TYPE can also be returned.

 话说,自己对文档和源码不熟悉,才导致了这次的问题;编写代码的时候还是要注意细节,要不很容易造成隐含的问题。不过话说回来,Android这API设计的,也太…