SharedElement and custom EnterTransition causes memory leak
Asked Answered
P

3

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;
    }
};
Poacher answered 21/9, 2015 at 14:40 Comment(0)
W
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();
    }
}
Winer answered 31/12, 2015 at 6:57 Comment(13)
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.Minstrelsy
@Minstrelsy I don't know about an open issue in the Android framework. If you want, you may do it.Winer
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 :(Lordling
@Minstrelsy Here's a link to the issue on the tracker. They said it will be fixed in Nougat: code.google.com/p/android/issues/detail?id=170469Grimy
Possibly the ugliest workaround I have ever found. There is no other way to solve this though :(Tychon
The only thing uglier is that I'm now using this solution AND fahmy's solution solution to plug the holes, since both fix different issues. Great hack, Delargo - sometimes "ugly" is the way we have to roll.Keelboat
No field sRunningTransitions in class Landroid/support/transition/TransitionManager;Subterranean
@mladj0ni my solution works with android.transition.TransitionManager class. Try to change imports.Winer
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.onTransitionEndSubterranean
@mladj0ni What version of android do you use?Winer
@mladj0ni The solution may do not work with transitions from support library. Check, that you use only built-in transitions.Winer
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....Lowdown
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.Lowdown
H
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.

Hillyer answered 3/5, 2016 at 18:17 Comment(2)
Each solution seems to fix a different leak, so I now have this code, and the workaround by Delargo in my app. Garbage collector is working correctly now.Keelboat
@RichardLeMesurier it still produces OOMNoncontributory
F
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.

Flagg answered 23/4, 2020 at 9:25 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.