FYI: The question is short, but just in case I added more information at the end that may be relevant.
I needed an infinite scrolling ViewPager2, and I wanted to reuse a Fragment from the project as it was already designed and calls already well stablished with its viewLifeCycle.. Also I am aware the VP recycles offscreen Fragments (1 offset position from Fragment shown) and has at least up to 3 Fragments at any given moment, so using Fragments was the choice.
The problem is that when going to the fourth page, the ViewPager2 tries to remove the first Fragment (as expected) and LeakCanary shows me this (Entire diagnosis at the end.):
D/LeakCanary: Watching instance of androidx.core.widget.NestedScrollView (com.****.****.ui.***.pages.add_element.SearchPageFragment2 received Fragment#onDestroyView() callback (references to its views should be cleared to prevent leaks)) with key 294cd9eb-3d6a-4c98-a69c-5d20e4c1652f
The diagnosis never points to my references, only to android library references.
Before the code bellow I had more lines, But I've been trimming them until kept with the bare minimum and the leak is still there.
// ----- onViewCreated() ------
MyPagerAdapter mPa = new MyPagerAdapter(
getChildFragmentManager(),
getViewLifecycleOwner().getLifecycle()
);
vp.setAdapter(mPa);
MyPagerAdapter.class that extends FragmentStateAdapter:
@NonNull
@Override
public Fragment createFragment(int position) {
return new SearchPageFragment2(); //Test Fragment
}
@Override
public int getItemCount() {
return 8; //Test fixed number
}
The Leaking Fragment:
public class SearchPageFragment2 extends Fragment {
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return FragmentSearchPageBinding.inflate(inflater).getRoot();
}
}
What is causing the memory leak??
The question ENDS HERE.
Preamble...
The main View (the farthest Fragment ancestor at display while the leak is happening) is a BackStackEntry that we navigate to, from the Home Fragment, this View holds a toolBar with main info about the app, bellow the toolBar is the main content of this view, a ViewPager2 of fixed size with 3 Fragments, and on the first Fragment... a "MutableFrameLayout" that I created:
private final MutableFrameLayoutAdapter<ElementDBLoaderViewModel.FrameActions> adapter = new MutableFrameLayoutAdapter<>(
this, //Fragment owner
this::getChildFragmentManager, //FragmentManager supplier
() -> ElementDBLoaderViewModel.FrameActions.initiating, //initialValue
action -> { //Function<X, Fragment>
switch (action) {
case crossed:
return new AddElementExpandedFragment();
case not_crossed:
return new AddElementFragment();
case explore:
return new MainDBPaginationFragment2();
}
return null;
}
);
So that it can be used like this:
binding.fragmentContainer.setAdapter(adapter);
adapter.changeContent(ElementDBLoaderViewModel.FrameActions.crossed)
The component is leak proof, with hours of testing on different situations.
The main "engine" of this component:
......
if (oldFragment != null) {
FragmentTransaction ft = stackFm.beginTransaction();
ft.remove(oldFragment);
addCommit(ft, newFragment);
}
Where "stackFm" is the result of having acquired the FragmentManager supplier in the constructor with childFragmentManager.get()
("this::getChildFragmentManager
")
......
private void addCommit(FragmentTransaction ft, Fragment newFragment) {
fragmentCreated.get().fragmentCreated(newFragment); //stateless adapter interface reference
ft.add(getId(), newFragment);
ft.commit();
}
......
The idea was to have a component easy to use with nothing fancy and straight forward.
Basically the first page (Fragment) of the ViewPager2 of fixed size where this MutableFrameLayout is placed, can take the form of 3 different Fragments (depending on DB size).
The Leaking ViewPager2 is inside MainDBPaginationFragment2.class
, BUT BEFORE arriving to the MainDBPaginationFragment2 Fragment, we MUST first go through the AddElementExpandedFragment.class
.
LEAK DIAGNOSIS (none of the references are mine)
┬───
│ GC Root: System class
│
├─ android.view.WindowManagerGlobal class
│ Leaking: NO (DecorView↓ is not leaking and a class is never leaking)
│ ↓ static WindowManagerGlobal.sDefaultWindowManager
├─ android.view.WindowManagerGlobal instance
│ Leaking: NO (DecorView↓ is not leaking)
│ ↓ WindowManagerGlobal.mViews
├─ java.util.ArrayList instance
│ Leaking: NO (DecorView↓ is not leaking)
│ ↓ ArrayList.elementData
├─ java.lang.Object[] array
│ Leaking: NO (DecorView↓ is not leaking)
│ ↓ Object[].[0]
├─ com.android.internal.policy.DecorView instance
│ Leaking: NO (ViewPager2$RecyclerViewImpl↓ is not leaking and View attached)
│ View is part of a window view hierarchy
│ View.mAttachInfo is not null (view attached)
│ View.mWindowAttachCount = 1
│ mContext instance of com.android.internal.policy.DecorContext, wrapping
│ activity com.****.****.ui.MainActivity with mDestroyed = false
│ ↓ DecorView.mAttachInfo
├─ android.view.View$AttachInfo instance
│ Leaking: NO (ViewPager2$RecyclerViewImpl↓ is not leaking)
│ ↓ View$AttachInfo.mScrollContainers
├─ java.util.ArrayList instance
│ Leaking: NO (ViewPager2$RecyclerViewImpl↓ is not leaking)
│ ↓ ArrayList.elementData
├─ java.lang.Object[] array
│ Leaking: NO (ViewPager2$RecyclerViewImpl↓ is not leaking)
│ ↓ Object[].[2]
├─ androidx.viewpager2.widget.ViewPager2$RecyclerViewImpl instance
│ Leaking: NO (View attached)
│ View is part of a window view hierarchy
│ View.mAttachInfo is not null (view attached)
│ View.mID = R.id.null
│ View.mWindowAttachCount = 1
│ mContext instance of com.****.****.ui.MainActivity with
│ mDestroyed = false
│ ↓ ViewPager2$RecyclerViewImpl.mRecycler
│ ~~~
├─ androidx.recyclerview.widget.RecyclerView$Recycler instance
│ Leaking: UNKNOWN
│ Retaining 48453 bytes in 442 objects
│ ↓ RecyclerView$Recycler.mRecyclerPool
│ ~~~~~
├─ androidx.recyclerview.widget.RecyclerView$RecycledViewPool instance
│ Leaking: UNKNOWN
│ Retaining 46192 bytes in 424 objects
│ ↓ RecyclerView$RecycledViewPool.mScrap
│ ~~
├─ android.util.SparseArray instance
│ Leaking: UNKNOWN
│ Retaining 46176 bytes in 423 objects
│ ↓ SparseArray.mValues
│ ~~~
├─ java.lang.Object[] array
│ Leaking: UNKNOWN
│ Retaining 46111 bytes in 421 objects
│ ↓ Object[].[0]
│ ~
├─ androidx.recyclerview.widget.RecyclerView$RecycledViewPool$ScrapData instance
│ Leaking: UNKNOWN
│ Retaining 46067 bytes in 420 objects
│ ↓ RecyclerView$RecycledViewPool$ScrapData.mScrapHeap
│ ~~~~
├─ java.util.ArrayList instance
│ Leaking: UNKNOWN
│ Retaining 46035 bytes in 419 objects
│ ↓ ArrayList.elementData
│ ~~~~~
├─ java.lang.Object[] array
│ Leaking: UNKNOWN
│ Retaining 46015 bytes in 418 objects
│ ↓ Object[].[0]
│ ~
├─ androidx.viewpager2.adapter.FragmentViewHolder instance
│ Leaking: UNKNOWN
│ Retaining 43762 bytes in 400 objects
│ ↓ FragmentViewHolder.itemView
│ ~~~~
├─ android.widget.FrameLayout instance
│ Leaking: UNKNOWN
│ Retaining 43677 bytes in 399 objects
│ View not part of a window view hierarchy
│ View.mAttachInfo is null (view detached)
│ View.mID = R.id.null
│ View.mWindowAttachCount = 1
│ mContext instance of com.****.****.ui.MainActivity with
│ mDestroyed = false
│ ↓ FrameLayout.mMatchParentChildren
│ ~~~~~~~~
├─ java.util.ArrayList instance
│ Leaking: UNKNOWN
│ Retaining 41573 bytes in 385 objects
│ ↓ ArrayList.elementData
│ ~~~~~
├─ java.lang.Object[] array
│ Leaking: UNKNOWN
│ Retaining 41553 bytes in 384 objects
│ ↓ Object[].[0]
│ ~
╰→ androidx.core.widget.NestedScrollView instance
Leaking: YES (ObjectWatcher was watching this because com.****.
****.ui.****.pages.add_element.SearchPageFragment2
received Fragment#onDestroyView() callback (references to its views
should be cleared to prevent leaks))
Retaining 41549 bytes in 383 objects
key = 294cd9eb-3d6a-4c98-a69c-5d20e4c1652f
watchDurationMillis = 7979
retainedDurationMillis = 2978
View not part of a window view hierarchy
View.mAttachInfo is null (view detached)
View.mID = R.id.scrolling_content_table
View.mWindowAttachCount = 1
mContext instance of com.****.****.ui.MainActivity with
mDestroyed = false
The solution for me at least was stop using the FragmentStateAdapter, and instead use a RecyclerViewAdapter, and submit to this adapter, a list of integers representing the index of each page (needed for db live connection), while at the same time determining the size of the batch of pages (given by the initial DB request).
Finally, on the view creation of each viewHolder, the content of each page was fed including resolved viewmodel contents.
A vital AND hardest part of the solution was granting each viewHolder its own lifeCycle, so that each could manage its db connection on its own.
As you can guess the code is horrendous, and even if I tried obsessively to order it the best I could, it is still not sufficiently organized IMO.
My interpretation of this is that doing pagination on your own is absolutely nightmarish... but possible.
Also encapsulating pagination functions such as page controls, db requests and display, make it so that the entire "concept" of pagination itself becomes a single unit on its own, making it absolutely difficult to separate each component on its own capsule as the function of every component is deeply interconnected with its consumer.
One example is the way your DB queries assume "pivot points" (whether they are inclusive or not, or maybe they are not even required at all and you can randomly jump at any page(index) you want), I am not sufficiently familiar with other db but if this changes enough across services, one may even need to change how the entire pagination works, bottom - up.
The components that could definitely be encapsulated are: the adapter, the "scroller" of your data, that will define the number of pages and page position while defining page control functions AND a viewmodel that will simply serve as an envelope to the scroller itself, but that's it. the actual display needs to be interwoven with all these components in the most savage way possible. and your scroller must have multiple sources of input/output (db input: local and remote, user input (page controller), graphical outputs: pages and page controller)