Saturday, October 25, 2014

Nested Fragment with ChildFragmentManager lost state in rev20/rev21 of Android support library

After Android support library v4 rev 20 released, our team excitedly try to move on to it. But there was an annoying bug found - after screen rotation the inner nested fragment lost its state!

What we got was a ViewPager with several tabs in the nested fragment. All fragments setRetaininstanceState(true). On Android support lib v4 rev 19.+ everything worked fine. After we jumped on to rev 20, tabs don't display after rotation any more. This issue still exists in rev 21.0.0.

I started scratching my hair about this crazy issue until I found this was caused by the update of the android support library. If we revert back to use rev 19, everything went back fine. But we don't want to be stuck on rev19 forever as new version introduced many other important features.

After blood, sweat and tears, I finally found a work around if we really want to leap forward from support library v4 rev19. By diff of the source code of rev19 and rev20 I found what change results the problem. (the source files for different revisions can be found on your local drive under [AndroidSdkFolder]/extras/android/m2repository/com/android/support/support-v4). If you are interested, you can look into the source code. The change related to the bug is

after rev 20.0.0 there was new line at #1204 in android.support.v4.app.Fragment.java
  mChildFragmentManager = null;
added in method Fragment#initState().

As Fragment#initState() would be called internally by the lib every time the fragment is created regardless it's newly created or recreated, retain instance state or not. In the life cycles of a fragment, if the lib found mChildFragmentManager is not null it will dispatch events to it but as you can see mChildFragmentManager is reset null in rev20, so nothing would happen to the previous child fragment manager after rotation.

Solution:
Ideally, I hope android team could fix this issue ASAP. But before that there is a work around I found work. The idea is we retain the child fragment manager ourselves! To do so, we need to do the below trick to all nested fragments.

1. Create a field in the fragment to keep the child fragment manager created by the lib. As we set retain instance true, the reference held by the field will be kept after the rotation.

2. Before the lib dispatch the life cycle events of the the recreated fragment, we need to set its previous mChildFragmentManger retained by step 1 back to the new created fragment. The hook point is onAttachActivity(). As there is no public accessor we have to use reflection to set the mChildFragmentManger in the fragment. Caveats: reflection may cause problem for the future version because the field name may change.

3. We need to replace the destroyed activity attached to the fragment manager and existing fragments by the latest recreated activity.

Sample code:
public class NestingFragment extends Fragment {
    //...other codes

    private FragmentManager retainedChildFragmentManager;
    private FragmentHostCallback currentHost;
    private Class fragmentImplClass;
    private Field mHostField;

    {
        //Prepare the reflections to manage hiden fileds
        try {
            fragmentImplClass = Class.forName("android.support.v4.app.FragmentManagerImpl");
            mHostField = fragmentImplClass.getDeclaredField("mHost");
            mHostField.setAccessible(true);
        } catch (ClassNotFoundException e) {
            throw new RuntimeException("FragmentManagerImpl is renamed due to the " +
                    "change of Android SDK, this workaround doesn't work any more. " +
                    "See the issue at " +
                    "https://code.google.com/p/android/issues/detail?id=74222", e);
        } catch (NoSuchFieldException e) {
            throw new RuntimeException("FragmentManagerImpl.mHost is found due to the " +
                    "change of Android SDK, this workaround doesn't work any more. " +
                    "See the issue at " +
                    "https://code.google.com/p/android/issues/detail?id=74222", e);
        }
    }

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

    protected FragmentManager childFragmentManager() {
        if (retainedChildFragmentManager == null) {
            retainedChildFragmentManager = getChildFragmentManager();
        }
        return retainedChildFragmentManager;
    }

    @Override
    public void onAttach(Context context) {
        super.onAttach(context);
        if (retainedChildFragmentManager != null) {
            //restore the last retained child fragment manager to the new
            //created fragment
            try {
                //Copy the mHost(Activity) to retainedChildFragmentManager
                currentHost = (FragmentHostCallback) mHostField.get(getFragmentManager());

                Field childFMField = Fragment.class.getDeclaredField("mChildFragmentManager");
                childFMField.setAccessible(true);
                childFMField.set(this, retainedChildFragmentManager);

                refreshHosts(getFragmentManager());
            } catch (Exception e) {
                logger.warn(e.getMessage(), e);
            }
            //Refresh children fragment's hosts
        } else {
            //If the child fragment manager has not been retained yet, let it hold the internal
            //child fragment manager as early as possible. This can prevent child fragment
            //manager from missing to be set and then retained, which could happen when
            //OS kills activity and restarts it. In this case, the delegate fragment restored
            //but childFragmentManager() may not be called so mRetainedChildFragmentManager is
            //yet set. If the fragment is rotated, the state of child fragment manager will be
            //lost since mRetainedChildFragmentManager hasn't set to be retained by the OS.
            retainedChildFragmentManager = getChildFragmentManager();
        }
    }

    private void refreshHosts(FragmentManager fragmentManager) throws IllegalAccessException {
        if (fragmentManager != null) {
            replaceFragmentManagerHost(fragmentManager);
        }

        //replace host(activity) of fragments already added
        List frags = fragmentManager.getFragments();
        if (frags != null) {
            for (Fragment f : frags) {
                if (f != null) {
                    try {
                        //Copy the mHost(Activity) to retainedChildFragmentManager
                        Field mHostField = Fragment.class.getDeclaredField("mHost");
                        mHostField.setAccessible(true);
                        mHostField.set(f, currentHost);
                    } catch (Exception e) {
                        logger.warn(e.getMessage(), e);
                    }
                    if (f.getChildFragmentManager() != null) {
                        refreshHosts(f.getChildFragmentManager());
                    }
                }
            }
        }
    }

    //replace host(activity) of the fragment manager so that new fragments it creates will be attached
    //with correct activity
    private void replaceFragmentManagerHost(FragmentManager fragmentManager) throws IllegalAccessException {
        if (currentHost != null) {
            mHostField.set(fragmentManager, currentHost);
        }
    }

    //...other codes
}