# ประสิทธิภาพ SwiftUI: การปรับแต่ง LazyVStack และรายการที่ซับซ้อน > เทคนิคการปรับแต่งสำหรับ LazyVStack และรายการ SwiftUI ลดการใช้หน่วยความจำ ปรับปรุงประสิทธิภาพการเลื่อน และหลีกเลี่ยงข้อผิดพลาดที่พบบ่อย - Published: 2026-03-06 - Updated: 2026-04-29 - Author: SharpSkill - Tags: swiftui, ios, performance, lazyvstack, swift - Reading time: 14 min --- รายการเป็นหนึ่งในส่วนประกอบที่ใช้บ่อยที่สุดในแอปพลิเคชัน iOS `LazyVStack` และ `List` ของ SwiftUI ให้โซลูชันที่มีประสิทธิภาพในการแสดงคอลเลกชันข้อมูล แต่การใช้งานที่ไม่ถูกต้องอาจลดประสบการณ์ผู้ใช้ได้อย่างรวดเร็ว การเข้าใจกลไกภายในของส่วนประกอบเหล่านี้ช่วยหลีกเลี่ยงข้อผิดพลาดที่พบบ่อยและสร้างอินเทอร์เฟซที่ลื่นไหล > **สิ่งที่บทความนี้ครอบคลุม** > > บทความนี้นำเสนอเทคนิคการปรับแต่งที่จำเป็นสำหรับรายการ SwiftUI: lazy loading การรีไซเคิลวิว การจัดการตัวระบุ และรูปแบบขั้นสูงสำหรับชุดข้อมูลขนาดใหญ่ ## ทำความเข้าใจ Lazy Loading ใน SwiftUI Lazy loading จะสร้างวิวเฉพาะเมื่อปรากฏบนหน้าจอเท่านั้น ต่างจาก `VStack` ที่สร้างวิวลูกทั้งหมดทันที `LazyVStack` จะเลื่อนการสร้างนี้ออกไป ลดการใช้หน่วยความจำและเวลาในการเรนเดอร์เริ่มต้นได้อย่างมาก ```swift // LazyVStackComparison.swift import SwiftUI // ❌ Problem: VStack instantiates all 10,000 views immediately struct NonLazyListView: View { let items = (1...10000).map { "Item \($0)" } var body: some View { ScrollView { VStack { ForEach(items, id: \.self) { item in // Each view is created at launch ExpensiveRowView(title: item) } } } } } // ✅ Solution: LazyVStack creates views on demand struct LazyListView: View { let items = (1...10000).map { "Item \($0)" } var body: some View { ScrollView { LazyVStack { ForEach(items, id: \.self) { item in // Only visible views are created ExpensiveRowView(title: item) } } } } } ``` ความแตกต่างของประสิทธิภาพจะเห็นได้ชัดเจนแม้กับเพียงไม่กี่ร้อยรายการ ด้วย 10,000 รายการ `VStack` อาจใช้เวลาหลายวินาทีในการเริ่มต้น ในขณะที่ `LazyVStack` ยังคงตอบสนองทันที ### การวัดผลกระทบของ Lazy Loading Instruments ช่วยให้สามารถวัดการใช้หน่วยความจำและ CPU ได้อย่างแม่นยำ นี่คือวิวทดสอบที่แสดงความแตกต่าง: ```swift // PerformanceMeasurement.swift struct ExpensiveRowView: View { let title: String // Simulating expensive initialization init(title: String) { self.title = title // Log to visualize when the view is created print("Creating row: \(title)") } var body: some View { HStack { // Image with processing Circle() .fill( LinearGradient( colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .frame(width: 50, height: 50) VStack(alignment: .leading) { Text(title) .font(.headline) Text("Subtitle with computation") .font(.caption) .foregroundStyle(.secondary) } Spacer() } .padding() } } ``` เมื่อรันด้วย `VStack` ล็อกทั้งหมด 10,000 รายการจะปรากฏทันที ด้วย `LazyVStack` มีเพียงรายการที่มองเห็นได้ (ประมาณ 15-20 ขึ้นอยู่กับขนาดหน้าจอ) ที่ถูกล็อกในตอนแรก โดยมีรายการเพิ่มเติมปรากฏขึ้นเมื่อเลื่อน > **พฤติกรรมการคงอยู่** > > `LazyVStack` เก็บวิวที่สร้างไว้ในหน่วยความจำหลังจากที่ปรากฏ ต่างจาก `List` ที่รีไซเคิลเซลล์อย่างแอคทีฟ วิวใน `LazyVStack` จะคงอยู่จนกว่าส่วนประกอบหลักจะถูกทำลาย ## ความสำคัญของตัวระบุที่เสถียร ตัวระบุเป็นกลไกหลักของการอัปเดตรายการ SwiftUI ตัวระบุที่ไม่เสถียรทำให้เกิดการสร้างวิวใหม่ที่ไม่จำเป็นและอาจกระตุ้นบั๊กทางสายตา เช่น แอนิเมชันที่ผิดพลาดหรือการสูญเสียตำแหน่งการเลื่อน ```swift // StableIdentifiers.swift // ❌ Problem: using index as identifier struct UnstableIdentifierView: View { @State private var items = ["A", "B", "C", "D"] var body: some View { List { // Index changes if an element is deleted ForEach(items.indices, id: \.self) { index in Text(items[index]) } } } } // ❌ Problem: using UUID() in ForEach struct RegeneratedIdentifierView: View { let items = ["A", "B", "C", "D"] var body: some View { List { // UUID() generates a new ID on each render ForEach(items, id: \.self) { item in // Subtle issue if items contain duplicates Text(item) } } } } // ✅ Solution: model with stable identifier struct Item: Identifiable { let id: UUID // Created once var name: String init(name: String) { self.id = UUID() self.name = name } } struct StableIdentifierView: View { @State private var items = [ Item(name: "A"), Item(name: "B"), Item(name: "C"), Item(name: "D") ] var body: some View { List { // id is stable for the item's lifetime ForEach(items) { item in Text(item.name) } } } } ``` การใช้ตัวระบุที่ไม่ซ้ำกันและคงทนช่วยให้ SwiftUI สามารถแยกแยะองค์ประกอบได้อย่างถูกต้องระหว่างการอัปเดต แอนิเมชัน และการเปรียบเทียบ ## การปรับแต่งเซลล์ด้วย Equatable SwiftUI เปรียบเทียบวิวเพื่อพิจารณาว่าจำเป็นต้องเรนเดอร์ใหม่หรือไม่ โดยค่าเริ่มต้น การเปรียบเทียบนี้ใช้ reflection ซึ่งอาจมีค่าใช้จ่ายสูง การใช้ `Equatable` ช่วยให้สามารถเปรียบเทียบได้อย่างมีประสิทธิภาพและชัดเจน ```swift // EquatableOptimization.swift // Data model struct Contact: Identifiable, Equatable { let id: UUID var name: String var email: String var avatarURL: URL? var lastActivity: Date // Custom comparison: ignore lastActivity // if other properties are identical static func == (lhs: Contact, rhs: Contact) -> Bool { lhs.id == rhs.id && lhs.name == rhs.name && lhs.email == rhs.email && lhs.avatarURL == rhs.avatarURL // lastActivity intentionally excluded } } // Optimized cell view struct ContactRow: View, Equatable { let contact: Contact // Explicit comparison to avoid unnecessary re-renders static func == (lhs: ContactRow, rhs: ContactRow) -> Bool { lhs.contact == rhs.contact } var body: some View { HStack(spacing: 12) { // Async avatar AsyncImage(url: contact.avatarURL) { phase in switch phase { case .success(let image): image .resizable() .aspectRatio(contentMode: .fill) case .failure: Image(systemName: "person.circle.fill") .foregroundStyle(.gray) default: ProgressView() } } .frame(width: 44, height: 44) .clipShape(Circle()) // Contact information VStack(alignment: .leading, spacing: 2) { Text(contact.name) .font(.body.weight(.medium)) Text(contact.email) .font(.caption) .foregroundStyle(.secondary) } Spacer() } .padding(.vertical, 4) } } // List using EquatableView struct ContactListView: View { let contacts: [Contact] var body: some View { List { ForEach(contacts) { contact in // EquatableView prevents re-renders if contact unchanged EquatableView(content: ContactRow(contact: contact)) } } } } ``` การปรับแต่งนี้ลดภาระ CPU อย่างมีนัยสำคัญในระหว่างการเลื่อนเร็ว โดยเฉพาะกับเซลล์ที่มีการคำนวณหรือรูปภาพ ## การจัดการการโหลดรูปภาพแบบอะซิงโครนัส รูปภาพมักจะกลายเป็นจุดคอขวดด้านประสิทธิภาพในรายการ การจัดการที่ไม่ถูกต้องทำให้เกิดการกระตุกในการเลื่อนและการใช้หน่วยความจำมากเกินไป ```swift // ImageLoadingOptimization.swift import SwiftUI // Singleton image cache actor ImageCache { static let shared = ImageCache() private var cache = NSCache() private init() { // Memory limit: 50 MB cache.totalCostLimit = 50 * 1024 * 1024 } func image(for url: URL) -> UIImage? { cache.object(forKey: url.absoluteString as NSString) } func setImage(_ image: UIImage, for url: URL) { // Cost estimation: image bytes let cost = Int(image.size.width * image.size.height * 4) cache.setObject(image, forKey: url.absoluteString as NSString, cost: cost) } } // Optimized image view with caching struct CachedAsyncImage: View { let url: URL? let size: CGSize @State private var image: UIImage? @State private var isLoading = false var body: some View { Group { if let image { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fill) } else if isLoading { Rectangle() .fill(Color.gray.opacity(0.2)) .overlay(ProgressView()) } else { Rectangle() .fill(Color.gray.opacity(0.2)) } } .frame(width: size.width, height: size.height) .clipped() .task(id: url) { await loadImage() } } private func loadImage() async { guard let url else { return } // Check cache if let cached = await ImageCache.shared.image(for: url) { self.image = cached return } isLoading = true defer { isLoading = false } // Download and resize do { let (data, _) = try await URLSession.shared.data(from: url) // Resize to save memory if let original = UIImage(data: data), let resized = await resizeImage(original, to: size) { await ImageCache.shared.setImage(resized, for: url) self.image = resized } } catch { // Handle error silently } } private func resizeImage(_ image: UIImage, to size: CGSize) async -> UIImage? { // Use screen scale let scale = await UIScreen.main.scale let targetSize = CGSize( width: size.width * scale, height: size.height * scale ) return await withCheckedContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { let renderer = UIGraphicsImageRenderer(size: targetSize) let resized = renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: targetSize)) } continuation.resume(returning: resized) } } } } ``` การใช้งานนี้ผสมผสานการแคชหน่วยความจำ การปรับขนาดล่วงหน้า และการโหลดแบบอะซิงโครนัสเพื่อประสบการณ์การเลื่อนที่ลื่นไหล ### Prefetching อัจฉริยะเพื่อการคาดการณ์ สำหรับรายการที่ยาวมาก prefetching จะโหลดรูปภาพก่อนที่จะปรากฏ: ```swift // ImagePrefetching.swift // Prefetching coordinator @Observable final class ImagePrefetcher { private var prefetchTasks: [URL: Task] = [:] private let prefetchDistance = 10 // Number of items ahead func prefetchImages(for items: [Contact], visibleRange: Range) { // Calculate prefetch range let prefetchStart = max(0, visibleRange.lowerBound - prefetchDistance) let prefetchEnd = min(items.count, visibleRange.upperBound + prefetchDistance) // Launch prefetch for items in range for index in prefetchStart.., items: [Contact]) { let validURLs = Set( items[validRange].compactMap { $0.avatarURL } ) for (url, task) in prefetchTasks { if !validURLs.contains(url) { task.cancel() prefetchTasks.removeValue(forKey: url) } } } } ``` ## การเลือกระหว่าง List และ LazyVStack การเลือกระหว่าง `List` และ `LazyVStack` ขึ้นอยู่กับกรณีการใช้งาน แต่ละส่วนประกอบมีข้อได้เปรียบเฉพาะที่ควรทราบ ```swift // ListVsLazyVStack.swift // ✅ List: ideal for interactive content // - Automatic cell recycling // - Native swipe actions support // - Built-in separators and styles struct ContactsWithSwipeActions: View { @State private var contacts: [Contact] = [] var body: some View { List { ForEach(contacts) { contact in ContactRow(contact: contact) .swipeActions(edge: .trailing) { Button(role: .destructive) { deleteContact(contact) } label: { Label("Delete", systemImage: "trash") } } .swipeActions(edge: .leading) { Button { favoriteContact(contact) } label: { Label("Favorite", systemImage: "star") } .tint(.yellow) } } } .listStyle(.plain) } private func deleteContact(_ contact: Contact) { contacts.removeAll { $0.id == contact.id } } private func favoriteContact(_ contact: Contact) { // Favorite logic } } // ✅ LazyVStack: ideal for custom layouts // - Full control over spacing and padding // - No imposed styles // - Better performance for simple display struct CustomFeedView: View { let posts: [Post] var body: some View { ScrollView { LazyVStack(spacing: 16) { ForEach(posts) { post in PostCard(post: post) } } .padding(.horizontal) } } } // Post model for example struct Post: Identifiable { let id: UUID let author: String let content: String let imageURL: URL? } struct PostCard: View { let post: Post var body: some View { VStack(alignment: .leading, spacing: 12) { // Header HStack { Circle() .fill(Color.blue) .frame(width: 40, height: 40) Text(post.author) .font(.headline) Spacer() } // Content Text(post.content) // Optional image if let imageURL = post.imageURL { CachedAsyncImage(url: imageURL, size: CGSize(width: 300, height: 200)) .cornerRadius(12) } } .padding() .background(Color(.systemBackground)) .cornerRadius(16) .shadow(color: .black.opacity(0.1), radius: 4, y: 2) } } ``` > **การรีไซเคิลเซลล์** > > `List` รีไซเคิลเซลล์อย่างแอคทีฟ ซึ่งอาจทำให้เกิดปัญหากับ state ภายใน (`@State`) ค่า `@State` ในเซลล์ของ `List` อาจถูกใช้ซ้ำโดยไม่คาดคิด ควรเก็บ state ไว้ในโมเดลข้อมูลหรือใน ViewModel ## เซกชันและส่วนหัวที่ปรับแต่งแล้ว การจัดเนื้อหาเป็นเซกชันช่วยปรับปรุงความสามารถในการอ่านแต่อาจส่งผลกระทบต่อประสิทธิภาพหากใช้งานไม่ดี ส่วนหัวที่ตรึงและการจัดการเซกชันต้องการความใส่ใจเป็นพิเศษ ```swift // OptimizedSections.swift // Grouped data model struct GroupedContacts { let letter: String let contacts: [Contact] } // View with optimized sections struct SectionedContactList: View { let groupedContacts: [GroupedContacts] var body: some View { ScrollView { LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { ForEach(groupedContacts, id: \.letter) { group in Section { // Section content ForEach(group.contacts) { contact in ContactRow(contact: contact) .padding(.horizontal) .padding(.vertical, 8) // Custom separator if contact.id != group.contacts.last?.id { Divider() .padding(.leading, 68) } } } header: { // Optimized pinned header SectionHeader(title: group.letter) } } } } } } // Lightweight header for performance struct SectionHeader: View { let title: String var body: some View { Text(title) .font(.headline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal) .padding(.vertical, 8) .background(.ultraThinMaterial) } } // Optimized grouping function extension Array where Element == Contact { func groupedByFirstLetter() -> [GroupedContacts] { // Dictionary for O(n) grouping var groups: [String: [Contact]] = [:] for contact in self { let letter = String(contact.name.prefix(1)).uppercased() groups[letter, default: []].append(contact) } // Sort groups alphabetically return groups .map { GroupedContacts(letter: $0.key, contacts: $0.value) } .sorted { $0.letter < $1.letter } } } ``` ส่วนหัวที่ตรึง (`pinnedViews: [.sectionHeaders]`) ยังคงมองเห็นได้ระหว่างการเลื่อน ปรับปรุงการนำทางในรายการที่ยาว ## การแบ่งหน้าและการเลื่อนแบบไม่จำกัด สำหรับชุดข้อมูลขนาดใหญ่ การแบ่งหน้าหลีกเลี่ยงการโหลดข้อมูลทั้งหมดลงในหน่วยความจำ การใช้งานต้องโปร่งใสสำหรับผู้ใช้ ```swift // InfiniteScrolling.swift // ViewModel handling pagination @Observable final class PaginatedListViewModel { private(set) var items: [Contact] = [] private(set) var isLoading = false private(set) var hasMorePages = true private var currentPage = 0 private let pageSize = 20 private let dataService: ContactDataService init(dataService: ContactDataService) { self.dataService = dataService } func loadInitialData() async { guard items.isEmpty else { return } await loadNextPage() } func loadMoreIfNeeded(currentItem: Contact) async { // Trigger loading when approaching the end guard let index = items.firstIndex(where: { $0.id == currentItem.id }) else { return } // Load 5 items before the end let thresholdIndex = items.count - 5 if index >= thresholdIndex { await loadNextPage() } } private func loadNextPage() async { guard !isLoading, hasMorePages else { return } isLoading = true defer { isLoading = false } do { let newItems = try await dataService.fetchContacts( page: currentPage, limit: pageSize ) items.append(contentsOf: newItems) currentPage += 1 hasMorePages = newItems.count == pageSize } catch { // Handle error } } } // View with infinite scroll struct InfiniteContactList: View { @State private var viewModel: PaginatedListViewModel init(dataService: ContactDataService) { _viewModel = State(initialValue: PaginatedListViewModel(dataService: dataService)) } var body: some View { List { ForEach(viewModel.items) { contact in ContactRow(contact: contact) .task { // Check if more loading needed await viewModel.loadMoreIfNeeded(currentItem: contact) } } // Loading indicator at end of list if viewModel.isLoading { HStack { Spacer() ProgressView() Spacer() } .padding() } } .task { await viewModel.loadInitialData() } } } // Protocol for data service protocol ContactDataService { func fetchContacts(page: Int, limit: Int) async throws -> [Contact] } ``` รูปแบบนี้รับประกันการโหลดที่ลื่นไหลโดยไม่ปิดกั้นอินเทอร์เฟซและช่วยให้สามารถจัดการหน่วยความจำได้อย่างมีประสิทธิภาพ ## การทำ Profiling ด้วย Instruments การระบุปัญหาด้านประสิทธิภาพต้องการเครื่องมือวัดผลที่แม่นยำ Instruments มีเทมเพลตหลายแบบที่เหมาะสำหรับ SwiftUI ```swift // ProfilingHelpers.swift // Measurement points for debugging struct PerformanceMonitor { // Measure view creation time static func measureViewCreation( _ name: String, @ViewBuilder content: () -> T ) -> T { let start = CFAbsoluteTimeGetCurrent() let view = content() let elapsed = CFAbsoluteTimeGetCurrent() - start #if DEBUG if elapsed > 0.016 { // More than 16ms = frame drop print("⚠️ [\(name)] View creation took \(elapsed * 1000)ms") } #endif return view } } // Extension to trace renders extension View { func debugRender(_ label: String) -> some View { #if DEBUG let _ = Self._printChanges() print("🔄 Rendering: \(label)") #endif return self } func measureRender(_ label: String) -> some View { modifier(RenderMeasureModifier(label: label)) } } struct RenderMeasureModifier: ViewModifier { let label: String @State private var renderCount = 0 func body(content: Content) -> some View { content .onAppear { renderCount += 1 #if DEBUG print("📊 [\(label)] Render count: \(renderCount)") #endif } } } ``` ### รายการตรวจสอบการปรับแต่งด้วย Instruments สำหรับการทำ profiling รายการ SwiftUI ที่มีประสิทธิภาพ: 1. **Time Profiler**: ระบุฟังก์ชันที่ใช้ CPU มากที่สุด 2. **Allocations**: ตรวจสอบการเติบโตของหน่วยความจำระหว่างการเลื่อน 3. **SwiftUI Instrument**: แสดงภาพการประเมิน body 4. **Core Animation**: ตรวจจับการลดลงของเฟรม ```swift // InstrumentsExample.swift // Instrumented view for profiling struct ProfiledContactList: View { let contacts: [Contact] var body: some View { let _ = Self._printChanges() // Shows changes triggering re-render List { ForEach(contacts) { contact in ContactRow(contact: contact) .measureRender("ContactRow-\(contact.id)") } } } } ``` > **Self._printChanges()** > > API debugging ของ SwiftUI นี้จะพิมพ์ไปยังคอนโซลว่ามีคุณสมบัติใดที่เปลี่ยนแปลงและกระตุ้นการประเมิน body ใหม่ จำเป็นสำหรับการระบุการเรนเดอร์ใหม่ที่ไม่จำเป็น ## การปรับแต่งขั้นสูงด้วย drawingGroup สำหรับวิวที่ซับซ้อนซึ่งมีเอฟเฟกต์ภาพหลายอย่าง `drawingGroup()` สามารถปรับปรุงประสิทธิภาพอย่างมีนัยสำคัญโดยการแรสเตอร์ไรซ์วิวเป็นเลเยอร์ Metal ```swift // DrawingGroupOptimization.swift // Cell with complex visual effects struct ComplexVisualRow: View { let item: Item var body: some View { HStack(spacing: 16) { // Circle with gradient and shadow Circle() .fill( RadialGradient( colors: [.blue, .purple, .pink], center: .center, startRadius: 0, endRadius: 25 ) ) .frame(width: 50, height: 50) .shadow(color: .purple.opacity(0.5), radius: 8, y: 4) VStack(alignment: .leading, spacing: 4) { Text(item.name) .font(.headline) // Progress bar with gradient GeometryReader { geometry in Capsule() .fill(Color.gray.opacity(0.2)) .overlay(alignment: .leading) { Capsule() .fill( LinearGradient( colors: [.green, .yellow, .orange], startPoint: .leading, endPoint: .trailing ) ) .frame(width: geometry.size.width * item.progress) } } .frame(height: 8) } } .padding() // Rasterization for performance .drawingGroup() } } // List using optimized cells struct OptimizedComplexList: View { let items: [Item] var body: some View { ScrollView { LazyVStack(spacing: 8) { ForEach(items) { item in ComplexVisualRow(item: item) } } .padding() } } } struct Item: Identifiable { let id: UUID let name: String let progress: Double } ``` `drawingGroup()` มีประสิทธิภาพเป็นพิเศษสำหรับวิวที่ผสมผสานเกรเดียนต์ เงา และเอฟเฟกต์เบลอ ## บทสรุป การปรับแต่งรายการ SwiftUI ขึ้นอยู่กับความเข้าใจอย่างลึกซึ้งเกี่ยวกับกลไก lazy loading การรีไซเคิล และการเปรียบเทียบวิว เทคนิคที่นำเสนอช่วยให้สามารถสร้างอินเทอร์เฟซที่สามารถจัดการกับองค์ประกอบหลายพันรายการในขณะที่ยังคงการเลื่อนที่ลื่นไหลที่ 60 FPS ### รายการตรวจสอบประสิทธิภาพ SwiftUI - ✅ ใช้ `LazyVStack` หรือ `List` แทน `VStack` สำหรับคอลเลกชัน - ✅ ใช้ `Identifiable` ด้วย ID ที่เสถียรและไม่ซ้ำกัน - ✅ นำ `Equatable` มาใช้สำหรับเซลล์ที่ซับซ้อน - ✅ แคชและปรับขนาดรูปภาพก่อนแสดงผล - ✅ โหลดข้อมูลล่วงหน้าด้วย prefetching อัจฉริยะ - ✅ เลือก `List` สำหรับการโต้ตอบ (swipe) หรือ `LazyVStack` สำหรับเลย์เอาต์ที่กำหนดเอง - ✅ ใช้ `pinnedViews` สำหรับส่วนหัวเซกชัน - ✅ ใช้การแบ่งหน้าสำหรับชุดข้อมูลขนาดใหญ่ - ✅ ทำ profiling เป็นประจำด้วย Instruments - ✅ ใช้ `drawingGroup()` กับวิวที่มีเอฟเฟกต์ภาพที่ซับซ้อน --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/th/blog/ios/swiftui-performance-lazyvstack-complex-lists