17 พ.ย. 2568·อ่าน 2 นาที

ปรับจูนประสิทธิภาพ SwiftUI สำหรับรายการยาว: วิธีแก้ปัญหาเชิงปฏิบัติ

การปรับจูนประสิทธิภาพ SwiftUI สำหรับรายการยาว: วิธีแก้ปฏิบัติสำหรับการ re-render, ตัวตนแถวที่เสถียร, pagination, การโหลดภาพ และการเลื่อนที่ลื่นบน iPhone รุ่นเก่า

ปรับจูนประสิทธิภาพ SwiftUI สำหรับรายการยาว: วิธีแก้ปัญหาเชิงปฏิบัติ

ลักษณะของ “รายการช้า” ในแอป SwiftUI ของจริง

"รายการช้า" ใน SwiftUI โดยทั่วไปไม่ใช่บั๊ก แต่มักเป็นช่วงที่ UI ตามนิ้วของคุณไม่ทัน คุณจะสังเกตได้ขณะเลื่อน: รายการสะดุด เฟรมตก และทุกอย่างรู้สึกหนัก

สัญญาณทั่วไป:

  • การเลื่อนสะดุด โดยเฉพาะบนอุปกรณ์เก่า
  • แถวกะพริบหรือแสดงเนื้อหาผิดชั่วคราว
  • การแตะรู้สึกหน่วง หรือการทำงานของท่าทางสไลด์เริ่มช้า
  • เครื่องร้อนและแบตเตอรี่ลดเร็วกว่าปกติ
  • การใช้งานหน่วยความจำเพิ่มขึ้นเมื่อเลื่อนนาน

รายการยาวอาจรู้สึกช้าแม้แต่ละแถวจะดู "เล็ก" เพราะต้นทุนไม่ได้มีแค่การวาดพิกเซล SwiftUI ยังต้องระบุว่าแต่ละแถวคืออะไร คำนวณเลย์เอาต์ แก้ไขฟอนต์และภาพ รันโค้ดฟอร์แมตของคุณ และ diff เมื่อข้อมูลเปลี่ยน หากงานเหล่านี้เกิดขึ้นบ่อยเกินไป รายการจะกลายเป็นจุดคอขวด

ควรแยกสองแนวคิดให้ชัด ใน SwiftUI การ "re-render" มักหมายถึง body ของวิวถูกคำนวณใหม่ ส่วนที่มักแพงคือสิ่งที่การคำนวณนั้นกระตุ้น: เลย์เอาต์หนัก การถอดรหัสภาพ การวัดข้อความ หรือการสร้างแถวจำนวนมากเพราะ SwiftUI คิดว่าตัวตนของพวกมันเปลี่ยน

ลองจินตนาการถึงแชทที่มีข้อความ 2,000 ข้อความ ข้อความใหม่มาถึงทุกวินาที แต่ละแถวฟอร์แมตเวลาที่แสดง วัดข้อความหลายบรรทัด และโหลดอวาตาร์ แม้คุณจะเพิ่มเพียงหนึ่งรายการ การเปลี่ยนสถานะที่ระบุไม่ดีอาจทำให้หลายแถวประเมินค่าใหม่ และบางส่วนต้องวาดใหม่

เป้าหมายไม่ใช่การทำ micro-optimization แต่คือการเลื่อนให้ลื่น การตอบสนองการแตะทันที และให้อัปเดตกระทบเฉพาะแถวที่เปลี่ยนจริง ๆ วิธีแก้ด้านล่างมุ่งไปที่ตัวตนที่เสถียร แถวที่เบาขึ้น การลดการอัปเดตที่ไม่จำเป็น และการควบคุมการโหลด

สาเหตุหลัก: ตัวตน งานต่อแถว และการอัปเดตที่ถล่ม

เมื่อ SwiftUI list รู้สึกช้า มักไม่ใช่เพราะ "มีแถวมากเกินไป" แต่เป็นงานเพิ่มเติมที่เกิดขึ้นขณะเลื่อน: สร้างแถวใหม่ คำนวณเลย์เอาต์ใหม่ หรือโหลดภาพซ้ำ ๆ

สาเหตุหลักมักอยู่ในสามกลุ่ม:

  • ตัวตนไม่เสถียร: แถวไม่มี id ที่คงที่ หรือคุณใช้ \.self กับค่าที่เปลี่ยนได้ SwiftUI จึงจับคู่แถวเก่า-ใหม่ไม่ได้และสร้างใหม่มากกว่าจำเป็น
  • งานต่อแถวมากเกินไป: ฟอร์แมตวันที่ กรองข้อมูล ย่อขนาดภาพ หรือทำงานเครือข่าย/ดิสก์ภายในวิวแถว
  • การอัปเดตถล่ม: การเปลี่ยนหนึ่งอย่าง (พิมพ์ ไทเมอร์ ติดตามความคืบหน้า) กระตุ้นการอัปเดตสถานะบ่อย ๆ และรายการรีเฟรชซ้ำ

ตัวอย่าง: มีคำสั่ง 2,000 รายการ แต่ละแถวฟอร์แมตสกุลเงิน สร้าง attributed string และเริ่มดึงภาพ ขณะเดียวกันไทเมอร์ "last synced" อัปเดตทุกวินาทีในวิว parent แม้ข้อมูลคำสั่งจะไม่เปลี่ยน ไทเมอร์นั้นก็อาจทำให้รายการไม่หยุดหย่อนจนการเลื่อนสะดุด

ทำไม List และ LazyVStack ถึงให้ความรู้สึกต่างกัน

List มากกว่า scroll view ธรรมดา มันออกแบบสำหรับพฤติกรรมแบบตาราง/คอลเลกชันและการปรับแต่งของระบบ มักจัดการชุดข้อมูลใหญ่ด้วยหน่วยความจำน้อยกว่า แต่ก็ไวต่อปัญหาตัวตนและการอัปเดตบ่อย

ScrollView + LazyVStack ให้การควบคุมเลย์เอาต์และภาพได้มากกว่า แต่ก็ง่ายที่จะเผลอทำงานเลย์เอาต์ซ้ำหรือกระตุ้นการอัปเดตที่แพงกว่า บนอุปกรณ์เก่า งานเพิ่มเติมนั้นจะแสดงผลให้เห็นเร็วขึ้น

ก่อนจะเขียน UI ใหม่ ให้วัดก่อน แก้เล็ก ๆ เช่น การทำ id ให้เสถียร ย้ายงานออกจากแถว และลด state churn มักจะแก้ปัญหาได้โดยไม่ต้องเปลี่ยนคอนเทนเนอร์

แก้ตัวตนของแถวให้ SwiftUI diff ได้อย่างมีประสิทธิภาพ

เมื่อรายการยาวรู้สึกสะดุด ตัวตนมักเป็นต้นเหตุ SwiftUI ตัดสินใจว่าแถวใดสามารถนำกลับมาใช้ใหม่ได้โดยการเปรียบเทียบ ID หาก ID เหล่านั้นเปลี่ยน SwiftUI จะมองว่าเป็นแถวใหม่ ทิ้งของเก่า และสร้างใหม่มากกว่าที่จำเป็น นั่นอาจทำให้เกิดการ re-render แบบสุ่ม ตำแหน่งการเลื่อนหาย หรืออนิเมชันเกิดขึ้นโดยไม่มีเหตุผล

ชัยชนะที่ง่าย: ทำให้ id ของแต่ละแถวเสถียรและผูกกับแหล่งข้อมูลของคุณ

ความผิดพลาดทั่วไปคือตัวตนถูกสร้างภายในวิว:

ForEach(items) { item in
  Row(item: item)
    .id(UUID())
}

นี่จะบังคับให้มี ID ใหม่ทุกครั้งที่เรนเดอร์ ดังนั้นทุกแถวจะกลายเป็น "ต่างกัน" ทุกครั้ง

ให้ใช้ ID ที่มีอยู่แล้วในโมเดล เช่น primary key ของฐานข้อมูล ID จากเซิร์ฟเวอร์ หรือ slug ที่เสถียร ถ้าไม่มี ให้สร้างมันครั้งเดียวตอนสร้างโมเดล — ไม่ใช่ในวิว

struct Item: Identifiable {
  let id: Int
  let title: String
}

List(items) { item in
  Row(item: item)
}

ระวังการใช้ดัชนี ForEach(items.indices, id: \.self) เพราะผูกตัวตนกับตำแหน่ง ถ้าคุณแทรก ลบ หรือเรียงใหม่ แถวจะ "ย้าย" และ SwiftUI อาจใช้วิวผิดกับข้อมูลผิด ใช้ดัชนีเฉพาะกับอาร์เรย์ที่ไม่เปลี่ยนตำแหน่งจริง ๆ

หากคุณใช้ id: \.self ให้แน่ใจว่าค่า Hashable ขององค์ประกอบนั้นเสถียร หากแฮชเปลี่ยนเมื่อฟิลด์อัปเดต ตัวตนของแถวก็จะเปลี่ยนด้วย กฎปลอดภัยสำหรับ Equatable/Hashable: ใช้บน ID เดียวที่เสถียร ไม่ใช่คุณสมบัติที่แก้ไขได้เช่น name หรือ isSelected

การตรวจสอบง่าย ๆ:

  • ID มาจากแหล่งข้อมูล (ไม่ใช่ UUID() ในวิว)
  • ID ไม่เปลี่ยนเมื่อเนื้อหาแถวเปลี่ยน
  • ตัวตนไม่ขึ้นกับตำแหน่งของอาร์เรย์ เว้นแต่รายการจะไม่เรียงใหม่

ลดการ re-render โดยทำให้วิวแถวเบาลง

รายการยาวมักรู้สึกช้าเพราะแต่ละแถวทำงานมากเกินไปเมื่อ SwiftUI ประเมิน body ใหม่ เป้าหมายคือทำให้แต่ละแถวถูกสร้างใหม่ได้ถูกและเร็ว

ต้นทุนที่ซ่อนอยู่ทั่วไปคือการส่งค่าที่ "ใหญ่" เข้าไปในแถว โครงสร้างใหญ่ โมเดลซ้อนลึก หรือ computed properties หนักสามารถกระตุ้นงานเพิ่มแม้ UI จะไม่เปลี่ยน คุณอาจกำลังสร้างสตริง ฟอร์แมตวันที่ ย่อขนาดภาพ หรือสร้างต้นไม้เลย์เอาต์ใหญ่บ่อยกว่าที่คิด

ย้ายงานหนักออกจาก body

ถ้าบางอย่างช้า อย่าให้มันถูกสร้างซ้ำใน body ของแถว คำนวณล่วงหน้าตอนข้อมูลมาถึง แคชไว้ใน ViewModel หรือ memoize ใน helper เล็ก ๆ

งานที่เพิ่มต้นทุนต่อแถวอย่างรวดเร็วได้แก่:

  • การสร้าง DateFormatter หรือ NumberFormatter ใหม่ต่อแถว
  • การฟอร์แมตสตริงหนักใน body (เช่น join, regex, การแปลง markdown)
  • การสร้างอาร์เรย์อนุพันธ์ด้วย .map หรือ .filter ภายใน body
  • อ่านบล็อบขนาดใหญ่แล้วแปลง (เช่น ถอดรหัส JSON) ในวิว
  • เลย์เอาต์ซับซ้อนเกินไปด้วย stack ซ้อนมากและเงื่อนไขเยอะ

ตัวอย่างง่าย ๆ: เก็บ formatter เป็น static และส่งสตริงที่ฟอร์แมตแล้วเข้าไปในแถว

enum Formatters {
    static let shortDate: DateFormatter = {
        let f = DateFormatter()
        f.dateStyle = .medium
        f.timeStyle = .none
        return f
    }()
}

struct OrderRow: View {
    let title: String
    let dateText: String

    var body: some View {
        HStack {
            Text(title)
            Spacer()
            Text(dateText).foregroundStyle(.secondary)
        }
    }
}

แยกแถวและใช้ Equatable เมื่อเหมาะสม

ถ้าส่วนเล็ก ๆ เท่านั้นที่เปลี่ยน (เช่น badge count) ให้แยกเป็น subview เพื่อให้ส่วนที่เหลือของแถวคงที่ การทำ subview ให้เป็น Equatable (หรือห่อด้วย EquatableView) จะช่วยให้ SwiftUI ข้ามงานเมื่ออินพุตไม่เปลี่ยนได้ แต่ให้จำกัดอินพุตที่เปรียบเทียบให้เล็กและเฉพาะเจาะจง — ไม่ใช่โมเดลทั้งก้อน

ควบคุมการอัปเดตที่ทำให้รายการรีเฟรชทั้งก้อน

Launch with the essentials included
Use prebuilt auth and Stripe payments to focus on performance, not plumbing.
เริ่มต้น

บางครั้งแถวดีแล้ว แต่มีบางอย่างคอยบอก SwiftUI ให้รีเฟรชทั้งรายการ ขณะเลื่อน แม้การอัปเดตเล็กน้อยก็สามารถกลายเป็นการสะดุด โดยเฉพาะบนอุปกรณ์เก่า

สาเหตุทั่วไปคือการสร้างโมเดลใหม่บ่อย ถ้า parent view สร้างใหม่และคุณใช้ @ObservedObject กับ ViewModel ที่วิวเป็นเจ้าของ SwiftUI อาจสร้างอีกรอบ รีเซ็ตการสมัคร และกระตุ้นการ publish ใหม่ หากวิวเป็นเจ้าของโมเดล ให้ใช้ @StateObject เพื่อสร้างครั้งเดียวและคงอยู่ ใช้ @ObservedObject กับอ็อบเจ็กต์ที่ถูกฉีดจากภายนอก

ตัวฆ่าประสิทธิภาพเงียบอีกอย่างคือการ publish บ่อยเกินไป ไทเมอร์ คอมไบน์ และการอัปเดตความคืบหน้าสามารถยิงหลายครั้งต่อวินาที หาก property ที่ publish ส่งผลต่อรายการ (หรืออยู่บน ObservableObject ที่แชร์โดยหน้าจอ) ทุกการ tick จะ invalidate รายการ

ตัวอย่าง: มีช่องค้นหาที่อัปเดต query ทุกครั้งที่พิมพ์ แล้วกรอง 5,000 รายการ หากกรองทันที รายการจะ diff ซ้ำขณะผู้ใช้พิมพ์ ให้ debounce คิวรีและอัปเดตรายการที่กรองแล้วหลังจากหยุดพิมพ์สั้น ๆ

รูปแบบที่ช่วยได้บ่อย ๆ:

  • เก็บค่าที่เปลี่ยนเร็วออกจากอ็อบเจ็กต์ที่เป็นแหล่งข้อมูลของรายการ (ใช้อ็อบเจ็กต์เล็กลงหรือ @State ในท้องที่)
  • Debounce การค้นหาและการกรองให้รายการอัปเดตหลังหยุดพิมพ์
  • หลีกเลี่ยงการ publish จากไทเมอร์ความถี่สูง; อัปเดตน้อยลงหรือเฉพาะเมื่อตัวแปรเปลี่ยนจริง
  • เก็บ state ต่อแถวเป็นท้องถิ่น (@State ในแถว) แทนการใช้ค่า global ที่เปลี่ยนบ่อย
  • แยกโมเดลใหญ่: หนึ่ง ObservableObject สำหรับข้อมูลรายการ อีกหนึ่งสำหรับสถานะ UI ของหน้าจอ

แนวคิดง่าย ๆ: ทำให้เวลาการเลื่อนสงบ ถ้าไม่มีอะไรสำคัญเปลี่ยน รายไม่ควรถูกบังคับให้ทำงาน

เลือกคอนเทนเนอร์ให้ถูก: List vs LazyVStack

คอนเทนเนอร์ที่คุณเลือกส่งผลต่อปริมาณงานที่ iOS ทำให้คุณ

List มักเป็นตัวเลือกที่ปลอดภัยเมื่อ UI ของคุณเป็นตารางมาตรฐาน: แถวที่มีข้อความ ภาพ swipe actions การเลือก เส้นคั่น โหมดแก้ไข และการเข้าถึง ในเบื้องหลังมันได้ประโยชน์จากการปรับแต่งของระบบที่ Apple ปรับมาหลายปี มักจัดการชุดข้อมูลใหญ่ด้วยหน่วยความจำต่ำกว่า

ScrollView กับ LazyVStack เหมาะเมื่อคุณต้องการเลย์เอาต์ที่กำหนดเอง: การ์ด เนื้อหาผสม หัวข้อพิเศษ หรือฟีดที่มีรูปแบบหลากหลาย “Lazy” หมายถึงมันสร้างแถวเมื่อขึ้นหน้าจอ แต่ไม่ได้ให้พฤติกรรมเดียวกับ List ในทุกกรณี กับชุดข้อมูลใหญ่ อาจหมายถึงการใช้หน่วยความจำสูงขึ้นและการเลื่อนที่สะดุดบนอุปกรณ์เก่ามากขึ้น

กฎตัดสินใจง่าย ๆ:

  • ใช้ List สำหรับหน้าจอตารางมาตรฐาน: การตั้งค่า กล่องจดหมาย คำสั่ง รายการผู้ดูแลระบบ
  • ใช้ ScrollView + LazyVStack เมื่อคุณต้องการเลย์เอาต์กำหนดเองและเนื้อหาผสม
  • ถ้าคุณมีรายการเป็นพันและต้องการตาราง เริ่มจาก List
  • ถ้าต้องการการควบคุมแบบพิกเซล ให้ลอง LazyVStack แล้ววัดหน่วยความจำและการตกของเฟรม

ระวังสไตลิงที่ทำให้การเลื่อนช้าซ่อนอยู่ เช่น เอฟเฟกต์ต่อแถวอย่างเงา เบลอ หรือ overlay ซับซ้อน อาจบังคับให้เกิดงานเรนเดอร์เพิ่ม หากต้องการฟิลลิ่งลึก ๆ ให้ใช้เอฟเฟกต์หนักกับองค์ประกอบเล็ก ๆ (เช่น ไอคอน) แทนทั้งแถว

ตัวอย่างชัดเจน: หน้าคำสั่ง 5,000 แถวมักลื่นใน List เพราะมีการ reuse แถว แต่ถ้าคุณเปลี่ยนเป็น LazyVStack และสร้างแถวแบบการ์ดพร้อมเงาใหญ่และ overlay หลายชั้น คุณอาจเห็นการสะดุดแม้โค้ดจะดูสะอาด

Pagination ที่รู้สึกลื่นและหลีกเลี่ยงการเพิ่มหน่วยความจำ

Choose how you ship and host
Deploy to AppMaster Cloud, AWS, Azure, Google Cloud, or export source for self-hosting.
Explore AppMaster

Pagination ช่วยให้รายการยาวทำงานเร็วเพราะคุณเรนเดอร์แถวจำนวนน้อยกว่า เก็บโมเดลในหน่วยความจำน้อยกว่า และให้ SwiftUI ทำการ diff น้อยลง

เริ่มด้วยสัญญาการแบ่งหน้าที่ชัดเจน: ขนาดหน้าแน่นอน (เช่น 30–60 รายการ), ธง "no more results", และแถวโหลดที่ปรากฏเฉพาะตอนที่ดึงข้อมูล

กับดักทั่วไปคือการทริกเกอร์หน้าถัดไปเมื่อแถวสุดท้ายปรากฏ — นั่นช้าเกินไป ให้เริ่มโหลดเมื่อหนึ่งในไม่กี่แถวสุดท้ายปรากฏแทน

นี่คือแพทเทิร์นเรียบง่าย:

@State private var items: [Item] = []
@State private var isLoading = false
@State private var reachedEnd = false

func loadNextPageIfNeeded(currentIndex: Int) {
    guard !isLoading, !reachedEnd else { return }
    let threshold = max(items.count - 5, 0)
    guard currentIndex >= threshold else { return }

    isLoading = true
    Task {
        let page = try await api.fetchPage(after: items.last?.id)
        await MainActor.run {
            let newUnique = page.filter { p in !items.contains(where: { $0.id == p.id }) }
            items.append(contentsOf: newUnique)
            reachedEnd = page.isEmpty
            isLoading = false
        }
    }
}

วิธีนี้ช่วยหลีกเลี่ยงปัญหาทั่วไปเช่น แถวซ้ำ (ผล API ทับกัน), เงื่อนไขการแข่งขันจาก onAppear หลายครั้ง, และการโหลดมากเกินไปพร้อมกัน

หากรายการของคุณรองรับ pull to refresh ให้รีเซ็ตสถานะของ pagination อย่างระมัดระวัง (ล้าง items รีเซ็ต reachedEnd ยกเลิก task ที่กำลังรันหากทำได้) หากคุณควบคุม backend ได้ การมี ID ที่เสถียรและ cursor-based paging จะทำให้ UI ลื่นขึ้นอย่างชัดเจน

ภาพ ข้อความ และเลย์เอาต์: ทำให้การเรนเดอร์แถวเบา

Go from UI to full stack
Ship production-ready SwiftUI apps with a backend in Go and a web admin panel in Vue3.
Generate Code

รายการยาวไม่ค่อยช้าจากคอนเทนเนอร์ แต่ส่วนใหญ่เป็นเพราะแถว ภาพมักเป็นตัวการ: การถอดรหัส การย่อขนาด และการวาดสามารถตามความเร็วการเลื่อนไม่ทัน โดยเฉพาะบนอุปกรณ์เก่า

ถ้าคุณโหลดภาพจากระยะไกล ให้แน่ใจว่างานหนักไม่เกิดบน main thread ขณะเลื่อน และอย่าโหลดสินทรัพย์ความละเอียดสูงสำหรับ thumbnail ขนาดเล็ก

ตัวอย่าง: หน้าข้อความที่มีอวาตาร์ ถ้าทุกแถวดาวน์โหลดภาพ 2000x2000 แล้วย่อขนาดและใส่เบลอหรือเงา รายการจะสะดุดแม้ข้อมูลโมเดลจะเรียบง่าย

ทำให้งานภาพคาดเดาได้

พฤติกรรมที่มีผลสูง:

  • ใช้ thumbnail จากเซิร์ฟเวอร์ที่มีขนาดใกล้เคียงกับขนาดแสดงผล
  • ถอดรหัสและย่อขนาดนอก main thread เมื่อเป็นไปได้
  • แคช thumbnail เพื่อการเลื่อนเร็วจะได้ไม่ต้องดึงหรือถอดรหัสซ้ำ
  • ใช้ placeholder ที่มีขนาดเดียวกับภาพสุดท้ายเพื่อลดการกะพริบและการกระโดดของเลย์เอาต์
  • หลีกเลี่ยง modifiers ที่หนักบนภาพในแถว (เงา หน้ากาก เบลอ)

ทำให้เลย์เอาต์คงที่เพื่อลด thrash

SwiftUI จะใช้เวลามากขึ้นในการวัดถ้าความสูงของแถวเปลี่ยนบ่อย พยายามทำให้แถวคาดเดาได้: กรอบคงที่สำหรับ thumbnail ขีดจำกัดจำนวนบรรทัดที่เหมาะสม และช่องว่างที่คงที่ หากข้อความขยายได้ ให้จำกัด (เช่น 1–2 บรรทัด) เพื่อไม่ให้การอัปเดตเดียวบังคับการวัดใหม่

Placeholder ก็สำคัญด้วย วงกลมสีเทาที่จะกลายเป็นอวาตาร์ภายหลังควรมีกรอบเดียวกัน เพื่อไม่ให้แถวไหลระหว่างการเลื่อน

วิธีวัด: เครื่องมือตรวจสอบที่เผยคอขวดจริง

การทำงานด้านประสิทธิภาพจะเดาไม่ได้ถ้าคุณอาศัยแค่ความรู้สึก Instruments บอกคุณว่าสิ่งใดรันบน main thread มีการจัดสรรวัตถุระหว่างการเลื่อนอย่างรวดเร็ว และอะไรทำให้เฟรมตก

กำหนด baseline บนอุปกรณ์จริง (ถ้ารองรับอุปกรณ์เก่าก็ใช้ตัวเก่านั้น) ทำการกระทำที่ทำซ้ำได้: เปิดหน้าจอ เลื่อนจากบนลงล่างเร็ว ๆ ทริกเกอร์โหลดเพิ่มเติมครั้งหนึ่ง แล้วเลื่อนกลับขึ้น บันทึกจุดสะดุด หน้าต่างหน่วยความจำสูงสุด และว่าระบบยังตอบสนองหรือไม่

มุมมอง Instruments สามอย่างที่ได้ผล

ใช้ร่วมกัน:

  • Time Profiler: มองหาสไปก์บน main-thread ขณะเลื่อน เลย์เอาต์ การวัดข้อความ การแปลง JSON และการถอดรหัสภาพในมุมมองนี้มักอธิบายการสะดุดได้
  • Allocations: ดูการพุ่งของอ็อบเจ็กต์ชั่วคราวระหว่างการเลื่อนเร็ว มักชี้ไปที่การฟอร์แมตซ้ำ การสร้าง attributed string ใหม่ หรือการสร้างโมเดลต่อแถวซ้ำ
  • Core Animation: ยืนยันเฟรมที่ตกและเวลาของเฟรมยาว แยกความกดดันระหว่างการเรนเดอร์กับงานข้อมูลช้า

เมื่อพบสไปก์ ให้คลิกดู call tree และถามว่า: เกิดครั้งเดียวต่อหน้าจอ หรือครั้งต่อแถว ต่อการเลื่อน? แบบหลังคือสิ่งที่ทำให้การเลื่อนห่วย

ใส่ signposts สำหรับเหตุการณ์การเลื่อนและ pagination

หลายแอปทำงานเพิ่มเติมระหว่างการเลื่อน (โหลดภาพ, pagination, การกรอง) Signpost ช่วยให้เห็นช่วงเวลานั้นบนไทม์ไลน์

import os
let log = OSLog(subsystem: "com.yourapp", category: "list")
os_signpost(.begin, log: log, name: "LoadMore")
// fetch next page
os_signpost(.end, log: log, name: "LoadMore")

ทดสอบใหม่หลังการเปลี่ยนแปลงแต่ละครั้ง ทีละอย่าง หาก FPS ดีขึ้นแต่ Allocations แย่ลง ก็อาจแลก stutter กับความกดดันหน่วยความจำ เก็บโน้ต baseline และรักษาเฉพาะการเปลี่ยนแปลงที่ทำให้ตัวเลขดีขึ้นจริง

ความผิดพลาดทั่วไปที่เงียบ ๆ ฆ่าประสิทธิภาพรายการ

Turn smooth scrolling into a product
Create an iOS app with a real backend so list updates stay predictable as data grows.
เริ่มสร้าง

บางปัญหาชัดเจน (ภาพใหญ่ ชุดข้อมูลมหาศาล) อื่น ๆ จะปรากฏเมื่อข้อมูลโต โดยเฉพาะบนอุปกรณ์เก่า

1) ID ของแถวไม่เสถียร

ความผิดพลาดคลาสสิกคือสร้าง ID ภายในวิว เช่น id: \.self สำหรับ reference types หรือ UUID() ใน body SwiftUI ใช้ identity เพื่อ diff อัปเดต ถ้า ID เปลี่ยน SwiftUI จะสร้างใหม่และอาจทิ้งการจัดวางที่แคชไว้

ใช้ ID ที่เสถียรจากโมเดล (primary key, server ID, หรือ UUID ที่เก็บไว้ตอนสร้างไอเท็ม) ถ้าไม่มี ให้เพิ่ม

2) งานหนักใน onAppear

onAppear ทำงานบ่อยกว่าที่คนคิดเพราะแถวเข้ามาและออกไปขณะเลื่อน ถ้าทุกแถวเริ่มถอดรหัสภาพ แปลง JSON หรือค้นหาจากฐานข้อมูลใน onAppear คุณจะเห็นสไปก์ซ้ำ ๆ

ย้ายงานหนักออกจากแถว คำนวณล่วงหน้าเมื่อโหลดข้อมูล แคชผล และให้ onAppear ทำงานที่เบาเท่านั้น เช่น ทริกเกอร์ pagination เมื่อใกล้ท้าย

3) ผูกทั้งรายการกับการแก้ไขแถว

เมื่อแต่ละแถวได้ @Binding ไปยังอาร์เรย์ใหญ่ การแก้ไขเล็กน้อยอาจถูกมองเป็นการเปลี่ยนใหญ่ นั่นทำให้หลายแถวประเมินค่าใหม่และบางครั้งรายการรีเฟรชทั้งก้อน

ส่งค่าแบบไม่เปลี่ยนแปลงเข้าไปในแถวแล้วส่งการเปลี่ยนกลับด้วย action เบา ๆ (เช่น "toggle favorite for id") เก็บ state ต่อแถวไว้ท้องถิ่นเมื่อมันเป็นของแถวนั้นจริง ๆ

4) แอนิเมชันมากเกินไปขณะเลื่อน

แอนิเมชันแพงในรายการเพราะอาจกระตุ้นเลย์เอาต์เพิ่ม การใช้ animation(.default, value:) สูง ๆ (บนทั้งรายการ) หรือแอนิเมตการเปลี่ยนแปลงเล็ก ๆ ทุกจุด จะทำให้การเลื่อนหน่วง

แนวทางง่าย ๆ:

  • ให้ขอบเขตแอนิเมชันอยู่ที่แถวที่เปลี่ยนจริง
  • หลีกเลี่ยงการแอนิเมตขณะเลื่อนเร็ว
  • ระวัง implicit animations บนค่าที่เปลี่ยนบ่อย
  • ใช้ transitions แบบเรียบง่ายแทนเอฟเฟกต์ผสมซับซ้อน

ตัวอย่างจริง: รายการแบบแชทที่แต่ละแถวเริ่มดึงข้อมูลใน onAppear ใช้ UUID() เป็น id และแอนิเมตสถานะ "seen" ผลลัพธ์คือ churn ของแถวอย่างต่อเนื่อง การแก้ตัวตน แคชงาน และจำกัดแอนิเมชันมักทำให้ UI รู้สึกลื่นขึ้นทันที

เช็คลิสต์ด่วน ตัวอย่างง่าย และขั้นตอนต่อไป

ถ้าทำได้ไม่กี่อย่าง ให้เริ่มที่นี่:

  • ใช้ id ที่ไม่เปลี่ยนสำหรับแต่ละแถว (ไม่ใช่ดัชนีอาร์เรย์ ไม่ใช่ UUID ที่สร้างใหม่)
  • ทำให้งานต่อแถวเล็ก: หลีกเลี่ยงการฟอร์แมตหนัก ต้นไม้วิวใหญ่ และ computed properties ที่แพงใน body
  • ควบคุมการ publish: อย่าให้ state ที่เปลี่ยนเร็ว (ไทเมอร์ การพิมพ์ ความคืบหน้า) invalidates ทั้งรายการ
  • โหลดเป็นหน้าและ prefetch เพื่อให้หน่วยความจำคงที่
  • วัดก่อนและหลังด้วย Instruments เพื่อไม่ให้เดา

ลองจินตนาการกล่องจดหมายสนับสนุน 20,000 รายการ แต่ละแถวแสดงหัวข้อ ข้อความย่อ เวลาล่าสุด ป้ายยังไม่ได้อ่าน และอวาตาร์ ผู้ใช้ค้นหา ขณะเดียวกันข้อความใหม่มาถึงในขณะเลื่อน เวอร์ชันช้าที่สุดมักทำหลายอย่างพร้อมกัน: สร้างแถวซ้ำเมื่อพิมพ์, วัดข้อความบ่อยเกินไป, และดึงภาพเยอะเกินความจำเป็น

แผนปฏิบัติไม่ต้องรื้อโค้ดทั้งฐาน:

  • Baseline: บันทึกการเลื่อนสั้น ๆ และการค้นหาใน Instruments (Time Profiler + Core Animation)
  • แก้ตัวตน: ตรวจให้แน่ใจว่าโมเดลมี id ที่แท้จริงจากเซิร์ฟเวอร์/ฐานข้อมูล และ ForEach ใช้มันอย่างสม่ำเสมอ
  • เพิ่ม paging: เริ่มด้วยล่าสุด 50–100 รายการ แล้วโหลดเพิ่มเมื่อผู้ใช้ใกล้ท้าย
  • ปรับภาพ: ใช้ thumbnail เล็ก แคชผล และไม่ถอดรหัสบน main thread
  • วัดซ้ำ: ยืนยันจำนวนเลย์เอาต์ที่น้อยลง การอัปเดตวิวที่น้อยลง และเวลาของเฟรมที่นิ่งขึ้นบนอุปกรณ์เก่า

ถ้าคุณสร้างผลิตภัณฑ์เต็มรูปแบบ (แอป iOS บวก backend และเว็บแอดมิน) การออกแบบโมเดลข้อมูลและสัญญาการแบ่งหน้าแต่แรกก็ช่วยได้ Platforms อย่าง AppMaster (appmaster.io) ถูกสร้างมาเพื่องาน full-stack แบบนั้น: คุณสามารถกำหนดข้อมูลและตรรกะธุรกิจเชิงภาพ แล้วยังสร้างซอร์สโค้ดจริงที่ deploy หรือ self-host ได้

คำถามที่พบบ่อย

วิธีแก้เร็วที่สุดเมื่อ SwiftUI list เลื่อนสะดุดคืออะไร?

เริ่มจากการแก้ id ให้มั่นคงก่อน ใช้ id ที่มาจากโมเดลของคุณและหลีกเลี่ยงการสร้าง ID ในมุมมอง เพราะการเปลี่ยน ID จะทำให้ SwiftUI มองว่าแถวเป็นของใหม่และต้องสร้างใหม่มากกว่าจำเป็น

SwiftUI ช้าเพราะมัน "re-render" มากเกินไปหรือเปล่า?

การคำนวณ body ใหม่มักจะไม่แพงนัก ส่วนที่หนักคือสิ่งที่การคำนวณนั้นเรียกใช้ เช่น การจัดวาง (layout) ที่หนัก การวัดข้อความ (text measurement) การถอดรหัสภาพ หรือการสร้างแถวใหม่จำนวนมากเพราะ identity ไม่เสถียร — สิ่งเหล่านี้มักเป็นสาเหตุของเฟรมตก

จะเลือก `id` ที่เสถียรสำหรับ `ForEach` และ `List` อย่างไร?

อย่าใช้ UUID() ภายในแถวหรือใช้ดัชนีของอาร์เรย์เป็นตัวกำหนดตัวตนถ้าข้อมูลสามารถแทรก ลบ หรือเรียงใหม่ได้ ให้ใช้ ID จากเซิร์ฟเวอร์/ฐานข้อมูลหรือ UUID ที่ถูกสร้างไว้ในโมเดลตอนสร้างครั้งแรก เพื่อให้ id คงที่ข้ามการอัปเดต

การใช้ `id: .self` ทำให้ประสิทธิภาพ list แย่ลงได้ไหม?

ได้ โดยเฉพาะเมื่อค่าที่ใช้เป็น Hashable เปลี่ยนเมื่อตัวแปรที่แก้ไขได้ถูกเปลี่ยน SwiftUI อาจมองว่าเป็นแถวใหม่ หากต้องใช้ Hashable ให้สร้างจากตัวระบุหนึ่งตัวที่เสถียรแทนที่จะอิงจากคุณสมบัติที่แก้ไขได้ เช่น name หรือ isSelected

ควรหลีกเลี่ยงการทำอะไรอยู่ภายใน `body` ของแถว?

ย้ายงานหนักออกจาก body ก่อน เช่น ฟอร์แมตวันที่/ตัวเลข ลองเก็บสตริงที่ฟอร์แมตแล้วไว้ใน ViewModel หรือคำนวณล่วงหน้าพวก array ที่ได้จาก map/filter อย่าสร้าง DateFormatter ใหม่ต่อแถวใน body เป็นต้น

ทำไม `onAppear` ของฉันถึงถูกเรียกบ่อยในรายการยาว?

onAppear ถูกเรียกบ่อยเพราะแถวจะเข้าออกหน้าจอเมื่อเลื่อน หากแต่ละแถวเริ่มงานหนักใน onAppear (เช่น ถอดรหัสภาพ อ่านฐานข้อมูล หรือแปลง JSON) คุณจะเห็นสไปก์ซ้ำ ๆ ให้ย้ายงานหนักไปทำตอนโหลดข้อมูลหรือแคชผลไว้ และใช้ onAppear เฉพาะกับงานที่เบา เช่น เรียกโหลดหน้าถัดไปเมื่อใกล้ถึงท้ายรายการ

อะไรเป็นสาเหตุของ "update storms" ที่ทำให้การเลื่อนรู้สึกหน่วง?

ค่าที่เปลี่ยนเร็วและมีการ publish บ่อย ๆ (เช่น ไทเมอร์ ขณะพิมพ์ หรือสถานะความคืบหน้า) หากแชร์ไปยังอ็อบเจ็กต์ที่เป็นแหล่งข้อมูลของรายการ จะทำให้รายการถูก invalidated บ่อย แยกค่าสถานะที่เปลี่ยนเร็วออก ใช้ @State ในที่เหมาะสม และทำ debounce สำหรับการค้นหา

เมื่อไหร่ควรใช้ `List` กับ `LazyVStack` สำหรับชุดข้อมูลขนาดใหญ่?

เลือก List เมื่อหน้าจอของคุณเป็นตารางมาตรฐาน (แถวข้อความ ภาพ swipe action เลือกได้ มีเส้นคั่น ฯลฯ) เพราะมันได้ประโยชน์จากการปรับแต่งของระบบ ใช้ ScrollView + LazyVStack เมื่อคุณต้องการเลย์เอาต์ที่กำหนดเอง แต่ต้องวัดหน่วยความจำและการลดเฟรมเพราะง่ายที่จะสร้างงานวัดซ้ำเพิ่ม

วิธีแบ่งหน้า (pagination) แบบง่ายที่ยังลื่นคืออะไร?

เริ่มโหลดก่อนถึงแถวสุดท้าย เช่น เมื่อผู้ใช้เห็นแถวใกล้ท้ายที่กำหนดไว้ แล้วป้องกันการเรียกซ้ำด้วย isLoading และตรวจจับ reachedEnd ใช้ขนาดหน้าที่เหมาะสมและยกเลิก/ระบายผลลัพธ์ซ้ำเมื่อต้องการ

จะวัดหาสิ่งที่ทำให้ SwiftUI list ช้าจริง ๆ ได้อย่างไร?

บันทึกการทำงานบนอุปกรณ์จริงและใช้ Instruments ดู Time Profiler เพื่อหาการกระโดดบน main thread, Allocations เพื่อตรวจหาวัตถุชั่วคราวที่เกิดขึ้นขณะเลื่อน และ Core Animation เพื่อตรวจเฟรมที่หล่น การผสานมุมมองเหล่านี้จะบอกได้ว่างานไหนคือต้นตอของปัญหา

ง่ายต่อการเริ่มต้น
สร้างบางสิ่งที่ น่าทึ่ง

ทดลองกับ AppMaster ด้วยแผนฟรี
เมื่อคุณพร้อม คุณสามารถเลือกการสมัครที่เหมาะสมได้

เริ่ม