DEV Community

Shubhanshu Shrimali
Shubhanshu Shrimali

Posted on

How We Reduced Unity Mobile Memory by 35% & Eliminated GC Spikes: An Addressables & ScriptableObject Architecture Guide

TL;DR: Scaling a production mobile title to 300K+ players across heterogeneous Android and iOS devices requires absolute mastery of runtime memory management. Uncontrolled Garbage Collection (GC) sweeps cause perceptible frame hitches, while unmanaged asset references trigger fatal Out-Of-Memory (OOM) crashes. This deep-dive outlines the production architecture—leveraging Unity Addressables, ScriptableObject event channels, zero-allocation pooling, and ASTC texture pipelines—that reduced our memory footprint by 35%, eliminated GC frame drops, and locked a steady 60 FPS.


1. The Mobile Memory Bottleneck: Why Games Crash on 3GB RAM

On mobile platforms, the OS aggressively terminates apps exceeding memory thresholds. In Unity mobile development, the two primary causes of failure are:

  1. Mono Managed Heap Fragmentation: In Unity's non-compacting Boehm GC, the managed heap only expands—it never shrinks back to the operating system during gameplay. A single spike in allocations permanently balloons the heap footprint.
  2. Monolithic Asset Retention: Direct SerializeField inspector links force all referenced prefabs, 4K textures, and audio clips into RAM upon initial scene loading.
Managed Heap Expansion Behavior (Non-Compacting GC)
├── Initial Allocation: [ 24 MB Heap ]
├── Spike Frame (String concats, LINQ in Update): [ 140 MB Heap Expands! ]
└── After GC Sweep: Free memory is fragmented; heap remains at 140 MB!
Enter fullscreen mode Exit fullscreen mode

2. Replacing Direct References with Unity Addressables

Migrating from direct inspector references and Resources.Load to Unity Addressables allows on-demand asynchronous streaming and explicit GPU/RAM unloading.

Production Asynchronous Spawner (AddressableSpawner.cs)

using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class AddressableSpawner : MonoBehaviour
{
    [SerializeField] private AssetReferenceGameObject enemyPrefabReference;
    private AsyncOperationHandle<GameObject> loadHandle;
    private GameObject spawnedInstance;

    public async Task<GameObject> SpawnEnemyAsync(Vector3 spawnPosition, Quaternion spawnRotation)
    {
        // 1. Asynchronously load asset into memory only when required
        loadHandle = Addressables.LoadAssetAsync<GameObject>(enemyPrefabReference);
        await loadHandle.Task;

        if (loadHandle.Status == AsyncOperationStatus.Succeeded)
        {
            spawnedInstance = Instantiate(loadHandle.Result, spawnPosition, spawnRotation);
            return spawnedInstance;
        }

        Debug.LogError($"[Addressables] Failed to load prefab reference: {enemyPrefabReference}");
        return null;
    }

    public void UnloadAndCleanup()
    {
        // 2. Destroy instance and release memory handle
        if (spawnedInstance != null)
        {
            Destroy(spawnedInstance);
        }

        if (loadHandle.IsValid())
        {
            Addressables.Release(loadHandle);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Best Practices for Addressable Asset Bundles:

  • Pack by Update Frequency: Divide bundles into Core_Static_Packed (shipped in APK/IPA) and Remote_LiveOps_Dynamic (streamed from CDN on demand).
  • Enforce Strict Handle Lifecycles: Every LoadAssetAsync MUST have an associated Addressables.Release when the asset is destroyed.

3. Decoupled Architecture via ScriptableObject Events

Direct manager singletons (GameManager.Instance.OnPlayerDied()) create hidden memory retention webs that prevent Garbage Collection.

We replaced singletons with ScriptableObject Event Channels:

// VoidEventChannelSO.cs - Asset stored in Project Window
using System;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName = "NewVoidEventChannel", menuName = "Events/Void Event Channel")]
public class VoidEventChannelSO : ScriptableObject
{
    private readonly List<Action> listeners = new List<Action>();

    public void RaiseEvent()
    {
        for (int i = listeners.Count - 1; i >= 0; i--)
        {
            listeners[i]?.Invoke();
        }
    }

    public void RegisterListener(Action listener)
    {
        if (!listeners.Contains(listener)) listeners.Add(listener);
    }

    public void UnregisterListener(Action listener)
    {
        if (listeners.Contains(listener)) listeners.Remove(listener);
    }
}
Enter fullscreen mode Exit fullscreen mode
// PlayerDeathBroadcaster.cs - Zero GC Allocations
public class PlayerDeathBroadcaster : MonoBehaviour
{
    [SerializeField] private VoidEventChannelSO playerDeathChannel;

    public void KillPlayer()
    {
        // Broadcasts state changes with ZERO heap allocations
        playerDeathChannel.RaiseEvent();
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Zero-Allocation Object Pooling with UnityEngine.Pool

Instantiating and destroying GameObjects during gameplay causes severe GC pauses. We implemented pre-warmed generic pooling using UnityEngine.Pool.ObjectPool<T>:

using UnityEngine;
using UnityEngine.Pool;

public class ProjectilePool : MonoBehaviour
{
    [SerializeField] private GameObject projectilePrefab;
    private IObjectPool<GameObject> pool;

    private void Awake()
    {
        pool = new ObjectPool<GameObject>(
            createFunc: () => Instantiate(projectilePrefab, transform),
            actionOnGet: (instance) => instance.SetActive(true),
            actionOnRelease: (instance) => instance.SetActive(false),
            actionOnDestroy: (instance) => Destroy(instance),
            collectionCheck: false,
            defaultCapacity: 50,
            maxSize: 200
        );
    }

    public GameObject Spawn(Vector3 position, Quaternion rotation)
    {
        GameObject projectile = pool.Get();
        projectile.transform.SetPositionAndRotation(position, rotation);
        return projectile;
    }

    public void Despawn(GameObject projectile) => pool.Release(projectile);
}
Enter fullscreen mode Exit fullscreen mode

5. Texture & Shader Optimization Pipeline

Textures represent 60–75% of total mobile memory. We enforced the following compression rules:

  • ASTC 6x6: Standard for UI elements, background props, and environment textures.
  • ASTC 4x4: Reserved strictly for hero character models and high-detail focal assets.
  • MaterialPropertyBlocks: Replaced all meshRenderer.material.color calls with MaterialPropertyBlock to prevent accidental runtime material cloning and preserve GPU static/dynamic batching.

6. Production Benchmarks (300K+ User Scale)

Benchmark Metric Before Optimization After Addressables & SO Architecture
Peak RAM Consumption 890 MB 578 MB (-35%)
Garbage Collection Allocations 42 KB / frame 0 Bytes / frame (Steady State)
Frame Rate on Low-Tier Devices 42 FPS (Frequent Spikes) Locked 60 FPS
Crash Rate (OOM on Low-End Android) 2.8% < 0.12%
Initial Download Size (Store APK) 215 MB 72 MB (Balance streamed dynamically)

7. The Performance Checklist for Senior Unity Devs

  • [x] Profile with Memory Profiler to identify duplicated textures and leaked unmanaged buffers.
  • [x] Replace all Resources.Load dependencies with Addressables.
  • [x] Enforce UnityEngine.Pool for all runtime spawned entities (projectiles, particles, VFX).
  • [x] Use MaterialPropertyBlock instead of modifying Renderer.material directly.
  • [x] Eliminate per-frame LINQ, string concatenation, and lambda closures in Update().

Find more architectural patterns on my GitHub or connect on LinkedIn!

Top comments (0)