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ü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 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
AudioClipImporteinstellungen, 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.Resetin 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
CopyDepthPass 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 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>: 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
WaitForSecondsInstanzen im Cache, anstattyield 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
Equalsaufgerufen, was zu Objekt-Boxing führt. ImplementIEqualityComparer<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.ReflectionAPIs, 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
MethodInfooderFieldInfowä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 aufUnityEngine.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.