Chociaż fizyczna pojemność pamięci RAM nowoczesnych urządzeń mobilnych stale rośnie, zużycie pamięci przez gry rośnie szybciej ze względu na zasoby o wysokiej rozdzielczości i złożone potoki renderowania.
Główne problemy spowodowane nadmiernym wykorzystaniem pamięci
- Zakończenie działania przez Low Memory Killer (LMK): system operacyjny zamyka aplikacje działające w tle a nawet na pierwszym planie, aby rozwiązać problemy z niedoborem pamięci w całym systemie.
- Spadki liczby klatek i zacinanie się (jank): częste skoki odśmiecania (GC) na stercie zarządzanej lub wymiana pamięci na poziomie systemu operacyjnego powodują wąskie gardła przetwarzania.
- Ograniczanie termiczne i szybkie zużycie baterii: ciągła alokacja pamięci, zwalnianie pamięci i zatwierdzanie stron pamięci powodują znaczne obciążenie procesora, co prowadzi do wzrostu temperatury i szybszego wyczerpywania się baterii.
Aktualizacje zarządzania pamięcią w Androidzie
- Android 17: wprowadzenie
MemoryLimiter: Android 17 wprowadzaMemoryLimiter, który aktywnie monitoruje zużycie pamięci przez aplikację w porównaniu z progami specyficznymi dla urządzenia. Aplikacje przekraczające limit pamięci są natychmiast zamykane na poziomie systemu. Ponieważ ten mechanizm jest bardziej rygorystyczny niż tradycyjny LMK, zarządzanie szczytowym wykorzystaniem pamięci jest teraz ważniejsze niż kiedykolwiek.
Zmniejszanie wykorzystania pamięci w Unity
Gdy silnik Unity rozszerzy swoje wewnętrzne pule pamięci (Native Block Allocators i Managed Heap) w celu dostosowania się do dużego obciążenia szczytowego, zachowuje te strony pamięci zamiast natychmiast zwracać je do systemu operacyjnego.
W rezultacie nawet po zwolnieniu dużych zasobów bazowa pamięć rezydentna pozostaje zawyżona, co sprawia, że aplikacja jest bardzo podatna na zakończenie procesu przez system operacyjny Android (np. przez LMK lub MemoryLimiter).
Aby zapobiec egzekwowaniu zasad na poziomie systemu operacyjnego, optymalizację pamięci należy przeprowadzić w 3 kluczowych obszarach:
- Kategoria A: zmniejszenie szczytowego wykorzystania pamięci
- Kategoria B: wyeliminowanie niepotrzebnych zasobów i śladów systemowych
- Kategoria C: wyeliminowanie niepotrzebnych alokacji GC
Kategoria A: zmniejszenie szczytowego wykorzystania pamięci
Alokatory Unity zachowują zwolnione strony pamięci do ponownego użycia zamiast natychmiast zwracać je do systemu operacyjnego, więc bazowa pamięć zwykle odzwierciedla najwyższy osiągnięty szczyt, a nie bieżące zużycie. Zapobieganie skokom jest zatem bardziej skuteczne niż czyszczenie po fakcie.
1. Unikaj zbyt dużych pakietów AssetBundle
Ze względu na mechanizm wczytywania pakietów w Unity duże pakiety AssetBundle prowadzą do znacznego wzrostu zużycia pamięci i awarii spowodowanych brakiem pamięci. Aby zapewnić efektywne zarządzanie zasobami, utrzymuj modułowość pakietów.
Główne problemy
- Duże obciążenie pamięci: żądanie pojedynczego małego zasobu zmusza Unity do wczytania do pamięci RAM całego pliku pakietu (w tym nagłówków, metadanych i buforów przesyłania strumieniowego).
- Pułapka zwalniania: jeśli jakikolwiek zasób w pakiecie jest aktywnie używany, nie można zwolnić całego pakietu, co powoduje, że nieużywane dane pozostają w pamięci RAM.
Sprawdzone metody
- Utrzymuj modułowość pakietów: grupuj zasoby logicznie według sceny lub cyklu życia.
- Wskazówka dotycząca Unity 6.6 i nowszych wersji: używaj katalogów treści , aby zapobiec niezamierzonym zależnościom między pakietami.
2. Optymalizuj odwołania do zasobów w ScriptableObjects
Bezpośrednie serializowane pola UnityEngine.Object w ScriptableObject tworzą bezpośrednie odwołania, co powoduje, że wszystkie zasoby, do których się odwołują, są wczytywane do pamięci RAM, gdy tylko ScriptableObject zostanie wczytany lub utworzony.
// 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. Konfiguruj klipy audio i kanały
Wczytywanie wszystkich nieskompresowanych klipów audio bezpośrednio do pamięci powoduje duże, trwałe skoki zużycia pamięci. Ustawienia wczytywania dźwięku należy skonfigurować na podstawie profilu ich użycia:
| Kategoria dźwięku | Typ obciążenia | Przyczyna |
|---|---|---|
| BGM (muzyka w tle) | Streaming | Przesyła strumieniowo dźwięk z dysku w małych buforach, aby wyeliminować skoki zużycia pamięci. |
| Długie efekty dźwiękowe | Compressed In Memory | Utrzymuje niskie obciążenie pamięci RAM i dekompresuje dźwięk na bieżąco podczas odtwarzania. |
| Krótkie i częste efekty dźwiękowe | Decompress On Load | Dekompresuje dźwięk do pamięci RAM podczas wczytywania, aby uniknąć obciążenia procesora podczas odtwarzania. |
Oprócz typów obciążenia zastosuj te konfiguracje dźwięku, aby jeszcze bardziej zmniejszyć obciążenie pamięci:
- Włącz opcję Force To Mono w przypadku dźwięku niedirectionalnego: wiele zasobów audio, takich
jak efekty dźwiękowe interfejsu, lektor i przestrzenne efekty dźwiękowe 3D, nie
wymaga separacji kanałów stereo. Włącz Force To Mono w
AudioClipustawieniach importu dla tych zasobów, aby zmniejszyć ich wykorzystanie pamięci i rozmiar bufora o połowę (50%). - Ograniczaj aktywne kanały audio według warstwy urządzenia: każdy aktywny głos audio
przydziela natywny mikser DSP i bufory dekompresji. Na urządzeniach z małą ilością pamięci,
zmniejsz maksymalną liczbę jednocześnie aktywnych kanałów, np. zmniejszając Max Real Voices (np. z 32 do 16) za pomocą
AudioSettings.Resetw Unity lub ograniczając kanały odtwarzania oprogramowania w silnikach audio innych firm.
4. Wdrażaj strategie puli obiektów i zwalniania
Niezwolnione instancje pozostające w pulach obiektów podczas przejść między scenami zajmują pamięć zarezerwowaną na czas nieokreślony, co powoduje niepotrzebne zwiększenie bazowego wykorzystania pamięci.
- Działanie: okresowo czyść lub przycinaj nieużywane obiekty w puli podczas przejść między scenami lub okresów niskiej aktywności, co pozwoli Unity zwrócić lub ponownie wykorzystać zarezerwowaną przestrzeń na inne alokacje.
Kategoria B: wyeliminowanie niepotrzebnego wykorzystania pamięci
Wyeliminowanie zbędnych zasobów graficznych i buforów docelowych renderowania bezpośrednio zmniejsza bazowe wykorzystanie pamięci.
1. Optymalizuj tekstury renderowania i głębię kamery
- Usuń bufory głębi/stencil: ustaw format głębi i stencil na Brak w przypadku tekstur renderowania, które wymagają tylko danych o kolorze.
- Wyłącz teksturę głębi kamery interfejsu: w przypadku kamer interfejsu, w których dane o głębi są
zbędne, wyłącz generowanie tekstury głębi w ustawieniach kamery URP, aby
wyeliminować przekazywanie
CopyDepthi powiązaną pamięć tekstury GPU.
2. Optymalizuj tekstury i siatki
| Kategoria | Wskazówka dotycząca optymalizacji |
|---|---|
| Kompresja tekstur | Zawsze stosuj formaty kompresji platformy docelowej (np. ASTC w przypadku Androida). |
| Włączony odczyt/zapis | Wyłączaj, chyba że jest to konieczne. Włączenie tej opcji powoduje duplikowanie pamięci tekstury w pamięci RAM procesora i GPU. |
| Mipmapy | Wyłącz mipmapy w przypadku tekstur interfejsu lub obiektów stałych w stałej odległości od kamery, co pozwala zaoszczędzić ok. 33% pamięci tekstury. |
| Złożoność siatki | Zmniejsz niepotrzebną liczbę wielokątów i strumieni wierzchołków, aby zmniejszyć wykorzystanie pamięci GPU i pamięci natywnej. |
3. Usuwaj warianty shaderów i optymalizuj pamięć
Uber-shadery (np. URP Lit Shader) obejmują wiele funkcji za pomocą słów kluczowych #multi_compile i shader_feature. Bez optymalizacji,
eksplozja kombinatoryczna tworzy dziesiątki tysięcy unikalnych
wariantów shaderów, co prowadzi do zwiększenia rozmiaru kompilacji, znacznego zużycia pamięci natywnej
i problemów z kompilacją sterowników GPU podczas rozgrywki.
A. Mechanika obciążenia pamięci przez warianty shaderów
- Eksplozja kombinatoryczna: łączna liczba możliwych wariantów rośnie wykładniczo z każdym dodanym zestawem słów kluczowych.
- Architektura alokacji fragmentów: Unity grupuje skompilowane warianty binarne w skompresowane bloki pamięci nazywane fragmentami (domyślnie: 4 MB).
- Zwiększenie zużycia pamięci natywnej: gdy kod środowiska wykonawczego zażąda nawet jednego wariantu w fragmencie, cały 4-megabajtowy fragment zostanie zdekompresowany do pamięci RAM. Jeśli nie zostanie zoptymalizowany, tysiące nieużywanych wariantów spakowanych w tych fragmentach będą na stałe zajmować pamięć natywną.
B. Wbudowany w Unity wieloetapowy potok usuwania: Unity automatycznie usuwa
niepotrzebne warianty podczas kompilacji na podstawie ustawień graficznych platformy docelowej
ustawień i nieużywanych funkcji silnika (np. Fog, Lightmaps i ustawień XR
ustawień). Ponadto warianty shader_feature są automatycznie
odfiltrowywane, jeśli ich słowa kluczowe nie są aktywnie używane przez żaden materiał w
projekcie, natomiast warianty #multi_compile są wymuszane niezależnie
od użycia. Jeśli jednak funkcje renderowania lub przetwarzania końcowego (np.
Additional Lights, cienie w czasie rzeczywistym lub nieużywane UberPost efekty) pozostaną
włączone w ustawieniach URP lub potoku, Unity nie będzie w stanie określić, czy Twoje
treści faktycznie ich używają, i wygeneruje wszystkie odpowiednie #multi_compile
warianty. Sprawdź i wyłącz nieużywane ustawienia potoku, aby odpowiadały
rzeczywistym wymaganiom dotyczącym renderowania i zapobiegały niepotrzebnemu generowaniu wariantów.
C. Niestandardowa architektura automatycznego usuwania
(IPreprocessShaders) ponieważ analiza statyczna nie może
wykryć słów kluczowych modyfikowanych dynamicznie za pomocą skryptów C# środowiska wykonawczego
(Material.EnableKeyword), standardowe usuwanie jest często niewystarczające. Aby
mieć pewność, że uwzględniane są tylko faktycznie używane warianty, możesz zbierać warianty
podczas testów QA za pomocą Player.log (włączając
Log Shader Compilation w ustawieniach Edytora) lub Profiler Traces
(Shader.CreateGPUProgram znaczniki). Następnie zaimplementuj
IPreprocessShaders.OnProcessShader w skrypcie Edytora, aby odfiltrować
warianty, które nigdy nie były wykonywane w środowisku wykonawczym, i zachować w kompilacji tylko te, które są potrzebne.
D. Konsoliduj zbędne shadery: Uber-shadery, takie jak Lit i Simple
Lit , zawierają wiele dyrektyw #multi_compile i zużywają znaczną ilość
pamięci podczas generowania wielu wariantów:
- Profiluj jednoczesne wykorzystanie: profiluj wykorzystanie pamięci, aby sprawdzić, czy oba shadery zużywają znaczną ilość pamięci w tym samym czasie, i sprawdź, czy Twój projekt faktycznie ich potrzebuje.
- Ustandaryzuj jeden shader: jeśli renderowanie oparte na fizyce (PBR) nie jest wymagane, skonfiguruj zasoby tak, aby wyeliminować zbędne użycie shaderów Lit i ustandaryzować Simple Lit (np. sprawdź, czy eksportowane modele FBX mają domyślne materiały Lit nieumyślnie zastosowane podczas importu).
Kategoria C: wyeliminowanie niepotrzebnych alokacji GC
Alokacje odśmiecania (GC) na stercie zarządzanej prowadzą do fragmentacji pamięci, rozszerzania sterty, które nigdy się nie zmniejsza, i poważnych spadków liczby klatek podczas wstrzymywania GC.
1. Zapobiegaj alokacjom zamknięcia lambda
Gdy wyrażenie lambda przechwytuje zewnętrzne zmienne lokalne, C# generuje niejawną klasę wyświetlania na stercie. Wykonanie tego w Update powoduje alokowanie instancji zamknięcia w każdej klatce.
// 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. Używaj stackalloc i Span
Unikaj alokacji sterty w przypadku krótkotrwałych tablic tymczasowych, używając pamięci stosu.
// 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. Optymalizuj iterację kolekcji
Unikaj alokacji sterty spowodowanych przez rozszerzenia LINQ lub akcesory ReadOnlyCollection.
// 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. Dodatkowe reguły zapobiegania alokacjom GC
- Unikaj używania
Camera.allCameras, ponieważ przy każdym wywołaniu generuje ono nową tablicęCamera[]na stercie. Zamiast tego zapisz w pamięci podręcznej tablicę kamer i przekaż ją doCamera.GetAllCameras(_allCameras). - Unikaj
foreachwIReadOnlyList<T>: iterowanie po interfejsie powoduje boxing enumeratora struktury, co generuje alokacje GC. Zamiast tego użyj standardowej pętlifor. - Zapisuj w pamięci podręcznej obiekty współprogramów: zapisuj w pamięci podręcznej instancje
WaitForSecondszamiast wielokrotnie tworzyć instancjeyield return new WaitForSeconds(time);. - Klucze struktur niestandardowych w słownikach: używanie struktur niestandardowych jako kluczy słownika
powoduje wywołanie domyślnej funkcji
Equals, co wywołuje boxing obiektu. ZaimplementujIEqualityComparer<T>i przekaż go do konstruktora słownika.
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. Minimalizuj użycie typów ogólnych i odbicia
Chociaż metody ogólne zapewniają doskonałe ponowne wykorzystanie kodu i łatwość utrzymania, ich nadmierne używanie może negatywnie wpłynąć na projekt w kontekście backendu Unity IL2CPP (Intermediate Language to C++).
- Zwiększenie rozmiaru kodu IL2CPP: w przypadku każdej unikalnej kombinacji typów ogólnych IL2CPP generuje wyspecjalizowaną wersję kodu. Nadmierne używanie złożonych typów ogólnych może prowadzić do „eksplozji kombinatorycznej” generowanego kodu C++, co znacznie zwiększa rozmiar binarny aplikacji i zużycie pamięci natywnej.
- Obciążenie odbicia: metody używające odbicia, takie jak
System.Reflectioninterfejsy API, są z natury wolne i często powodują alokacje sterty w środowisku wykonawczym. - Sprawdzona metoda: używaj typów ogólnych z rozwagą – priorytetowo traktuj je pod kątem
przejrzystości architektury, a nie szerokiego, bezkrytycznego zastosowania. W przypadkach, w których wydajność ma kluczowe znaczenie, preferuj typy konkretne lub polimorfizm oparty na interfejsach. W przypadku odbicia zapisuj w pamięci podręcznej wyniki, takie jak
MethodInfolubFieldInfo, podczas inicjowania zamiast wysyłać do nich zapytania w pętli aktualizacji.
6. Unikaj wycieków zarządzanych powłok
Każdy UnityEngine.Object, taki jak MonoBehaviour, Texture lub GameObject, ma otokę „Managed Shell” w C#, która komunikuje się z natywnym silnikiem C++.
- Problem: jeśli powłoka zarządzana jest przechowywana w pamięci przez odwołanie statyczne, trwałą subskrypcję zdarzenia lub nieoczyszczone zamknięcie, GC nie może odzyskać pamięci. Nawet jeśli obiekt natywny zostanie zniszczony, otoka zarządzana pozostanie, co spowoduje „duchy” wycieków pamięci, które zwiększają rozmiar sterty zarządzanej.
- Rozwiązanie: zawsze wdrażaj niezawodne wzorce czyszczenia. Podczas niszczenia obiektów lub przechodzenia między scenami wyraźnie anuluj subskrypcję zdarzeń za pomocą operatora
-=i ustaw odwołania statyczne do typówUnityEngine.Objectna wartość null. To czyszczenie zapewnia, że GC może pomyślnie zebrać otokę, gdy silnik natywny zwolni swój uchwyt.
7. Używaj katalogów binarnych w przypadku Addressables
W Unity 2022 i starszych wersjach Addressables domyślnie używa katalogów JSON zamiast katalogów binarnych (które są domyślne w Unity 2023 i nowszych wersjach). Jeśli Twój projekt używa katalogów JSON, wczytanie dużego katalogu podczas uruchamiania powoduje wczytanie całego pliku do jednego dużego ciągu C# i przydzielenie tysięcy zarządzanych ciągów oraz tymczasowych tablic byte[] podczas analizowania JSON i dekodowania Base64. Powoduje to duży skok alokacji GC, który na stałe rozszerza stertę zarządzaną, więc przełącz się na katalog binarny, aby przesyłać strumieniowo skompresowane dane bezpośrednio.