Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

getMvpDelegate().onDestroy() not called in MvpAppCompatFragment in some cases #95

Open
september669 opened this issue Mar 13, 2020 · 9 comments
Labels
bug Something isn't working

Comments

@september669
Copy link

For reproducing
State 1. Activity -> FullScreenFragment1
Then run

        fragmentTransaction
            .replace(fragmentContainerId, FullScreenFragment2, screen.screenKey)
            .addToBackStack(screen.screenKey)
            .commit()

State 2. Activity -> FullScreenFragment1 -> FullScreenFragment2
Collapse the app and restore it.
Then remove all fragments and add another.

Log for FullScreenFragment1

        Start FullScreenFragment1

FullScreenFragment1.onAttach()
FullScreenFragment1.providePresenter()
FullScreenFragment1.onCreateView()
FullScreenFragment1.onViewCreated()
FullScreenFragment1.onStart()
FullScreenFragment1.onResume()

        Start FullScreenFragment2

FullScreenFragment1.onPause()
FullScreenFragment1.onStop()
FullScreenFragment1.onDestroyView()

        Hide

FullScreenFragment1.onSaveInstanceState() isStateSaved = true

        Restore

nothing called for  FullScreenFragment1

        Go away

FullScreenFragment1.onDestroy() isStateSaved == true
FullScreenFragment1.onDetach()

As a result getMvpDelegate().onDestroy() will not be called for FullScreenFragment1
This is because onStart() not called for fragments in backstack and variable isStateSaved will be true.

Possible solution is the using activity.isChangingConfigurations() instead of isStateSaved

@alaershov alaershov added the bug Something isn't working label Mar 13, 2020
@bejibx
Copy link

bejibx commented Oct 2, 2020

Can you please specify reproduction steps a bit more clearly? I can't quite understand how exactly should I "remove all fragments and add another" to reproduce this issue.

@bomiyr
Copy link

bomiyr commented Oct 5, 2020

Seems I have similar issue, but with different flow. You just need to open activity for result and change fragments in onActivityResult when it's closed.

Simple project to reproduce the issue: https://drive.google.com/file/d/1FXbj1F0norFwjzyMM-z8MaSkmkg20ZAU/view?usp=sharing

@september669
Copy link
Author

Can you please specify reproduction steps a bit more clearly? I can't quite understand how exactly should I "remove all fragments and add another" to reproduce this issue.

For reproduce you need clear backstack in fragment manager, e.g.

fragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE);

Now i use this implementation (mixed with Mosby's solution) :

open class MvpAppCompatFragment : Fragment, MvpDelegateHolder {
...

    override fun onDestroy() {
        super.onDestroy()

        //We leave the screen and respectively all fragments will be destroyed
        if (requireActivity().isFinishing) {
            getMvpDelegate().onDestroy()
            return
        }

        // When we rotate device isRemoving() return true for fragment placed in backstack
        // http://stackoverflow.com/questions/34649126/fragment-back-stack-and-isremoving
        if (activity?.isChangingConfigurations == true) {
            return
        }

        var anyParentIsRemoving = false
        var parent = parentFragment
        while (!anyParentIsRemoving && parent != null) {
            anyParentIsRemoving = parent.isRemoving
            parent = parent.parentFragment
        }

        if (!isInBackStackAndroidX()) {
            getMvpDelegate().onDestroy()
        }
    }

    override fun getMvpDelegate(): MvpDelegate<*> {
        if (mvpDelegate == null) {
            mvpDelegate = MvpDelegate(this)
        }

        return mvpDelegate as MvpDelegate<out MvpAppCompatFragment>
    }
}

fun Fragment.isInBackStackAndroidX(): Boolean {
/*
    // see https://github.com/sockeqwe/mosby/blob/6edf7f3674013b57bde3fb11ae139a30680286e3/utils-fragment/src/main/java/android/support/v4/app/BackstackAccessor.java
    // and https://github.com/sockeqwe/mosby/blob/master/mvi/src/main/java/com/hannesdorfmann/mosby3/FragmentMviDelegateImpl.java
    // and this https://github.com/grandcentrix/ThirtyInch/issues/206
    val writer = StringWriter()
    dump("", null, PrintWriter(writer), null)
    val dump: String = writer.toString()
    return !dump.contains("mBackStackNesting=0")
*/

    //  see https://github.com/grandcentrix/ThirtyInch/pull/205/commits/aaea0a2994dda83876d360fef593a8c8a4df0639
    return try {
        val backStackNestingField: Field = Fragment::class.java.getDeclaredField("mBackStackNesting")
        backStackNestingField.isAccessible = true
        val backStackNesting: Int = backStackNestingField.getInt(this)
        backStackNesting > 0
    } catch (e: NoSuchFieldException) {
        throw RuntimeException(e)
    } catch (e: IllegalAccessException) {
        throw RuntimeException(e)
    }
}

@bejibx
Copy link

bejibx commented Oct 6, 2020

Thank you for clarification! I'll look at this issue soon.

@bejibx
Copy link

bejibx commented Oct 6, 2020

So yeah, it is definitely a problem. I kinda want to stop using those hacky solutions but it seems there is no official way to check if fragment is removing finally or temporary. I have another idea though. What if for every MvpFragment we add some retained child fragment so we could use its onDestroy lifecycle hook to know if our parent fragment is removing completely? I checked this approach in your scenario and looks like it's working fine. I should check it in another cases.

@xanderblinov @alaershov @aasitnikov @senneco what do you think about this approach?

@alaershov
Copy link
Member

There kinda is a way, take a look at how the viewModelStore is implemented in JetPack ViewModel. There is a check, something like activity.isFinishing && !fragment.isChangingConfigurations.
The only problem with this check is that it does not handle "Don't keep activities" flag. Like you have an Activity, go forward, go back, and ViewModel is recreated.
This is not so bad, because it's a developer flag, and official doc states that this behavior never actually happens in production. But some hacky vendors enable this flag by default, and break apps.
So we have 2 options I'm aware of: ignore the problem with "Don't keep activities" and behave like ViewModels, or keep using our hacky solution.

@bejibx
Copy link

bejibx commented Oct 7, 2020

So it seems for me we have 3 options to fix this:

  1. Reset isStateSaved flag for fragments in backstack. It seems like when you hide and show application no callbacks are called so we have to reset this flag from outside component. It seems like Activity#onStart() might be a good place for this. There is another problem though - getting fragments from FragmentManager backstack is not an easy task. Only solution that I found require reflection:
override fun onStart() {
    super.onStart()
    try {
        val activeFragmentMethod = FragmentManager::class.java.getDeclaredMethod("getActiveFragments")
        activeFragmentMethod.isAccessible = true
        @Suppress("UNCHECKED_CAST")
        val activeFragments = activeFragmentMethod.invoke(supportFragmentManager) as List<Fragment>
        activeFragments.forEach {
            (it as? MvpFragment)?.resetSavedStateFlag()
        }
    } catch (t: Throwable) {
        Log.w(javaClass.simpleName, t)
    }
}

Obvious drawbacks are - already hacky solution became even more hacky, reflection usage (which is slow on Android), usage of private api which may change unpredictably and also increased difficulty to properly setup library for users when they are not using our base containers.

  1. Try to solve initial problem (incorrect isRemoving flag) with another approach. Looks like instead of using isStateSaved flag we can use following check:
override fun onDestroy() {
    super.onDestroy()

    if (activity?.isFinishing == true) {
        mvpDelegate.onDestroy()
        return
    }

    if (activity?.isChangingConfigurations == true && isInBackStack) {
        return
    }

    if (isRemoving) {
        mvpDelegate.onDestroy()
        return
    }
    
    val isAnyParentRemoving = generateSequence(parentFragment) { parentFragment }
            .any { it.isRemoving }
    if (isAnyParentRemoving) {
        mvpDelegate.onDestroy()
    }
}

private val isInBackStack: Boolean
    get() {
        return try {
            val backStackNestingField = Fragment::class.java.getDeclaredField("mBackStackNesting")
            backStackNestingField.isAccessible = true
            val backStackNesting: Int = backStackNestingField.getInt(this)
            backStackNesting > 0
        } catch (t: Throwable) {
            false
        }
    }

Again, very hacky solution, usage of private api, reflection, but not that different from current one in terms of setup difficulty. Actually in this case we can get rid of isStateSaved flag and move onDestroy logic to some utility class which will simplify library setup for users (no need to copy-paste this logic from MvpFragment).

  1. Stop Don't keep Activities support. I mean, if even android architecture components don't support this, why should we keep all this hacky stuff in our library? Yeah, I understand we cannot just change this behavior because many library users may rely on it, but maybe we can add an option for users to decide which one to use?

@bejibx
Copy link

bejibx commented Nov 25, 2020

So we had some internal discussion about this issue and also I've done some research which led to creating new issue in androidx.fragment library. Basically android fragments using mBackStackNesting field to identify if fragment is finishing or not. Here is relevant line where you can see this. This approach tho' have one issue - mBackStackNesting is > 0 even if we add our fragment after state save which leads to some unpleasant memory leaks.

It seems to me that checking if fragment saves its state before onDestroy is the only way to be sure if fragment is finishing or not, so I'll stick with option 1 for now. I also have some ideas how to make MvpDelegate handling easier, maybe even without need to extend MvpFragment's.

@AlexeyKorshun
Copy link

Hi, I faced with similar issue when I use ViewPager2 with FragmentStateAdapter. When we leave screen with ViewPager inner presenters for fragments(pages) are not destroying. FragmentStateAdapter use removeFragment in this method mFragmentManager.saveFragmentInstanceState is called, and after it mFragmentManager.beginTransaction().remove(fragment).commitNow();
For this case moxy doesn't call this.getMvpDelegate().onDestroy(); because isStateSaved is true. But I think it isn't a moxy problem, it is for your information.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

5 participants