Tối ưu hoá bộ nhớ trò chơi Unity trên Android

Mặc dù dung lượng RAM thực của các thiết bị di động hiện đại tiếp tục tăng lên, nhưng mức tiêu thụ bộ nhớ của trò chơi đang vượt quá mức tăng trưởng này do các thành phần có độ phân giải cao và quy trình kết xuất phức tạp.

Các vấn đề chính do mức sử dụng bộ nhớ quá cao gây ra

  • Lỗi chấm dứt do low memory killer (LMK): Hệ điều hành buộc đóng các ứng dụng nền hoặc thậm chí cả ứng dụng trên nền trước để giải quyết tình trạng thiếu bộ nhớ trên toàn hệ thống.
  • Rớt khung hình và giật (jank): Việc thu gom rác (GC) thường xuyên tăng đột biến trong Vùng nhớ khối xếp được quản lý hoặc việc hoán đổi bộ nhớ ở cấp hệ điều hành sẽ tạo ra các nút thắt trong quá trình xử lý.
  • Điều tiết nhiệt và tiêu hao pin: Việc phân bổ bộ nhớ, huỷ phân bổ và xác nhận trang bộ nhớ liên tục sẽ làm tăng đáng kể mức hao tổn CPU, dẫn đến tăng nhiệt và tiêu hao pin nhanh hơn.

Bản cập nhật về tính năng quản lý bộ nhớ trên Android

  • Android 17: Giới thiệu về MemoryLimiter: Android 17 giới thiệu MemoryLimiter. Đây là một API chủ động giám sát mức tiêu thụ bộ nhớ ứng dụng dựa trên các ngưỡng dành riêng cho thiết bị. Các ứng dụng vượt quá giới hạn bộ nhớ sẽ bị chấm dứt ngay lập tức ở cấp hệ thống. Vì cơ chế này nghiêm ngặt hơn LMK truyền thống, nên việc quản lý mức sử dụng bộ nhớ cao nhất hiện quan trọng hơn bao giờ hết.

Giảm mức sử dụng bộ nhớ trong Unity

Trong Unity, sau khi công cụ này mở rộng các nhóm bộ nhớ nội bộ (Native Block AllocatorsManaged Heap) để đáp ứng mức tải cao, công cụ này sẽ giữ lại các trang bộ nhớ đó thay vì trả lại ngay cho hệ điều hành. Do đó, ngay cả sau khi các thành phần nặng được gỡ tải, Resident Memory (Bộ nhớ thường trú) cơ bản vẫn tăng lên, khiến ứng dụng rất dễ bị hệ điều hành Android chấm dứt quy trình (chẳng hạn như LMK hoặc MemoryLimiter).

Để ngăn chặn việc thực thi ở cấp hệ điều hành, bạn phải tiếp cận việc tối ưu hoá bộ nhớ thông qua 3 trụ cột chính:

  • Danh mục A: Giảm mức sử dụng bộ nhớ cao nhất
  • Danh mục B: Loại bỏ dấu vết không cần thiết của tài sản và hệ thống
  • Danh mục C: Loại bỏ các hoạt động phân bổ GC không cần thiết

Danh mục A: Giảm mức sử dụng bộ nhớ cao nhất

Các trình phân bổ của Unity giữ lại các trang bộ nhớ đã giải phóng để sử dụng lại thay vì trả lại ngay cho hệ điều hành, vì vậy, bộ nhớ cơ sở có xu hướng phản ánh mức sử dụng cao nhất đã đạt được thay vì mức sử dụng hiện tại. Do đó, việc ngăn chặn các đợt tăng đột biến ngay từ đầu sẽ hiệu quả hơn so với việc dựa vào dọn dẹp sau đó.

1. Tránh AssetBundle quá lớn

Do cơ chế Bundle Loading (Tải gói) của Unity, các AssetBundle khổng lồ sẽ dẫn đến tình trạng tràn bộ nhớ nghiêm trọng và sự cố OOM. Giữ các gói theo kiểu mô-đun để đảm bảo quản lý tài nguyên hiệu quả.

Các vấn đề chính

  • Tốn nhiều bộ nhớ: Việc yêu cầu một thành phần nhỏ duy nhất buộc Unity phải tải toàn bộ tệp gói (bao gồm cả tiêu đề, siêu dữ liệu và vùng đệm truyền phát trực tuyến) vào RAM.
  • Bẫy gỡ tải: Nếu bất kỳ thành phần nào trong một gói đang được sử dụng, thì toàn bộ gói sẽ không thể gỡ tải, khiến dữ liệu không dùng đến bị mắc kẹt trong RAM.

Các phương pháp hay nhất

  • Giữ các gói theo kiểu mô-đun: Nhóm các thành phần một cách hợp lý theo cảnh hoặc vòng đời.
  • Mẹo cho Unity 6.6 trở lên: Sử dụng Thư mục nội dung để ngăn các phần phụ thuộc ngoài ý muốn giữa các gói.

2. Tối ưu hoá các tham chiếu đến thành phần trong ScriptableObjects

Các trường được tuần tự hoá UnityEngine.Object trực tiếp trong ScriptableObject sẽ tạo ra các thông tin tham chiếu trực tiếp và cố định, buộc tất cả các thành phần được tham chiếu phải tải vào RAM ngay khi ScriptableObject được tải hoặc khởi tạo.

// 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. Định cấu hình các loại tải đoạn âm thanh

Việc tải tất cả các đoạn âm thanh chưa nén trực tiếp vào bộ nhớ sẽ tạo ra các đỉnh bộ nhớ lớn, vĩnh viễn. Bạn nên định cấu hình chế độ cài đặt tải âm thanh dựa trên hồ sơ sử dụng:

Danh mục âm thanh Loại cửa Lý do
BGM (Nhạc nền) Phát trực tuyến Truyền trực tuyến âm thanh từ đĩa trong các vùng đệm nhỏ để loại bỏ các đỉnh bộ nhớ.
Long SFX Được nén trong bộ nhớ Giảm mức sử dụng RAM và giải nén âm thanh ngay trong khi phát.
Hiệu ứng đặc biệt ngắn và thường xuyên Giải nén khi tải Giải nén âm thanh vào RAM khi tải để tránh hao tổn CPU trong thời gian chạy khi phát.

4. Triển khai chiến lược phát hành và nhóm đối tượng

Các thực thể chưa phát hành còn lại bên trong Nhóm đối tượng trong quá trình chuyển đổi cảnh sẽ giữ lại Bộ nhớ dự phòng vô thời hạn, khiến mức sử dụng bộ nhớ cơ bản tăng lên một cách không cần thiết.

  • Hành động: Định kỳ xoá hoặc cắt bớt các đối tượng được gộp không dùng đến trong quá trình chuyển đổi cảnh hoặc trong các khoảng thời gian có ít hoạt động, cho phép Unity trả lại hoặc sử dụng lại không gian đã đặt trước đó cho các hoạt động phân bổ khác.

Danh mục B: Loại bỏ mức sử dụng bộ nhớ không cần thiết

Việc loại bỏ các thành phần đồ hoạ dư thừa và trực tiếp kết xuất các vùng đệm mục tiêu giúp giảm mức sử dụng bộ nhớ cơ bản.

1. Tối ưu hoá kết cấu kết xuất và độ sâu của camera

  • Xoá bộ đệm chiều sâu/stencil: Đặt Định dạng stencil chiều sâu thành Không có cho Kết cấu kết xuất chỉ yêu cầu dữ liệu màu.
  • Tắt kết cấu độ sâu camera giao diện người dùng: Đối với những camera giao diện người dùng không cần dữ liệu độ sâu, hãy tắt tính năng tạo kết cấu độ sâu trong chế độ cài đặt camera URP để loại bỏ CopyDepth Pass và bộ nhớ kết cấu GPU liên kết.

2. Tối ưu hoá hoạ tiết và lưới

Danh mục Nguyên tắc tối ưu hoá
Nén hoạ tiết Luôn áp dụng các định dạng nén nền tảng nhắm mục tiêu (ví dụ: ASTC cho Android).
Đã bật chế độ Đọc/Ghi Tiếp tục tắt trừ phi cần thiết. Việc bật chế độ này sẽ sao chép bộ nhớ kết cấu trên RAM CPU và GPU.
Mipmap Tắt Mipmap cho các đối tượng hoặc hoạ tiết giao diện người dùng được cố định ở khoảng cách không đổi với camera, giúp tiết kiệm khoảng 33% bộ nhớ hoạ tiết.
Độ phức tạp của lưới Giảm số lượng đa giác và luồng đỉnh không cần thiết để giảm mức sử dụng GPU và bộ nhớ gốc.

3. Loại bỏ các biến thể chương trình đổ bóng và tối ưu hoá bộ nhớ

Uber-shader (ví dụ: URP Lit Shader) đóng gói nhiều tính năng bằng cách sử dụng các từ khoá #multi_compileshader_feature. Nếu không được tối ưu hoá, sự bùng nổ tổ hợp sẽ tạo ra hàng chục nghìn biến thể chương trình đổ bóng duy nhất, dẫn đến kích thước bản dựng tăng lên, mức tiêu thụ bộ nhớ gốc lớn và các trục trặc khi biên dịch trình điều khiển GPU trong quá trình chơi.

A. Cơ chế hao tổn bộ nhớ của biến thể chương trình đổ bóng

  • Tổ hợp bùng nổ: Tổng số biến thể có thể tăng theo cấp số nhân với mỗi nhóm từ khoá được thêm.
  • Cấu trúc phân bổ khối: Unity nhóm các biến thể nhị phân đã biên dịch thành các khối bộ nhớ nén được gọi là Khối (mặc định: 4 MB).
  • Tình trạng phình to bộ nhớ gốc: Khi mã thời gian chạy yêu cầu dù chỉ một biến thể bên trong một đoạn, toàn bộ đoạn 4 MB sẽ được giải nén vào RAM. Nếu không được tối ưu hoá, hàng nghìn biến thể không dùng đến được đóng gói trong các đoạn đó sẽ chiếm bộ nhớ gốc vĩnh viễn.

B. Quy trình loại bỏ nhiều giai đoạn tích hợp sẵn của Unity: Unity tự động loại bỏ các biến thể không cần thiết tại thời điểm tạo bản dựng dựa trên chế độ cài đặt đồ hoạ của nền tảng mục tiêu và các tính năng không dùng đến của công cụ (ví dụ: Fog, Lightmaps và chế độ cài đặt XR). Ngoài ra, các biến thể shader_feature sẽ tự động bị lọc nếu không có tài liệu nào trong dự án đang sử dụng từ khoá của các biến thể đó, trong khi các biến thể #multi_compile sẽ bị buộc phải đưa vào bất kể mức độ sử dụng.

C. Cấu trúc loại bỏ tự động tuỳ chỉnh (IPreprocessShaders) Vì quy trình phân tích tĩnh không thể phát hiện các từ khoá được sửa đổi linh hoạt bằng cách sử dụng tập lệnh C# thời gian chạy (Material.EnableKeyword), nên quy trình loại bỏ tiêu chuẩn thường không đủ. Để đảm bảo chỉ những biến thể thực sự được sử dụng mới được đưa vào, bạn có thể thu thập các biến thể trong bộ kiểm thử QA bằng cách sử dụng Player.log (bật Log Shader Compilation trong phần cài đặt Trình chỉnh sửa) hoặc Profiler Traces (các điểm đánh dấu Shader.CreateGPUProgram). Sau đó, hãy triển khai IPreprocessShaders.OnProcessShader trong một tập lệnh Editor để lọc ra những biến thể chưa bao giờ được thực thi trong thời gian chạy, chỉ giữ lại những biến thể cần thiết trong bản dựng.

Danh mục C: Loại bỏ các trường hợp phân bổ GC không cần thiết

Việc phân bổ Quy trình thu gom rác (GC) trong Nhóm được quản lý dẫn đến tình trạng phân mảnh bộ nhớ, việc mở rộng nhóm không bao giờ giảm và tình trạng giảm khung hình nghiêm trọng trong thời gian tạm dừng GC.

1. Ngăn chặn việc phân bổ đóng lambda

Khi biểu thức lambda nắm bắt các biến cục bộ bên ngoài, C# sẽ tạo một lớp hiển thị ngầm định trên Heap. Việc thực thi thao tác này bên trong Update sẽ phân bổ các phiên bản đóng mỗi khung hình.

// 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. Sử dụng stackalloc và Span

Tránh phân bổ Heap cho các mảng tạm thời tồn tại trong thời gian ngắn bằng cách sử dụng bộ nhớ Stack.

// 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. Tối ưu hoá quá trình lặp lại của bộ sưu tập

Tránh việc phân bổ vùng nhớ khối xếp Enumerator và boxing do các tiện ích LINQ hoặc các trình truy cập ReadOnlyCollection gây ra.

// 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. Các quy tắc bổ sung để ngăn chặn việc phân bổ GC

  • Tránh sử dụng Camera.allCameras vì thao tác này sẽ tạo một mảng Camera[] mới trên vùng nhớ khối xếp trong mỗi lệnh gọi. Thay vào đó, hãy lưu vào bộ nhớ đệm một mảng camera và truyền mảng đó đến Camera.GetAllCameras(_allCameras).
  • Tránh foreach trên IReadOnlyList<T>: Việc lặp lại trên một giao diện sẽ gây ra thao tác đóng gói trình liệt kê cấu trúc, tạo ra các hoạt động phân bổ GC. Thay vào đó, hãy sử dụng một vòng lặp for tiêu chuẩn.
  • Lưu các đối tượng coroutine vào bộ nhớ đệm: Lưu các thực thể WaitForSeconds vào bộ nhớ đệm thay vì liên tục tạo thực thể yield return new WaitForSeconds(time);.
  • Khoá struct tuỳ chỉnh trong từ điển: Việc sử dụng struct tuỳ chỉnh làm khoá Dictionary sẽ gọi Equals mặc định, kích hoạt tính năng đóng gói đối tượng. Triển khai IEqualityComparer<T> và truyền nó vào hàm khởi tạo Từ điển.
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. Giảm thiểu mức sử dụng chung và mức sử dụng phản chiếu

Mặc dù các phương thức chung cung cấp khả năng sử dụng lại và duy trì mã tuyệt vời, nhưng việc sử dụng quá nhiều các phương thức này có thể ảnh hưởng tiêu cực đến dự án của bạn trong bối cảnh phần phụ trợ Unity IL2CPP (Ngôn ngữ trung gian sang C++).

  • IL2CPP code bloat: Đối với mỗi tổ hợp kiểu chung riêng biệt, IL2CPP sẽ tạo một phiên bản chuyên biệt của mã. Việc sử dụng quá nhiều kiểu chung phức tạp có thể dẫn đến "vụ nổ tổ hợp" của mã C++ được tạo, làm tăng đáng kể kích thước tệp nhị phân và mức sử dụng bộ nhớ gốc của ứng dụng.
  • Chi phí phản chiếu: Các phương thức sử dụng tính năng phản chiếu, chẳng hạn như API System.Reflection, vốn dĩ chậm và thường gây ra việc phân bổ vùng nhớ heap trong thời gian chạy.
  • Phương pháp hay nhất: Sử dụng kiểu chung một cách thận trọng – ưu tiên kiểu chung để có cấu trúc rõ ràng thay vì ứng dụng rộng rãi, bừa bãi. Khi hiệu suất là yếu tố quan trọng, hãy ưu tiên các loại cụ thể hoặc tính đa hình dựa trên giao diện. Đối với hoạt động phản chiếu, hãy lưu vào bộ nhớ đệm các kết quả như MethodInfo hoặc FieldInfo trong quá trình khởi chạy thay vì truy vấn chúng trong vòng lặp cập nhật.

6. Tránh để lộ các shell được quản lý

Mọi UnityEngine.Object, chẳng hạn như MonoBehaviour, Texture hoặc GameObject, đều có trình bao bọc "Managed Shell" C# giao tiếp với công cụ C++ gốc.

  • Vấn đề: Nếu một Managed Shell được giữ trong bộ nhớ bằng một tham chiếu tĩnh, một đăng ký sự kiện liên tục hoặc một bao đóng chưa được dọn dẹp, thì GC không thể thu hồi bộ nhớ. Ngay cả khi đối tượng gốc bị huỷ, trình bao bọc được quản lý vẫn duy trì, dẫn đến tình trạng rò rỉ bộ nhớ "ảo" làm phình to Nhóm được quản lý.
  • Giải pháp: Luôn triển khai các mẫu dọn dẹp mạnh mẽ. Khi huỷ các đối tượng hoặc chuyển đổi cảnh, hãy huỷ đăng ký rõ ràng khỏi các sự kiện bằng cách sử dụng toán tử -= và vô hiệu hoá các tham chiếu tĩnh đến các loại UnityEngine.Object. Quá trình dọn dẹp này đảm bảo GC có thể thu thập trình bao bọc thành công sau khi công cụ gốc phát hành trình xử lý.