Продуктивність SwiftUI: Оптимізація LazyVStack та Складних Списків
Техніки оптимізації для LazyVStack та списків SwiftUI. Зменшення споживання пам'яті, покращення продуктивності прокручування та уникнення поширених помилок.

Списки представляють один із найчастіше використовуваних компонентів у застосунках iOS. LazyVStack та List від SwiftUI пропонують продуктивні рішення для відображення колекцій даних, але неправильне використання може швидко погіршити користувацький досвід. Розуміння внутрішніх механізмів цих компонентів допомагає уникнути поширених пасток і будувати плавні інтерфейси.
Стаття представляє основні техніки оптимізації списків SwiftUI: lazy loading, переробку View, керування ідентифікаторами та просунуті патерни для великих наборів даних.
Розуміння Lazy Loading у SwiftUI
Lazy loading створює View лише тоді, коли вони стають видимими на екрані. На відміну від VStack, що створює всі дочірні View одразу, LazyVStack відкладає це створення, різко зменшуючи споживання пам'яті та час початкового рендерингу.
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, що ілюструє різницю:
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 і може викликати візуальні баги, такі як неправильні анімації чи втрату позиції прокручування.
// ❌ 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 забезпечує оптимізоване та явне порівняння.
// 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 під час швидкого прокручування, особливо для комірок із обчисленнями чи зображеннями.
Готовий до співбесід з iOS?
Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.
Керування Асинхронним Завантаженням Зображень
Зображення часто стають вузьким місцем продуктивності у списках. Неправильне керування призводить до заїкань під час прокручування та надмірного споживання пам'яті.
import SwiftUI
// Singleton image cache
actor ImageCache {
static let shared = ImageCache()
private var cache = NSCache<NSString, UIImage>()
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 завантажує зображення до того, як вони стануть видимими:
// Prefetching coordinator
@Observable
final class ImagePrefetcher {
private var prefetchTasks: [URL: Task<Void, Never>] = [:]
private let prefetchDistance = 10 // Number of items ahead
func prefetchImages(for items: [Contact], visibleRange: Range<Int>) {
// 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..<prefetchEnd {
guard let url = items[index].avatarURL else { continue }
// Avoid duplicates
guard prefetchTasks[url] == nil else { continue }
prefetchTasks[url] = Task {
// Check if already cached
if await ImageCache.shared.image(for: url) != nil {
return
}
// Prefetch
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let image = UIImage(data: data) {
await ImageCache.shared.setImage(image, for: url)
}
} catch {
// Ignore prefetch errors
}
}
}
// Cancel out-of-range prefetches
cancelOutOfRangePrefetches(validRange: prefetchStart..<prefetchEnd, items: items)
}
private func cancelOutOfRangePrefetches(validRange: Range<Int>, 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 залежить від випадку використання. Кожен компонент має специфічні переваги, які варто знати.
// ✅ 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.
Оптимізовані Секції та Заголовки
Організація вмісту у секції покращує читабельність, але може впливати на продуктивність при поганій реалізації. Закріплені заголовки та керування секціями вимагають особливої уваги.
// 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]) залишаються видимими під час прокручування, покращуючи навігацію у довгих списках.
Пагінація та Нескінченне Прокручування
Для великих наборів даних пагінація уникає завантаження всіх даних у пам'ять. Реалізація має бути прозорою для користувача.
// 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]
}Цей патерн забезпечує плавне завантаження без блокування інтерфейсу та дозволяє ефективно керувати пам'яттю.
Готовий до співбесід з iOS?
Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.
Профілювання з Instruments
Виявлення проблем продуктивності вимагає точних інструментів вимірювання. Instruments пропонує кілька шаблонів, придатних для SwiftUI.
// Measurement points for debugging
struct PerformanceMonitor {
// Measure view creation time
static func measureViewCreation<T: View>(
_ 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:
- Time Profiler: визначте функції з найбільшим споживанням CPU
- Allocations: перевірте зростання пам'яті під час прокручування
- SwiftUI Instrument: візуалізуйте обчислення body
- Core Animation: виявляйте втрати кадрів
// 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)")
}
}
}
}Цей API дебагу SwiftUI друкує у консоль, які властивості змінилися та спричинили повторне обчислення body. Незамінний для виявлення непотрібних рендерингів.
Просунуті Оптимізації з drawingGroup
Для складних View з багатьма візуальними ефектами drawingGroup() може значно покращити продуктивність, растеризуючи View у шар Metal.
// 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 зі складними візуальними ефектами
Починай практикувати!
Перевір свої знання з нашими симуляторами співбесід та технічними тестами.
Теги
Поділитися
Пов'язані статті

Користувацькі ViewModifier у SwiftUI: патерни багаторазового використання для design system
Створюйте користувацькі ViewModifier у SwiftUI для узгодженого design system. Патерни, кращі практики та практичні приклади ефективного стилізування iOS view.

SwiftUI @Observable vs @State: Коли Що Використовувати у 2026
Опануйте відмінності між @Observable та @State у SwiftUI, щоб обрати правильний інструмент керування станом для застосунків iOS.

SwiftUI: створення сучасних інтерфейсів для iOS
Посібник зі створення сучасних інтерфейсів за допомогою SwiftUI: декларативний синтаксис, компоненти, анімації та найкращі практики для iOS 18.