Site icon AppTractor

Я сократил потребление памяти SwiftUI на 80% с помощью одного контринтуитивного трюка

Три месяца назад я был готов отказаться от SwiftUI.

Мое приложение падало. Использование памяти росло каждый раз, когда пользователи переходили туда и сюда. И сколько бы раз я ни говорил себе: «SwiftUI управляет памятью автоматически», цифры в Instruments говорили совсем о другом.

Затем я заметил нечто маленькое — почти скучное — что полностью изменило поведение моего приложения.

Это была не библиотека. Это не была новая архитектура. Это был даже не новый код.

Это было понимание того, как SwiftUI на самом деле поддерживает работу приложений.

Результаты меня удивили:

Вот что именно я изменил — и почему это сработало.

Проблема: память утекает не у SwiftUI, а у вас

SwiftUI имеет репутацию «безопасного по умолчанию». Декларативные представления, типы значений, автоматические обновления — все это звучит так, будто проблемы с памятью невозможны.

Именно в этом предположении и кроется ошибка.

SwiftUI постоянно пересоздает представления. Каждое изменение состояния может перестроить часть вашего дерева представлений (или все). Это нормально. Так оно и работает.

На самом деле проблема заключается в следующем:

SwiftUI с радостью сохранит ваши тяжелые объекты живыми, если вы дадите ему для этого повод.

И большинство приложений так и поступают, не осознавая этого.

Я тоже делал это в своем приложении.

Мои представления не были тяжелыми. А вот состояния представлений — были.

Пробуждающий звонок

Мое приложение представляло собой фитнес-трекер с подробным экраном, на котором отображалась история тренировок. Достаточно просто:

struct WorkoutListView: View {
    @State private var workouts: [Workout] = []
    
    var body: some View {
        NavigationStack {
            List(workouts) { workout in
                NavigationLink(value: workout) {
                    WorkoutRow(workout: workout)
                }
            }
            .navigationDestination(for: Workout.self) { workout in
                WorkoutDetailView(workout: workout)
            }
        }
        .onAppear {
            loadWorkouts()
        }
    }
}

Выглядит нормально, верно? Этот невинный на вид код был источником утечки 300 МБ памяти.

Контринтуитивное открытие

После нескольких дней профилирования с помощью Instruments я нашел виновника. Дело было не в коде, как я думал, а в том, как SwiftUI сохраняет представления в памяти.

Вот что происходило:

  1. Пользователь переходит к WorkoutDetailView
  2. SwiftUI создает представление и все его вложенные представления
  3. Пользователь переходит обратно
  4. Представление деаллоцируется… в конце концов
  5. Но до этого все тяжелые объекты, на которые оно ссылается, остаются в памяти

Проблема? Мой WorkoutDetailView содержал ссылки на:

И ни одна из них не очищалась должным образом.

Момент «Ага!»

Контринтуитивный трюк заключался не в том, чтобы уменьшить количество хранимых данных. Он заключался в понимании разницы между состоянием представления и состоянием модели представления.

Представления SwiftUI это структуры — легкие value-type объекты. Но в тот момент, когда вы добавляете @State, @StateObject или @ObservedObject, вы говорите SwiftUI, что нужно сохранить что-то за пределами естественного жизненного цикла представления.

Хитрость: Переместите тяжелые данные из состояния представления в правильно управляемые модели представления с явной очисткой.

Решение: правильная архитектура управления состоянием

Вот паттерн, который позволил мне сократить использование памяти на 80%.

До:

struct WorkoutDetailView: View {
    let workout: Workout
    
    // ❌ PROBLEM: Heavy state living in the view
    @State private var heartRateData: [HeartRatePoint] = []
    @State private var cadenceData: [CadencePoint] = []
    @State private var elevationData: [ElevationPoint] = []
    @State private var routeImage: UIImage?
    @State private var analysis: WorkoutAnalysis?
    
    var body: some View {
        ScrollView {
            VStack {
                if let image = routeImage {
                    Image(uiImage: image)
                        .resizable()
                        .frame(height: 200)
                }
                
                HeartRateChart(data: heartRateData)
                CadenceChart(data: cadenceData)
                ElevationChart(data: elevationData)
                
                if let analysis = analysis {
                    AnalysisView(analysis: analysis)
                }
            }
        }
        .onAppear {
            loadAllData()
        }
    }
    
    func loadAllData() {
        // Loading massive amounts of data into @State
        heartRateData = DataService.loadHeartRate(for: workout)
        cadenceData = DataService.loadCadence(for: workout)
        elevationData = DataService.loadElevation(for: workout)
        routeImage = ImageService.renderRoute(for: workout)
        analysis = AnalysisService.analyze(workout)
    }
}

Влияние на память: 45-60 МБ на экземпляр просмотра. При 5-6 переходах использование памяти возрастало до 300 МБ+.

После:

// MARK: - View Model with Explicit Lifecycle Management
@MainActor
class WorkoutDetailViewModel: ObservableObject {
    @Published private(set) var state: ViewState = .loading
    
    private var cancellables = Set<AnyCancellable>()
    private let workout: Workout
    
    enum ViewState {
        case loading
        case loaded(LoadedData)
        case error(Error)
    }
    
    struct LoadedData {
        let heartRateData: [HeartRatePoint]
        let cadenceData: [CadencePoint]
        let elevationData: [ElevationPoint]
        let routeImage: UIImage?
        let analysis: WorkoutAnalysis
    }
    
    init(workout: Workout) {
        self.workout = workout
    }
    
    func load() {
        state = .loading
        
        // Load data asynchronously
        Task {
            do {
                async let heartRate = DataService.loadHeartRate(for: workout)
                async let cadence = DataService.loadCadence(for: workout)
                async let elevation = DataService.loadElevation(for: workout)
                async let route = ImageService.renderRoute(for: workout)
                async let analysis = AnalysisService.analyze(workout)
                
                let data = LoadedData(
                    heartRateData: try await heartRate,
                    cadenceData: try await cadence,
                    elevationData: try await elevation,
                    routeImage: try await route,
                    analysis: try await analysis
                )
                
                state = .loaded(data)
            } catch {
                state = .error(error)
            }
        }
    }
    
    // ✅ CRITICAL: Explicit cleanup
    func cleanup() {
        cancellables.removeAll()
        state = .loading // Release heavy data
    }
    
    deinit {
        print("✅ WorkoutDetailViewModel deallocated")
    }
}

// MARK: - Lightweight View
struct WorkoutDetailView: View {
    let workout: Workout
    
    // ✅ SOLUTION: StateObject manages lifecycle properly
    @StateObject private var viewModel: WorkoutDetailViewModel
    
    init(workout: Workout) {
        self.workout = workout
        _viewModel = StateObject(wrappedValue: WorkoutDetailViewModel(workout: workout))
    }
    
    var body: some View {
        Group {
            switch viewModel.state {
            case .loading:
                ProgressView("Loading workout data...")
            
            case .loaded(let data):
                ScrollView {
                    VStack(spacing: 20) {
                        if let image = data.routeImage {
                            RouteMapView(image: image)
                        }
                        
                        HeartRateChart(data: data.heartRateData)
                        CadenceChart(data: data.cadenceData)
                        ElevationChart(data: data.elevationData)
                        AnalysisView(analysis: data.analysis)
                    }
                    .padding()
                }
            
            case .error(let error):
                ErrorView(error: error) {
                    viewModel.load()
                }
            }
        }
        .navigationTitle(workout.name)
        .navigationBarTitleDisplayMode(.inline)
        .onAppear {
            viewModel.load()
        }
        .onDisappear {
            // ✅ CRITICAL: Clean up when leaving
            viewModel.cleanup()
        }
    }
}

Влияние на память: 8-12 МБ на экземпляр просмотра. Даже после 20 с лишним переходов память не превышает 100 МБ.

Ключевые принципы, благодаря которым это работает

1. @StateObject — ваш друг (при правильном использовании)

@StateObject создает модель представления один раз и сохраняет ее на протяжении всего жизненного цикла представления. Это идеально подходит для управления большими объемами данных, потому что:

2. Отделите состояние представления от состояния данных

Это контринтуитивная часть: ваше представление должно быть настолько тупым, насколько это возможно.

// ❌ BAD: Heavy data in @State
@State private var largeImageArray: [UIImage] = []

// ✅ GOOD: Heavy data in ViewModel
@StateObject private var viewModel: ImageGalleryViewModel

Представления постоянно пересоздаются. Модели представлений создаются один раз. Храните тяжелые вещи в моделях представлений.

3. Используйте управление состоянием на основе перечислений

Вместо нескольких свойств @Published используйте одно перечисление состояния:

enum ViewState {
    case idle
    case loading
    case loaded(Data)
    case error(Error)
}

Почему это важно:

4. Реализуйте явную очистку

Это самая важная часть:

.onDisappear {
    viewModel.cleanup()
}

SwiftUI не гарантирует, когда представления будут удалены. Но onDisappear вызывается надежно, когда представление покидает экран. Используйте его для:

Измерения влияния: До и после

Для измерения влияния я использовал инструменты Xcode (Memory Profiler):

До оптимизации

После оптимизации

Это на 80% меньше памяти и на 94% меньше сбоев.

Общие ошибки, которых следует избегать

Ошибка № 1: использование @ObservedObject вместо @StateObject

// ❌ WRONG: Creates new view model on every view redraw
@ObservedObject private var viewModel = WorkoutDetailViewModel()

// ✅ RIGHT: Creates view model once
@StateObject private var viewModel = WorkoutDetailViewModel()

Разница: аннотация @ObservedObject не владеет объектом — SwiftUI может освободить его в любой момент. @StateObject гарантирует владение.

Ошибка №2: забыли об очистке при исчезновении (onDisappear)

// ❌ WRONG: Memory leak waiting to happen
.onAppear {
    viewModel.startTimer()
}

// ✅ RIGHT: Explicit cleanup
.onAppear {
    viewModel.startTimer()
}
.onDisappear {
    viewModel.stopTimer()
}

Ошибка №3: ​​загрузка всех данных одновременно

// ❌ WRONG: Loads everything upfront
func loadAllData() {
    self.data = DataService.loadEverything() // 50MB+
}

// ✅ RIGHT: Load on demand
func loadData() async {
    self.summary = await DataService.loadSummary() // 5KB
    // Load details only when needed
}
func loadDetails() async {
    self.details = await DataService.loadDetails() // 10MB
}

Ошибка №4: сохранение ссылок на родительские представления

// ❌ WRONG: Creates retain cycle
class ViewModel: ObservableObject {
    weak var parentView: ContentView? // Don't do this!
}
// ✅ RIGHT: Use callbacks or Combine
class ViewModel: ObservableObject {
    var onComplete: (() -> Void)?
}

Полный чеклист по оптимизации памяти

Используйте этот контрольный список для каждого представления SwiftUI, обрабатывающего значительные объемы данных:

Ментальная модель, которая наконец-то мне помогла

Если вы ничего больше не запомните, запомните это:

Представления SwiftUI временны. Ваши данные не должны быть временными.

Представления появляются и исчезают. Модели представления управляют реальностью.

Как только я начал рассматривать представления как одноразовые, все остальное встало на свои места.

Источник

Exit mobile version