16.3 Resource, Serialization และ Reflection

เฟส 16 · Engine Programming (C++ ลึก, ไม่บังคับ) · เวลาเรียน: 40–60 h

การโหลดและ cook asset, binary serialization ที่เร็วพร้อม versioning และระบบ reflection ที่ขับ editor และการเซฟ

ทุกเกมต้องโหลดของจากดิสก์ — texture, audio clip, ข้อมูลไอเทม, ไฟล์เซฟทั้งไฟล์ — แล้วแปลงมันให้เป็น object จริง ๆ ที่โค้ดเราใช้งานได้ ก่อนที่สุดท้ายจะปล่อยมันทิ้งไปอีกครั้ง จนถึงตอนนี้ในหลักสูตรนี้ เกือบทุกอย่างที่เราสร้างมาอยู่ในหน่วยความจำล้วน ๆ ตลอดเวลาที่โปรแกรมรัน ไม่ว่าจะเป็นสถานะฟิสิกส์ ท่าแอนิเมชัน หรือข้อความเครือข่าย บทนี้ครอบคลุมสามงานที่เกี่ยวโยงกันแน่นแฟ้น ซึ่งอยู่ใต้แทบทุกอย่างในเอนจิ้น resource system เป็นตัวตัดสินว่าไฟล์หนึ่งจะถูกอ่านจากดิสก์จริง ๆ เมื่อไหร่ และถูกทิ้งไปอีกครั้งเมื่อไหร่ serialization แปลง object ให้เป็น byte หรือ text (แล้วแปลงกลับ) เพื่อให้เซฟ โหลด หรือส่งไปที่อื่นได้ reflection คือกลไกที่ทำให้เครื่องมือของเอนจิ้นเอง — หน้าต่าง Inspector, ตัว serializer เอง — ทำงานกับ class ไหนก็ได้ที่เราเขียน โดยที่คนเขียนเครื่องมือไม่เคยเห็นโค้ดของเรามาก่อนเลย Unity พึ่งพาทั้งสามอย่างนี้ตลอดเวลา บทนี้เลยจบด้วยส่วนที่เป็น Unity โดยตรง — ScriptableObject, [SerializeField], และไฟล์ .meta — ซึ่งเป็นสิ่งที่มือใหม่เจอก่อนและมักเข้าใจผิดมากที่สุด

1. โหลดไฟล์ครั้งเดียวแล้วแชร์ใช้ร่วมกัน

การอ่านไฟล์จากดิสก์ช้ากว่าแทบทุกอย่างที่โค้ดเราทำ — ช้ากว่าการเรียกฟังก์ชัน ช้ากว่าการวน loop ผ่าน array ในหน่วยความจำ ถ้าโค้ด UI กับโค้ดโหลดเลเวลของเราต่างก็ต้องการ texture ก้อนหินตัวเดียวกัน แล้วทั้งคู่ก็แค่เรียก "โหลดไฟล์นี้" ทุกครั้งที่ต้องการมัน เราก็ต้องจ่ายค่าอ่านดิสก์ที่ช้านั้นถึงสองครั้งสำหรับ byte ชุดเดียวกันเป๊ะ ๆ แล้วสุดท้ายก็ได้ภาพเดียวกันสองชุดแยกกันนอนอยู่ในหน่วยความจำ

resource system (เรียกอีกอย่างว่า asset system) แก้ปัญหานี้ด้วยตาราง lookup: ครั้งแรกที่มีใครขอ path หนึ่ง มันจะอ่านไฟล์จริง ๆ แล้วจำผลลัพธ์ไว้ภายใต้ path นั้น ทุกครั้งหลังจากนั้นที่มีการขอ path เดิม มันก็แค่ส่ง object ที่มีอยู่แล้วกลับไป

using System;
using System.Collections.Generic;

class ResourceCache<T> where T : class
{
    Dictionary<string, T> cache = new Dictionary<string, T>();

    public T Load(string path, Func<string, T> loader)
    {
        if (cache.TryGetValue(path, out T existing))
        {
            Console.WriteLine($"[cache hit]  {path}");
            return existing;
        }

        Console.WriteLine($"[cache miss] {path} -- reading from disk");
        T loaded = loader(path);
        cache[path] = loaded;
        return loaded;
    }
}

class Texture
{
    public string Path;
    public Texture(string path) { Path = path; }
}

class Program
{
    static void Main()
    {
        var cache = new ResourceCache<Texture>();

        Texture uiIcon    = cache.Load("art/rock.png", p => new Texture(p));
        Texture levelRock = cache.Load("art/rock.png", p => new Texture(p));

        Console.WriteLine(ReferenceEquals(uiIcon, levelRock) ? "same object" : "different objects");
    }
}

output จริงที่คาดว่าจะได้:

[cache miss] art/rock.png -- reading from disk
[cache hit]  art/rock.png
same object

ฟังก์ชัน loader (ตัวที่อ่าน byte จากดิสก์จริง ๆ แล้วสร้าง Texture) รันแค่ครั้งเดียว ตอนเรียกครั้งแรกเท่านั้น การเรียกครั้งที่สองเจอว่า "art/rock.png" อยู่ใน dictionary แล้ว เลย return object ตัวเดิมเป๊ะ ๆ กลับไป ReferenceEquals ยืนยันเรื่องนี้ — ตัวแปรทั้งสองตัวชี้ไปที่ Texture ตัวเดียวกันในหน่วยความจำ ไม่ใช่สองชุดแยกกัน

CALLER A (UI system) CALLER B (Level system) | | | Load("art/rock.png") | Load("art/rock.png") v v RESOURCE CACHE (one Dictionary<path, object>) -------------------------------------------------- 1st call: "art/rock.png" not in the table yet -> read the file from disk, decode it, store it 2nd call: "art/rock.png" already in the table -> hand back the SAME object, no disk read -------------------------------------------------- | v ONE Texture object in memory, referenced by BOTH callers

ทุกเอนจิ้นที่เราจะได้ทำงานด้วยมีอะไรแบบนี้เวอร์ชันหนึ่งเสมอ: asset loading ของ Unity, asset registry ของ Unreal, หรือ texture/mesh manager ของเอนจิ้นที่เขียนเอง ชื่อเรียกเปลี่ยนไป แต่แนวคิด (ตารางจาก key ที่คงที่ ไปยัง object ที่สร้างเสร็จแล้ว) ไม่เปลี่ยน

2. Reference Counting: ยกเลิกโหลดเมื่อไม่มีใครต้องการมันอีกแล้ว

cache จากข้อ 1 มีปัญหาจริงจังอยู่หนึ่งอย่าง: มันไม่เคยลืมอะไรเลย ทุก texture ที่เคยโหลดมาจะอยู่ในหน่วยความจำไปตลอดอายุโปรแกรม ไม่ว่าจะยังมีใครต้องการมันอยู่หรือไม่ ในเกมจริง เลเวลถูกโหลดออกแล้วโหลดกลับเข้ามาใหม่ตลอด ผู้เล่นเปิดปิด inventory ที่เต็มไปด้วยไอคอนตลอดเวลา การเก็บทุกอย่างไว้ตลอดไปแบบนี้สุดท้ายก็หน่วยความจำหมดแน่นอน

ทางแก้คือ reference counting: แทนที่จะจำแค่ "โหลดแล้วหรือยัง" cache จะจำว่า ตอนนี้มีกี่อย่างที่ต้องการมันอยู่ ทุกคนที่ยืมไปใช้จะเรียก Acquire เพื่อขอ object (ซึ่งจะเพิ่ม count) แล้วเรียก Release ตอนใช้เสร็จ (ซึ่งจะลด count) เมื่อ count ลงมาเป็นศูนย์ แปลว่าไม่มีใครต้องการมันแล้ว เลยปลอดภัยที่จะปล่อยหน่วยความจำจริง ๆ

using System;
using System.Collections.Generic;

class ResourceCache<T> where T : class
{
    Dictionary<string, T> cache = new Dictionary<string, T>();
    Dictionary<string, int> refCount = new Dictionary<string, int>();

    public T Acquire(string path, Func<string, T> loader)
    {
        if (cache.TryGetValue(path, out T existing))
        {
            refCount[path]++;
            Console.WriteLine($"[acquire]    {path}  refs={refCount[path]}  (reused)");
            return existing;
        }

        T loaded = loader(path); // actually reads the file from disk
        cache[path] = loaded;
        refCount[path] = 1;
        Console.WriteLine($"[acquire]    {path}  refs=1  (loaded from disk)");
        return loaded;
    }

    public void Release(string path)
    {
        if (!refCount.ContainsKey(path)) return;

        refCount[path]--;
        Console.WriteLine($"[release]    {path}  refs={refCount[path]}");

        if (refCount[path] <= 0)
        {
            cache.Remove(path);
            refCount.Remove(path);
            Console.WriteLine($"[unload]     {path}  no users left, memory freed");
        }
    }
}

class Program
{
    static void Main()
    {
        var textures = new ResourceCache<Texture>();

        textures.Acquire("art/rock.png", p => new Texture(p)); // UI system needs it
        textures.Acquire("art/rock.png", p => new Texture(p)); // Level system needs it too

        textures.Release("art/rock.png"); // UI system is done
        textures.Release("art/rock.png"); // Level system is done -- now it unloads
    }
}
[acquire]    art/rock.png  refs=1  (loaded from disk)
[acquire]    art/rock.png  refs=2  (reused)
[release]    art/rock.png  refs=1
[release]    art/rock.png  refs=0
[unload]     art/rock.png  no users left, memory freed
refcount for "art/rock.png" Acquire (UI) Acquire (Level) Release (UI) Release (Level) | | | | v v v v count: 0 -> 1 count: 1 -> 2 count: 2 -> 1 count: 1 -> 0 (load from disk) (reuse cached) (still in use, (no users left keep it loaded) -> UNLOAD, free memory)
Tip นี่คือวินัยแบบเดียวกันเป๊ะ ๆ กับ malloc/free จากบท C หรือ new/delete ใน C++ แค่เปลี่ยนจากครั้งเดียวจบเป็นแบบนับจำนวน: ทุก Acquire ต้องคู่กับ Release พอดีหนึ่งครั้ง ลืม Release แล้ว count จะไม่มีวันถึงศูนย์ — memory leak จริง ๆ แค่ซ่อนอยู่หลัง resource cache แทนที่จะเป็น raw pointer เรียก Release เกินจำนวนไปหนึ่งครั้ง count ก็ติดลบได้ ทำให้สิ่งที่ส่วนอื่นของเกมยังคิดว่าตัวเองเป็นเจ้าของอยู่ถูก unload ไปทั้งที่ยังใช้อยู่

3. Asset GUID กับ File Path

มันเป็นเรื่องน่าลองที่จะระบุ asset ด้วย file path ของมัน — "Art/Player/hero.png" เป็น key ที่คนอ่านเข้าใจได้ดีเลย ปัญหาจะโผล่มาทันทีที่มีใครเปลี่ยนชื่อโฟลเดอร์หรือย้ายไฟล์ ซึ่งเกิดขึ้นตลอดเวลาในทีมจริง ๆ: artist จัดโฟลเดอร์ Art ใหม่ designer เปลี่ยนชื่อ hero.png เป็น hero_v2.png asset อื่น ๆ ทุกตัวที่เก็บ path string นั้นไว้ตรง ๆ เป็น reference — prefab, scene, ไฟล์เซฟ — ตอนนี้ก็ชี้ไปที่ไฟล์ที่ไม่มีอยู่ตรงนั้นแล้ว นั่นคือ broken reference และในทีมที่มี asset เป็นพัน ๆ ตัว การเปลี่ยนชื่ออะไรก็ตามกลายเป็นเรื่องน่ากลัว

ทางแก้ที่เอนจิ้นจริงจังทุกตัวใช้คือ GUID (Globally Unique Identifier) — ID ที่คงที่ สุ่มมาแบบที่ถือว่าไม่ซ้ำใคร กำหนดให้ asset แค่ครั้งเดียวตอนที่มันถูก import เข้ามาครั้งแรก asset อื่น ๆ จะเก็บ reference ไปที่ GUID ไม่ใช่ path ดิบ ๆ มีตาราง lookup แยกต่างหาก ที่เอนจิ้นดูแลไว้ คอยแมป GUID แต่ละตัวไปยัง path ปัจจุบัน ของ asset นั้น การเปลี่ยนชื่อหรือย้ายไฟล์แค่อัปเดตแถวเดียวในตารางนั้น GUID reference ทุกที่ในโปรเจกต์ก็ยัง resolve ถูกต้องต่อไป โดยไม่มีใครต้องไปตามหาแก้เอง

using System;
using System.Collections.Generic;

class AssetDatabase
{
    // GUID -> current file path -- this table IS the .meta file system, simplified
    Dictionary<Guid, string> guidToPath = new Dictionary<Guid, string>();

    public Guid Register(string path)
    {
        Guid id = Guid.NewGuid(); // assigned ONCE, when the asset is first imported
        guidToPath[id] = path;
        return id;
    }

    public void Rename(Guid id, string newPath)
    {
        guidToPath[id] = newPath; // GUID stays the same, only the path column changes
    }

    public string Resolve(Guid id) => guidToPath[id];
}

class Program
{
    static void Main()
    {
        var db = new AssetDatabase();

        Guid heroTextureId = db.Register("Art/Player/hero.png");

        // a prefab "remembers" the asset by storing this GUID, not the path
        Guid prefabReference = heroTextureId;

        Console.WriteLine("Before rename: " + db.Resolve(prefabReference));

        db.Rename(heroTextureId, "Art/Characters/hero_v2.png");

        Console.WriteLine("After rename:  " + db.Resolve(prefabReference));
    }
}
Before rename: Art/Player/hero.png
After rename:  Art/Characters/hero_v2.png

prefabReference ไม่เคยเปลี่ยนเลย — มันเป็น GUID ตัวเดิมตลอด การ lookup ผ่าน db.Resolve ให้ path ปัจจุบันที่ถูกต้องทั้งก่อนและหลังเปลี่ยนชื่อ เพราะการเปลี่ยนชื่อแตะแค่คอลัมน์ path ของตาราง ไม่ได้แตะ GUID เอง

BEFORE RENAME Prefab field stores: GUID 3f9a1b7c AssetDatabase table: GUID 3f9a1b7c -> "Art/Player/hero.png" Prefab loads: "Art/Player/hero.png" ARTIST RENAMES THE FILE: "Art/Player/hero.png" becomes "Art/Characters/hero_v2.png" the .meta file keeps the SAME GUID, so the table just updates its path column: GUID 3f9a1b7c -> "Art/Characters/hero_v2.png" AFTER RENAME Prefab field still stores: GUID 3f9a1b7c (unchanged) AssetDatabase table: GUID 3f9a1b7c -> "Art/Characters/hero_v2.png" Prefab loads: "Art/Characters/hero_v2.png" -- NOT broken COMPARE: a field that had stored the raw path "Art/Player/hero.png" directly would now point at a location that no longer has a file -- a broken reference, with no table to save it.
Tip GUID ถูกกำหนดครั้งเดียวแล้วไม่เปลี่ยนอีกเลยตลอดอายุของ asset นั้น แม้จะแก้ไขเนื้อหาข้างในก็ตาม การแก้พิกเซลของ hero.png ไม่แตะ GUID ของมันเลย — มีแต่การลบไฟล์ (หรือทำ metadata หายไป ซึ่งพูดถึงในข้อ 10) เท่านั้นที่จะทำให้ GUID เปลี่ยน

4. Serialization: แปลง Object เป็น Byte แล้วแปลงกลับ

Serialization คือกระบวนการแปลง object ที่อยู่ในหน่วยความจำ ให้กลายเป็นชุดของ byte หรือ character ที่เขียนลงไฟล์ ส่งผ่านเครือข่าย หรือเก็บลงฐานข้อมูลได้ Deserialization คือกระบวนการย้อนกลับ: อ่าน byte หรือ character เหล่านั้นกลับมา แล้วสร้าง object ที่เทียบเท่ากันขึ้นมาใหม่

using System;
using System.Text.Json;

class PlayerSave
{
    public string Name { get; set; }
    public int Level { get; set; }
    public float[] Position { get; set; }
}

class Program
{
    static void Main()
    {
        var save = new PlayerSave { Name = "Aran", Level = 7, Position = new float[] { 12.5f, 0f, -3.2f } };

        string json = JsonSerializer.Serialize(save);
        Console.WriteLine(json);

        PlayerSave loaded = JsonSerializer.Deserialize<PlayerSave>(json);
        Console.WriteLine($"{loaded.Name} is level {loaded.Level}");
    }
}
{"Name":"Aran","Level":7,"Position":[12.5,0,-3.2]}
Aran is level 7

Serialize จะไล่ผ่าน property ของ save ทุกตัว แล้วเขียนค่าของแต่ละตัวออกมาเป็นรูปแบบ text ของ JSON Deserialize จะ parse text นั้นแล้วสร้าง PlayerSave object ใหม่เอี่ยมที่มีค่าเหมือนกัน — เป็น object คนละตัวในหน่วยความจำจาก save ตัวเดิม แต่ถือข้อมูลที่เทียบเท่ากัน

IN MEMORY IN MEMORY (new object) (PlayerSave object) SERIALIZED FORM (PlayerSave object) (text or bytes, on disk or sent over a network) Name = "Aran" Name = "Aran" Level = 7 --Serialize--> {"Name":"Aran",...} --Deserialize--> Level = 7 Position = [..] (a JSON string, or Position = [..] raw bytes for binary) Same DATA, but now it is just a string of characters (or bytes) -- no pointers, no object identity, nothing that only makes sense while the ORIGINAL program is still running.

บรรทัดสุดท้ายนั้นสำคัญกว่าที่มันดูนะ ข้อมูลที่ serialize แล้วจะมี raw pointer อยู่ไม่ได้เลย — memory address จากเนื้อหาเรื่อง pointer ในบทที่ 2 address จะมีความหมายก็ต่อเมื่ออยู่ในโปรแกรมที่กำลังรันและเป็นเจ้าของหน่วยความจำนั้นเท่านั้น เซฟมันลงดิสก์แล้วโหลดกลับในโปรเซสใหม่ (หรือคนละเครื่องไปเลย) ตัวเลขนั้นก็เป็นขยะ หรือแย่กว่านั้นคือชี้ไปที่หน่วยความจำอื่นที่ไม่เกี่ยวข้องกันที่บังเอิญมีอยู่ตรงนั้นตอนนี้ นี่คือเหตุผลที่ระบบ GUID ในข้อ 3 มีอยู่: แทนที่จะเซฟ raw address ไปที่ "hero texture" serializer จะเซฟ ID ที่คงที่ (GUID, path, หรือ index) แล้วไปตามหา object จริงกลับผ่าน resource cache หลังโหลดเสร็จ แบบเดียวกับที่ AssetDatabase.Resolve ทำ

5. รูปแบบ Text กับ Binary

ข้อมูลที่ serialize แล้วแบ่งเป็นสองตระกูลใหญ่ ๆ Text format (JSON, YAML, XML) เก็บข้อมูลเป็น character ที่คนอ่านออก — เปิดไฟล์ด้วย text editor ตัวไหนก็อ่านได้เลย Binary format เก็บข้อมูลเป็น byte ดิบ ๆ ที่จัดวางให้ตรงกับที่ค่าต่าง ๆ อยู่ในหน่วยความจำ หรือ encoding แบบกระชับแบบอื่น — โดยทั่วไปต้องมีโปรแกรม (หรือเอกสารที่ถูกต้อง) ถึงจะแปลความหมายมันได้

TEXT (JSON / YAML) BINARY (custom / packed) ------------------------------------------------------------- Human-readable yes, open in any editor no, needs a tool to decode Diff / merge clean line-by-line git diffs, opaque; a single changed easy to review in a pull request value can make the whole diff unreadable File size larger (field names repeated as smaller (no field names text, numbers written as digits) stored, tight binary encoding of numbers) Parse speed slower (scan characters, parse faster (read fixed-size numbers out of text) fields straight into memory) Typical use config files, save files, any network packets, large data designers hand-edit or asset caches, anywhere review in source control performance is critical
using System;
using System.IO;
using System.Text.Json;

struct SaveDataBinary
{
    public int Level { get; set; }
    public float X { get; set; }
    public float Y { get; set; }
    public float Z { get; set; }
}

class Program
{
    static byte[] WriteBinary(SaveDataBinary data)
    {
        using var stream = new MemoryStream();
        using var writer = new BinaryWriter(stream);

        writer.Write(data.Level); // 4 bytes, no field name stored anywhere
        writer.Write(data.X);     // 4 bytes
        writer.Write(data.Y);     // 4 bytes
        writer.Write(data.Z);     // 4 bytes

        return stream.ToArray();
    }

    static SaveDataBinary ReadBinary(byte[] bytes)
    {
        using var stream = new MemoryStream(bytes);
        using var reader = new BinaryReader(stream);

        // fields must be read back in the EXACT same order they were written
        return new SaveDataBinary
        {
            Level = reader.ReadInt32(),
            X = reader.ReadSingle(),
            Y = reader.ReadSingle(),
            Z = reader.ReadSingle()
        };
    }

    static void Main()
    {
        var data = new SaveDataBinary { Level = 7, X = 12.5f, Y = 0f, Z = -3.2f };

        byte[] binary = WriteBinary(data);
        string json = JsonSerializer.Serialize(data);

        Console.WriteLine($"binary size: {binary.Length} bytes");
        Console.WriteLine($"json size:   {json.Length} chars -- \"{json}\"");

        SaveDataBinary back = ReadBinary(binary);
        Console.WriteLine($"read back: Level={back.Level} pos=({back.X},{back.Y},{back.Z})");
    }
}
binary size: 16 bytes
json size:   35 chars -- "{"Level":7,"X":12.5,"Y":0,"Z":-3.2}"
read back: Level=7 pos=(12.5,0,-3.2)

สิบหก byte สำหรับฟอร์ม binary (สี่ field, field ละสี่ byte ไม่มีชื่อเก็บไว้ที่ไหนเลย — ตัวอ่านรู้แค่ลำดับ) สามสิบห้า character สำหรับฟอร์ม JSON เพราะมันสะกดคำว่า "Level", "X", "Y", "Z" เป็น text ตรง ๆ ทุกครั้งไป บวกเครื่องหมายวรรคตอนอีก สังเกตว่า ReadBinary ไม่รู้เลยด้วยซ้ำว่า "Level" หรือ "X" คืออะไร — มันแค่เชื่อว่าอะไรก็ตามที่เขียน byte พวกนี้ เขียนมาตามลำดับเดียวกับที่มันกำลังจะอ่าน ความเปราะบางแบบนี้คือราคาจริงของความเร็วและขนาดของ binary และมันคือสิ่งที่ข้อ 6 ต้องแก้ไขต่อไป

ข้อผิดพลาดที่พบบ่อย พอได้ยินคำว่า "performance" ปุ๊บก็รีบคว้า binary format มาใช้ ทั้งที่ข้อมูลนั้นจริง ๆ แล้วเป็นไฟล์เซฟหรือไฟล์ config ที่ designer ต้องอ่าน แก้ไข หรือรีวิวใน diff binary คุ้มกับความเปราะบางของมันสำหรับ network packet และ asset cache ขนาดใหญ่ ที่ขนาดและความเร็วในการ parse สำคัญจริง ๆ ทุกเฟรม แต่สำหรับข้อมูลเซฟส่วนใหญ่ ความอ่านง่ายของ JSON กับความสามารถ diff ผ่าน git คุ้มค่ากว่า byte ที่มันเสียไปเยอะมาก

6. การทำ Version ข้อมูลเซฟ (ให้เซฟเก่ายังโหลดได้)

นี่คือสถานการณ์ที่ทำให้เกมที่ shipped ไปแล้วพังมากกว่าเรื่องอื่นแทบทุกเรื่องในหัวข้อนี้เลย: patch 1.0 ออกไปพร้อมกับ save format หนึ่ง patch 1.1 เพิ่ม field ใหม่หนึ่งตัวเข้าไปใน format นั้น ผู้เล่นทุกคนที่เซฟเกมไว้ตอน 1.0 ตอนนี้มีไฟล์บนดิสก์ที่ไม่มี field นั้นอยู่เลย — และเกมต้องโหลดไฟล์นั้นให้ถูกต้องตลอดไป เพราะเราย้อนเวลาไปเซฟไฟล์เก่าของทุกคนใหม่ไม่ได้

ถ้าไม่ทำ versioning จะเกิดอะไรขึ้น

สมมติว่า v1.0 shipped SaveData ที่มี Name กับ Level patch 1.1 เพิ่ม Stamina เข้ามา ถ้าเกมแค่เรียก JsonSerializer.Deserialize<SaveData>(oldJson) กับไฟล์ยุค 1.0 ตัว JSON deserializer จะไม่ crash — มันแค่เงียบ ๆ ปล่อย field ที่ขาดหายไปจากไฟล์ให้เป็นค่า default ของ type นั้น สำหรับ int ค่า default คือ 0 ผู้เล่นที่กลับมาเล่นทุกคนเลยมี Stamina = 0 แบบเงียบ ๆ ทันทีที่โหลด ไม่ว่าค่านั้นจะสมเหตุสมผลหรือไม่ — ในกรณีนี้มันแปลว่าผู้เล่นเดิมทุกคนตอนนี้หมดแรงถาวร โดยไม่มี error ไม่มี crash และไม่มีสาเหตุที่ชัดเจนใน bug report เลย นี่คือบัคที่พบบ่อยที่สุดในหัวข้อนี้ทั้งหมด: ไม่ใช่ crash แต่เป็น default ผิด ๆ แบบเงียบ ๆ ที่ดูเหมือนบัค gameplay จนกว่าจะมีใครนึกขึ้นได้ว่า save format เปลี่ยนไปแล้ว

ทางแก้: เลขเวอร์ชันและ chain ของ migration

ทางแก้คือเก็บเลข Version ไว้ในตัวข้อมูลเซฟเองเลย แล้วตอนโหลดก็เช็คมัน: ถ้า version ของไฟล์เก่ากว่า version ปัจจุบันของเกม ให้รันฟังก์ชันเล็ก ๆ ที่เติมค่าที่ถูกต้องให้กับสิ่งที่เปลี่ยนไป จากนั้นค่อย mark ข้อมูลว่าเป็น version ปัจจุบัน ก่อนที่อย่างอื่นจะแตะมันต่อ

using System;
using System.Text.Json;

class SaveData
{
    public int Version { get; set; }
    public string PlayerName { get; set; }
    public int Level { get; set; }
    public int Stamina { get; set; } // added in version 2 -- default should NOT be 0
}

class SaveLoader
{
    const int CurrentVersion = 2;

    public static SaveData Load(string json)
    {
        SaveData data = JsonSerializer.Deserialize<SaveData>(json);

        if (data.Version < 2)
            data = MigrateV1ToV2(data);

        // future patches add more steps here, e.g.:
        // if (data.Version < 3) data = MigrateV2ToV3(data);

        data.Version = CurrentVersion;
        return data;
    }

    static SaveData MigrateV1ToV2(SaveData data)
    {
        Console.WriteLine("migrating save from v1 to v2...");
        data.Stamina = 100; // v1 saves had no Stamina -- give a sensible starting value
        return data;
    }
}

class Program
{
    static void Main()
    {
        string oldSaveFromDisk = "{\"Version\":1,\"PlayerName\":\"Aran\",\"Level\":7}";

        SaveData loaded = SaveLoader.Load(oldSaveFromDisk);

        Console.WriteLine($"{loaded.PlayerName}: level {loaded.Level}, stamina {loaded.Stamina}, version {loaded.Version}");
    }
}
migrating save from v1 to v2...
Aran: level 7, stamina 100, version 2
FILE ON DISK (saved by v1.0 of the game) {"Version":1,"PlayerName":"Aran","Level":7} | v JsonSerializer.Deserialize | v SaveData { Version=1, PlayerName="Aran", Level=7, Stamina=0 (default) } | v Version < 2 ? --yes--> MigrateV1ToV2() | | no v | SaveData { ..., Stamina=100 } | | +--------- <--------+ v set Version = CurrentVersion (2) | v game runs with correct, fully-populated SaveData

จุดสำคัญคือ MigrateV1ToV2 เลือกค่า 100 โดยตั้งใจ — เป็นค่าที่มีคนตัดสินใจแล้วว่าสมเหตุสมผลสำหรับผู้เล่นที่กลับมา — แทนที่จะปล่อยให้รับค่า default อะไรก็ตามที่ deserializer เลือกให้แบบเงียบ ๆ พอ save format โตขึ้นเรื่อย ๆ ข้ามหลาย patch field ใหม่แต่ละตัวก็จะมี migration step ของตัวเอง เชื่อมต่อกันเป็นลำดับ ทำให้ไฟล์เซฟจากสามปีก่อนและสิบ patch ที่แล้ว ยังคงเดินผ่านขั้นตอนได้ถูกต้อง ทีละขั้น จนไปถึงสิ่งที่ version ปัจจุบันคาดหวัง

ข้อผิดพลาดที่พบบ่อย ทดสอบ field ใหม่กับเซฟสด ๆ ที่สร้างจาก build ปัจจุบันเท่านั้น ไม่เคยทดสอบกับไฟล์เซฟที่ export มาจาก build เดือนที่แล้ว บัคแบบนี้มองไม่เห็นตอน develop ปกติ (เพราะเรามีเซฟ version ปัจจุบันวางอยู่ทั่วไปตลอด) แล้วจะโผล่มาให้เห็นก็ตอนผู้เล่นจริงอัปเดตมาจาก patch เก่าเท่านั้น — ซึ่งเป็นคนกลุ่มที่เราพังใส่ไม่ได้เลย เก็บไฟล์เซฟเก่าจากทุก version ที่ shipped ไว้อย่างน้อยหนึ่งไฟล์ ไว้ทดสอบ migration โดยเฉพาะ

7. Reflection: โค้ดที่ตรวจสอบ Type ของตัวเอง

Reflection คือความสามารถของโปรแกรมที่จะมองดู type ของตัวเอง — field, method, และ attribute — ระหว่างที่มันกำลังรันอยู่ แทนที่จะให้ทุกอย่างถูกกำหนดตายตัวและเรียกชื่อไว้ตอน compile time โค้ดปกติต้องรู้ชื่อ field ก่อนถึงจะใช้มันได้: stats.Health จะ compile ผ่านก็ต่อเมื่อ Health เป็น field จริงบน type ของ stats โค้ดที่ใช้ reflection กลับถาม type เองในฐานะข้อมูล ว่า "มี field อะไรบ้าง" แล้วก็อ่านหรือเขียนตัวไหนก็ได้ผ่านชื่อของมัน ที่ค้นพบตอน runtime

using System;
using System.Reflection;

class EnemyStats
{
    public string Name = "Goblin";
    public int Health = 30;
    public float MoveSpeed = 3.5f;
}

class Program
{
    static void Main()
    {
        EnemyStats stats = new EnemyStats();
        Type type = stats.GetType(); // ask the OBJECT what type it is, at runtime

        Console.WriteLine($"Type name: {type.Name}");

        foreach (FieldInfo field in type.GetFields())
        {
            object value = field.GetValue(stats); // read a field WITHOUT knowing its name at compile time
            Console.WriteLine($"  {field.Name} ({field.FieldType.Name}) = {value}");
        }
    }
}
Type name: EnemyStats
  Name (String) = Goblin
  Health (Int32) = 30
  MoveSpeed (Single) = 3.5

type.GetFields() คืนลิสต์ของ FieldInfo object — ตัวละหนึ่ง field บน EnemyStats — ที่ค้นพบจากการมองดู class เอง ไม่ใช่จากที่ใครไปเขียนคำว่า "Name", "Health", "MoveSpeed" เป็น literal string ไว้ที่ไหนเลย แล้ว field.GetValue(stats) ก็อ่านค่าจริงของ field นั้นออกมาจาก object stats ตัวนั้นโดยเฉพาะ ไม่มีอะไรใน Program ที่ต้องรู้รูปร่างของ EnemyStats ล่วงหน้าเลย — loop นี้ทำงานเหมือนเดิมเป๊ะ ๆ กับ class ไหนก็ได้ทั้งนั้น

8. เอนจิ้นใช้ Reflection ยังไง: Inspector, Serializer, และ Script Binding

loop จากข้อ 7 — "เอา field ทุกตัวมา อ่านชื่อกับค่าของมัน" — ไม่ใช่แค่เรื่องน่ารู้เฉย ๆ มันคือกลไกเดียวที่อยู่เบื้องหลังสามอย่างที่ทุกเอนจิ้นต้องมี ซึ่งถ้าไม่มี reflection ก็ต้องเขียนโค้ดมือให้กับทุก ๆ class ที่เราสร้างขึ้นมา:

แต่ถ้ามี reflection อย่างเดียว มันจะโชว์ ทุก field เลย รวมถึงตัวที่ควรเป็น internal ล้วน ๆ ด้วย เอนจิ้นแก้ปัญหานี้ด้วย attribute — ข้อมูล metadata ชิ้นเล็ก ๆ ที่แปะไว้กับโค้ด ซึ่งโค้ดอื่นค้นพบได้ผ่าน reflection [SerializeField] ของ Unity เอง (ข้อ 10) ก็คือ attribute แบบนี้เป๊ะ ๆ นี่คือเวอร์ชันเล็ก ๆ ของไอเดียเดียวกัน:

using System;
using System.Reflection;

[AttributeUsage(AttributeTargets.Field)]
class ShowInInspectorAttribute : Attribute { }

class EnemySpawner
{
    public int MaxEnemies = 10;             // public -- shown automatically

    [ShowInInspector]
    private float spawnRadius = 5.0f;       // private, but explicitly marked -- shown too

    private string internalCacheKey = "x1"; // private, not marked -- hidden
}

class MiniInspector
{
    public static void Draw(object target)
    {
        Type type = target.GetType();
        Console.WriteLine($"--- Inspector: {type.Name} ---");

        foreach (FieldInfo field in type.GetFields(BindingFlags.Public | BindingFlags.Instance))
            Console.WriteLine($"{field.Name} = {field.GetValue(target)}");

        foreach (FieldInfo field in type.GetFields(BindingFlags.NonPublic | BindingFlags.Instance))
            if (field.GetCustomAttribute<ShowInInspectorAttribute>() != null)
                Console.WriteLine($"{field.Name} = {field.GetValue(target)}  (private, marked)");
    }
}

class Program
{
    static void Main() => MiniInspector.Draw(new EnemySpawner());
}
--- Inspector: EnemySpawner ---
MaxEnemies = 10
spawnRadius = 5  (private, marked)

internalCacheKey ไม่โผล่มาเลย — มันเป็น private และไม่มี attribute [ShowInInspector] ติดอยู่ ทั้งสอง loop เลยข้ามมันไป กฎเล็ก ๆ นี้ "field ที่เป็น public บวก field ที่เป็น private ที่ mark attribute ไว้ชัดเจน" ไม่ใช่เรื่องบังเอิญเลย มันคือกฎเดียวกันเป๊ะ ๆ ที่ Inspector และ serializer จริงของ Unity ใช้ ซึ่งข้อ 10 จะพูดถึงโดยตรง

Tip นี่คือเหตุผลที่การเพิ่ม field ใหม่เอี่ยมลงไปใน class ใน Unity แค่ทำให้มันโผล่ไปที่ Inspector เองโดยไม่ต้องทำอะไรเพิ่มเลย: Inspector ไม่เคยถูกเขียนขึ้นมาเฉพาะสำหรับ class ของเราตั้งแต่แรกอยู่แล้ว มัน reflect ผ่าน field ที่มีอยู่ ณ ตอนนั้นทุกครั้งที่มัน redraw

9. ScriptableObject ของ Unity

MonoBehaviour อยู่บน GameObject ใน scene หนึ่ง — มันต้องมี scene อยู่ถึงจะมีตัวมันได้ ข้อมูลเกมเยอะมากที่ไม่ได้เป็นของ scene ไหนโดยเฉพาะ: ชื่อกับสถิติของไอเทม สถิติพื้นฐานของศัตรูแต่ละชนิด การตั้งค่าเกมแบบ global ScriptableObject ของ Unity เป็น base class สำหรับข้อมูลที่อยู่เป็น asset file ของตัวเองบนดิสก์ ไม่ขึ้นกับ scene ไหนเลย ให้ scene และ object กี่ตัวก็ได้ reference มันได้

using UnityEngine;

[CreateAssetMenu(fileName = "NewItem", menuName = "Game/Item Definition")]
public class ItemDefinition : ScriptableObject
{
    public string ItemName;
    public int MaxStack = 99;
    public Sprite Icon;
}

[CreateAssetMenu] เพิ่มเมนู "Create > Game > Item Definition" เข้าไปใน editor menu ของ Unity ทำให้ designer สร้างไฟล์ .asset ใหม่ ๆ — Sword.asset, HealthPotion.asset — ได้ด้วยมือล้วน ๆ ไม่ต้องเขียนโค้ดเลย แต่ละไฟล์คือชุดค่าที่เซฟแยกกันของ field ใน class นี้

using UnityEngine;

public class ItemPickup : MonoBehaviour
{
    public ItemDefinition Item; // drag an ItemDefinition asset here in the Inspector

    void OnTriggerEnter(Collider other)
    {
        Debug.Log($"Picked up {Item.ItemName} (max stack {Item.MaxStack})");
    }
}

ItemPickup object หลาย ๆ ตัวที่กระจายอยู่คนละ scene สามารถชี้ไปที่ Sword.asset ไฟล์เดียวกันเป๊ะ ๆ ได้ทั้งหมด เรื่องนี้เชื่อมกลับไปที่ข้อ 1 ถึง 3 โดยตรง: Unity โหลด Sword.asset แค่ครั้งเดียว ทุก reference แชร์ object ที่โหลดแล้วตัวนั้นตัวเดียว (พูดง่าย ๆ คือ resource cache จากข้อ 1 กับ 2 ที่ฝังอยู่ในตัวเอนจิ้นนั่นเอง) และแต่ละ reference ก็ถูก track ด้วย GUID (ข้อ 3) ทำให้การเปลี่ยนชื่อ Sword.asset เป็น IronSword.asset ไม่ทำให้ ItemPickup ตัวไหนที่ reference มันพังเลยสักตัว

10. [SerializeField], Meta File, และความหมายที่แท้จริงของ "Inspector แสดง Field ของฉัน"

serializer ของ Unity เอง ข้างในก็คือ loop reflect-over-fields จากข้อ 8 นั่นแหละ แค่มีกฎเฉพาะหนึ่งข้อว่า field ไหนถึงจะนับ field หนึ่งจะถูก serialize (เซฟลงไฟล์ scene หรือ asset และแสดงใน Inspector แบบ default) ก็ต่อเมื่อทุกข้อต่อไปนี้เป็นจริง:

using UnityEngine;
using System.Collections.Generic;

public class EnemyStatsComponent : MonoBehaviour
{
    public int Health = 30;                  // public -- serialized, shown

    [SerializeField]
    private float moveSpeed = 3.5f;          // private + [SerializeField] -- serialized, shown

    private int internalFrameCounter;        // private, no attribute -- NOT serialized, NOT shown

    public static int EnemyCount;            // static -- NEVER serialized

    public int Damage { get; set; } = 5;     // a PROPERTY -- NOT serialized, NOT shown

    public Dictionary<string, int> Resistances; // Dictionary -- NOT serialized (no built-in support)
}

สองบรรทัดในนี้ทำให้มือใหม่เกือบทุกคนพลาดอย่างน้อยหนึ่งครั้ง Damage มองจากข้างนอกเหมือน field เป๊ะ ๆ — enemy.Damage = 10; compile ผ่านและทำงานได้ปกติ — แต่มันเป็น property (คู่ของ get/set method ที่ซ่อนอยู่) และ serializer ของ Unity เดินผ่านแค่ field จริง ๆ เท่านั้น ไม่ใช่ property นี่ตรงข้ามกับ System.Text.Json จากข้อ 4 เลย ซึ่งโดย default จะอ่าน property ไม่ใช่ field — สองระบบที่ใช้ reflection เหมือนกัน แต่กฎ "อะไรถึงจะนับ" ต่างกันคนละแบบ Resistances ก็ compile ผ่านเหมือนกัน แต่ Unity ไม่มี serializer built-in ให้กับ Dictionary<TKey,TValue> มันเลยโชว์เป็นค่าว่างเปล่าเงียบ ๆ ใน Inspector แล้วก็ไม่เซฟด้วย ทางแก้ทั่วไปคือใช้ list ของ struct เล็ก ๆ ที่ serialize ได้แทน:

[System.Serializable]
public struct StatResistance
{
    public string StatName;
    public int Amount;
}

public class EnemyStatsComponent : MonoBehaviour
{
    public List<StatResistance> Resistances; // works: a List of a [Serializable] struct
}
Tip อ่านคำว่า "Inspector แสดง field ของฉัน" ให้ถูกต้องแม่นยำ: มันแปลว่า public-หรือ-[SerializeField], ไม่ใช่ static, ไม่ใช่ const, ไม่ใช่ readonly และเป็น type ที่รองรับ — ไม่ใช่ "field อะไรก็ตามที่ฉันประกาศไว้" property ที่ทำงานเหมือนกันเป๊ะจากภายนอกจะไม่โผล่มา ซึ่งนี่คือช่องว่างระหว่าง "compile ผ่าน" กับ "Unity serialize มันจริง ๆ" ที่ทำให้ developer ที่ย้ายจากสไตล์ C# ทั่วไปมาสู่โมเดลแบบ field ของ Unity สะดุดบ่อย ๆ

Meta File

ข้อ 3 พูดถึงตาราง lookup ของ GUID โดยยังไม่บอกว่า Unity เก็บมันไว้ที่ไหนจริง ๆ — คำตอบคือใน meta file ทุก asset ในโปรเจกต์ Unity — texture, script, โฟลเดอร์, asset ของ ScriptableObject — จะมีไฟล์คู่กันที่ต่อท้ายชื่อด้วย .meta ถูกสร้างและดูแลโดย Unity เอง

Project folder on disk: Assets/Art/hero.png Assets/Art/hero.png.meta (generated by Unity, holds the GUID) hero.png.meta (simplified): guid: 3f9a1b7c884d2e11c textureImporter settings... Any other asset that references hero.png does NOT store the path "Assets/Art/hero.png" -- it stores the GUID 3f9a1b7c884d2e11c, exactly like Section 3's AssetDatabase example. If hero.png.meta is deleted (say, by a careless .gitignore rule) and Unity regenerates a brand-new meta file, hero.png gets a brand-new random GUID -- every prefab, scene, and ScriptableObject that pointed at the OLD GUID is now a broken/missing reference, even though the hero.png file itself was never touched at all.
ข้อผิดพลาดที่พบบ่อย ลบไฟล์ .meta หรือใส่มันไว้ใน .gitignore หรือเปลี่ยนชื่อ/ย้าย asset นอกหน้าต่าง Project ของ Unity (ไปทำใน Finder หรือ File Explorer ตรง ๆ) ทั้งสองแบบทำให้ GUID ถูกสร้างใหม่ และทำให้ reference ทุกตัวที่ชี้ไปที่ asset นั้นพังแบบเงียบ ๆ ทั่วทั้งโปรเจกต์ ต้อง commit ไฟล์ .meta ลง source control เสมอ และเปลี่ยนชื่อ/ย้าย asset จากในตัว Unity เองเสมอ

11. Hot-Reloading Asset ระหว่างพัฒนา

ตอนที่เกมรันอยู่ใน editor (หรือ development build ที่เปิด asset watching ไว้) การแก้ source asset — retouch texture ในโปรแกรมแก้ภาพแล้วเซฟ หรือปรับตัวเลขบน ScriptableObject ใน Inspector — ควรอัปเดตสำเนาในหน่วยความจำของเกมที่กำลังรันอยู่ โดยไม่ต้อง restart ใหม่ทั้งหมด นี่คือ hot reloading และมันสำคัญล้วน ๆ เพื่อความเร็วในการทำงานซ้ำ (iteration speed): artist อยากเห็น texture เปลี่ยนภายในไม่กี่วินาที ไม่ใช่ต้อง relaunch เกมทั้งเกมแล้ววนกลับไปจุดเดิมใหม่

ไอเดียหลักนี้ทำงานได้ก็เพราะการออกแบบจากข้อ 1 กับ 2: resource cache แจก shared reference ไม่ใช่สำเนาส่วนตัว ให้กับทุกคนที่เรียกใช้ นั่นแปลว่า hot reloading แค่ต้องเขียนทับเนื้อหาของ object ที่ cache ไว้ ตัวเดียว นั้นในที่เดิม — ทุกคนที่ถือ reference นั้นจะเห็นข้อมูลใหม่โดยอัตโนมัติ เพราะพวกเขาไม่เคยถือสำเนาของตัวเองตั้งแต่แรกอยู่แล้ว

using System.Reflection;

partial class ResourceCache<T> where T : class
{
    public void Reload(string path, T freshData)
    {
        if (!cache.TryGetValue(path, out T existing))
            return; // nothing cached yet, nothing to reload

        CopyInto(existing, freshData); // overwrite the EXISTING object's contents in place
        System.Console.WriteLine($"[hot reload] {path} updated -- {refCount[path]} holder(s) see new data instantly");
    }

    static void CopyInto(object existing, object fresh)
    {
        // the same reflect-over-fields loop from Section 7 and 8, used to copy generically
        foreach (FieldInfo field in existing.GetType().GetFields())
            field.SetValue(existing, field.GetValue(fresh));
    }
}

CopyInto ไม่ต้องมีโค้ดเฉพาะของแต่ละ type เลย: มัน reflect ผ่าน field ที่ type ของ existing มีอยู่จริง แล้ว copy แต่ละตัวข้ามมาจาก fresh เป็นเทคนิค generic แบบเดียวกับที่ loop print field ในข้อ 7 และ MiniInspector ในข้อ 8 ใช้

Disk file "enemy_goblin.asset" is edited (designer changes Health 30 -> 45) | v file watcher notices the change | v engine re-reads the file into a FRESH temporary object | v ResourceCache.Reload copies the fresh values ONTO the EXISTING cached object -- same object identity, new contents | +-----------+-----------+ v v EnemySpawner A EnemySpawner B (already holds a (already holds a reference) reference) sees Health = 45 sees Health = 45 WITHOUT reloading WITHOUT reloading or reconnecting or reconnecting
Tip Unity ทำเรื่องพวกนี้ให้เราส่วนใหญ่อยู่แล้ว — reimport asset ที่เปลี่ยนไปโดยอัตโนมัติ compile และ reload script ใหม่ตอนเซฟ การเข้าใจกลไก shared-reference ที่อยู่ข้างใต้ช่วยอธิบายอาการเวลา hot reload ดูเหมือน "ไม่ทำงาน": ถ้ามีระบบไหนแอบสร้างสำเนาข้อมูลส่วนตัวของตัวเองแทนที่จะผ่าน shared cache/reference สำเนานั้นก็จะเก่าค้างอยู่แบบนั้น แล้วไม่มีอะไรอัปเดตมันได้อีกจนกว่าจะ restart จริง ๆ

12. อภิธานศัพท์

13. แบบฝึกหัด

แบบฝึกหัดที่ 1 — หา Leak ให้เจอ ฟังก์ชันข้างล่างนี้ acquire texture จาก ResourceCache<Texture> (ข้อ 2) ทำงานบางอย่างกับมัน แล้ว release มัน หา path ในฟังก์ชันนี้ที่ไม่เคยเรียก Release เลย อธิบายว่าจริง ๆ แล้วเกิดอะไรขึ้นกับ refcount เพราะเรื่องนี้ แล้วเขียนฟังก์ชันใหม่ให้ทุก path ที่เป็นไปได้ release พอดีหนึ่งครั้ง แม้ DoWork จะ throw exception ก็ตาม
void UseTextureTemporarily(ResourceCache<Texture> cache, string path, bool skipWork)
{
    Texture tex = cache.Acquire(path, Loader.LoadTexture);

    if (skipWork)
        return; // uh oh

    DoWork(tex);

    cache.Release(path);
}
Show answer

ตอน skipWork เป็น true ฟังก์ชันจะ return ทันทีหลังจาก Acquire ก่อนที่จะไปถึง cache.Release(path) เลย path การเรียกแบบนี้เพิ่ม refcount แต่ไม่เคยลดมันเลย ทุกครั้งที่เรื่องนี้เกิดขึ้น count จะสูงกว่าจำนวนผู้ใช้งานจริงอยู่หนึ่งเสมอ — asset นั้นไม่มีวันถึงศูนย์แล้ว unload ได้เลย แม้จะผ่านไปนานแล้วที่ไม่มีอะไรในเกมต้องการมันจริง ๆ นี่คือ resource leak ที่มีรูปแบบเดียวกันเป๊ะ ๆ กับการลืมเรียก free() คู่กับ malloc() ในบท C: early return ที่ข้ามโค้ด cleanup ด้านล่างไป

ทางแก้คือ block try/finally ซึ่งรับประกันว่าส่วน finally จะรันในทุก exit path ที่ออกจาก try — ไม่ว่าจะ return ปกติ, early return, หรือ exception ที่ DoWork throw ออกมา:

void UseTextureTemporarily(ResourceCache<Texture> cache, string path, bool skipWork)
{
    Texture tex = cache.Acquire(path, Loader.LoadTexture);
    try
    {
        if (skipWork)
            return;

        DoWork(tex);
    }
    finally
    {
        cache.Release(path); // runs no matter which path we took, or even if DoWork throws
    }
}

ตอนนี้มี Acquire พอดีหนึ่งครั้งและ Release พอดีหนึ่งครั้งในทุกเส้นทางที่เป็นไปได้ของฟังก์ชัน ซึ่งเป็น invariant ที่ reference-counted cache ต้องพึ่งพา ถึงจะ unload อะไรได้เลย

แบบฝึกหัดที่ 2 — เขียน Migration Step ข้อ 6 อัปเกรดเซฟ v1 (ไม่มี Stamina) ไปเป็น v2 ด้วยการเพิ่ม Stamina ที่ default เป็น 100 ตอนนี้เกมต้องการ v3: field ใหม่ชื่อ Difficulty (int, 0 = Normal, 1 = Hard) ซึ่งควร default เป็น 1 สำหรับตัวละครที่อยู่ Level 20 ขึ้นไปอยู่แล้วตอนถูก migrate ครั้งแรก และเป็น 0 สำหรับกรณีอื่น เพิ่ม Difficulty ลงใน SaveData เขียน MigrateV2ToV3 อัปเดต SaveLoader.Load ให้รัน migration ทั้งสองขั้นตามลำดับ แล้ว trace ว่าเกิดอะไรขึ้นตอนโหลดไฟล์ v1 ของตัวละคร level 25
string oldSaveFromDisk = "{\"Version\":1,\"PlayerName\":\"Rin\",\"Level\":25}";
Show answer
using System;
using System.Text.Json;

class SaveData
{
    public int Version { get; set; }
    public string PlayerName { get; set; }
    public int Level { get; set; }
    public int Stamina { get; set; }     // added in version 2
    public int Difficulty { get; set; }  // added in version 3
}

class SaveLoader
{
    const int CurrentVersion = 3;

    public static SaveData Load(string json)
    {
        SaveData data = JsonSerializer.Deserialize<SaveData>(json);

        if (data.Version < 2)
            data = MigrateV1ToV2(data);

        if (data.Version < 3)
            data = MigrateV2ToV3(data);

        data.Version = CurrentVersion;
        return data;
    }

    static SaveData MigrateV1ToV2(SaveData data)
    {
        Console.WriteLine("migrating save from v1 to v2...");
        data.Stamina = 100;
        return data;
    }

    static SaveData MigrateV2ToV3(SaveData data)
    {
        Console.WriteLine("migrating save from v2 to v3...");
        data.Difficulty = data.Level >= 20 ? 1 : 0; // Hard for already-strong characters
        return data;
    }
}

class Program
{
    static void Main()
    {
        string oldSaveFromDisk = "{\"Version\":1,\"PlayerName\":\"Rin\",\"Level\":25}";

        SaveData loaded = SaveLoader.Load(oldSaveFromDisk);

        Console.WriteLine($"{loaded.PlayerName}: level {loaded.Level}, stamina {loaded.Stamina}, difficulty {loaded.Difficulty}, version {loaded.Version}");
    }
}
migrating save from v1 to v2...
migrating save from v2 to v3...
Rin: level 25, stamina 100, difficulty 1, version 3

field Version ของไฟล์เริ่มที่ 1 เช็ค migration ทั้งสอง ตัวเลยทำงานตามลำดับ: MigrateV1ToV2 รันก่อนแล้วเติม Stamina จากนั้น MigrateV2ToV3 รันกับข้อมูลที่อัปเดตแล้วนั้น แล้วตั้งค่า Difficulty ตาม Level (ซึ่งคือ 25 ดังนั้น Difficulty เลยกลายเป็น 1, Hard) เซฟที่เริ่มจาก v2 อยู่แล้วจะข้ามไปเช็คตัวที่สองเลยตัวเดียว และเซฟที่เป็น v3 อยู่แล้วจะข้ามทั้งสองตัว — แต่ละขั้นจะรันเฉพาะกับ version ที่ต้องการมันจริง ๆ เท่านั้น

แบบฝึกหัดที่ 3 — Mini Serializer ด้วย Reflection ใช้ pattern reflection จากข้อ 7 (การวน loop ผ่าน type.GetFields()) เขียนฟังก์ชัน string ToKeyValueText(object obj) ที่แปลง field แบบ public ของ object ไหนก็ได้ให้เป็น text คู่ Name=Value บรรทัดละคู่ — เช่น object EnemyStats ที่มี Name="Goblin", Health=30 ควรได้ text Name=Goblin ตามด้วย Health=30 ในบรรทัดถัดไป ไม่ต้องจัดการ nested object หรือ array ก็ได้ จากนั้นอธิบายในหนึ่งหรือสองประโยคว่าทำไมฟังก์ชันนี้ถึงไม่ต้องแก้อะไรเลยถ้ามีใครเพิ่ม field ใหม่เอี่ยมลงใน EnemyStats เดือนหน้า และทำไมข้อเท็จจริงเดียวกันนี้ถึงเป็นจริงกับ Inspector ของ Unity และ JsonSerializer.Serialize ด้วยเหมือนกัน
Show answer
using System;
using System.Reflection;
using System.Text;

static class MiniSerializer
{
    public static string ToKeyValueText(object obj)
    {
        var sb = new StringBuilder();

        foreach (FieldInfo field in obj.GetType().GetFields())
            sb.Append($"{field.Name}={field.GetValue(obj)}\n");

        return sb.ToString();
    }
}

class EnemyStats
{
    public string Name = "Goblin";
    public int Health = 30;
}

class Program
{
    static void Main()
    {
        Console.Write(MiniSerializer.ToKeyValueText(new EnemyStats()));
    }
}
Name=Goblin
Health=30

ToKeyValueText ไม่เคยเรียกชื่อ Name หรือ Health ตรง ๆ ในซอร์สโค้ดของมันเองเลย — มันถาม obj.GetType() ว่ามี field อะไรอยู่ตอนนี้บ้างแล้ว loop ผ่านลิสต์นั้น ถ้ามีใครเพิ่ม field MoveSpeed ลงใน EnemyStats เดือนหน้า การเรียก ToKeyValueText ครั้งถัดไปจะมีบรรทัด MoveSpeed=... เพิ่มเข้ามาเองโดยอัตโนมัติ โดยไม่ต้องแก้โค้ดของ MiniSerializer สักบรรทัดเดียว นี่คือเหตุผลเป๊ะ ๆ ว่าทำไม Inspector ของ Unity ถึงแสดง field [SerializeField] ตัวใหม่ทันทีที่มันถูกเพิ่มเข้ามา และทำไม JsonSerializer.Serialize ถึงจัดการ class ไหนก็ได้โดยไม่ต้องมีโค้ดเฉพาะของแต่ละ class: ทั้งสามอย่างนี้คือ loop reflect-over-fields ตัวเดียวกันจากข้อ 7 แค่ใส่เสื้อผ้าคนละชุดเท่านั้นเอง

← กลับไปหน้ารวมบท