Memory Leak in React Navigation on Android
Asked Answered
P

3

14

We are encountering an issue where our React Native Android application crashes on certain devices due to a memory leak. While it works perfectly on most devices, roughly 25% of users have reported this crash. The problem has been tracked via Crashlytics, and upon further investigation using LeakCanary, it appears that the memory leak occurs when navigating between screens, either bottom tabs or stack navigation.

Repo that Demonstrates the issue Github Link

Below is the navigation structure:

// main navigation
<NavigationContainer>
  <Stack.Navigator>
    <Stack.Screen name="Auth" component={Auth} />
    <Stack.Screen name="App" component={Drawer} />
  </Stack.Navigator>
</NavigationContainer>

// drawer
<Drawer.Navigator drawerContent={(props) => <DrawerContent {...props} />}>
    <Drawer.Screen name="Main" component={BottomTabs} />
</Drawer.Navigator>


// Bottom Tabs
<BottomTab.Navigator>
  <BottomTab.Screen name="Tab1" component={Stack1} />
  <BottomTab.Screen name="Tab2" component={Stack2} />
  <BottomTab.Screen name="Tab3" component={Stack3} />
  <BottomTab.Screen name="Tab4" component={Stack4} />
</BottomTab.Navigator>

// Stack 1 
<Stack.Navigator>
  <Stack.Screen name="Main" component={Screen} />
  <Stack.Screen name="Screen2" component={Screen2} />
  <Stack.Screen name="Screen3" component={Screen3} />
  <Stack.Screen name="Screen4" component={Screen4} />
  <Stack.Screen name="Screen5" component={Screen5} />
</Stack.Navigator>

Issue Logcat

┬───
│ GC Root: Thread object
│
├─ android.net.ConnectivityThread instance
│    Leaking: NO (PathClassLoader↓ is not leaking)
│    Thread name: 'ConnectivityThread'
│    ↓ Thread.contextClassLoader
├─ dalvik.system.PathClassLoader instance
│    Leaking: NO (InternalLeakCanary↓ is not leaking and A ClassLoader is never
│    leaking)
│    ↓ ClassLoader.runtimeInternalObjects
├─ java.lang.Object[] array
│    Leaking: NO (InternalLeakCanary↓ is not leaking)
│    ↓ Object[1048]
├─ leakcanary.internal.InternalLeakCanary class
│    Leaking: NO (MainActivity↓ is not leaking and a class is never leaking)
│    ↓ static InternalLeakCanary.resumedActivity
├─ com.appname.MainActivity instance
│    Leaking: NO (Activity#mDestroyed is false)
│    mApplication instance of com.appname.MainApplication
│    mBase instance of androidx.appcompat.view.ContextThemeWrapper
│    ↓ AppCompatActivity.mDelegate
│                        ~~~~~~~~~
├─ androidx.appcompat.app.AppCompatDelegateImpl instance
│    Leaking: UNKNOWN
│    Retaining 1.1 kB in 16 objects
│    mAppCompatCallback instance of com.appname.MainActivity with
│    mDestroyed = false
│    mContext instance of com.appname.MainActivity with mDestroyed = false
│    mHost instance of com.appname.MainActivity with mDestroyed = false
│    ↓ AppCompatDelegateImpl.mActionBar
│                            ~~~~~~~~~~
├─ androidx.appcompat.app.ToolbarActionBar instance
│    Leaking: UNKNOWN
│    Retaining 5.7 MB in 12165 objects
│    ↓ ToolbarActionBar.mDecorToolbar
│                       ~~~~~~~~~~~~~
├─ androidx.appcompat.widget.ToolbarWidgetWrapper instance
│    Leaking: UNKNOWN
│    Retaining 5.7 MB in 12161 objects
│    ↓ ToolbarWidgetWrapper.mToolbar
│                           ~~~~~~~~
├─ com.swmansion.rnscreens.ScreenStackHeaderConfig$DebugMenuToolbar instance
│    Leaking: UNKNOWN
│    Retaining 5.7 MB in 12148 objects
│    View not part of a window view hierarchy
│    View.mAttachInfo is null (view detached)
│    View.mWindowAttachCount = 2
│    mPopupContext instance of com.facebook.react.uimanager.ThemedReactContext,
│    wrapping activity com.appname.MainActivity with mDestroyed = false
│    mContext instance of com.facebook.react.uimanager.ThemedReactContext,
│    wrapping activity com.appname.MainActivity with mDestroyed = false
│    ↓ View.mParent
│           ~~~~~~~
├─ com.google.android.material.appbar.AppBarLayout instance
│    Leaking: UNKNOWN
│    Retaining 3.3 kB in 80 objects
│    View not part of a window view hierarchy
│    View.mAttachInfo is null (view detached)
│    View.mWindowAttachCount = 1
│    mContext instance of com.appname.MainActivity with mDestroyed = false
│    ↓ View.mParent
│           ~~~~~~~
╰→ com.swmansion.rnscreens.ScreenStackFragment$ScreensCoordinatorLayout instance
​     Leaking: YES (ObjectWatcher was watching this because com.swmansion.
​     rnscreens.ScreenStackFragment received Fragment#onDestroyView() callback
​     (references to its views should be cleared to prevent leaks))
​     Retaining 2.8 kB in 74 objects
​     key = edfb8295-6373-4ec3-b16b-565e1448a34d
​     watchDurationMillis = 6377
​     retainedDurationMillis = 1377
​     View not part of a window view hierarchy
​     View.mAttachInfo is null (view detached)
​     View.mWindowAttachCount = 1
​     mContext instance of com.appname.MainActivity with mDestroyed = false

METADATA

Build.VERSION.SDK_INT: 33
Build.MANUFACTURER: Google
LeakCanary version: 2.11
App process name: com.appname
Class count: 27700
Instance count: 268061
Primitive array count: 148295
Object array count: 41071
Thread count: 56
Heap total bytes: 32668238
Bitmap count: 83
Bitmap total bytes: 24208979
Large bitmap count: 0
Large bitmap total bytes: 0
Db 1: open /data/user/0/com.appnamw/databases/com.
google.android.datatransport.events
Db 2: open /data/user/0/com.
appname/databases/RKStorage
Db 3: open /data/user/0/com.appname/no_backup/androidx.work.workdb
Stats: LruCache[maxSize=3000,hits=162815,misses=288528,hitRate=36%]
RandomAccess[bytes=14810283,reads=288528,travel=86887776715,range=38641306,size=
47641520]
Analysis duration: 13551 ms
│ GC Root: Thread object
│
├─ android.net.ConnectivityThread instance
│    Leaking: NO (PathClassLoader↓ is not leaking)
│    Thread name: 'ConnectivityThread'
│    ↓ Thread.contextClassLoader
├─ dalvik.system.PathClassLoader instance
│    Leaking: NO (InternalLeakCanary↓ is not leaking and A ClassLoader is never
│    leaking)
│    ↓ ClassLoader.runtimeInternalObjects
├─ java.lang.Object[] array
│    Leaking: NO (InternalLeakCanary↓ is not leaking)
│    ↓ Object[2191]
├─ leakcanary.internal.InternalLeakCanary class
│    Leaking: NO (MainActivity↓ is not leaking and a class is never leaking)
│    ↓ static InternalLeakCanary.resumedActivity
├─ com.appname.MainActivity instance
│    Leaking: NO (Activity#mDestroyed is false)
│    mApplication instance of com.appname.MainApplication
│    mBase instance of androidx.appcompat.view.ContextThemeWrapper
│    ↓ AppCompatActivity.mDelegate
│                        ~~~
├─ androidx.appcompat.app.AppCompatDelegateImpl instance
│    Leaking: UNKNOWN
│    Retaining 1.0 kB in 16 objects
│    mAppCompatCallback instance of com.appname.MainActivity with
│    mDestroyed = false
│    mContext instance of com.appname.MainActivity with mDestroyed = false
│    mHost instance of com.appname.MainActivity with mDestroyed = false
│    ↓ AppCompatDelegateImpl.mActionBar
│                            ~~~~
├─ androidx.appcompat.app.ToolbarActionBar instance
│    Leaking: UNKNOWN
│    Retaining 552.4 kB in 2395 objects
│    ↓ ToolbarActionBar.mDecorToolbar
│                       ~~~~~
├─ androidx.appcompat.widget.ToolbarWidgetWrapper instance
│    Leaking: UNKNOWN
│    Retaining 552.4 kB in 2391 objects
│    ↓ ToolbarWidgetWrapper.mToolbar
│                           ~~~~
├─ com.swmansion.rnscreens.ScreenStackHeaderConfig$DebugMenuToolbar instance
│    Leaking: UNKNOWN
│    Retaining 552.2 kB in 2387 objects
│    View not part of a window view hierarchy
│    View.mAttachInfo is null (view detached)
│    View.mWindowAttachCount = 1
│    mPopupContext instance of com.facebook.react.uimanager.ThemedReactContext,
│    wrapping activity com.appname.MainActivity with mDestroyed = false
│    mContext instance of com.facebook.react.uimanager.ThemedReactContext,
│    wrapping activity com.appname.MainActivity with mDestroyed = false
│    ↓ CustomToolbar.config
│                    ~~
├─ com.swmansion.rnscreens.ScreenStackHeaderConfig instance
│    Leaking: UNKNOWN
│    Retaining 2.0 kB in 14 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.facebook.react.uimanager.ThemedReactContext,
│    wrapping activity com.appname.MainActivity with mDestroyed = false
│    ↓ View.mParent
│           ~~~
├─ com.swmansion.rnscreens.Screen instance
│    Leaking: UNKNOWN
│    Retaining 511.8 kB in 1829 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.facebook.react.uimanager.ThemedReactContext,
│    wrapping activity com.appname.MainActivity with mDestroyed = false
│    ↓ Screen.fragment
│             ~~~~
╰→ com.swmansion.rnscreens.ScreenStackFragment instance
​     Leaking: YES (ObjectWatcher was watching this because com.swmansion.
​     rnscreens.ScreenStackFragment received Fragment#onDestroy() callback and
​     Fragment#mFragmentManager is null)
​     Retaining 2.1 kB in 72 objects
​     key = b116c7d1-e55c-4a42-9170-eca82ba9dd7d
​     watchDurationMillis = 7292
​     retainedDurationMillis = 2249

METADATA

Build.VERSION.SDK_INT: 28
Build.MANUFACTURER: HUAWEI
LeakCanary version: 2.11
App process name: appname
Class
    
    
    ───
│ GC Root: Global variable in native code
│
├─ com.swmansion.reanimated.NativeProxy instance
│    Leaking: UNKNOWN
│    Retaining 221 B in 8 objects
│    ↓ NativeProxyCommon.mNodesManager
│                        ~~~~~
├─ com.swmansion.reanimated.NodesManager instance
│    Leaking: UNKNOWN
│    Retaining 9.4 kB in 318 objects
│    mContext instance of com.facebook.react.bridge.ReactApplicationContext,
│    wrapping com.appname.MainApplication
│    mReactApplicationContext instance of com.facebook.react.bridge.
│    ReactApplicationContext, wrapping com.appname.MainApplication
│    ↓ NodesManager.mAnimationManager
│                   ~~~~~~~
├─ com.swmansion.reanimated.layoutReanimation.AnimationsManager instance
│    Leaking: UNKNOWN
│    Retaining 794 B in 24 objects
│    mContext instance of com.facebook.react.bridge.ReactApplicationContext,
│    wrapping com.appname.MainApplication
│    ↓ AnimationsManager.mReanimatedNativeHierarchyManager
│                        ~~~~~~~~~~~
├─ com.swmansion.reanimated.layoutReanimation.ReanimatedNativeHierarchyManager
│  instance
│    Leaking: UNKNOWN
│    Retaining 942.6 kB in 6901 objects
│    ↓ NativeViewHierarchyManager.mTagsToViews
│                                 ~~~~
├─ android.util.SparseArray instance
│    Leaking: UNKNOWN
│    Retaining 929.7 kB in 6881 objects
│    ↓ SparseArray.mValues
│                  ~~~
├─ java.lang.Object[] array
│    Leaking: UNKNOWN
│    Retaining 923.6 kB in 6879 objects
│    ↓ Object[217]
│            ~~~
├─ com.swmansion.rnscreens.Screen instance
│    Leaking: UNKNOWN
│    Retaining 2.2 kB in 17 objects
│    View not part of a window view hierarchy
│    View.mAttachInfo is null (view detached)
│    View.mID = R.id.null
│    View.mWindowAttachCount = 5
│    mContext instance of com.facebook.react.uimanager.ThemedReactContext,
│    wrapping activity com.appname.MainActivity with mDestroyed = false
│    ↓ View.mParent
│           ~~~
╰→ com.swmansion.rnscreens.ScreenStackFragment$ScreensCoordinatorLayout instance
​     Leaking: YES (ObjectWatcher was watching this because com.swmansion.
​     rnscreens.ScreenStackFragment received Fragment#onDestroyView() callback
​     (references to its views should be cleared to prevent leaks))
​     Retaining 3.7 kB in 72 objects
​     key = 8b340022-4f6e-4653-a6c8-ad5639e3ff8e
​     watchDurationMillis = 5828
​     retainedDurationMillis = 827
​     View not part of a window view hierarchy
​     View.mAttachInfo is null (view detached)
​     View.mWindowAttachCount = 1
​     mContext instance of com.appname.MainActivity with mDestroyed = false

METADATA

Build.VERSION.SDK_INT: 28
Build.MANUFACTURER: HUAWEI
LeakCanary version: 2.11
App process name:appname
Class count: 18907
Instance count: 266551
Primitive array count: 172560
Object array count: 29962
Thread count: 57
Heap total bytes: 26966868
Bitmap count: 75
Bitmap total bytes: 14020308
Large bitmap count: 0
Large bitmap total bytes: 0
Db 1: open /data/user/0/appnamer/databases/RKStorage
Db 2: open /data/user/0/com.appname/databases/com.
google.android.datatransport.events
Db 3: open /data/user/0/com.appname/no_backup/androidx.work.workdb
Stats: LruCache[maxSize=3000,hits=84650,misses=180148,hitRate=31%]
RandomAccess[bytes=9194085,reads=180148,travel=66525003196,range=33782477,size=4
0598308]
Analysis duration: 15559 ms

Solutions attempted so far without success:

 // it fixes the leak when navigating between the ButtomTabs 
 // but it leaks when navigating in the stack(ex:to screen2)
- enableScreens(false)
- super.onCreate(null);

Module Versions:

   "react-native-screens": "^3.22.0",
   "@react-navigation/bottom-tabs": "^6.3.2",
   "@react-navigation/drawer": "^6.4.4",
   "@react-navigation/native": "^6.0.11",
   "@react-navigation/native-stack": "^6.7.0",
   "react-native-reanimated": "^3.3.0",
   "react-native-gesture-handler": "^2.12.0",

The problem was initially reported three years ago during the v4 release. Despite the updates, it continues to persist in v6. I suspect the root of the issue lies in the interaction between react-native-screens, react-native-reanimated, and react-navigation. Any solutions or workarounds to address this challenge would be greatly appreciated.

Related reports

Pectoral answered 30/6, 2023 at 2:26 Comment(6)
Any long-running processes in your app code?Mahlstick
There is none, this is a a well known issue... check the related reports, the issue is related to react-native-reanimated, react-native-screensPectoral
any updates on this one?Brogan
Would using PureComponent help, instead of using Component. Just a thought, as it minimises the number of re-renders, could help a little bit with memory leaks.Bosch
@djmonki, I appreciate your suggestion. As we are working with functional components, we have implemented React.memo for our components.Pectoral
@Bosch I have added a repo that demonstrate the issue github.com/Ahmed-Imam/memoryLeakPectoral
O
3

It is not a actual memory leak

The behavior described by the tools as a leak is the consequence of keeping the ScreenFragments in the memory. It is done like this because, in react-native, we cannot destroy and then make new views by restoring the state of the Fragment, since each view has its reactTag etc. The behavior is shown as a leak due to heuristics of the leak detector tools, which say that if onDestroy was called on a Fragment, then the reference to it should not be kept anywhere, but, as mentioned above, it is not applicable to react-native apps, since we do not recreate the views of the Fragment, but rather call remove on the them when they become invisible and then add them back on the Screen becoming visible with the same Screen attached to it.

Hopefully this will resolve your issue

Referance from developer of Software Mansion https://github.com/software-mansion/react-native-screens/issues/843#issuecomment-832034119

Referance from react-native-screens

Outroar answered 5/7, 2023 at 12:48 Comment(0)
A
0

The use of navigation listeners that are not properly removed when the component unmounts is a typical cause of memory leaks in React Navigation. Here is some sample code that shows how to properly handle navigation listeners:


import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createStackNavigator } from '@react-navigation/stack';

// Create a custom hook to handle navigation listeners
function useNavigationListeners(navigation) {
  React.useEffect(() => {
    const unsubscribe = navigation.addListener('focus', () => {
      // Handle focus event
    });

    return () => {
      unsubscribe();
    };
  }, [navigation]);
}

// Stack 1
const Stack1 = createStackNavigator();

function Stack1Screen() {
  const navigation = useNavigation();

  // Attach the navigation listener using the custom hook
  useNavigationListeners(navigation);

  return (
    <Stack1.Navigator>
      <Stack1.Screen name="Main" component={Screen} />
      <Stack1.Screen name="Screen2" component={Screen2} />
      <Stack1.Screen name="Screen3" component={Screen3} />
      <Stack1.Screen name="Screen4" component={Screen4} />
      <Stack1.Screen name="Screen5" component={Screen5} />
    </Stack1.Navigator>
  );
}

// Bottom Tabs
const BottomTab = createBottomTabNavigator();

function BottomTabs() {
  return (
    <BottomTab.Navigator>
      <BottomTab.Screen name="Tab1" component={Stack1Screen} />
      <BottomTab.Screen name="Tab2" component={Stack2} />
      <BottomTab.Screen name="Tab3" component={Stack3} />
      <BottomTab.Screen name="Tab4" component={Stack4} />
    </BottomTab.Navigator>
  );
}

// main navigation
const Stack = createStackNavigator();

function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen name="Auth" component={Auth} />
        <Stack.Screen name="App" component={Drawer} />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

export default App;

Using the addListener method offered by React Navigation, we build a custom hook called useNavigationListeners that connects a navigation listener. When the Stack1Screen component mounts, the listener is attached using the useNavigationListeners hook. By returning a cleaning function from the useEffect hook, the listener is appropriately unmounted when the component unmounts. To avoid memory leaks that can happen if the navigation listeners are not properly cleaned up, you make sure they are properly removed when the related components are unmounted.

Ashti answered 8/7, 2023 at 9:34 Comment(0)
T
0

The observed "leak" behavior is a result of keeping ScreenFragments in memory in react-native apps. This is necessary because react-native does not allow destroying and recreating views with the same state, as each view has a unique reactTag.

The profiler tools incorrectly interpret this as a leak because it expect Fragments to be completely removed when onDestroy is called. In react-native, we simply remove and add back the Fragments when they become invisible and visible again, maintaining the same Screen attachment.

Twentieth answered 8/7, 2023 at 13:54 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.