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

Mặc dù dung lượng RAM vật lý 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 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á mức

  • Việc chấm dứt do trình tắt ứng dụng khi bộ nhớ thấp (LMK): Hệ điều hành buộc phải đóng các ứng dụng ở chế độ 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.
  • Giảm số khung hình và giật (jank): Các đợt thu gom rác (GC) thường xuyên tăng đột biến trong Vùng nhớ đượ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à cam kết trang bộ nhớ liên tục sẽ gây ra hao tổn đáng kể cho CPU, dẫn đến tình trạng 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ớ của Android

  • Android 17: Giới thiệu về MemoryLimiter: Android 17 giới thiệu MemoryLimiter giúp chủ động theo dõi mức tiêu thụ bộ nhớ của ứ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ộ (Trình phân bổ khối gốc và Vùng nhớ được quản lý) để đáp ứng mức tải cao nhất, 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 huỷ tải, Bộ nhớ thường trú cơ sở vẫn bị phình to, 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 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ỏ các dấu vết không cần thiết của thành phần và hệ thống
  • Danh mục C: Loại bỏ các trường hợp 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

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 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ọn dẹp sau đó.

1. Tránh AssetBundle quá lớn

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

Các vấn đề chính

  • Hao tổn bộ nhớ cao: 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 phát trực tuyến) vào RAM.
  • Bẫy huỷ 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ể huỷ 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 ở dạng 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 không mong muốn giữa các gói.

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

Các trường được tuần tự hoá UnityEngine.Object trực tiếp trong ScriptableObject tạo ra các thông tin tham chiếu cứng trực tiếp, 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 tạo thực thể.

// 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 đoạn âm thanh và kênh

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 đợt tăng đột biến bộ nhớ lớn và 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 của chúng:

Danh mục âm thanh Loại tải Lý do
BGM (Nhạc nền) Phát trực tuyến Phát trực tuyến âm thanh từ đĩa trong các vùng đệm nhỏ để loại bỏ các đợt tăng đột biến bộ nhớ.
Hiệu ứng âm thanh dài Đã nén trong bộ nhớ Giữ mức hao tổn RAM ở mức thấp và giải nén âm thanh ngay lập tức trong khi phát.
Hiệu ứng âm thanh 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.

Ngoài các loại tải, hãy áp dụng các cấu hình âm thanh sau để giảm thêm mức hao tổn bộ nhớ:

  • Bật tính năng Buộc chuyển sang đơn âm cho âm thanh không định hướng: Nhiều thành phần âm thanh (chẳng hạn như hiệu ứng âm thanh của giao diện người dùng, lồng tiếng và hiệu ứng âm thanh không gian 3D) không yêu cầu tách kênh âm thanh nổi. Bật Buộc chuyển sang đơn âm trong chế độ cài đặt nhập AudioClip cho các thành phần này để giảm một nửa mức sử dụng bộ nhớ và dung lượng bộ nhớ đệm (giảm 50%).
  • Điều tiết các kênh âm thanh đang hoạt động theo cấp thiết bị: Mỗi giọng âm thanh đang hoạt động sẽ phân bổ bộ trộn DSP gốc và vùng đệm giải nén. Trên các thiết bị có bộ nhớ thấp, hãy giảm số lượng kênh đang hoạt động đồng thời tối đa (ví dụ: bằng cách giảm Số giọng thực tối đa (chẳng hạn như từ 32 xuống 16) bằng cách sử dụng AudioSettings.Reset trong Unity hoặc bằng cách hạn chế các kênh phát phần mềm trong các công cụ âm thanh của bên thứ ba.

4. Triển khai các 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ành riêng vô thời hạn, khiến mức sử dụng bộ nhớ cơ sở bị phình to 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 nhóm không dùng đến trong quá trình chuyển đổi cảnh hoặc các giai đoạn hoạt động thấp, cho phép Unity trả lại hoặc sử dụng lại không gian dành riêng đó cho các trường hợp 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à kết xuất trực tiếp các vùng đệm mục tiêu sẽ làm giảm mức sử dụng bộ nhớ cơ sở.

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

  • Xoá vùng đệm độ sâu/khuôn tô: Đặt Định dạng khuôn tô độ sâu thành Không có cho các 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 của camera giao diện người dùng: Đối với các 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ỏ Lượt CopyDepth và bộ nhớ kết cấu GPU được liên kết.

2. Tối ưu hoá kết cấu và lưới

Danh mục Nguyên tắc tối ưu hoá
Nén kết cấu Luôn áp dụng các định dạng nén nền tảng mục tiêu (ví dụ: ASTC cho Android).
Đã bật tính năng Đọc/Ghi Luôn tắt trừ khi cần thiết. Việc bật tính năng 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 kết cấu hoặc đối tượng giao diện người dùng được cố định ở khoảng cách camera không đổi, giúp tiết kiệm khoảng 33% bộ nhớ kết cấu.
Độ 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 bộ nhớ gốc và GPU.

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

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

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

  • Sự bùng nổ tổ hợp: 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 vào.
  • Kiến 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ó tên là Khối (mặc định: 4 MB).
  • Phình to bộ nhớ gốc: Khi mã thời gian chạy yêu cầu ngay cả một biến thể duy nhất bên trong một khối, toàn bộ khối 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 khối đó 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 xây 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 công cụ không dùng đến (ví dụ: Sương mù, Bản đồ ánh sáng và chế độ cài đặt XR ). Ngoài ra, các biến thể shader_feature sẽ tự động bị lọc nếu từ khoá của chúng không được bất kỳ thành phần nào trong dự án sử dụng, 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. Tuy nhiên, nếu các tính năng kết xuất hoặc xử lý hậu kỳ (chẳng hạn như Đèn bổ sung, bóng theo thời gian thực hoặc các hiệu ứng không dùng đến UberPost) vẫn được bật trong chế độ cài đặt URP hoặc quy trình của bạn, thì Unity không thể xác định xem nội dung của bạn có thực sự sử dụng các tính năng đó hay không và tạo tất cả các biến thể#multi_compile tương ứng. Kiểm tra và tắt các chế độ cài đặt quy trình không dùng đến để chúng khớp với các yêu cầu kết xuất thực tế của bạn và ngăn việc tạo biến thể không cần thiết.

C. Kiến trúc loại bỏ tự động tuỳ chỉnh (IPreprocessShaders) Vì phân tích tĩnh không thể phát hiện các từ khoá được sửa đổi linh động bằng cách sử dụng tập lệnh C# thời gian chạy (Material.EnableKeyword), nên việc loại bỏ tiêu chuẩn thường không đủ. Để đảm bảo chỉ các 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 chế độ cài đặt Trình chỉnh sửa) hoặc Profiler Traces (Shader.CreateGPUProgram dấu hiệu). Sau đó, hãy triển khai IPreprocessShaders.OnProcessShader trong tập lệnh Trình chỉnh sửa để lọc ra các 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.

D. Hợp nhất các trình đổ bóng dư thừa: Các trình đổ bóng Uber như Lit và Simple Lit chứa nhiều #multi_compile chỉ thị và tiêu tốn đáng kể bộ nhớ khi tạo nhiều biến thể:

  • Lập hồ sơ mức sử dụng đồng thời: Lập hồ sơ mức sử dụng bộ nhớ để kiểm tra xem cả hai trình đổ bóng có tiêu tốn đáng kể bộ nhớ cùng một lúc hay không và xác minh xem dự án của bạn có thực sự cần cả hai hay không.
  • Tiêu chuẩn hoá trên một trình đổ bóng duy nhất: Khi không cần kết xuất dựa trên vật lý (PBR) hãy định cấu hình các thành phần của bạn để loại bỏ việc sử dụng trình đổ bóng Lit dư thừa và tiêu chuẩn hoá trên Simple Lit (ví dụ: kiểm tra xem các mô hình FBX đã xuất có vô tình áp dụng các thành phần Lit mặc định trong quá trình nhập hay khô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ổ Thu gom rác (GC) trong Vùng nhớ được quản lý sẽ dẫn đến tình trạng phân mảnh bộ nhớ, việc mở rộng vùng nhớ không bao giờ thu hẹp và giảm số khung hình nghiêm trọng trong quá trình 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 ẩn trên Vùng nhớ. Việc thực thi lớp này bên trong Update sẽ phân bổ các thực thể đó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ổ Vùng nhớ 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 bộ sưu tập

Tránh phân bổ vùng nhớ đóng gói và Enumerator do các tiện ích LINQ hoặc bộ 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ì nó tạo một mảng Camera[] mới trên vùng nhớ 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 việc đóng gói bộ liệt kê cấu trúc, tạo ra các trường hợp phân bổ GC. Thay vào đó, hãy sử dụng vòng lặp for tiêu chuẩn.
  • Lưu vào bộ nhớ đệm các đối tượng coroutine: Lưu vào bộ nhớ đệm các thực thể WaitForSeconds thay vì tạo thực thể yield return new WaitForSeconds(time); nhiều lần.
  • Các khoá cấu trúc tuỳ chỉnh trong từ điển: Việc sử dụng các cấu trúc tuỳ chỉnh làm khoá Từ điển sẽ gọi Equals, kích hoạt việc đóng gói đối tượng. Triển khai IEqualityComparer<T> và truyền nó đến 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 việc sử dụng chung và 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++).

  • Phình to mã IL2CPP: Đối với mỗi tổ hợp loại chung duy nhấ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 các loại chung phức tạp có thể dẫn đến "sự bùng nổ tổ hợp" của mã C++ được tạo, làm tăng đáng kể kích thước nhị phân và dung lượng bộ nhớ gốc của ứng dụng.
  • Hao tổn phản chiếu: Các phương thức sử dụng phản chiếu, chẳng hạn như System.Reflection API, vốn chậm và thường gây ra các trường hợp phân bổ vùng nhớ trong thời gian chạy.
  • Phương pháp hay nhất: Sử dụng các loại chung một cách thận trọng – ưu tiên các loại chung để có tính rõ ràng về kiến trúc thay vì ứng dụng rộng rãi, không phân biệt. 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 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 các trình bao được quản lý bị rò rỉ

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

  • Vấn đề: Nếu Trình bao được quản lý được giữ trong bộ nhớ bằng thông tin tham chiếu tĩnh, đăng ký sự kiện liên tục hoặc đó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 được quản lý vẫn tồn tại, dẫn đến tình trạng rò rỉ bộ nhớ "bóng ma" làm phình to Vùng nhớ đượ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 các sự kiện bằng cách sử dụng toán tử -= và vô hiệu hoá các thông tin tham chiếu tĩnh đối với các loại UnityEngine.Object. Việc dọn dẹp này đảm bảo GC có thể thu thập thành công trình bao sau khi công cụ gốc phát hành trình xử lý.

7. Sử dụng danh mục nhị phân cho Addressables

Trong Unity 2022 trở về trước, Addressables mặc định sử dụng danh mục JSON thay vì danh mục nhị phân (mặc định trong Unity 2023 trở lên). Nếu dự án của bạn sử dụng danh mục JSON, việc tải một danh mục lớn khi khởi động sẽ đọc toàn bộ tệp vào một chuỗi C# khổng lồ duy nhất và phân bổ hàng nghìn chuỗi được quản lý và mảng byte[] tạm thời trong quá trình phân tích cú pháp JSON và giải mã Base64. Điều này gây ra đợt tăng đột biến phân bổ GC lớn, vĩnh viễn mở rộng Vùng nhớ được quản lý, vì vậy, hãy chuyển sang danh mục nhị phân để phát trực tuyến dữ liệu nhỏ gọn trực tiếp.