Другое

Оптимизация производительности SwiftUI UI: снижение использования CPU

Узнайте, почему обновления SwiftUI UI со скоростью 30 FPS потребляют чрезмерные ресурсы CPU, и изучите проверенные стратегии оптимизации для снижения нагрузки при сохранении плавной производительности.

Почему обновление SwiftUI UI 30 раз в секунду потребляет чрезмерные ресурсы CPU? Я реализовал таймер, который обновляет Text представление каждые 30 раз в секунду, но он использует 10% CPU на моем M4 Mac. Когда я комментирую Text представление, использование CPU падает до 0%.

Вот мой код:

swift
import SwiftUI

struct ContentView: View {
    @State private var model = PlayerModel()
    var body: some View {
        VStack(spacing: 16) {
            Text(model.timeString) // только это изменяется
                .font(.system(size: 44, weight: .semibold, design: .monospaced))
                .transaction { $0.animation = nil } // неявных анимаций нет
            HStack {
                Button(model.running ? "Пауза" : "Воспроизведение") {
                    model.running ? model.pause() : model.start()
                }
                Button("Сброс") { model.seek(0) }
                Stepper("FPS: \(Int(model.fps))", value: $model.fps, in: 10...120, step: 1)
                    .onChange(of: model.fps) { _, _ in model.applyFPS() }
            }
        }
        .padding()
        .onAppear { model.start() }
        .onDisappear { model.stop() }
    }
}

@Observable
final class PlayerModel {
    var timeString: String = "0.000 с"
    var fps: Double = 30
    var running = false
    
    private var formatter: NumberFormatter = {
        let f = NumberFormatter()
        f.minimumFractionDigits = 3
        f.maximumFractionDigits = 3
        return f
    }()
    
    @ObservationIgnored private let q = DispatchQueue(label: "tc.timer", qos: .userInteractive)
    @ObservationIgnored private var timer: DispatchSourceTimer?
    @ObservationIgnored private var startHost: UInt64 = 0
    @ObservationIgnored private var pausedAt: Double = 0
    @ObservationIgnored private var lastFrame: Int = -1
    
    private static let secsPerTick: Double = {
        var info = mach_timebase_info_data_t()
        mach_timebase_info(&info)
        return Double(info.numer) / Double(info.denom) / 1_000_000_000.0
    }()
    
    func start() {
        guard timer == nil else { running = true; return }
        
        let desiredUIFPS: Double = 30
        let periodNs = UInt64(1_000_000_000 / desiredUIFPS)
        
        running = true
        startHost = mach_absolute_time()
        
        let t = DispatchSource.makeTimerSource(queue: q)
        t.schedule(
            deadline: .now(),
            repeating: .nanoseconds(Int(periodNs)),
            leeway: .milliseconds(30)
        )
        t.setEventHandler { [weak self] in self?.tick() }
        timer = t
        t.resume()
    }
    
    func pause() {
        guard running else { return }
        pausedAt = now()
        running = false
    }
    
    func stop() {
        timer?.cancel()
        timer = nil
        running = false
        pausedAt = 0
        lastFrame = -1
    }
    
    func seek(_ seconds: Double) {
        pausedAt = max(0, seconds)
        startHost = mach_absolute_time()
        lastFrame = -1
    }
    
    func applyFPS() { lastFrame = -1 }
    
    private func tick() {
        let s = now()
        let str = formatter.string(from: s as NSNumber) ?? String(format: "%.3f", s)
        let display = "\(str) с"
        
        DispatchQueue.main.async { [weak self] in
            self?.timeString = display
        }
    }
    
    private func now() -> Double {
        guard running else { return pausedAt }
        let delta = mach_absolute_time() &- startHost
        return pausedAt + Double(delta) * Self.secsPerTick
    }
}

Таймер работает в фоновом потоке, и только свойство timeString обновляется в основном потоке. Почему такое простое обновление UI так дорогое, и как можно его оптимизировать?

Оптимизация производительности SwiftUI при частых обновлениях интерфейса

Обновления UI в SwiftUI с частотой 30 FPS потребляют чрезмерные ресурсы CPU, поскольку каждый триггер заставляет фреймворк выполнять сложные расчеты компоновки, сравнение представлений и координацию рендеринга, даже при простых изменениях текста. Высокая частота перегружает механизмы оптимизации SwiftUI, вызывая совокупные накладные расходы от повторяющегося наблюдения за свойствами, перерисовки представлений и координации на основном потоке.

Содержание

Понимание проблемы производительности

Когда SwiftUI обновляет ваше представление Text каждые 33 миллисекунды (30 FPS), это запускает каскад операций, которые в совокупности потребляют значительные ресурсы CPU. Проблема не в самом обновлении, а в частоте и в том, как SwiftUI обрабатывает быстрые изменения.

Ваша реализация правильно изолирует работу таймера в фоновую очередь и обновляет UI только на основном потоке, но архитектура SwiftUI все равно сталкивается с этими вызовами:

swift
// Текущий подход работает, но неэффективен для 30 FPS
private func tick() {
    let s = now()
    let str = formatter.string(from: s as NSNumber) ?? String(format: "%.3f", s)
    let display = "\(str) s"
    
    DispatchQueue.main.async { [weak self] in
        self?.timeString = display // Это запускает обновления SwiftUI
    }
}

Проблема возникает потому, что SwiftUI рассматривает каждое изменение свойства @State как потенциальное изменение дерева представлений, даже когда на самом деле обновляется только небольшая его часть.


Почему SwiftUI испытывает трудности с высокочастотными обновлениями

Оптимизация производительности SwiftUI relies relies на несколько механизмов, которые выходят из строя при высокочастотных обновлениях:

1. Алгоритм сравнения представлений (View Diffing)

SwiftUI использует эффективный алгоритм сравнения для определения изменений в дереве представлений. При 30 FPS этот алгоритм работает непрерывно:

swift
// Концептуальный процесс сравнения представлений
func diff(oldView: some View, newView: some View) -> ViewChanges {
    // SwiftUI анализирует всю иерархию представлений
    // для поиска минимального набора изменений
    // Это становится дорогостоящим при вызове 30 раз в секунду
}

2. Расчеты компоновки текста

Рендеринг текста вычислительно затратен. Каждое обновление требует:

  • Расчетов метрик шрифта
  • Формирования текста и позиционирования глифов
  • Решения ограничений компоновки
  • Обновлений списка отображения

Для шрифта размером 44pt полужирного моноширинного эти расчеты происходят 30 раз в секунду.

3. Узкое место на основном потоке

Хотя ваш таймер работает в фоновом потоке, все обновления UI должны происходить на основном потоке. При 30 FPS это создает конкуренцию с другими обязанностями основного потока:

  • Обработка событий
  • Координация анимаций
  • Управление окнами
  • Обработка взаимодействия с пользователем

4. Накладные расходы наблюдения за свойствами

Система наблюдения за свойствами SwiftUI использует под капотом Combine publishers. Каждое обновление @State запускает:

  • Эмиссию publisher’а
  • Уведомления подписчиков
  • Отслеживание зависимостей

Стратегии оптимизации

Стратегия 1: Снижение частоты обновлений

Проблема: 30 FPS избыточны для текстовых обновлений, не требующих плавной анимации.

Решение: Обновлять только при необходимости, обычно 10-15 FPS для текста:

swift
private var lastUpdateTime: Double = 0
private var updateThreshold: Double = 1.0 / 15.0 // 15 FPS

private func tick() {
    let currentTime = now()
    
    // Обновлять только если прошло достаточно времени
    guard currentTime - lastUpdateTime >= updateThreshold else { return }
    lastUpdateTime = currentTime
    
    let s = now()
    let str = formatter.string(from: s as NSNumber) ?? String(format: "%.3f", s)
    let display = "\(str) s"
    
    DispatchQueue.main.async { [weak self] in
        self?.timeString = display
    }
}

Стратегия 2: Использование CADisplayLink

Проблема: Ваш таймер не синхронизирован с частотой обновления дисплея.

Решение: Использовать CADisplayLink для лучшей синхронизации с дисплеем:

swift
@ObservationIgnored private var displayLink: CADisplayLink?

func start() {
    guard displayLink == nil else { running = true; return }
    
    running = true
    startHost = mach_absolute_time()
    
    let link = CADisplayLink(target: self, selector: #selector(displayLinkTick))
    link.add(to: .main, forMode: .common)
    displayLink = link
}

@objc private func displayLinkTick() {
    tick()
}

func stop() {
    displayLink?.invalidate()
    displayLink = nil
    // ... остальная очистка
}

Стратегия 3: Оптимизация структуры представлений

Проблема: SwiftUI может перерисовывать больше вашего представления, чем необходимо.

Решение: Изолировать часто обновляемый текст в отдельном представлении:

swift
struct ContentView: View {
    @State private var model = PlayerModel()
    var body: some View {
        VStack(spacing: 16) {
            TimeTextView(timeString: model.timeString)
            // ... остальные элементы управления
        }
        .padding()
        .onAppear { model.start() }
        .onDisappear { model.stop() }
    }
}

struct TimeTextView: View {
    let timeString: String
    
    var body: some View {
        Text(timeString)
            .font(.system(size: 44, weight: .semibold, design: .monospaced))
            .transaction { $0.animation = nil }
            .id(timeString) // Заставить SwiftUI рассматривать каждую строку как уникальную
    }
}

Продвинутые решения

Решение 1: Использование UIViewRepresentable для прямого управления

Проблема: Абстракции SwiftUI добавляют накладные расходы при высокочастотных обновлениях.

Решение: Обойти SwiftUI для рендеринга текста с помощью UIViewRepresentable:

swift
struct NativeTextView: UIViewRepresentable {
    let text: String
    
    func makeUIView(context: Context) -> UILabel {
        let label = UILabel()
        label.font = UIFont.systemFont(ofSize: 44, weight: .semibold)
        label.font = UIFontMetrics.default.scaledFont(for: label.font)
        label.adjustsFontForContentSizeCategory = true
        label.textAlignment = .center
        return label
    }
    
    func updateUIView(_ uiView: UILabel, context: Context) {
        uiView.text = text
    }
}

// Использование в вашем представлении
NativeTextView(text: model.timeString)

Решение 2: Реализация ленивых обновлений

Проблема: Обновление каждые 33мс не требуется, когда миллисекунды не меняются.

Решение: Обновлять только когда отображаемое время действительно изменяется:

swift
private var lastDisplayedTime: Double = -1
private var lastDisplayedString: String = ""

private func tick() {
    let currentTime = now()
    
    // Обновлять только если время изменилось достаточно для отображения
    let difference = currentTime - lastDisplayedTime
    let shouldUpdate = difference >= 0.01 || // Разница не менее 10мс
                       abs(Int(currentTime * 1000) - Int(lastDisplayedTime * 1000)) != 0
    
    guard shouldUpdate else { return }
    
    lastDisplayedTime = currentTime
    let str = formatter.string(from: currentTime as NSNumber) ?? String(format: "%.3f", currentTime)
    lastDisplayedString = str
    
    DispatchQueue.main.async { [weak self] in
        self?.timeString = "\(str) s"
    }
}

Решение 3: Использование Timer с объединением (coalescing)

Проблема: SwiftUI может обрабатывать обновления неэффективно.

Решение: Использовать Timer с объединением для пакетной обработки обновлений:

swift
func start() {
    guard timer == nil else { running = true; return }
    
    running = true
    startHost = mach_absolute_time()
    
    let t = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
        self?.tick()
    }
    timer = t
}

func stop() {
    timer?.invalidate()
    timer = nil
    // ... остальная очистка
}

Сравнительный анализ

Подход Использование CPU Сложность реализации Плавность Интеграция с SwiftUI
Текущий (30 FPS) Высокое Низкая Хорошая Полная
Оптимизированный (15 FPS) Среднее Низкая Хорошая Полная
CADisplayLink Среднее Средняя Отличная Полная
UIViewRepresentable Очень низкое Средняя Хорошая Частичная
Ленивые обновления Очень низкое Средняя Переменная Полная

Лучшие практики

  1. Выбирайте подходящую частоту обновления: Большинство приложений не нуждаются в 30 FPS для текстовых обновлений. Рассмотрите 10-15 FPS для лучшей производительности.

  2. Профилируйте перед оптимизацией: Используйте Instruments в Xcode для определения реальных узких мест в вашем конкретном случае.

  3. Учитывайте пользовательский опыт: Спросите себя, могут ли пользователи различить 15 FPS и 30 FPS для текстовых обновлений.

  4. Используйте синхронизацию с дисплеем: Используйте CADisplayLink, когда точность тайминга критична для визуальной плавности.

  5. Изолируйте критичные к производительности компоненты: Создавайте специализированные представления для часто обновляемых элементов.

  6. Используйте ленивую оценку: Обновляйте только когда отображаемое значение действительно значительно изменяется.

  7. Рассмотрите альтернативные методы рендеринга: В крайних случаях обходите SwiftUI с помощью UIViewRepresentable или NSViewRepresentable.

  8. Реализуйте управление FPS: Позвольте пользователям регулировать частоту обновления в зависимости от возможностей их устройства.

Для вашего конкретного случая использования начните с оптимизации 15 FPS (Стратегия 1), что, вероятно, снизит использование CPU на 50% при сохранении хорошей визуальной плавности. Если требуется дальнейшая оптимизация, подход с UIViewRepresentable обеспечит лучшую производительность, но за счет некоторого удобства SwiftUI.

Авторы
Проверено модерацией
Модерация