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

Updates zur Android-Speicherverwaltung

  • 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 in Unity 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 (z. B. LMK oder MemoryLimiter) ist.

Um die Durchsetzung auf Betriebssystemebene zu verhindern, muss die Arbeitsspeicheroptimierung über drei Hauptsäulen angegangen werden:

  • 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 den höchsten erreichten Spitzenwert wider und nicht die aktuelle Nutzung. Es ist daher effektiver, Spitzenwerte 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 erheblichen 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 müssen, 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. Audio-Clips und -Kanäle konfigurieren

Wenn alle Audio-Clips unkomprimiert direkt in den Arbeitsspeicher geladen werden, entstehen große, dauerhafte Arbeitsspeicherspitzen. Die Einstellungen für das Laden von Audio sollten anhand des Nutzungsprofils konfiguriert werden:

Audiokategorie Lasttyp Grund
BGM (Background Music) Streaming Streamt Audio von der Festplatte in kleinen Puffern, um Arbeitsspeicherspitzen 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.

Zusätzlich zu den Lasttypen können Sie die folgenden Audiokonfigurationen anwenden, um den Arbeitsspeicher-Overhead weiter zu reduzieren:

  • „Force To Mono“ für nicht gerichtetes Audio aktivieren: Viele Audio-Assets wie UI-Soundeffekte, Voice-Overs und 3D-räumliche Soundeffekte erfordern keine Stereokanal-Trennung. Aktivieren Sie für diese Assets Force To Mono in den AudioClip Importeinstellungen, um ihren Speicherbedarf und die Puffergröße um die Hälfte zu reduzieren (50% Reduzierung).
  • Aktive Audiokanäle nach Geräteklasse drosseln: Jeder aktive Audiokanal weist native DSP-Mixer- und Dekompressionspuffer zu. Reduzieren Sie auf Geräten mit wenig Arbeitsspeicher die maximale Anzahl gleichzeitig aktiver Kanäle, z. B. durch Reduzierung von Max Real Voices (z. B. von 32 auf 16) mit AudioSettings.Reset in Unity oder durch Einschränkung der Software-Wiedergabekanäle in Audio-Engines von Drittanbietern.

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 erhöht wird.

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

Kategorie B: Unnötige Arbeitsspeichernutzung entfernen

Durch das Entfernen redundanter Grafikinhalte und das direkte Rendern von Zielpuffern wird der Baseline-Speicherbedarf gesenkt.

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 Komprimierungsformate für die Zielplattform an (z. B. ASTC für Android).
Lesen/Schreiben aktiviert Deaktiviert lassen, es sei denn, es ist erforderlich. Wenn diese Option aktiviert ist, wird der Textur-Arbeitsspeicher im CPU- und GPU-RAM dupliziert.
Mipmaps Deaktivieren Sie Mipmaps für UI-Texturen oder Objekte, die in einem konstanten Kameraabstand fixiert sind, um etwa 33% des Textur-Arbeitsspeichers 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 eindeutigen Shader-Varianten, was zu überhöhten Build-Größen, massivem nativem Arbeitsspeicherverbrauch und Problemen bei der GPU-Treiberkompilierung während des Spiels 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).
  • Nativer Arbeitsspeicher-Bloat: Wenn der Laufzeitcode auch nur eine einzelne Variante in einem Chunk anfordert, wird der gesamte 4-MB-Chunk in den RAM dekomprimiert. Ohne Optimierung 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. Fog, 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. Wenn jedoch Rendering- oder Post-Processing-Funktionen (z. B. zusätzliche Lichter, Echtzeitschatten oder ungenutzte UberPost Effekte) in Ihren URP- oder Pipeline-Einstellungen aktiviert bleiben, kann Unity nicht feststellen, ob Ihre Inhalte sie tatsächlich verwenden, und generiert alle entsprechenden #multi_compile Varianten. Prüfen und deaktivieren Sie ungenutzte Pipeline-Einstellungen, damit sie Ihren tatsächlichen Rendering-Anforderungen entsprechen und die unnötige Variantengenerierung verhindert wird.

C. Benutzerdefinierte automatisierte Stripping-Architektur (IPreprocessShaders) Da die statische Analyse Keywords, die dynamisch mit C#-Laufzeitskripts (Material.EnableKeyword) geändert werden, nicht erkennen kann, ist das Standard-Stripping oft unzureichend. Um sicherzustellen, dass nur tatsächlich verwendete Varianten eingeschlossen werden, können Sie Varianten während QA-Test-Suiten mit Player.log (Aktivieren von Log Shader Compilation in den Editoreinstellungen) oder Profiler Traces (Shader.CreateGPUProgram Markierungen) 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.

D. Redundante Shader konsolidieren: Uber-Shader wie Lit und Simple Lit enthalten viele #multi_compile Direktiven und verbrauchen erheblichen Arbeitsspeicher, wenn zahlreiche Varianten generiert werden:

  • Gleichzeitige Nutzung profilieren: Profilieren Sie Ihre Arbeitsspeichernutzung, um zu prüfen, ob beide Shader gleichzeitig erheblichen Arbeitsspeicher verbrauchen, und prüfen Sie, ob Ihr Projekt beide tatsächlich benötigt.
  • Auf einen einzelnen Shader standardisieren: Wenn kein physikalisch basiertes Rendering (PBR) erforderlich ist, konfigurieren Sie Ihre Assets so, dass die redundante Verwendung des Lit-Shaders vermieden wird, und standardisieren Sie auf Simple Lit. Prüfen Sie beispielsweise, ob exportierte FBX-Modelle beim Import unbeabsichtigt Standardmaterialien vom Typ Lit erhalten .

Kategorie C: Unnötige GC-Zuweisungen entfernen

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

1. Zuweisungen für Lambda-Closures vermeiden

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

  • Verwenden Sie nicht 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>: Die Iteration über eine Schnittstelle führt zu Boxing des Struktur-Enumerators und generiert GC-Zuweisungen. 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 Struktur-Schlüssel in Dictionaries: Wenn benutzerdefinierte Strukturen als Dictionary Schlüssel verwendet werden, wird die Standardmethode Equals aufgerufen, was zu Objekt-Boxing führt. Implement IEqualityComparer<T> und übergeben Sie sie 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-Bloat: 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, wie 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 schnittstellenbasierte 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 von verwalteten Shells vermeiden

Jedes UnityEngine.Object, z. B. MonoBehaviour, Texture oder GameObject, hat einen C#-Wrapper vom Typ „Managed Shell“, der mit der nativen C++-Engine kommuniziert.

  • Das Problem: Wenn eine Managed Shell durch eine statische Referenz, ein dauerhaftes Ereignis-Abo oder eine nicht bereinigte Closure im Arbeitsspeicher gehalten wird, kann die automatische Speicherbereinigung den Arbeitsspeicher nicht freigeben. Auch wenn das native Objekt zerstört wird, bleibt der verwaltete Wrapper bestehen, was zu „Ghost“-Speicherlecks 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 Abo von Ereignissen explizit mit dem Operator -= auf und setzen Sie statische Referenzen auf UnityEngine.Object-Typen auf „null“. Diese Bereinigung stellt sicher, dass die automatische Speicherbereinigung den Wrapper erfolgreich erfassen kann, sobald die native Engine ihr Handle freigegeben hat.

7. Binäre Kataloge für Addressables verwenden

In Unity 2022 und früher verwendet Addressables standardmäßig JSON-Kataloge anstelle von binären Katalogen (die in Unity 2023 und höher die Standardeinstellung sind). Wenn Ihr Projekt JSON-Kataloge verwendet, wird beim Laden eines großen Katalogs beim Start die gesamte Datei in einen einzelnen riesigen C#-String gelesen und Tausende von verwalteten Strings und temporären byte[]-Arrays werden während der JSON-Analyse und Base64-Decodierung zugewiesen. Dies führt zu einer großen GC-Zuweisungsspitze, die den verwalteten Heap dauerhaft erweitert. Wechseln Sie daher zu einem binären Katalog, um kompakte Daten direkt zu streamen.