ตัวอย่างวางแผนจัดโฟลเดอร์เพื่อแยก assembly อัตโนมัติ

ใน Unity 2017.2 เราสามารถแยก assembly โดยใช้ assembly definition file .asm ได้ ถ้าแยกสำเร็จแล้วจะทำให้ compile ไวมาก…

ตัวอย่างวางแผนจัดโฟลเดอร์เพื่อแยก assembly อัตโนมัติ

ใน Unity 2017.2 เราสามารถแยก assembly โดยใช้ assembly definition file .asm ได้ ถ้าแยกสำเร็จแล้วจะทำให้ compile ไวมาก แต่ไม่ใช่โปรเจคไหนๆก็แยกได้เลย ความยากมันอยู่ที่

  1. แต่ละ assembly จะไม่รู้จักของใน assembly อื่น นอกจากจะตั้ง dependency ไว้
  2. dependency ต้องไปทางเดียว อ้างอิงถึงกันไปมาฟรีสไตล์ได้เฉพาะใน assembly เดียวกัน (อันนี้ยากที่สุด สำหรับโปรเจคที่ไม่ได้คิดแบบนี้มาก่อน)
  3. Unity มีกฎรวบ assembly โดยใช้โฟลเดอร์ ทุกไฟล์ในโปรเจคจะวิ่งขึ้นมาจนกว่าจะเจอ .asm ที่ใกล้ที่สุด เพื่อดูว่าจะเข้าไปใน .dll ไหน กฏข้อนี้มักจะทำให้โปรเจคที่จัดไว้เป็นแนวคิดอื่น (เช่น แยกเป็น scene) อาจจะเจอความลำบากเพราะหลายๆอย่างใน scene ต้องพึ่ง scene อื่น แล้วทีนี้ไอ้ scene อื่นที่ว่าก็ดันต้องพึ่ง scene นี้ทำให้แยก .dll เป็น sceneๆไปไม่ได้
    ตัวอย่างเช่น หน้า Gameplay มีสคริปต์ที่บอกว่าจะไปหน้า Result เลยต้องอ้างอิง dll ของ Result ทีนี้ Result กลับมา ModeSelect แล้ว ModeSelect ไป Gameplay จนเกิด cyclic dependency ฯลฯ

จุดมุ่งหมาย

  1. ทำให้ได้จำนวน .asm ที่เยอะที่สุดที่ทำให้โปรเจคยังเรียบร้อยอยู่ เพื่อจะเพิ่มโอกาสแก้ script แล้ว compile dll ใหม่น้อยชิ้นที่สุด
  2. ให้ .asm มีประโยชน์ต่อส่วนรวมสูงสุด อันไหนมีโอกาสจะโดนอ้างอิงพร้อมๆกันบ่อยก็ควรเอามาไว้ด้วยกันเวลาตั้งค่า dependency จะได้ไม่ปวดหัวลืมใส่โน่นนี่
  3. ดูชื่อ .asm แล้ว make sense

Tactics

อันนี้ของผมเท่านั้นนะ เกมผมก็เคยเป็นเกมที่ไม่มี .asm มาก่อน อ้างอิงกันฟรีสไตล์

จัดสคริปต์แยกเป็น Scene ไป

ยังไงก็ไม่อยากทิ้งระเบียบนี้ เลยออกมาเป็นประมาณนี้

แต่ละ Folder ให้มี 2 ชั้น

ถ้า ideally เราจะเอา .asm ไปวางไว้ในโฟลเดอร์แต่ละอันแล้วตั้งชื่อ .dll ตามชื่อ scene ของโฟลเดอร์นั้นๆ แต่ทุกอย่างมันไม่ได้สวยหรูขนาดนั้นน่ะสิ เพราะสคริปต์บางอย่างต้องอ้างอิงถึงกันบ้าง แล้วอ้างอิงไปมาเราก็ไม่ชัวร์แล้วว่า .dll ไหนต้องชี้ไปทางไหน ชี้ไปชี้มา cyclic แน่

ดังนั้นในแต่ละโฟลเดอร์ scene เราจะมีอีกชั้น ที่ทีนี้มี .asm อยู่จริงๆส่วนชั้นนอกไม่มี ถามว่าสคริปต์พวกนั้นจะไปไหน คำตอบคือ

Scripts.dll ไว้ดักจับทุกอย่างที่หลงเหลือ

อันนี้ไว้ชั้นนอกสุดในโฟลเดอร์ Scripts ตามที่เห็น ตามกฏของ .asm บอกว่าให้สคริปต์วิ่งออกมาจนกว่าจะเจอ .asm ก็เลยเป็นว่า อันไหนของ scene นั้นรู้สึกว่า “เอกเทศ” พอสมควรก็เอาไปไว้ใน อันไหนรู้สึกอุ้ยุ้ก็เอาออกมาข้างนอกให้มันไปยำรวมกันใน Scripts.dll อ้างอิงถึงกันได้ตามสบาย

กฏข้อ 1 : SceneName.dll ห้ามอ้างอิงกันเอง ห้ามอ้างอิง Scripts.dll ด้วย แต่ Scripts.dll อ้างอิงทุกๆ SceneName.dllได้ตามสบาย เพราะในนั้นมีของของแต่ละ scene ปนอยู่ด้วยกันทั้งหมด

จากภาพจะเห็นว่ามีส่วนหนึ่งของ MusicSelect ที่ผมแยกไปไว้ใน dll ต่างหากได้สำเร็จ ที่เหลือยากก็เลยเอาไว้ข้างนอก

-Common โฟลเดอร์ส่วนรวม

ยังมี scripts หลายๆอันที่ไม่ได้ผูกกับ scene ใดๆ แต่เป็นใช้ๆด้วยกันทั้งโปรเจค อันนี้เราจะเอาไว้ใน Common ซึ่งมีสองชั้นเช่นกัน ชั้นในเราจะเรียกว่า CommonCore

ชั้นนอกที่ไม่โดน CommonCore.dll ก็จะไปอยู่ใน Scripts.dll เช่นกันกับสคริปต์พเนจรจาก scene ต่างๆ ให้ไปอ้างอิงกันตามใจชอบ แล้วก็กฏที่ว่า SceneName.dll ห้ามอ้างอิงใคร คราวนี้อ้างอิง CommonCore.dll เพิ่มขึ้นมาได้แล้ว

กฏข้อ 2 : CommonCore.dll ห้ามอ้างอิงใครเด็ดขาด (ของแท้) ต้องเป็นที่สุดท้ายจริงๆ เพื่อให้มีสมบัติที่พร้อมให้ทุกคนอ้างอิงได้เสมอ ส่วน Scripts.dll ที่เป็นจุดมักง่ายรวมทั้ง SceneName.dll ก็อ้างอิง CommonCore.dll เพิ่มได้

เป้าหมายของเกม ก็คือพยายามทำให้สคริปต์ไปอยู่ใน SceneName.dll ให้ได้ ถ้าไม่ได้ก็ CommonCore.dll ถ้ายังไม่ได้อีกก็ Scripts.dll

ถ้าใครยังดูกฏการรวบ asm ไม่ออก ลองดูภาพนี้

สีส้มที่เห็น คืออยู่คนละ scene นั่นแหละแต่เนื่องจากแยกยาก เลยให้มันลอยไปกองรวมกัน

ทีนี้อาจต้อง refactor โค้ดเพื่อลด dependencies

เช่น แต่ก่อนคลาสผมมี static method (หรืออาจจะเป็น instance method ของตัวแปรหนึ่ง) แบบนี้อยู่

ทำให้ dll ที่ไอ้คลาสนี่อยู่ต้องรู้จัก SnapIndicator แต่ทว่า dll ที่คลาสนี้อยู่ มีลักษณะที่ “ทุกคนต้องใช้” เลยเอาไปไว้ใน CommonCore ถ้าเกิดเราตบะแตกเสียศักดิ์ศรีไป reference อันลูกกระจ๊อกกลับ ก็มีโอกาสสูงมากที่จะเกิด cyclic dependency เพราะผิดสัญญาที่ว่า “ทุกคนต้องใช้” ของสคริปต์ใน common ที่วางแผนไว้

วิธีแก้ บางคนทำ dependency injection อยู่แล้วซึ่งก็ดีแต่ก็ต้องรู้จักคลาสที่ inject มาอยู่ดีครับ เรามาลองก้าวสู่ขั้นกว่า ด้วยการเปลี่ยนเป็น lambda injection แทน!

ก็คือรับ delegate/lambda เข้ามาตอน constructor แทน ทีนี้ก็ไม่ต้องแคร์ว่าจะต้องรู้จักคลาสอะไร ขอถามไปตอบมาเป็นค่าคือใช้ได้ แน่นอนว่า สคริปต์ที่เรียก constructor นี้ก็ควรจะอยู่ใน dll จับฉ่าย Scripts.dll ที่มีโอกาสใช้ CommonCore.dll เหมือนคนอื่นๆ วิธีนี้น่าจะดี ถ้าทั้งไฟล์มีเรียกหาค่าอะไรนิดๆหน่อยๆแค่ไม่กี่จุด แต่ต้องเรียกบ่อยๆทุกครั้งที่ update เป็นต้น

เทคนิคอื่นๆเช่นแทนที่จะรับตัวแปรมา . หาค่า ก็รับค่าพวกนั้นมาตั้งแต่ constructor ให้เป็นตัวเลขเลย (ถ้าไม่คิดจะอัพเดทตามๆกัน)

ยังมีต่อ! นอกโฟลเดอร์ Scripts ไว้เป็นที่อยู่ของ plugins

จริงๆแล้ว CommonCore.dll สามารถอ้างอิงใครได้อยู่ นั่นคือ plugins ทั้งหลายนั่นเอง ตั้งแต่แรกจะเห็นว่าทุกอย่างอยู่ใน Assets/Scripts แต่นอก Scripts ก็ยังมีสคริปต์อยู่อีก… พวกนั้นคือ plugins นั่นเอง (แยกเป็นโฟลเดอร์ตามชื่อ plugin)

มาถึงจุดนี้ง่ายแล้วเพราะ plugin โดย definition คือ มันไม่รู้จักอะไรในโปรเจคเราก็ทำงานได้ แต่เราต้องไปรู้จักมัน (เหมือนถ้าทำ C++ ก็คือการ include #include <OpenGL/gl.h> แต่ฝั่งนั้นแน่นอนว่าไม่รู้จักเกมเรา)

ดังนั้นข้างนอกสุด เรามาเปลี่ยน plugin ทุกตัวให้กลายเป็น dll แยกกันได้เลย ซึ่งมีจุดยากอยู่จุดเดียวคือ .asm ทำให้โฟลเดอร์ชื่อพิเศษหมดความหมาย (ก็คือ Editor ) พวกไฟล์ใน Editor อย่าให้โดนรวบเข้า dll เด็ดขาดไม่งั้นมันจะหยุดทำงาน

การอยู่ในโฟลเดอร์ Editor ทำให้ Unity เก็บเข้า dll พิเศษ เป็นมานานตั้งแต่ Unity เวอร์ชั่นแรกแล้ว เป็นการ “แยก dll” แบบเดียวที่ทำได้ก่อนจะถึง 2017.2

โชคดีที่ dll ของ .asm นี้ก็สามารถเลือกได้ว่าจะให้อยู่ใน Editor เท่านั้น ดังนั้น เราก็สามารถใช้ tactics แบ่งทุกๆ plugin เป็น 2 ชั้นคืออันปกติกับอัน editor

แบบนี้ ผมมี plugin อยู่ 3 ตัวคือ I2, Ink กับ Introloop จะเห็นว่ามี 2 assembly อยู่ข้างใน อันนึงไว้ดักจับของ Editor แล้ว asm ของ Editor ก็ไปติ้กถูกตรง Include Platformซะ พร้อมทั้ง reference ไอ้ตัวหลักที่ไม่อยู่ใน editor

แน่นอนว่าคราวนี้ไอ้ตัวหลักห้าม reference ใครไม่งั้นคงเสียชาติเกิด plugin หมด ทีนี้ ของใน Scripts ก็สามารถมาอ้างอิง plugin dll ได้เลยไม่ต้องกลัว cyclic dependency 100%

  • Introloop-Editor.dll -> Introloop.dll
  • Gameplay.dll -> Introloop.dll CommonCore.dll
  • Scripts.dll -> Gameplay.dll CommonCore.dll Introloop.dll
  • CommonCore.dll -> Introloop.dll

เพื่อให้เห็นภาพชัดๆ อันนี้คือ reference ของ Scripts.dll ผม

จะเห็นว่าอ้างอิง scene มากมาย รวมทั้ง plugins อย่าง Introloop

อันนี้ Gameplay

อ้างอิง CommonCore ได้ รวมทั้ง plugins แต่ห้ามอ้างอิง Scripts.dll

สุดท้ายอันนี้ Scripts.dll

อ้างอิงอะไรก็ตามสบาย จริงๆอยากได้ปุ่มที่คลิกทีเดียว reference ทุกอย่างทั้งในโปรเจคทั้ง plugins เลยด้วยซ้ำ ไม่แคร์ 555

สคริปต์ที่เราวางแผนจะใช้ในเกมอื่นในอนาคตก็ถือเป็น plugin

ยังมี “plugin ส่วนตัว” อีกที่สมควรแยกออกมาข้างนอก ผมมี git submodule นึง ที่รวมของที่น่าจะใช้กับเกมไหนก็ได้ แล้วก็กะจะก้อปไปใส่ทุกเกมที่ผมจะทำในอนาคตด้วย สิ่งนี้ผมเรียกว่ E7Unity

5argon/E7Unity
E7Unity — Common Unity resources Exceed7 Experiments uses in projects.github.com

(เอาไปใช้ได้ฟรี แจก)

จากสมบัติที่ว่า ก็เหมาะอย่างยิ่งที่จะรวมเป็น .dll เดียวกัน เรียกว่า E7Unity.dll ไอ้ dll ตัวนี้ แทบทุก assembly ในเกมผมอ้างอิงหมดเลย ทั้ง CommonCore.dll Scripts.dll MusicSelect.dll … ต่างก็ชี้มาหา E7Unity.dll แสดงถึงความสุดยอดของมัน

Integration Test

ไฟล์ Integration Test เราจะให้มันขึ้นไปปนอยู่ใน Scripts.dll เพราะการเทสมีลักษณะที่ต้องจับนู่นโยงนี่ ขณะที่ไม่ควรจะมีใครอื่นจำเป็นต้องรู้จักการมีตัวตนอยู่ของมันเลย ตาม definition นี้เอาไว้ใน Scripts.dll จะเหมาะสมที่สุด ดังนั้นโฟลเดอร์ Integration Test เอาไว้ใน Scripts แล้วไม่ต้องให้มี .asm ใดๆอยู่ข้างใน

แล้วก็อย่าลืมว่า Integration Test สามารถรันในเครื่องจริงได้ ดังนั้นมันต้องอยู่ในเกมจริงๆไม่ใช่อยู่แค่ใน Editor เหมือน Unit Test (ห้ามใช้ using UnityEditor; นั่นเอง)

หรือว่าถ้าแน่จริง จะให้มี IntegrationTest.dll -> Scripts.dll + ทุกอย่างเหมือนที่ Scripts.dll อ้างอิงอยู่ ดูก็ได้นะครับ (อย่าลืมว่าเราไม่สามารถอ้างอิงแค่ Scripts.dll แล้วหวังว่าทั้งยวงที่มันอ้างอิงอยู่จะติดมาด้วย… มันไม่ได้ทำงานแบบนั้น) ถ้าไม่มีเออเร่อก็แปลว่าดี IntegrationTest ก็จะกลายเป็นตัวที่ไม่มีใครอิงอย่างแท้จริงต่อจาก Scripts.dll ครับ เป็นเทสผู้มีทุกอย่างอยู่ในกำมืออย่างแท้จริง (ธรรมชาติของ IntegrationTest นี่จะบรรทัดเยอะ ดังนั้นถ้าทำแบบนี้ได้จะลดเวลา compile ของ Scripts.dll ได้มาก เอาบรรทัดแยกออกมาได้เยอะ)

Unit Test

อันนี้มีข้อจำกัดจาก Unity เพิ่มเข้ามา ว่า Unit Test ต้องอยู่ใน folder Editor เท่านั้นถึงจะขึ้นมาในหน้าต่าง Test Runner ด้วย ด้วยความช่วยไม่ได้นี้ Unit Test เลยเป็นอันเดียวที่เราจะใช้การแยก dll ปกติของ Unity โดยการไม่ทำอะไรมัน แล้วเอาไว้นอกสุดเพื่อป้องกันการโดนรวบเข้า Scripts.dll ครับ (อย่าลืมว่าถ้าโดนรวบแล้วโฟลเดอร์ชื่อพิเศษจะหมดความหมาย)

ไม่มี .asm ใน UnitTests

สรุป

โดยภาพรวมแล้ว ออกมาเป็นตามแผนภาพนี้ครับ

แผนภาพนี้ Integration Test ยังรวมกับ Scripts.dll อยู่นะ ถ้าแยกออกมาได้จะดีมาก

จะเห็นว่า

  • Scripts.dll มีศักดิ์ต่ำสุด เพราะอ้างอิงได้ทุกอย่างในโลกนี้แต่คนอื่นห้ามอ้างอิง
  • ส่วน CommonCore.dll ต้องระวังไม่อ้างอิงใครในเกมเลย นอกจาก external plugins เท่านั้นแล้วโดนคนอื่นอ้างอิงอย่างเดียว
  • SceneName.dll เหมือนเป็น CommonCore.dll ขนาดจิ๋ว ที่เอาไว้ดักสคริปต์ต่างๆที่เฉพาะทางและเอกเทศยิ่งกว่า CommonCore.dll เวลาแก้อะไรเฉพาะ scene จะได้ compile เร็วๆ

เท่านี้เกมก็จะกลายเป็นชิ้นเล็กชิ้นน้อย ที่มีโอกาส compile เร็วโดยรวมสูงขึ้น ขึ้นอยู่กับว่า script ที่เราไปแก้อยู่ตรง dll ไหน มี 3 กรณีใหญ่ๆ

  • ถ้าอยู่ CommonCore.dll ก็จะช้าสุด เพราะทุกคนอ้างอิง ต้อง compile ใหม่ทั้งโปรเจคคล้ายๆตอนก่อนจะทำ asm แต่ก็ไม่ต้อง compile พวก external plugin ใหม่อยู่ดี ยังเร็วกว่าเดิมอยู่
  • ถ้าอยู่ Scripts.dll จะไม่มีใครอ้างอิงเลย compile ใหม่แค่ตัวเองเท่านั้น อาจจะเร็วหน่อยแต่ก็ไม่แน่เพราะมี trade off ที่ว่ายิ่งสคริปต์มากองในนี้เท่าไหร่ยิ่งพากันช้าลง ทำๆเกมไปถ้าวางแผนไม่ดี Scripts.dll อาจจะค่อยๆใหญ่ขึ้น
  • ถ้าอยู่ SceneName.dll อันนี้จะทำให้ต้อง compile Scripts.dll ที่อิงคนอื่นไปทั่ว แล้วก็ตัวเอง รวมเป็น 2 อัน

ถ้าแก้ plugins คงต้อง compile ใหม่ทั้งโปรเจคจริงๆ แต่โอกาสแก้ plugins มีน้อยกว่าแก้ของในเกมมากครับ