# Продуктивність 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, переробку View, керування ідентифікаторами та просунуті патерни для великих наборів даних. ## Розуміння Lazy Loading у SwiftUI Lazy loading створює View лише тоді, коли вони стають видимими на екрані. На відміну від `VStack`, що створює всі дочірні View одразу, `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. Ось тестова View, що ілюструє різницю: ```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` зберігає створені View у пам'яті після їх появи. На відміну від `List`, який активно переробляє комірки, View у `LazyVStack` зберігаються до знищення батьківського компонента. ## Важливість Стабільних Ідентифікаторів Ідентифікатори утворюють центральний механізм оновлень списків SwiftUI. Нестабільний ідентифікатор спричиняє непотрібні перестворення View і може викликати візуальні баги, такі як неправильні анімації чи втрату позиції прокручування. ```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 порівнює View, щоб визначити, чи потрібен повторний рендеринг. За замовчуванням це порівняння використовує 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` у комірках `List` можуть бути несподівано перевикористані. Краще зберігати стан у моделі даних або у 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] } ``` Цей патерн забезпечує плавне завантаження без блокування інтерфейсу та дозволяє ефективно керувати пам'яттю. ## Профілювання з 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 Для ефективного профілювання списків 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 дебагу SwiftUI друкує у консоль, які властивості змінилися та спричинили повторне обчислення body. Незамінний для виявлення непотрібних рендерингів. ## Просунуті Оптимізації з drawingGroup Для складних View з багатьма візуальними ефектами `drawingGroup()` може значно покращити продуктивність, растеризуючи View у шар 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()` особливо ефективний для View, що поєднують градієнти, тіні та ефекти розмиття. ## Висновок Оптимізація списків SwiftUI ґрунтується на глибокому розумінні механізмів lazy loading, переробки та порівняння View. Представлені техніки дозволяють будувати інтерфейси, здатні обробляти тисячі елементів зі збереженням плавного прокручування при 60 FPS. ### Чек-лист Продуктивності SwiftUI - ✅ Використовуйте `LazyVStack` або `List` замість `VStack` для колекцій - ✅ Реалізуйте `Identifiable` зі стабільними, унікальними ID - ✅ Застосовуйте `Equatable` для складних комірок - ✅ Кешуйте та змінюйте розмір зображень перед відображенням - ✅ Попередньо завантажуйте дані з розумним prefetching - ✅ Обирайте `List` для взаємодій (swipe) або `LazyVStack` для кастомних розкладок - ✅ Використовуйте `pinnedViews` для заголовків секцій - ✅ Реалізуйте пагінацію для великих наборів даних - ✅ Регулярно профілюйте з Instruments - ✅ Застосовуйте `drawingGroup()` до View зі складними візуальними ефектами --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/uk/blog/ios/swiftui-performance-lazyvstack-complex-lists