Swift 6 กับการทำงานพร้อมกัน: คู่มือย้ายระบบในทางปฏิบัติ
ใน Swift 5 คอมไพเลอร์ยอมให้มีโค้ดที่เสี่ยงต่อการเข้าถึงข้อมูลพร้อมกัน โดยเตือนบางกรณีและปล่อยผ่านบางกรณี โค้ดจึงมักเรียก DispatchQueue.main.async ในบางจุดแต่ลืมในจุดอื่น และทำงานได้จนกว่าจะเกิดปัญหา Swift 6 เปลี่ยนกติกา: คำเตือนเรื่องการแยกขอบเขต actor และการรองรับ Sendable ทั้งหมดกลายเป็นข้อผิดพลาดที่หยุดการบิลด์ จนกว่าจะกำจัดความเสี่ยงในการเข้าถึงข้อมูลพร้อมกันหรือยอมรับความเสี่ยงนั้นอย่างชัดเจน
ในทางปฏิบัติ แอป SwiftUI เดิมส่วนใหญ่ไม่ได้ซ่อนปัญหาการเข้าถึงข้อมูลพร้อมกันที่ร้ายแรง แต่มีรูปแบบโค้ดที่ใช้งานได้อย่างปลอดภัยในทางปฏิบัติ ทว่าไม่ถูกต้องตามแบบจำลองการทำงานพร้อมกันของ Swift งานย้ายระบบจึงมักเป็นการทำให้คอมไพเลอร์เข้าใจว่าโค้ดปลอดภัย มากกว่าการแก้ข้อบกพร่องที่เกิดขึ้นจริง ซึ่งเป็นงานที่คุ้มค่า แต่ต้องเข้าใจก่อนว่าข้อผิดพลาดเหล่านั้นกำลังบอกอะไร
เปิดการตรวจสอบการทำงานพร้อมกันอย่างเคร่งครัด
ก่อนเปลี่ยนเป็นโหมดภาษา Swift 6 ควรดูปัญหาในรูปแบบคำเตือนก่อน ใน Xcode ให้ตั้งค่าคอมไพเลอร์ Swift ของ target ที่ชื่อ SWIFT_STRICT_CONCURRENCY เป็น complete เพื่อดูทุกจุดที่ Swift 6 จะไม่ยอมรับ โดยยังไม่ทำให้การบิลด์ล้มเหลว แก้คำเตือนอย่างเป็นระบบก่อนเปลี่ยนเวอร์ชันภาษา
หากต้องการเปิดโหมดภาษา Swift 6 ให้ตั้งค่า SWIFT_VERSION เป็น 6 ใน Build Settings ของ target เมื่อกำหนดแล้ว ระบบจะใช้การตรวจสอบการทำงานพร้อมกันแบบ complete โดยอัตโนมัติ และคำเตือนทั้งหมดจะกลายเป็นข้อผิดพลาด สำหรับโครงการที่มีหลายแพ็กเกจ ควรย้ายทีละ target โดยเริ่มจากแพ็กเกจที่พึ่งพา UI น้อยที่สุด เช่น แพ็กเกจตรรกะล้วนและเครือข่าย แล้วจึงย้ายแพ็กเกจ UI เป็นลำดับสุดท้าย
ปัญหาขอบเขต MainActor
ข้อผิดพลาดที่พบบ่อยที่สุดในการย้ายแอป SwiftUI มีลักษณะดังนี้:
// Error: Main actor-isolated property 'items' can not be
// mutated from a nonisolated context
class DataStore: ObservableObject {
@Published var items: [Item] = []
func fetch() {
Task {
let result = await API.load()
self.items = result // ← error
}
}
}
@Published ที่กำกับพร็อพเพอร์ตีของ ObservableObject ไม่ได้ทำให้พร็อพเพอร์ตีถูกแยกไว้บน @MainActor โดยอัตโนมัติ แม้ SwiftUI จะสังเกตค่าบนเธรดหลัก เมื่อ Task {} ทำงานบน executor ที่ไม่ได้ระบุไว้และพยายามเขียนค่า items Swift 6 จึงตรวจพบความเสี่ยงในการเข้าถึงข้อมูลพร้อมกัน สำหรับคลาสที่เขียนไว้ตามตัวอย่างนี้ คอมไพเลอร์ Swift 6 จะรายงานความเสี่ยงที่ข้อมูลจะเกิดการแข่งขันกันที่คลอเชอร์ของ Task ซึ่งจับค่า self ส่วนข้อความเกี่ยวกับการแยกไว้บน main actor ในคอมเมนต์จะปรากฏเมื่อกำกับ items เองด้วย @MainActor แล้วเท่านั้น
วิธีแก้ที่ชัดเจนที่สุดคือกำกับทั้งคลาสด้วย @MainActor:
@MainActor
class DataStore: ObservableObject {
@Published var items: [Item] = []
func fetch() async {
let result = await API.load()
items = result // ✓ — entire class is MainActor-isolated
}
}
วิธีนี้ทำให้ขอบเขตการแยกของคลาสชัดเจน ผู้ที่เรียก fetch() จากบริบทเบื้องหลังจะต้องใช้ await ซึ่งถูกต้องแล้ว เพราะควรอยู่บน main actor เมื่อทำงานกับคลาสที่ควบคุมการอัปเดต UI หากกำกับทั้งคลาสไม่ได้ อีกทางเลือกคือย้ายไปทำงานบน actor ที่ต้องการโดยตรง:
func fetch() {
Task {
let result = await API.load()
await MainActor.run {
self.items = result
}
}
}
ทั้งสองวิธีใช้ได้ การกำกับคลาสด้วย @MainActor ชัดเจนกว่าเมื่อคลาสมีหน้าที่หลักในการควบคุมมุมมอง SwiftUI ซึ่งแทบจะเป็นหน้าที่ของคลาส ObservableObject ทั้งหมดในแอปที่จัดโครงสร้างอย่างเหมาะสม
PhotosPicker และการแยกขอบเขต delegate
PHPickerViewControllerDelegate มักทำให้เกิดข้อผิดพลาดใน Swift 6 ใน SDK ปัจจุบัน เมธอดของ delegate เองถูกแยกไว้บน main actor แต่การเรียกกลับสำหรับโหลดข้อมูลของ item provider ภายในเมธอดนั้นทำงานบนคิวเบื้องหลัง และโค้ดในการเรียกกลับนี้มักต้องอัปเดต UI รูปแบบที่ใช้กันใน Swift 5 คือ:
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
dismiss(animated: true)
guard let provider = results.first?.itemProvider else { return }
provider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
if let image = object as? UIImage {
self?.imageView.image = image // ← Swift 6 error
}
}
}
ใน Swift 6 คลอเชอร์ที่ส่งให้ loadObject เป็น @Sendable จึงไม่สามารถจับค่า self ได้ หาก self ไม่เป็น Sendable UIViewController ผ่านเงื่อนไขนี้ เพราะคลาสที่แยกไว้บน main actor เป็น Sendable โดยปริยาย ข้อผิดพลาดที่แท้จริงคือการเข้าถึง imageView.image ซึ่งเป็นพร็อพเพอร์ตีที่แยกไว้บน @MainActor จากบริบทเบื้องหลัง
วิธีแก้คือย้ายไปทำงานบน main actor ภายในคลอเชอร์:
provider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
guard let image = object as? UIImage else { return }
Task { @MainActor [weak self] in
self?.imageView.image = image
}
}
ไวยากรณ์ Task { @MainActor in } สร้างงานที่แยกไว้บน main actor อย่างชัดเจน การจับค่า image ใช้ได้ เพราะ UIImage รองรับ Sendable ส่วนการจับค่า self ผ่าน [weak self] ใช้ได้ เพราะ view controller ถูกแยกไว้บน main actor จึงเป็น Sendable ส่วน weak เพียงป้องกันวงจรการอ้างอิงที่ทำให้วัตถุไม่ถูกคืนหน่วยความจำ และไม่ได้ทำให้การจับค่าชนิดที่ไม่เป็น Sendable ปลอดภัย
สำหรับ PhotosPicker ของ SwiftUI ซึ่งเป็น API รุ่นใหม่ รูปแบบจะชัดเจนกว่าเพราะใช้ Transferable:
// In a @MainActor view model:
func handlePickerSelection(_ item: PhotosPickerItem?) async {
guard let item else { return }
if let data = try? await item.loadTransferable(type: Data.self) {
selectedImage = UIImage(data: data)
}
}
เนื่องจากการเรียก loadTransferable เป็น async จึงต้องใช้ await และการกำหนดค่าจะเกิดบน actor ที่ฟังก์ชันถูกแยกไว้ ซึ่งก็คือ @MainActor หาก view model มีตัวกำกับนี้ จึงไม่ต้องย้าย actor เอง
ชนิดข้อมูลที่เป็นและไม่เป็น Sendable
ชนิดข้อมูลเป็น Sendable เมื่อสามารถส่งข้ามขอบเขต actor ได้อย่างปลอดภัย โดยคร่าว ๆ คือชนิดข้อมูลแบบค่า หรือคลาสที่ป้องกันสถานะด้วยการซิงโครไนซ์ ใน Swift 6 คอมไพเลอร์จะบังคับใช้เงื่อนไขนี้ทุกครั้งที่ค่าส่งข้ามขอบเขตการแยก
รูปแบบข้อผิดพลาดที่พบบ่อยที่สุดคือ:
// Error: Capture of 'config' with non-sendable type 'RequestConfig'
// in a '@Sendable' closure
struct RequestConfig {
var endpoint: String
var headers: [String: String]
var body: Data?
}
Task {
let result = await API.fetch(config) // config captured here
}
วิธีแก้มักตรงไปตรงมา คือเพิ่มการรองรับ Sendable ให้ชนิดข้อมูลแบบค่า:
struct RequestConfig: Sendable {
var endpoint: String
var headers: [String: String]
var body: Data?
}
สำหรับ struct ที่สมาชิกทั้งหมดเป็น Sendable ซึ่งรวมถึง String, Data และชนิดข้อมูลแบบค่ามาตรฐานทั้งหมด การรองรับเงื่อนไขนี้ทำได้โดยตรง คอมไพเลอร์จะอนุมานการรองรับโดยปริยายให้ struct ที่ไม่เป็นสาธารณะ ดังนั้นในทางปฏิบัติ ข้อผิดพลาดนี้จึงเกิดกับชนิดข้อมูล public ที่ถูกใช้จากโมดูลอื่น และคอมไพเลอร์จะตรวจสอบการประกาศ : Sendable อย่างชัดเจน แต่สำหรับคลาสจะซับซ้อนกว่า คอมไพเลอร์ตรวจสอบการรองรับ Sendable ได้เฉพาะคลาส final ที่พร็อพเพอร์ตีแบบเก็บค่าทั้งหมดเป็นค่าคงที่ let ของชนิดข้อมูล Sendable หรือคลาสที่แยกไว้บน global actor เช่น @MainActor คลาสที่ปกป้องสถานะที่เปลี่ยนแปลงได้ด้วยกลไกซิงโครไนซ์ภายในของตัวเองต้องใช้ @unchecked Sendable เพราะคอมไพเลอร์ตรวจสอบการล็อกไม่ได้ สำหรับ ObservableObject ที่เข้าถึงผ่าน main actor เสมอ ควรเลือกใช้ @MainActor ซึ่งทำให้เป็น Sendable โดยอัตโนมัติ และใช้ @unchecked Sendable เป็นทางเลือกสุดท้าย ไม่ใช่วิธีแรก
SharedDataProvider และสถานะที่ใช้ร่วมกันระหว่าง view model
แอป SwiftUI ขนาดใหญ่มักมีตัวให้บริการข้อมูลร่วมกัน ซึ่งส่งให้หลายมุมมองผ่าน environment ใน Swift 6 ต้องกำหนดขอบเขต actor ของออบเจ็กต์นี้ให้ชัดเจน เพราะมีการเข้าถึงจาก body ของมุมมองที่ทำงานบน main actor:
@MainActor
class SharedDataProvider: ObservableObject {
static let shared = SharedDataProvider()
@Published var currentUser: User?
@Published var sessionToken: String?
func refreshSession() async {
let token = await AuthClient.refresh()
sessionToken = token
}
}
ตัวกำกับ @MainActor มีหน้าที่จริงในกรณีนี้: ทำให้การเข้าถึง currentUser และ sessionToken ทั้งหมดเรียงผ่าน main actor ซึ่งเป็นขอบเขตที่เหมาะสมสำหรับพร็อพเพอร์ตีที่ควบคุมการอัปเดตมุมมอง SwiftUI งานเบื้องหลังที่ต้องเขียนค่าในคลาสนี้จะต้องใช้ await ซึ่งถูกต้องแล้ว เพราะควรรอให้ main actor พร้อม แทนที่จะเขียนค่าพร้อมกัน
จุดที่ต้องระวังคือ static let shared บนคลาส @MainActor เข้าถึงได้จาก main actor เท่านั้น หากมีเส้นทางโค้ดที่พยายามเข้าถึง SharedDataProvider.shared จากบริบทเบื้องหลัง Swift 6 จะไม่ยอมรับ วิธีแก้คือส่งอินสแตนซ์ผ่านลำดับการเรียกฟังก์ชันอย่างชัดเจน (การเข้าถึงพร็อพเพอร์ตีจากนอก main actor ยังต้องใช้ await) หรือครอบการเข้าถึงด้วย await MainActor.run { ... }
ทางออกด้วย @unchecked Sendable
บางคลาสไม่สามารถทำให้เป็น Sendable อย่างสมบูรณ์ได้ในทางปฏิบัติ เช่น คลาสที่ห่อไลบรารี C เชื่อมกับชนิดข้อมูล Objective-C หรือดูแลสถานะภายในอย่างปลอดภัยแต่คอมไพเลอร์มองไม่เห็น ในกรณีเหล่านี้ @unchecked Sendable บอกคอมไพเลอร์ว่า “เชื่อฉัน ฉันจัดการการซิงโครไนซ์แล้ว”:
final class AudioPlayer: @unchecked Sendable {
private let queue = DispatchQueue(label: "audio.player")
private var _isPlaying = false
var isPlaying: Bool {
queue.sync { _isPlaying }
}
func play() {
queue.async { self._isPlaying = true }
}
}
วิธีนี้ไม่ใช่ทางลัดเพื่อหลีกเลี่ยงงานย้ายระบบ แต่เป็นการประกาศอย่างชัดเจนว่าคุณพิจารณาวิธีทำงานพร้อมกันและรับผิดชอบต่อความปลอดภัยแล้ว ใช้กับคลาสที่ต้องมีกลไกซิงโครไนซ์ภายในจริง ๆ และเพิ่มคำอธิบายว่ากลไกใดรับประกันความปลอดภัย
ลำดับการย้ายระบบในทางปฏิบัติ
เปิด SWIFT_STRICT_CONCURRENCY = complete ก่อน และแก้คำเตือนทั้งหมดก่อนเปลี่ยนเป็นโหมดภาษา Swift 6 เริ่มจากแพ็กเกจปลายกราฟการพึ่งพา ซึ่งไม่พึ่งโมดูลภายในอื่น แก้และเปลี่ยนแพ็กเกจเหล่านั้นเป็นโหมดภาษา Swift 6 แล้วจึงขยับไปชั้นถัดไป เพื่อไม่ให้การแก้แพ็กเกจปลายน้ำบังคับให้แก้แพ็กเกจต้นน้ำที่ยังอยู่ในโหมดคำเตือนพร้อมกัน
งานย้ายระบบส่วนใหญ่ในโค้ด SwiftUI ทั่วไปคือเพิ่มตัวกำกับ @MainActor ให้คลาส ObservableObject เพิ่มการรองรับ Sendable ให้ชนิดข้อมูลแบบค่าที่ส่งข้ามขอบเขตงาน และย้าย actor อย่างชัดเจนในการเรียกกลับของ delegate แม้รายการเปลี่ยนแปลงจะดูมาก แต่แต่ละจุดเป็นการแก้ตามรูปแบบที่ชัดเจน คอมไพเลอร์จะชี้ทุกตำแหน่งที่ต้องจัดการ ซึ่งเป็นเป้าหมายของการตรวจสอบการทำงานพร้อมกันอย่างเคร่งครัด
บทความที่เกี่ยวข้อง
- สร้างแอปเตรียมสอบที่มีคำถามหลายพันข้อ – สถาปัตยกรรม SPM ที่มีหลายแพ็กเกจ ซึ่งทำให้ขอบเขตการแยกเหล่านี้มีความสำคัญเป็นพิเศษ
- จากแนวคิด SwiftUI สู่ App Store ในไม่กี่สัปดาห์ – ควรเลื่อนการย้ายไป Swift 6 เมื่อใดในช่วงที่พัฒนาฟีเจอร์อย่างรวดเร็ว
- แก้ปัญหาไอคอนที่ถูก App Store ปฏิเสธ – ปัญหาอีกประเภทก่อนส่งแอป ที่มักทำให้ทีมสะดุดระหว่างการตรวจสอบคุณภาพขั้นสุดท้าย