ธงในวิดเจ็ต: ทำไมรหัสประเทศ ISO จึงดีกว่าชื่อตาม locale
บั๊กนี้สังเกตได้ยาก วิดเจ็ต iOS แสดงชื่อประเทศถูกต้องเมื่อใช้ภาษาอังกฤษ แต่แสดงอีโมจิธงผิดเมื่อใช้ภาษาเยอรมัน ส่วนภาษาฝรั่งเศสก็แสดงธงของอีกประเทศผิด ธงไม่ได้ผิดแบบสุ่ม แต่มีรูปแบบที่ต้องใช้เวลาจึงมองออก การค้นหาคืนธงของประเทศที่มีชื่อในภาษาปัจจุบันคล้ายกับชื่อที่เก็บไว้
สาเหตุคือการใช้ชื่อประเทศที่แปลแล้วเป็นคีย์ของดิกชันนารี ข้อความที่คงที่ในภาษาหนึ่งจะเปลี่ยนเมื่อใช้อีกภาษา การแก้จึงต้องเปลี่ยนการค้นหาทั้งหมดไปใช้รหัสประเทศ ISO 3166-1 alpha-2 และบทเรียนนี้ใช้ได้กับข้อมูลอื่นนอกจากอีโมจิธง
อาการของบั๊กที่พบ
วิดเจ็ตแสดงรายการประเทศพร้อมอีโมจิธงและข้อมูลอื่น โค้ดเดิมเก็บชื่อที่แสดง เช่น “Germany” หรือ “France” เป็นตัวระบุ และใช้ชื่อเหล่านี้เป็นคีย์เพื่อค้นหาธง
เมื่อใช้ภาษาอังกฤษ ชื่อที่เก็บกับคีย์ต่างเป็น “Germany” จึงคืนธงถูกต้อง แต่เมื่ออุปกรณ์ใช้ภาษาเยอรมัน NSLocale.current.localizedString(forRegionCode: "DE") จะคืน “Deutschland” วิดเจ็ตเรียกเมธอดนี้เพื่อสร้างชื่อที่แสดงตามภาษาปัจจุบัน ชื่อ “Deutschland” จึงถูกต้อง แต่ค้นไม่พบในดิกชันนารีที่ใช้คีย์ภาษาอังกฤษ แล้วตกไปใช้ค่าเริ่มต้นที่เป็นธงผิด
อีกรูปแบบที่ตรวจยากกว่าคือการจับคู่บางส่วน ชื่อประเทศในบางภาษาอาจมีส่วนต้นของชื่อหรือรูปคำคล้ายชื่อภาษาอังกฤษของอีกประเทศ หากไม่ตรวจว่าตรงกันทุกตัวอักษร การค้นหาแบบใกล้เคียงจะให้ผลที่ดูสมเหตุสมผลแต่ผิด การตรวจคุณภาพเฉพาะภาษาอังกฤษจะไม่พบปัญหานี้
เหตุใดชื่อที่ขึ้นอยู่กับ locale จึงเป็นกับดัก
ปัญหาพื้นฐานคือการใช้ข้อความที่ขึ้นอยู่กับ locale เป็นตัวระบุ ตัวระบุต้องคงที่ไม่ว่าใครอ่าน เมื่อใด หรือในบริบทภาษาใด ชื่อประเทศตามภาษาปัจจุบันเป็นค่าที่ใช้แสดงผล เหมาะสำหรับแสดงแก่ผู้ใช้ แต่เปราะบางเมื่อใช้เป็นคีย์ค้นหา ตัวระบุฐานข้อมูล คีย์แคช หรือข้อมูลอ้างอิงที่ต้องคงที่
ปัญหาเดียวกันเกิดกับชื่อสกุลเงิน ชื่อภาษา ชื่อ locale ที่แสดง และคำอธิบายเขตเวลา ระบบสร้างข้อความเหล่านี้เพื่อแสดงผล ไม่ใช่ระบุเอกลักษณ์ NSLocale.current.localizedString(forCurrencyCode: "EUR") คืน “Euro” ทั้งในภาษาอังกฤษและเยอรมัน ซึ่งตรงกันโดยบังเอิญ แต่ localizedString(forCurrencyCode: "GBP") คืน “British Pound” ในภาษาอังกฤษและ “Britisches Pfund” ในภาษาเยอรมัน การใช้ชื่อใดเป็นคีย์ก็เสี่ยงบั๊กประเภทเดียวกัน
หลักคือใช้รหัส ISO เป็นตัวระบุทุกจุดในตรรกะ และเรียกเมธอดแปลเฉพาะตอนสร้างข้อความแสดงผล อย่าเก็บข้อความที่แปลแล้ว อย่าใช้เป็นคีย์ และอย่าเปรียบเทียบข้อความข้าม locale ด้วยโค้ด
การสร้างอีโมจิธงจากรหัส ISO
Unicode เข้ารหัสอีโมจิธงเป็นคู่ของ Regional Indicator Symbols โดยแต่ละตัวอักษรในรหัสประเทศ ISO 3166-1 alpha-2 แทนด้วยสัญลักษณ์ที่ตรงกัน สัญลักษณ์เหล่านี้เริ่มจาก U+1F1E6 สำหรับ A ถึง U+1F1FF สำหรับ Z
ใน Swift การสร้างอีโมจิธงจากรหัส ISO ใช้ฟังก์ชันช่วยสั้น ๆ ดังนี้:
func flagEmoji(for isoCode: String) -> String {
let base: UInt32 = 0x1F1E6 - 65 // offset from A
return isoCode.uppercased().unicodeScalars.compactMap {
UnicodeScalar(base + $0.value)
}.map { String($0) }.joined()
}
ฟังก์ชันรับ “DE” แล้วคืน 🇩🇪 รับ “SG” แล้วคืน 🇸🇬 ไม่ต้องมีดิกชันนารี ไฟล์จับคู่ หรือการบำรุงรักษา การจับคู่กำหนดตามมาตรฐาน Unicode และสัมพันธ์กับรหัส ISO โดยตรง
กรณีพิเศษที่ต้องดูแลคือไม่ใช่รหัสสองตัวอักษรทุกค่าจะมีอีโมจิธง รหัสอย่าง UN และ EU ซึ่งเป็นรหัสที่สงวนไว้ใน ISO 3166-1 ไม่ใช่รหัสที่กำหนดให้ประเทศ มีอีโมจิธงที่แต่ละแพลตฟอร์มแสดงไม่สม่ำเสมอ ฟังก์ชันข้างต้นยังไม่มีการตรวจสอบอินพุต โดยจะแปลงอักขระทุกตัวที่ได้รับ จึงควรตรวจสอบอินพุตกับรายการรหัส ISO 3166-1 alpha-2 ที่มีการกำหนดแล้วก่อนแปลง โดยต้องเป็นตัวอักษร A–Z จำนวนสองตัวพอดี การจำกัดอินพุตให้เป็นรหัสที่กำหนดให้ประเทศและดินแดน พร้อมทดสอบบนแพลตฟอร์มเป้าหมาย ก็ครอบคลุมกรณีนี้
ตารางจับคู่ 150+ ประเทศที่เรายังต้องใช้
การสร้างธงด้วยอัลกอริทึมแก้ส่วนแสดงผลได้ แต่เรายังต้องเก็บและค้นหาประเทศอย่างมีโครงสร้าง วิดเจ็ตต้องกรองตามภูมิภาค เรียงตามเกณฑ์ต่าง ๆ และเชื่อมข้อมูลเมตา จึงยังต้องมีตารางจับคู่ที่เหมาะสม
ตารางที่เราสร้างจับคู่รหัส ISO 3166-1 alpha-2 กับชื่อภาษาอังกฤษมาตรฐานสำหรับใช้ภายในและบันทึกล็อก รหัส ISO 3166-1 แบบตัวเลขสำหรับระบบที่ต้องใช้ และกลุ่มภูมิภาค ชื่อภาษาอังกฤษไม่เคยใช้เป็นคีย์ค้นหา แต่มีไว้ตรวจบั๊กและแสดงแก่ผู้ดูแลในบริบทที่ไม่แปลภาษา ชื่อที่ผู้ใช้เห็นสร้างตอนแสดงผลผ่าน NSLocale.current.localizedString(forRegionCode:)
การแยกนี้เป็นการตัดสินใจสำคัญทางสถาปัตยกรรม ชั้นข้อมูลใช้รหัส ISO ส่วนชั้นแสดงผลแปลตาม locale ปัจจุบัน ทั้งสองไม่ปะปนกัน การค้นหาธง จัดกลุ่มภูมิภาค และเรียงลำดับใช้รหัส ISO ทั้งหมด เฉพาะข้อความสุดท้ายที่ผู้ใช้เห็นเท่านั้นที่ผ่านการแปล
การขยายแนวทางแก้ไปยังข้อมูลอื่น
หลังแก้ธง เราตรวจทั้งฐานโค้ดหาปัญหารูปแบบเดียวกัน พบอีกสามจุด ได้แก่ โมดูลแสดงสกุลเงินที่ใช้ชื่อแปลเป็นคีย์แคช ตัวเลือกภาษาที่เก็บชื่อภาษาที่แปลแล้วไว้ในการตั้งค่าผู้ใช้ และโมดูลจัดรูปแบบวันที่ที่เปรียบเทียบชื่อเดือนตาม locale
ทุกจุดแก้ด้วยวิธีเดียวกัน คือแทนข้อความที่ขึ้นกับ locale ด้วยค่ามาตรฐาน ISO หรือ IETF ที่คงที่ ใช้รหัสสกุลเงิน ISO 4217 แทนชื่อสกุลเงิน ใช้แท็กภาษา BCP 47 แทนชื่อภาษา และใช้เลขเดือนแทนชื่อเดือน ข้อความที่แปลแล้วเป็นหน้าที่ของชั้นแสดงผลเท่านั้น
การตรวจอย่างเป็นระบบคุ้มค่า เพราะบั๊กทั้งหมดนี้ไม่ปรากฏในการทดสอบภาษาอังกฤษ แต่จะเกิดในระบบใช้งานจริงบนอุปกรณ์ที่ตั้งภาษาอื่น แอปมีผู้ใช้หลายภาษา จึงอาจได้รับผลกระทบมาก
ขอบเขตของปัญหาในสภาพแวดล้อมใช้งานจริง
บั๊กประเภทนี้คงอยู่เพราะไม่ปรากฏในสภาพแวดล้อมพัฒนาที่ใช้ locale เดียว นักพัฒนาที่ใช้ภาษาอังกฤษสามารถสร้างและทดสอบฟีเจอร์แสดงประเทศทั้งหมดได้โดยไม่พบปัญหา บั๊กเกิดเมื่อ locale ของอุปกรณ์ต่างจากที่ใช้สร้างดิกชันนารีหรือตารางจับคู่ ซึ่งตรงกับผู้ใช้เป้าหมายส่วนใหญ่
สำหรับวิดเจ็ต ช่องว่างนี้กว้างกว่าแอปหลัก เพราะส่วนขยายทำงานในบริบทจำกัด โดยใช้ locale ปัจจุบันของระบบซึ่งอาจต่างจากที่นักพัฒนาทดสอบ การตรวจบั๊กยังไม่ตรงไปตรงมาเท่าแอปหลัก โดยต้องเรียกใช้ scheme ของส่วนขยายวิดเจ็ตใน Xcode (หรือใช้ Debug > Attach to Process) และโค้ดไทม์ไลน์กับโค้ดมุมมองจะทำงานตามกำหนดเวลาของ WidgetKit แทนที่จะตอบสนองต่อการโต้ตอบของคุณเอง การหาปัญหาแสดงผลในวิดเจ็ตที่ขึ้นกับ locale ต้องเรียกใช้ส่วนขยายภายใต้ locale ที่ต้องการทดสอบ ใช้พรีวิวของ Xcode กับรายการไทม์ไลน์ตัวอย่าง และตรวจบริบทการแสดงผลจริงของส่วนขยายอย่างรอบคอบ ซึ่งเป็นขั้นตอนที่มักถูกข้ามไปจนกว่าผู้ใช้จะรายงานปัญหาในระบบที่ใช้งานจริง
แนวทางปฏิบัติคือถือว่าตรรกะแสดงผลที่ขึ้นกับ locale ทั้งหมดเป็นกลุ่มความเสี่ยง ไม่ใช่แก้แค่บั๊กเดียว การตรวจโค้ดควรตรวจข้อความที่ขึ้นกับ locale แล้วถูกใช้เป็นตัวระบุ ถามว่าข้อความผ่านเมธอดแปลใดก่อนใช้เป็นคีย์หรือค่าเปรียบเทียบหรือไม่ หากใช่ ต้องเปลี่ยนการออกแบบ ใช้รหัส ISO ตัวระบุตัวเลข หรือข้อมูลอ้างอิงที่ไม่ขึ้นกับ locale ทุกชั้น ยกเว้นการแสดงผลสุดท้าย
การทดสอบหลาย locale
การทดสอบ locale ควรอยู่ในชุดทดสอบมาตรฐาน iOS ทำได้ค่อนข้างตรงไปตรงมา โปรแกรมจำลองเปลี่ยน locale ได้โดยไม่เปลี่ยนภาษาระบบ และกำหนดพารามิเตอร์ locale ให้การทดสอบ UI ได้
สำหรับธงและชื่อประเทศ เราเพิ่ม snapshot test ที่ใช้ locale ภาษาอังกฤษ เยอรมัน ฝรั่งเศส ญี่ปุ่น และอาหรับ ตรวจว่าธงของรหัส ISO ชุดเดิมถูกต้องในทุกภาษา หากมีใครเพิ่มการค้นหาที่ขึ้นกับ locale ในเส้นทางแสดงผล การทดสอบจะพบการถดถอยทันที
หลักทั่วไปคือฟีเจอร์ที่เกี่ยวข้องกับการแปลภาษา การรองรับหลายภาษา หรือการแสดงผลตามภูมิภาคควรมีการทดสอบอัตโนมัติหลาย locale การตรวจด้วยมือใน locale เดียวทดแทนไม่ได้ บั๊กจากสมมติฐานที่ขึ้นกับ locale พบได้บ่อย และการทดสอบเพียง locale เดียวก็ตรวจไม่พบเป็นประจำ
บทความที่เกี่ยวข้อง
- Dynamic Island สำหรับแอปจับเวลา: การพัฒนาและบทเรียนที่ได้ — อีกตัวอย่างของบริบทส่วนขยาย iOS ที่พฤติกรรมของแพลตฟอร์มต่างจากสิ่งที่การทดสอบในแอปแสดงให้เห็น
- Closure Table สำหรับการคำนวณความสัมพันธ์ — ใช้ตัวระบุที่คงที่ (ID แทนชื่อ) เพื่อสร้างตรรกะค้นหาและไล่ตามความสัมพันธ์ที่เชื่อถือได้