ทุกวันนี้ แอปพลิเคชันบนมือถือแทบจะถูกบังคับให้ต้องทำงานได้อย่างราบรื่นบนอุปกรณ์ที่หลากหลายและเพิ่มขึ้นเรื่อยๆ ตั้งแต่สมาร์ทโฟน โทรศัพท์จอพับ (Foldables) ไปจนถึงแท็บเล็ต โดยที่ยังต้องดูแลรักษาและต่อยอดได้ง่ายสำหรับฝั่งนักพัฒนาด้วย
เพื่อเจาะลึกถึงโซลูชันที่นำไปใช้งานได้จริงสำหรับความท้าทายเหล่านี้ เมื่อเร็วๆ นี้ Seven Peaks ได้จัดงาน Meetup ที่รวบรวม 4 วิศวกรชั้นนำในประเทศไทยมาแบ่งปันความรู้ ได้แก่ Per-Erik Bergman (Principal Mobile Architect จาก Seven Peaks), สมเกียรติ กิจวงศ์วัฒนา (Software Engineer จาก LINE MAN Wongnai), Fedor Erofeev (Senior Android Developer จาก Seven Peaks) และ ทิพไตย พุทธานุกุลกิจ (Principal Engineer จาก MuvMi)
แม้ว่าเซสชันต่างๆ จะเน้นไปที่การพัฒนา Mobile App ด้วย Jetpack Compose และ Compose Multiplatform แต่เนื้อหากลับเต็มไปด้วยข้อมูลเชิงลึกด้านสถาปัตยกรรมที่วิศวกรซอฟต์แวร์ทุกคนสามารถนำไปปรับใช้เพื่อสร้างแอปฯ ที่ดูแลรักษาง่ายและมีคุณภาพสูงได้
เซสชันเหล่านี้ได้เผยให้เห็นถึงธีมหลักที่เชื่อมโยงกัน ซึ่งนิยามรูปแบบการพัฒนา Compose ในยุคสมัยใหม่ไว้ดังนี้:
ความแตกต่างระหว่าง Responsive กับ Adaptive design จะเห็นชัดเจนมากเมื่อคุณเปิดแอปฯ แบบ Responsive บนอุปกรณ์จอพับ แล้วพบว่าข้อความซ้อนทับกัน ปุ่มอยู่ผิดที่ผิดทาง และอินเทอร์เฟซดูรู้เลยว่าถูกออกแบบมาสำหรับหน้าจอขนาดอื่น
เพื่อให้เห็นภาพว่าเรื่องนี้เกิดขึ้นได้อย่างไร ลองนึกภาพตามนี้: สมมติว่ามีวิศวกรสองคนได้รับมอบหมายให้สร้างสะพาน วิศวกรคนแรกออกแบบสะพานสองเลนที่สมบูรณ์แบบและปรับให้เหมาะกับรถยนต์ซึ่งเป็นผู้ใช้งานหลัก แต่พอมีรถบรรทุกส่งของ เครื่องจักรการเกษตร หรือนักปั่นจักรยานต้องการข้าม สะพานนี้กลับใช้งานได้ไม่ดีหรือไม่สามารถใช้งานได้เลย ส่วนวิศวกรคนที่สองก็ให้ความสำคัญกับรถยนต์เป็นหลักเช่นกัน แต่เขาศึกษาความต้องการในอนาคตก่อนลงมือวาดพิมพ์เขียว เขาจึงออกแบบสะพานที่มีเลนกว้างขวาง มีเสารองรับที่แข็งแรง และมีทางเดินเท้าที่ปลอดภัย ซึ่งรองรับการจราจรทุกประเภทได้ตั้งแต่วันแรกที่เปิดใช้งาน
แนวทางการสร้างสะพานสองแบบนี้ สะท้อนให้เห็นถึงความแตกต่างระหว่าง Responsive และ Adaptive design ได้อย่างดี
Responsive UIs ตอบสนองต่อการเปลี่ยนแปลง มันจะไหล (Reflow) เนื้อหาใหม่เมื่อข้อจำกัดของหน้าจอเปลี่ยนไป เช่น ตัดขึ้นบรรทัดใหม่ หรือเปลี่ยนจากลิสต์แนวตั้งเป็นกริดเมื่อหน้าจอกว้างขึ้น เหมือนกับสะพานของวิศวกรคนแรก แนวทางนี้ปรับให้เหมาะสมกับกรณีการใช้งานหลัก แต่จะเริ่มมีปัญหาเมื่อความต้องการเปลี่ยนไปอย่างมาก
Adaptive UIs ถูกวางสถาปัตยกรรมมารองรับบริบทที่แตกต่างกันตั้งแต่แรก มันจะสลับโครงสร้าง UI ทั้งหมด แทนที่จะแค่จัดเรียงเนื้อหาใหม่ บนหน้าจอเล็ก Adaptive design อาจจะแสดงแค่ลิสต์ แต่พอบนหน้าจอใหญ่ มันจะแสดงลิสต์ควบคู่ไปกับหน้าต่างรายละเอียด (Detail pane) เหมือนกับสะพานของวิศวกรคนที่สอง โครงสร้างของมันแตกต่างกันโดยพื้นฐานและถูกปรับให้เหมาะสมกับแต่ละกรณีการใช้งานอย่างแท้จริง
ระบบ WindowSizeClass ของ Google ช่วยให้การทำ Adaptive design ง่ายขึ้น โดยการรวบขนาดอุปกรณ์นับพันรูปแบบให้เหลือเพียงความกว้างมาตรฐาน 3 หมวดหมู่
แทนที่จะต้องมานั่งนึกถึงอุปกรณ์รุ่นเฉพาะเจาะจง นักพัฒนาสามารถโฟกัสไปที่พื้นที่หน้าจอที่มีอยู่ได้เลย แท็บเล็ตขนาด 36 นิ้วก็จะถูกจัดอยู่ในหมวด Expanded และได้รับเลย์เอาต์ที่เหมาะสมโดยอัตโนมัติ
การใช้งานหมวดหมู่เหล่านี้ในโค้ดนั้นตรงไปตรงมามาก รูปแบบที่นิยมคือการสร้าง AdaptiveLayout composable ที่ใช้ซ้ำได้ ซึ่งสามารถรับเนื้อหาที่แตกต่างกันสำหรับแต่ละขนาด (Size class) นักพัฒนาแค่กำหนดเลย์เอาต์สำหรับโหมด Compact, Medium และ Expanded รวมถึงตั้งค่า Fallbacks สำหรับหน้าจอขนาดใหญ่หรือใหญ่พิเศษที่พบได้น้อยกว่า
|
Kotlin
compact = { /* List only */ }, medium = { /* List + icon navigation rail */ }, expanded = { /* List + full detail pane */ }) |
Google ได้เตรียมรูปแบบเลย์เอาต์มาตรฐาน (Canonical layouts) ไว้ 3 แบบที่ตอบโจทย์โครงสร้างแอปฯ ส่วนใหญ่:
Snackbar มักจะสร้างปัญหาเรื่องจังหวะเวลา (Timing) ในการทดสอบ Compose เมื่อแอ็กชันของผู้ใช้ทำให้เกิดการเปลี่ยน State อย่างรวดเร็ว (เช่น กดใช้คูปองแล้วเซิร์ฟเวอร์ตอบกลับมาทันที) ข้อความ Snackbar หลายอันอาจจะซ้อนทับกัน การทดสอบแบบ End-to-end จะรวนทันทีเมื่อข้อความแรกยังไม่ทันหายไปแล้วข้อความที่สองก็โผล่ขึ้นมา การทำ Assertions (การตรวจสอบความถูกต้อง) จึงล้มเหลวแบบคาดเดาไม่ได้ในไพพ์ไลน์ CI/CD
แพตเทิร์นการใช้ Controller และ Container เข้ามาแก้ปัญหานี้ ด้วยการบังคับให้ Snackbar แสดงค้างไว้ (Indefinite duration) ระหว่างการทำ UI Test ในขณะที่การทำงานบน Production ยังคงเป็นปกติ แนวทางนี้ต้องอาศัย 3 คอมโพเนนต์ทำงานร่วมกัน
การใช้งานคอมโพเนนต์เหล่านี้ในทางปฏิบัติก็ไม่ซับซ้อน เพียงแค่ให้ SnackbarContainer ห่อ Snackbar ไว้ใน Host
Kotlin |
เมื่อตั้งค่าเสร็จแล้ว โค้ดทดสอบของคุณก็จะสามารถควบคุมจังหวะเวลาของ Snackbar ได้อย่างสมบูรณ์ คุณสามารถสั่งปิด Snackbar ได้อย่างชัดเจนในจังหวะที่เหมาะสม โดยใช้ SnackbarUiTestController.dismissCurrentSnackbar() การควบคุมด้วยตนเองนี้ช่วยกำจัดปัญหาการทดสอบรวนจากเรื่อง Timing ในขณะที่ยังคงทำให้โค้ดทดสอบอ่านง่ายเหมือนเดิม
แพตเทิร์นนี้ต้องจัดการอย่างระมัดระวังในโปรเจกต์แบบ Multi-module พวก Test dependencies ต้องห้ามหลุดเข้าไปใน Production builds เด็ดขาด ไม่อย่างนั้น Snackbar อาจจะแสดงค้างอยู่บนหน้าจอของผู้ใช้จริงตลอดกาลได้ ไพพ์ไลน์ CI/CD ควรมีการใส่การตรวจสอบ เพื่อตรวจจับว่ามีการเผลอรวม Test module เข้าไปในคอนฟิกของ Production หรือไม่
นอกจากนี้ แอปฯ ที่ซับซ้อนและมีจุดที่เรียกใช้ Snackbar หลายๆ จุด ก็อาจทำให้เกิดการล้มเหลวในการทดสอบแบบไม่คาดคิดได้เช่นกัน เมื่อคอมโพเนนต์หลายตัวสั่งแสดง Snackbar อย่างอิสระ โค้ดทดสอบอาจจะไปเจอข้อความจากฟีเจอร์ที่ไม่เกี่ยวข้องกันได้ ดังนั้นทีมพัฒนาต้องมีแพตเทิร์นที่ชัดเจนในการแยกพฤติกรรมของ Snackbar ระหว่างการทดสอบ
การนำทาง (Navigation) ใน Compose พัฒนาไปไกลมาก โดยเปลี่ยนผ่านจาก Routing แบบใช้ String ที่เปราะบาง ไปสู่ API ที่ปลอดภัยและยืดหยุ่นมากขึ้นเรื่อยๆ
อดีตที่ใช้ String เป็นฐานสร้างจุดล้มเหลวหลายแห่ง พิมพ์ Route ผิดแค่ตัวเดียวก็ทำแอปฯ แครช ลืมส่ง Argument ก็แครช ลืมเข้ารหัส (Encode) String arguments เป็น UTF-8 ก็แครช แม้แต่ตอนดึง Arguments มาใช้ โค้ดก็ยังเยิ่นเย้อและเสี่ยงต่อการเกิดข้อผิดพลาด
ปัจจุบันที่เป็นยุคของ Type-safe (Navigation 2.8+) ได้ช่วยกำจัดปัญหาเหล่านี้ผ่าน Compile-time guarantees ด้วยการใช้ Kotlin serialization และ Data classes ในการกำหนดหน้าจอ (Screens) และ Nested graphs คอมไพเลอร์จะดักจับข้อผิดพลาดได้ตั้งแต่ก่อนรันแอปฯ เลย Deep links ทำงานได้อัตโนมัติ การเช็ก Route ปัจจุบันก็ตรงไปตรงมา และอาการแครชต่างๆ ก็หายไป
ส่วนอนาคตกับ Navigation 3 นั้นสัญญาว่าจะให้การควบคุมที่มากกว่าเดิม ไลบรารีเวอร์ชันใหม่จะมอง Navigation เหมือนกับการจัดการ List การเพิ่มหรือลบปลายทางออกจาก Back stack จะง่ายพอๆ กับการจัดการ Collection ทั่วไป แม้ว่าระบบรองรับ Deep link จะยังอยู่ในระหว่างพัฒนา แต่สถาปัตยกรรมใหม่นี้จะมอบอำนาจให้นักพัฒนาควบคุม Navigation state ได้แบบ 100%
สำหรับตอนนี้ Navigation 2.8+ มอบความปลอดภัยแบบ Type safety ที่แข็งแกร่งสำหรับแอปฯ บน Production ได้เป็นอย่างดี ข้อจำกัดเดียวใน Compose Multiplatform ตอนนี้คือ: Bottom sheets ยังไม่สามารถใช้เป็นจุดหมายปลายทางของ Navigation ได้ (แม้ว่าเรื่องนี้จะทำได้ใน Compose บน Android ปกติก็ตาม) แอปฯ แบบ Multi-module สามารถจัดระเบียบ Navigation แยกออกเป็นกราฟต่างๆ ได้ ตัวอย่างเช่น กราฟระบบสุริยะ (Solar System graph) อาจจะมีหน้าจอโลก (Earth) และดาวอังคาร (Mars) ในขณะที่กราฟระบบ Kepler-22 ก็ดูแลดาวเคราะห์นอกระบบ Kepler-22b แต่ละกราฟจะกำหนด Routes เป็น Serializable objects แทนที่จะใช้ Strings:
Kotlin |
Nested graphs ช่วยจัดระเบียบ Navigation แยกตามฟีเจอร์ โดยยังรักษาความเป็นอิสระของแต่ละ Module ไว้ได้ ฟังก์ชัน Navigation จะกำหนดขอบเขตของกราฟพร้อมกับจุดหมายปลายทางเริ่มต้น (Start destination):
Kotlin |
ส่วน Arguments ก็จะถูกส่งผ่าน Parameter ของ Data class อย่างเป็นธรรมชาติ ฟังก์ชันส่วนขยาย (Extension function) อย่าง toRoute() จะดึงข้อมูลที่มีการระบุ Type อย่างชัดเจนออกมาจาก Back stack entry ช่วยกำจัดปัญหาการนั่ง Parse string เอง และไม่ต้องกังวลเรื่องการเข้ารหัสข้อมูลอีกต่อไป
Type-safe navigation สามารถจัดการความซับซ้อนของ Deeplink ได้โดยอัตโนมัติ หลังจากที่คุณตั้งค่า Android manifests และไฟล์ iOS Info.plist ด้วย URI schemes แล้ว ไลบรารี Navigation จะทำการ Mapping ตัว Deep links เข้ากับ Routes โดยที่คุณไม่ต้องเขียนโค้ดเพิ่มเลย
Kotlin |
เมื่อมี Deep link อย่าง galaxy://navigation/earth/SevenPeaks วิ่งเข้ามา ไลบรารีจะทำการ Parse ตัว Path parameter แล้วสร้างอินสแตนซ์ EarthScreen(spacePort = "SevenPeaks") ขึ้นมาให้โดยอัตโนมัติ
สำหรับฝั่ง iOS จะต้องมีโครงสร้างพื้นฐานเพิ่มเติมเพื่อเชื่อมเอา Deep links เข้ามาในระบบ Navigation ตัว Object อย่าง ExternalUriHandler พร้อมกับ Channel หรือ Flow จะคอยเก็บรวบรวม URIs ขาเข้า ซึ่งแอปฯ จะนำไปประมวลผลเมื่อกลับมาทำงานต่อ:
Kotlin |
แพตเทิร์นนี้มีประโยชน์กับแอปฯ บน Android ด้วยเช่นกัน ในแอปฯ Production เรามักจะต้องมีการตรวจสอบสิทธิ์ (Validate authentication) ของผู้ใช้ก่อนที่จะพาไปยังคอนเทนต์ของ Deeplink, มีการนำ Deeplink ไปเข้าคิวระหว่างที่มีการเรียก API หรือต้อง Redirect ผู้ใช้ที่ยังไม่ล็อกอินให้ไปหน้า Login ก่อน ตัว Handler นี้จะเป็นศูนย์กลางสำหรับการจัดการลอจิกเหล่านี้ได้อย่างดีเยี่ยม
Extension อย่าง hasRoute() ช่วยให้การเขียนเงื่อนไข UI ตามสถานะของ Navigation ทำได้ง่ายขึ้น Top bars สามารถแสดง Title แบบไดนามิกได้, ปุ่ม Back จะโผล่มาก็ต่อเมื่อไม่ได้อยู่ที่หน้า Root screens และ Bottom navigation ก็สามารถไฮไลต์หัวข้อที่กำลังเปิดอยู่ได้อย่างถูกต้อง:
Kotlin |
การเช็กลำดับชั้นของปลายทางแทนที่จะเช็กแค่ Current route จะช่วยในการระบุสถานะของ Bottom bar ได้แม่นยำกว่า เพราะ Nested screens ควรสั่งไฮไลต์แท็บที่เป็นกราฟแม่ของมัน
Composable คนละประเภทก็ต้องการแนวทางการทดสอบที่ต่างกัน และการตัดสินใจเชิงสถาปัตยกรรมในช่วงต้นของการพัฒนา จะเป็นตัวกำหนดว่าการทดสอบจะมีประสิทธิภาพหรือถูกจำกัด กลยุทธ์การทดสอบที่มีโครงสร้างที่ดีจะต้องจับคู่วิธีการทดสอบให้ตรงกับบทบาทของคอมโพเนนต์ในแอปพลิเคชัน
แพตเทิร์น MVI (Model-View-Intent) แบบที่มีการยก State ได้พิสูจน์แล้วว่าเหมาะสมที่สุดสำหรับการทดสอบใน Compose หน้าจอแต่ละหน้าจะถูกแบ่งออกเป็นสอง Composables: อันแรกคือหน้าจอที่มี State สำหรับสังเกตการณ์ ViewModel และจัดการเรื่อง Effects ส่วนอันที่สองคือ Content composable ที่ไม่มี State ซึ่งคอยรับ State เข้ามาแล้วสั่ง Dispatch actions ออกไป
Kotlin
@Composable
|
ตัว LoginContent แบบ Stateless จะรับ Data class อย่าง LoginViewState และฟังก์ชัน Dispatch เข้ามา การแยกส่วนแบบนี้ช่วยปลดล็อกขีดความสามารถในการทดสอบได้อย่างทรงพลัง เพราะ Content สามารถถูกทดสอบกับ State ใดๆ ก็ได้ โดยไม่ต้องไปยุ่งเกี่ยวกับความซับซ้อนของ ViewModel ไม่ต้องทำ Mocking และไม่ต้องห่วงเรื่อง Dependency injection เลย
การทดสอบด้วย Screenshot โดยใช้ Paparazzi (หรือตัว Compose preview screenshot test อย่างเป็นทางการของ Google) จะทำหน้าที่เก็บภาพ UI เพื่อเป็นเอกสาร (Visual documentation) และตรวจจับการเปลี่ยนแปลงของ UI ที่ไม่ได้ตั้งใจ พวก Components และ Stateless content คือเป้าหมายในอุดมคติของการทำเทสต์แบบนี้เลย:
Kotlin |
การใช้ฟังก์ชัน Preview หลายๆ ตัวจะครอบคลุม States ต่างๆ ได้ครบ เช่น Default, Loading, Error conditions และกรณีที่ฟิลด์ข้อมูลถูกกรอกไว้แล้ว Paparazzi ทำงานบน JVM ได้เลยโดยไม่ต้องใช้ Emulator ทำให้มันเร็วพอที่จะรันการทดสอบคอมโพเนนต์หลายร้อยตัวได้สบายๆ สกรีนช็อตเหล่านี้จะทำหน้าที่เป็นทั้ง Visual regression tests และ Document ให้นักพัฒนาคนอื่นๆ ดูได้ด้วย
ข้อควรระวังคือควรบังคับเปิดใช้ LocalInspectionMode เป็น true ใน Screenshot tests เพื่อให้ได้ผลลัพธ์ที่สม่ำเสมอ โดยมันจะทำการปิด Animations, Network calls และพฤติกรรมตอน Runtime อื่นๆ ออกไป
API สำหรับการทดสอบของ Compose จะตรวจสอบพฤติกรรมของคอมโพเนนต์ผ่าน Finders, Matchers, Actions และ Assertions ไลบรารีคอมโพเนนต์จะได้ประโยชน์มหาศาลจาก Interaction tests เพราะมันทำหน้าที่เป็นเอกสารที่รันได้ (Executable documentation) เลยทีเดียว
Kotlin |
การทดสอบเหล่านี้สามารถรันแบบ Instrumented tests บนอุปกรณ์จริง หรือรันด้วย Robolectric บน JVM ก็ได้ การทดสอบทีละหน้าจอเดี่ยวๆ จะให้ประโยชน์น้อยกว่าการทดสอบ Flow แบบครอบคลุม ซึ่งจะช่วยยืนยันความถูกต้องของการนำทาง และการโต้ตอบระหว่างหน้าจอต่างๆ
การทดสอบแบบ End-to-end ใช้ Activities และ Dependencies ของจริง แต่จะทำการ Mock แหล่งข้อมูลภายนอก เพื่อให้การทดสอบมีความเสถียร Robot pattern (หรือบางทีเรียกว่า Page Object pattern) จะห่อหุ้มการโต้ตอบกับหน้าจอให้อยู่ในรูปแบบเมธอดที่อ่านเข้าใจง่าย
Kotlin |
Robot แต่ละตัวจะห่อหุ้ม API ทดสอบของ Compose สำหรับหน้าจอนั้นๆ ไว้ ทำให้ได้โค้ดทดสอบที่อ่านแล้วเหมือนกำลังอ่าน User stories เลย วิศวกร QA และ Product Managers สามารถเข้าใจ Flow การทดสอบได้โดยไม่ต้องมานั่งตีความรายละเอียดทางเทคนิคของการ Implement เลย
การพัฒนา Compose ยุคใหม่เรียกร้องให้เราต้องมองไกลกว่าแค่ Requirement เฉพาะหน้า Adaptive layouts คือการคาดการณ์เผื่อจอพับและแท็บเล็ตตั้งแต่ตอนที่มันยังไม่กลายเป็นปัญหา Type-safe navigation ช่วยป้องกันบั๊กแบบยกแผงผ่านการรับประกันจากคอมไพเลอร์ และกลยุทธ์การทดสอบแบบครบวงจรที่จับคู่วิธีการทดสอบให้ตรงกับประเภทของคอมโพเนนต์ ก็ช่วยรับประกันคุณภาพโดยไม่ต้องออกแรงจนเกินความจำเป็น
แพตเทิร์นเหล่านี้มีจุดร่วมเดียวกัน: นั่นคือพวกมันดึงเอาการตัดสินใจเชิงสถาปัตยกรรมขึ้นมาทำตั้งแต่ช่วงแรกเริ่ม (Front-load architectural decisions) ซึ่งจะทำให้แอปพลิเคชันขยายต่อ ดูแลรักษา และรีแฟกเตอร์ได้ง่ายขึ้นเมื่อความต้องการเปลี่ยนไป การลงทุนกับ Abstractions ที่เหมาะสม การแบ่งแยกความรับผิดชอบที่ชัดเจน (Clear separation of concerns) และการออกแบบ API อย่างรอบคอบ จะให้ผลตอบแทนที่คุ้มค่าไปตลอดวงจรชีวิตของแอปพลิเคชันเลยล่ะ
Seven Peaks จัดงานพูดคุยเชิงเทคนิคอย่างสม่ำเสมอ เพื่อรวบรวมนักพัฒนามาแบ่งปันข้อมูลเชิงลึกที่ใช้งานได้จริงจากโปรเจกต์ระดับโลก มาร่วมงานอีเวนต์ที่กำลังจะมาถึงของเรา เพื่อพูดคุยเกี่ยวกับการสร้างซอฟต์แวร์ที่ดียิ่งขึ้นไปด้วยกันนะ