tva
← บทความเชิงลึก

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 แม้รายการเปลี่ยนแปลงจะดูมาก แต่แต่ละจุดเป็นการแก้ตามรูปแบบที่ชัดเจน คอมไพเลอร์จะชี้ทุกตำแหน่งที่ต้องจัดการ ซึ่งเป็นเป้าหมายของการตรวจสอบการทำงานพร้อมกันอย่างเคร่งครัด

บทความที่เกี่ยวข้อง

บทความที่เกี่ยวข้อง