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ührtMemoryLimiterein, 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
CopyDepthPass 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 neuesCamera[]-Array im Heap generiert wird. Speichern Sie stattdessen ein Kamera-Array im Cache und übergeben Sie es anCamera.GetAllCameras(_allCameras). - Vermeiden Sie
foreachfürIReadOnlyList<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
WaitForSecondsInstanzen im Cache, anstattyield 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
Equalsaufgerufen, wodurch das Objekt geboxt wird. Implementieren SieIEqualityComparer<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
MethodInfooderFieldInfowä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 aufUnityEngine.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.