Speicheroptimierung von Unity-Spielen unter Android

Obwohl die physische RAM-Kapazität moderner Mobilgeräte immer weiter wächst, übersteigt der Arbeitsspeicherverbrauch von Spielen dieses Wachstum aufgrund von Assets mit hoher Auflösung und komplexen Rendering-Pipelines.

Hauptprobleme durch übermäßige Arbeitsspeichernutzung

  • Beendigungen durch Low-Memory-Killer (LMK): Das Betriebssystem schließt zwangsweise Hintergrund- oder sogar Vordergrund-Apps, um systemweite Arbeitsspeichermangel zu beheben.
  • Frame-Drops und Ruckeln (Jank): Häufige Spitzen bei der automatischen Speicherbereinigung (Garbage Collection, GC) im verwalteten Heap oder Speicherauslagerung auf Betriebssystemebene führen zu Engpässen bei der Verarbeitung.
  • Thermische Drosselung und schnelle Akkuentladung: Kontinuierliche Arbeitsspeicherzuweisung, -freigabe und Arbeitsspeicherseiten-Commits verursachen einen erheblichen CPU-Aufwand, was zu erhöhter Wärme und schnellerer Akkuentladung führt.

Updates zur Android-Arbeitsspeicherverwaltung

  • Android 17: Einführung von MemoryLimiter: Android 17 führt MemoryLimiter ein, das den Arbeitsspeicherverbrauch von Anwendungen aktiv anhand gerätespezifischer Grenzwerte überwacht. Apps, die ihr Arbeitsspeicherlimit überschreiten, werden sofort auf Systemebene beendet. Da dieser Mechanismus strenger ist als der herkömmliche LMK, ist die Verwaltung der maximalen Arbeitsspeichernutzung wichtiger denn je.

Arbeitsspeichernutzung in Unity reduzieren

Wenn die Engine ihre internen Arbeitsspeicherpools (Native Block Allocators und Managed Heap) erweitert, um eine hohe Spitzenlast zu bewältigen, behält sie diese Arbeitsspeicherseiten bei, anstatt sie sofort an das Betriebssystem zurückzugeben. Auch nachdem große Assets entladen wurden, bleibt der Resident Memory überhöht, wodurch die Anwendung sehr anfällig für die Beendigung von Android-Betriebssystemprozessen ist (z. B. durch LMK oder MemoryLimiter).

Um die Erzwingung auf Betriebssystemebene zu verhindern, muss die Arbeitsspeicheroptimierung auf drei Säulen beruhen:

  • Kategorie A: Maximale Arbeitsspeichernutzung reduzieren
  • Kategorie B: Unnötige Asset- und System-Footprints entfernen
  • Kategorie C: Unnötige GC-Zuweisungen entfernen

Kategorie A: Maximale Arbeitsspeichernutzung reduzieren

Die Allocators von Unity behalten freigegebene Arbeitsspeicherseiten zur Wiederverwendung bei, anstatt sie sofort an das Betriebssystem zurückzugeben. Daher spiegelt der Baseline-Arbeitsspeicher in der Regel die höchste erreichte Spitze wider und nicht die aktuelle Nutzung. Es ist daher effektiver, Spitzen von vornherein zu vermeiden, als sich auf die Bereinigung im Nachhinein zu verlassen.

1. Übermäßig große AssetBundles vermeiden

Aufgrund des Bundle Loading -Mechanismus von Unity führen riesige AssetBundles zu einer starken Arbeitsspeicherüberlastung und OOM-Abstürzen. Halten Sie Bundles modular, um eine effiziente Ressourcenverwaltung zu gewährleisten.

Hauptprobleme

  • Hoher Arbeitsspeicher-Overhead: Wenn ein einzelnes kleines Asset angefordert wird, muss Unity die gesamte Bundle-Datei (einschließlich Header, Metadaten und Streaming-Puffer) in den RAM laden.
  • Die Entladefalle: Wenn irgendein Asset in einem Bundle aktiv verwendet wird, kann das gesamte Bundle nicht entladen werden, wodurch ungenutzte Daten im RAM verbleiben.

Best Practices

  • Bundles modular halten: Gruppieren Sie Assets logisch nach Szene oder Lebenszyklus.
  • Tipp für Unity 6.6 und höher: Verwenden Sie Content Directories , um unbeabsichtigte Bundle-übergreifende Abhängigkeiten zu vermeiden.

2. Asset-Referenzen in ScriptableObjects optimieren

Direkte serialisierte UnityEngine.Object-Felder in einem ScriptableObject erstellen direkte Hard-Referenzen, wodurch alle referenzierten Assets in den RAM geladen werden, sobald das ScriptableObject selbst geladen oder instanziiert wird.

// BEFORE: Loading SceneRequiredAssets forces _worldAsset and _spawnSettings into RAM immediately
public class SceneRequiredAssets : ScriptableObject
{
    public string sceneName;
    public Object _worldAsset;
    public Object _spawnSettings;
}

// AFTER: Use AssetReference to enable asynchronous, on-demand loading using Addressables
public class SceneRequiredAssets : ScriptableObject
{
    public string sceneName;
    public AssetReference _worldAsset;
    public AssetReference _spawnSettings;
}

3. Ladetypen für Audioclips konfigurieren

Wenn alle Audioclips unkomprimiert direkt in den Arbeitsspeicher geladen werden, entstehen große, dauerhafte Arbeitsspeicher-Spitzen. Die Audio-Ladeeinstellungen sollten anhand des Nutzungsprofils konfiguriert werden:

Audiokategorie Ladetyp Grund
Hintergrundmusik (BGM) Streaming Streamt Audio von der Festplatte in kleinen Puffern, um Arbeitsspeicher-Spitzen zu vermeiden.
Lange SFX Komprimiert im Arbeitsspeicher Hält den RAM-Overhead niedrig und dekomprimiert Audio während der Wiedergabe.
Kurze und häufige SFX Beim Laden dekomprimieren Dekomprimiert Audio beim Laden in den RAM, um CPU-Overhead während der Wiedergabe zu vermeiden.

4. Strategien für Objekt-Pooling und -Freigabe implementieren

Nicht freigegebene Instanzen, die in Objekt-Pools über Szenenübergänge hinweg verbleiben, belegen den reservierten Arbeitsspeicher unbegrenzt, wodurch der Baseline-Speicherbedarf unnötig aufgebläht wird.

  • Aktion: Bereinigen oder reduzieren Sie regelmäßig ungenutzte gepoolte Objekte während Szenen übergängen oder in Zeiten geringer Aktivität, damit Unity diesen reservierten Speicherplatz für andere Zuweisungen zurückgeben oder wiederverwenden kann.

Kategorie B: Unnötige Arbeitsspeichernutzung entfernen

Das Entfernen redundanter Grafikinhalte und das direkte Rendern von Zielpuffern senkt den Baseline-Speicherbedarf.

1. Render-Texturen und Kameratiefe optimieren

  • Tiefen-/Stencil-Puffer entfernen: Legen Sie das Tiefen-Stencil-Format für Render-Texturen, die nur Farbdaten benötigen, auf None fest.
  • UI-Kameratiefe-Textur deaktivieren: Deaktivieren Sie für UI-Kameras, bei denen Tiefendaten nicht erforderlich sind, die Erstellung von Tiefentexturen in den URP-Kameraeinstellungen, um den CopyDepth Pass und den zugehörigen GPU-Textur-Arbeitsspeicher zu entfernen.

2. Texturen und Meshes optimieren

Kategorie Optimierungsrichtlinie
Texturkomprimierung Wenden Sie immer die Komprimierungsformate der Zielplattform an (z. B. ASTC für Android).
Lesen/Schreiben aktiviert Deaktiviert lassen, es sei denn, es ist erforderlich. Wenn Sie diese Option aktivieren, wird der Textur-Arbeitsspeicher im CPU- und GPU-RAM dupliziert.
Mipmaps Deaktivieren Sie Mipmaps für UI-Texturen oder Objekte, die sich in einem konstanten Kameraabstand befinden, um etwa 33% Textur-Arbeitsspeicher zu sparen.
Mesh-Komplexität Reduzieren Sie unnötige Polygonanzahlen und Vertex-Streams, um den GPU- und nativen Speicherbedarf zu verringern.

3. Shader-Varianten entfernen und Arbeitsspeicher optimieren

Uber-Shader (z. B. URP Lit Shader) kapseln zahlreiche Funktionen mithilfe der Keywords #multi_compile und shader_feature. Ohne Optimierung führt die kombinatorische Explosion zu Zehntausenden von eindeutigenShader-Varianten, was zu aufgeblähten Build-Größen, massivem nativem Arbeitsspeicherverbrauch und Problemen bei der GPU-Treiberkompilierung während des Gameplays führt.

A. Mechanismen des Arbeitsspeicher-Overheads von Shader-Varianten

  • Kombinatorische Explosion: Die Gesamtzahl der möglichen Varianten wächst mit jeder hinzugefügten Keyword-Gruppe exponentiell.
  • Chunk-Zuweisungsarchitektur: Unity gruppiert kompilierte binäre Varianten in komprimierte Arbeitsspeicherblöcke, die als Chunks bezeichnet werden (Standard: 4 MB).
  • Aufblähung des nativen Arbeitsspeichers: Wenn der Laufzeitcode auch nur eine einzelne Variante in einem Chunk anfordert, wird der gesamte 4-MB-Chunk in den RAM dekomprimiert. Wenn nicht optimiert, belegen Tausende von ungenutzten Varianten, die in diesen Chunks enthalten sind, dauerhaft den nativen Arbeitsspeicher.

B. Integrierte mehrstufige Stripping-Pipeline von Unity: Unity entfernt automatisch unnötige Varianten zur Build-Zeit basierend auf den Grafikeinstellungen der Zielplattform und ungenutzten Engine-Funktionen (z. B. Nebel, Lightmaps und XR Einstellungen). Außerdem werden shader_feature Varianten automatisch herausgefiltert, wenn ihre Keywords von keinem Material im Projekt aktiv verwendet werden, während #multi_compile Varianten unabhängig von der Nutzung zwangsweise eingeschlossen werden.

C. Benutzerdefinierte automatisierte Stripping-Architektur (IPreprocessShaders) Da die statische Analyse Keywords nicht erkennen kann, die dynamisch mithilfe von C#-Laufzeitskripten geändert werden (Material.EnableKeyword), ist das Standard-Stripping oft nicht ausreichend. Um sicherzustellen, dass nur tatsächlich verwendete Varianten eingeschlossen werden, können Sie Varianten während der QA-Testsuiten mithilfe von Player.log (aktivieren Sie Log Shader Compilation in den Editoreinstellungen) oder Profiler Traces (Shader.CreateGPUProgram Marker) erfassen. Implementieren Sie dann IPreprocessShaders.OnProcessShader in einem Editorskript, um Varianten herauszufiltern, die während der Laufzeit nie ausgeführt wurden, und behalten Sie nur die erforderlichen im Build bei.

Kategorie C: Unnötige GC-Zuweisungen entfernen

GC-Zuweisungen (Garbage Collection) im verwalteten Heap führen zu Arbeitsspeicherfragmentierung, Heap-Erweiterungen, die nie verkleinert werden, und erheblichen Frame-Drops während GC-Pausen.

1. Zuweisungen für Lambda-Closures verhindern

Wenn ein Lambda-Ausdruck äußere lokale Variablen erfasst, generiert C# eine implizite Display-Klasse im Heap. Wenn dies in Update ausgeführt wird, werden in jedem Frame Closure-Instanzen zugewiesen.

// Bad: Capturing local variable 'targetId' allocates a new closure object on the Heap every frame
void Update()
{
    int targetId = 100;
    Monster target = monsterList.Find(m => m.Id == targetId);
}

// Good 1: Replace with a standard 'for' loop (Recommended: 0 B allocation)
void Update()
{
    int targetId = 100;
    Monster target = null;
    for (int i = 0; i < monsterList.Count; i++)
    {
        if (monsterList[i].Id == targetId)
        {
            target = monsterList[i];
            break;
        }
    }
}

// Good 2: Use a static lambda (C# 9.0+) if no outer variables are captured
Monster target = monsterList.Find(static m => m.Id == 100);

2. `stackalloc` und `Span` verwenden

Vermeiden Sie Heap-Zuweisungen für kurzlebige temporäre Arrays, indem Sie Stack-Arbeitsspeicher verwenden.

// Before: Allocates an array on the Heap every call (GC Target)
Vector2[] pos = new Vector2[4];

// After: Utilizes Stack memory using System.Span (0 B Heap Allocation)
System.Span<Vector2> pos = stackalloc Vector2[4];

3. Sammlungsiteration optimieren

Vermeiden Sie Boxing- und Enumerator-Heap-Zuweisungen, die durch LINQ-Erweiterungen oder ReadOnlyCollection-Accessors verursacht werden.

// Before: LINQ Count() causes internal GetEnumerator() heap allocations
bool hasData = component != null && component.parameters.Count(parameter => parameter.overrideState) > 0;

// After: Replaced with indexer and direct loop iteration
bool hasData = HasDataOptimized(component);

private bool HasDataOptimized(TestComponent component)
{
    if (component == null) return false;

    var count = component.parameters.Count;
    for (var i = 0; i < count; ++i)
    {
        if (component.parameters[i].overrideState)
            return true;
    }
    return false;
}

4. Zusätzliche Regeln zur Vermeidung von GC-Zuweisungen

  • Vermeiden Sie die Verwendung von Camera.allCameras, da bei jedem Aufruf ein neues Camera[]-Array im Heap generiert wird. Speichern Sie stattdessen ein Kamera-Array im Cache und übergeben Sie es an Camera.GetAllCameras(_allCameras).
  • Vermeiden Sie foreach für IReadOnlyList<T>: Wenn Sie ein Interface durchlaufen, wird der Struct-Enumerator geboxt, wodurch GC-Zuweisungen entstehen. Verwenden Sie stattdessen eine Standard-for-Schleife.
  • Coroutine-Objekte im Cache speichern: Speichern Sie WaitForSeconds Instanzen im Cache, anstatt yield return new WaitForSeconds(time); wiederholt zu instanziieren.
  • Benutzerdefinierte Struct-Schlüssel in Dictionaries: Wenn Sie benutzerdefinierte Structs als Dictionary Schlüssel verwenden, wird die Standardmethode Equals aufgerufen, wodurch das Objekt geboxt wird. Implementieren Sie IEqualityComparer<T> und übergeben Sie es an den Dictionary-Konstruktor.
public struct TypeKey
{
    public int v1;
    public int v2;

    public class TypeKeyComparer : IEqualityComparer<TypeKey>
    {
        public bool Equals(TypeKey x, TypeKey y) => x.v1 == y.v1 && x.v2 == y.v2;
        public int GetHashCode(TypeKey obj) => obj.v1.GetHashCode() ^ obj.v2.GetHashCode();
    }
}

// Pass custom comparer during Dictionary initialization to prevent boxing
public readonly Dictionary<TypeKey, int> _typeKeyDictionary = new(new TypeKey.TypeKeyComparer());

5. Verwendung von Generics und Reflection minimieren

Generische Methoden bieten zwar eine hervorragende Code-Wiederverwendung und Wartbarkeit, ihre übermäßige Verwendung kann sich jedoch im Kontext des Unity IL2CPP-Backends (Intermediate Language to C++) negativ auf Ihr Projekt auswirken.

  • IL2CPP-Code-Aufblähung: Für jede eindeutige generische Typkombination generiert IL2CPP eine spezielle Version des Codes. Die übermäßige Verwendung komplexer Generics kann zu einer „kombinatorischen Explosion“ des generierten C++-Codes führen, wodurch die binäre Größe und der native Arbeitsspeicher-Footprint der Anwendung erheblich zunehmen.
  • Reflection-Overhead: Methoden, die Reflection verwenden, z. B. System.Reflection-APIs, sind von Natur aus langsam und verursachen oft Heap Zuweisungen während der Laufzeit.
  • Best Practice: Verwenden Sie Generics mit Bedacht. Priorisieren Sie sie für die architektonische Klarheit und nicht für eine breite, wahllos Anwendung. Wenn die Leistung entscheidend ist, bevorzugen Sie konkrete Typen oder interfacebasierte Polymorphie. Speichern Sie für Reflection Ergebnisse wie MethodInfo oder FieldInfo während der Initialisierung im Cache, anstatt sie in der Update-Schleife abzufragen.

6. Lecks bei verwalteten Shells vermeiden

Jedes UnityEngine.Object, z. B. MonoBehaviour, Texture oder GameObject, hat einen C#-Wrapper für die „verwaltete Shell“, der mit der nativen C++-Engine kommuniziert.

  • Das Problem: Wenn eine verwaltete Shell durch eine statische Referenz, ein dauerhaftes Ereignisabonnement oder eine nicht bereinigte Closure im Arbeitsspeicher gehalten wird, kann der Arbeitsspeicher nicht von der automatischen Speicherbereinigung freigegeben werden. Auch wenn das native Objekt zerstört wird, bleibt der verwaltete Wrapper bestehen, was zu „Geist“-Arbeitsspeicherlecks führt, die den verwalteten Heap aufblähen.
  • Lösung: Implementieren Sie immer robuste Bereinigungsmuster. Wenn Sie Objekte zerstören oder Szenen wechseln, heben Sie das Abonnement von Ereignissen explizit mit dem Operator -= auf und setzen Sie statische Referenzen auf UnityEngine.Object-Typen auf „null“. Diese Bereinigung stellt sicher, dass der Wrapper von der automatischen Speicherbereinigung erfasst werden kann, sobald die native Engine ihr Handle freigegeben hat.