之前在撰寫 Unity 套件的時候,因為不知道使用者會用什麼類別來傳資料,所以都轉成 object 型別來傳遞,後來發現這會有效能上的問題。本篇文章就來探討這個問題以及解法。

問題

根據官方文件,在 value type(如:intfloat 等)與 object type 之間轉換會有效能問題:

  • 如果將一個 value type 轉成 object type 的話,C# 會額外在 heap 建立一個 instance 來儲存 value type 的值。這稱為 boxing
  • 反之將 object type 轉回 value type 的話,C# 會先檢查該 object 存的值(boxed value)是否可以轉到指定的 value type,然後將值複製回 value type。這稱為 unboxing

如下圖(參考官方文件繪製):

boxing-unboxing

再依這篇官方文件,boxing 比直接的 reference 賦值 1 最多差 20 倍的效能,unboxing 則最多差 4 倍。

解法

避免這種情況的解法是:建立一個資料類別來包裝要傳的值,並用這個類別的物件來傳遞。

傳送方

第一個類別 ContentBankA 會直接將 int 轉成 object type 傳出去,這會有 boxing 的情況發生:

public class ContentBankA : MonoBehaviour
{
    private readonly int[] _contents = {1, 2, 3, 4, 5};

    public object GetContent(int id)
    {
        return _contents[id];
    }
}

第二個類別 ContentBankB 則是將 int 先包在類別 DataWrapper 裡再傳出去:

public class ContentBankB : MonoBehaviour
{
    private readonly int[] _contents = {1, 2, 3, 4, 5};

    private DataWrapper _dataWrapper;

    private void Awake()
    {
        _dataWrapper = new DataWrapper();
    }

    public object GetContent(int id)
    {
        _dataWrapper.value = _contents[id];
        return _dataWrapper;
    }
}

public class DataWrapper
{
    public int value;
}

取用方

取用方也會依照傳送方使用對應的方式。第一個是類別是 ContentDisplayerA,它會直接將 object type 轉回 int,這會有 unboxing 的情況發生:

public class ContentDisplayerA : MonoBehaviour
{
    [SerializeField]
    private ContentBankA _contentBank;

    private int _content;

    public void LoadContent()
    {
        _content =
            (int) _contentBank.GetContent(Random.Range(0, 5));
    }
}

第二個類別 ContentDisplayerB 則是從收到的 DataWrapper 物件中取值:

public class ContentDisplayerB : MonoBehaviour
{
    [SerializeField]
    private ContentBankB _contentBank;

    private int _content;

    public void LoadContent()
    {
        _content = 
            ((DataWrapper) _contentBank.GetContent(Random.Range(0, 5))).value;
    }
}

效能測試

測試類別

建立一個測試類別來分別執行這兩個類別數次:

public class TesterA : MonoBehaviour
{
    [SerializeField]
    private ContentDisplayerA _contentDisplayer;

    private int _loadTimes = 100000;
    private int _testIteration = 10;

    private void Start()
    {
        var totalTicks = 0L;
        var stopWatch = new Stopwatch();

        for (var t = 0; t < _testIteration; ++t) {
            stopWatch.Restart();
            for (var i = 0; i < _loadTimes; ++i)
                _contentDisplayer.LoadContent();
            stopWatch.Stop();

            totalTicks += stopWatch.ElapsedTicks;
        }

        Debug.Log(
            $"{name}: " +
            $"Average {totalTicks / (float)_testIteration:F1} ticks");
    }
}

每一個測試會傳送與讀取資料十萬次,用 C# 的 StopWatch 來記錄執行時間,執行十次後,取平均做為結果。TesterBTesterA 一樣,只是是取用 ContentDisplayerB

設置起來會像這個樣子:
Tester setup

測試結果

TesterA 是有 boxing/unboxing 的,而 TesterB 是用一個資料類別物件傳的,其結果是:

  • TesterA: Average 75541.1 ticks
  • TesterB: Average 17795.3 ticks

可以看到效能約差 4.5 倍。

每次都建立資料類別物件

要注意的是,ContebtBankB 在使用資料類別傳送資料都是使用同一個物件。如果每次傳送都建立一個物件的話,其實就跟 boxing 的情況沒有兩樣了。如果 ContentBankB 長這樣:

public class ContentBankB : MonoBehaviour
{
    private readonly int[] _contents = {1, 2, 3, 4, 5};

    public object GetContent(int id)
    {
        var dataWrapper = new DataWrapper {
            value = _contents[id]
        };
        return dataWrapper;
    }
}

public class DataWrapper
{
    public int value;
}

結果為:

  • TesterA: Average 78446.0 ticks
  • TesterB: Average 77913.0 ticks

接著來測試傳送不同的資料。

傳遞 struct

struct 是 value type,所以轉換 struct 成 object type 也會遇到 boxing 的問題。例如有個 struct 是這樣:

public struct PlayerData
{
    public int id;
    public string name;
    public int hp;
}

則執行結果:

  • TesterA: Average 108210.3 ticks
  • TesterB: Average 38747.1 ticks

效能差約 3 倍。

傳遞 reference type

如果傳遞的資料本來就是 reference type 的話,如:string、類別等。這邊用一個字串陣列:

private readonly string[] _strings = {"a", "b", "c", "d", "f"};

結果為:

  • TesterA: Average 26283.7 ticks
  • TesterB: Average 31247.4 ticks

可以看到因為都是 reference type,所以效能差不多。甚至 TesterB 因為多一個資料類別的轉換而稍為慢一點。

總結

如果有需要從 value type 轉成 object type 的情況,就用一個資料類別來攜帶要傳送的值,並在使用方取出使用。要注意的是因為用的都是同一個資料類別物件,使用方要避免直接改到該物件的內容。


  1. 即 reference type 傳給另一個 reference type,object type 也是 reference type