9

Having a shared element animation and also a custom enter animation causes the activity to leak.

Any idea what might be the cause?

09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * com.feeln.android.activity.MovieDetailActivity has leaked: 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * GC ROOT android.app.ActivityThread$ApplicationThread.this$0 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * references android.app.ActivityThread.mActivities 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * references android.util.ArrayMap.mArray 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * references array java.lang.Object[].[1] 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * references android.app.ActivityThread$ActivityClientRecord.activity 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * references com.feeln.android.activity.MovieDetailActivity.mActivityTransitionState 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * references android.app.ActivityTransitionState.mEnterTransitionCoordinator 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * references android.app.EnterTransitionCoordinator.mEnterViewsTransition 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * references android.transition.TransitionSet.mParent 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * references android.transition.TransitionSet.mListeners 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * references java.util.ArrayList.array 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * references array java.lang.Object[].[1] 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * references android.transition.TransitionManager$MultiListener$1.val$runningTransitions (anonymous class extends android.transition.Transition$TransitionListenerAdapter) 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * references android.util.ArrayMap.mArray 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * references array java.lang.Object[].[2] 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * references com.android.internal.policy.impl.PhoneWindow$DecorView.mContext 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * leaks com.feeln.android.activity.MovieDetailActivity instance 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ [ 09-21 16:19:31.007 28269:31066 D/LeakCanary ] * Reference Key: af2b6234-297e-4bab-96e9-02f1c4bca171 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * Device: LGE google Nexus 5 hammerhead 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * Android Version: 5.1.1 API: 22 LeakCanary: 1.3.1 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ * Durations: watch=6785ms, gc=262ms, heap dump=8553ms, analysis=33741ms 09-21 16:19:31.007 28269-31066/com.sample.android D/LeakCanary﹕ [ 09-21 16:19:31.007 28269:31066 D/LeakCanary ]

To reproduce you need to have a big shared image animation and also a custom EnterAnimation and setEnterSharedElementCallback . All this are from the support library.

Here is how i set the EnterTransition:

private SharedElementCallback mCallback = new SharedElementCallback() {
    @Override
    public void onSharedElementStart(List<String> sharedElementNames, List<View> sharedElements, List<View> sharedElementSnapshots) {
        if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
        {
            if(sharedElements.size()>0)
                getWindow().setEnterTransition(makeEnterTransition(getWindow().getEnterTransition(), getSharedElement(sharedElements)));
        }
    }


    private View getSharedElement(List<View> sharedElements)
    {
        for (final View view : sharedElements)
        {
            if (view instanceof ImageView)
            {
                return view;
            }
        }
        return null;
    }
};
Olsi Saqe
  • 383
  • 5
  • 12

3 Answers3

19

Case of leaks lies in TransitionManager.sRunningTransitions where each DecorView adds and never removes. DecorView has link to his Activity's Context. Because of sRunningTransitions is static field, it has permanent chain of references to Activity, which will never be collected by GC.

I don't known why TransitionManager.sRunningTransitions needs, but if you remove Activity's DecorView from it, your problem will be solved. Follow code is example, how do it. In your activity class:

@Override
protected void onDestroy() {
    super.onDestroy();
    removeActivityFromTransitionManager(Activity activity);
}

private static void removeActivityFromTransitionManager(Activity activity) {
    if (Build.VERSION.SDK_INT < 21) {
        return;
    }
    Class transitionManagerClass = TransitionManager.class;
    try {
        Field runningTransitionsField = transitionManagerClass.getDeclaredField("sRunningTransitions");
            runningTransitionsField.setAccessible(true);
        //noinspection unchecked
        ThreadLocal<WeakReference<ArrayMap<ViewGroup, ArrayList<Transition>>>> runningTransitions
                = (ThreadLocal<WeakReference<ArrayMap<ViewGroup, ArrayList<Transition>>>>)
                runningTransitionsField.get(transitionManagerClass);
        if (runningTransitions.get() == null || runningTransitions.get().get() == null) {
            return;
        }
        ArrayMap map = runningTransitions.get().get();
        View decorView = activity.getWindow().getDecorView();
        if (map.containsKey(decorView)) {
            map.remove(decorView);
        }
    } catch (NoSuchFieldException e) {
        e.printStackTrace();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    }
}
Sergei Vasilenko
  • 2,313
  • 2
  • 27
  • 38
  • Is there an open issue in the Android framework about this? I'm having the same issue but I couldn't find any mention of this anywhere else. – fast3r Feb 25 '16 at 11:05
  • @fast3r I don't know about an open issue in the Android framework. If you want, you may do it. – Sergei Vasilenko Feb 26 '16 at 13:23
  • It works for me. Not sure why this is required. I wonder if I'm doing something wrong. I'm having the same leak on 2 different apps :( – Albert Vila Calvo Mar 28 '16 at 19:13
  • 2
    @fast3r Here's a link to the issue on the tracker. They said it will be fixed in Nougat: https://code.google.com/p/android/issues/detail?id=170469 – Jarett Millard Jul 07 '16 at 14:27
  • Possibly the ugliest workaround I have ever found. There is no other way to solve this though :( – BamsBamx Mar 02 '17 at 08:47
  • 1
    The only thing uglier is that I'm now using this solution AND [fahmy's solution](http://stackoverflow.com/a/37011524/383414) solution to plug the holes, since both fix different issues. Great hack, Delargo - sometimes "ugly" is the way we have to roll. – Richard Le Mesurier Mar 09 '17 at 13:29
  • No field sRunningTransitions in class Landroid/support/transition/TransitionManager; – Mladen Rakonjac Jun 13 '17 at 07:09
  • @mladj0ni my solution works with android.transition.TransitionManager class. Try to change imports. – Sergei Vasilenko Jun 13 '17 at 19:39
  • 4
    Thanks for answer. Now I am getting this error: Attempt to invoke virtual method 'boolean java.util.ArrayList.remove(java.lang.Object)' on a null object reference at android.transition.TransitionManager$MultiListener$1.onTransitionEnd – Mladen Rakonjac Jun 14 '17 at 08:45
  • @mladj0ni What version of android do you use? – Sergei Vasilenko Jun 14 '17 at 10:47
  • @mladj0ni The solution may do not work with transitions from support library. Check, that you use only built-in transitions. – Sergei Vasilenko Jun 14 '17 at 10:57
  • 2
    This solution worked for me, but it is really not working when you need to deal with orientation changes and shared element transactions. Unfortunately it's crashing the app with a crash mentioned above in the comments.... – Alex Nov 21 '17 at 13:46
  • I don't think so. As I thought about it, it works as expected since on rotation the activity is destroyed and recreated. I had a base activity where i called your method, so i just removed the base activty from that specific class where i need to have orientation change. Gladly on other screens the orientation is locked to portrait. – Alex Nov 21 '17 at 18:06
6

The solution by @Delargo did not work for me. However, I stumbled upon this solution on Android issue tracker that did finally work for me.

The idea is to use the following class (aptly named LeakFreeSupportSharedElementCallback, subclassed from the SharedElementCallback) in activities that are using the activity transitions. Just copy the entire class to your project.

  1. LeakFreeSupportSharedElementCallback

You'll also need the static methods createDrawableBitmap(Drawable) and createViewBitmap(View, Matrix, RectF) from the following class. These are used by the LeakFreeSupportSharedElementCallback class.

  1. TransitionUtils

After you've got the the LeakFreeSupportSharedElementCallback class setup add the following to the activities that uses activity transition framework:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        setEnterSharedElementCallback(new LeakFreeSupportSharedElementCallback());
        setExitSharedElementCallback(new LeakFreeSupportSharedElementCallback());
}

With that memory was being freed up by GC after the transition animations.

fahmy
  • 3,543
  • 31
  • 47
0

Sergei Vasilenko's solution in tandem with fahmy's seems to work the best for me, but the former does introduce the crash Mladen Rakonjac mentioned:

Attempt to invoke virtual method 'boolean java.util.ArrayList.remove(java.lang.Object)' on a null object reference
android.transition.TransitionManager$MultiListener$1.onTransitionEnd (TransitionManager.java:306)

This happens because under the hood there's a TransitionListener in the TransitionManager that tries to access the list of running transitions by using the DecorView as key. But since the hack removes the DecorView and some part of this transition process is asynchronous, plus that the listener is not expecting null answers, sometimes it will result in a crash here:

mTransition.addListener(new TransitionListenerAdapter() {
    @Override
    public void onTransitionEnd(Transition transition) {
        ArrayList<Transition> currentTransitions =
                   runningTransitions.get(mSceneRoot); //"mSceneRoot" is basically the DecorView
            currentTransitions.remove(transition); //This line crashes, because "currentTransitions" is null
            transition.removeListener(this);
        }
    });

To fix that, I made the following changes to the workaround:

fun AppCompatActivity.removeActivityFromTransitionManager() {
    if (Build.VERSION.SDK_INT < 21) {
        return;
    }
    val transitionManagerClass: Class<*> = TransitionManager::class.java
    try {
        val runningTransitionsField: Field =
            transitionManagerClass.getDeclaredField("sRunningTransitions")
        runningTransitionsField.isAccessible = true
        @Suppress("UNCHECKED_CAST")
        val runningTransitions: ThreadLocal<WeakReference<ArrayMap<ViewGroup, ArrayList<Transition>>>?> =
            runningTransitionsField.get(transitionManagerClass) as ThreadLocal<WeakReference<ArrayMap<ViewGroup, ArrayList<Transition>>>?>
        if (runningTransitions.get() == null || runningTransitions.get()?.get() == null) {
            return
        }
        val map: ArrayMap<ViewGroup, ArrayList<Transition>> =
            runningTransitions.get()?.get() as ArrayMap<ViewGroup, ArrayList<Transition>>
        map[window.decorView]?.let { transitionList ->
            transitionList.forEach { transition ->
                //Add a listener to all transitions. The last one to finish will remove the decor view:
                transition.addListener(object : Transition.TransitionListener {
                    override fun onTransitionEnd(transition: Transition) {
                        //When a transition is finished, it gets removed from the transition list
                        // internally right before this callback. Remove the decor view only when
                        // all the transitions related to it are done:
                        if (transitionList.isEmpty()) {
                            map.remove(window.decorView)
                        }
                        transition.removeListener(this)
                    }

                    override fun onTransitionCancel(transition: Transition?) {}
                    override fun onTransitionPause(transition: Transition?) {}
                    override fun onTransitionResume(transition: Transition?) {}
                    override fun onTransitionStart(transition: Transition?) {}
                })
            }
            //If there are no active transitions, just remove the decor view immediately:
            if (transitionList.isEmpty()) {
                map.remove(window.decorView)
            }
        }
    } catch (_: Throwable) {}
}

So basically my fix is does the following:

  1. Check if there are transitions running related to the DecorView. If no, then remove the DecorView immediately.
  2. If yes, add a TransitionListener to all the transitions related to the DecorView. When each transition ends, these listeners check if they were the last transition to finish, and if yes, they will remove the DecorView. This approach makes the DecorView available to racing transitions, but makes sure that at the end it will get removed.

Now, I did not confirm if this solves the crash related to orientation changes, but I'm cautiously optimistic that it does.

Jeopardy
  • 179
  • 1
  • 4