Привет, меня зовут Никита, я iOS-разработчик в команде Яндекс Диска. В этой статье я расскажу про наш опыт разработки на SwiftUI с минимальным деплоймент таргетом iOS 14. Да-да, и с ним можно жить — знаю, что многие уже перешли на iOS 15 или 16, но те, кто ещё поддерживают 14 версию, могут почитать про наш кейс и облегчить себе жизнь.

В статье я собрал топ-6 багов, с которыми можно столкнуться, а еще поделился, что помогает улучшить перформанс SwiftUI и ускорить ваши view в 3 раза на всех версиях iOS.

SwiftUI vs. UIKit+Frames, все что угодно, только не легаси модуль на Obj-C

Началось все с задачи: добавить вид плиткой в раздел Файлы Яндекс Диска. Такая фича была реализована на Android, пользователи на iOS ее тоже просили. Мы взялись за задачу, чтобы поддерживать консистентность продукта и закрыть популярную хотелку пользователей.

Вид плиткой в Яндекс Диске
Вид плиткой в Яндекс Диске

Казалось бы, ничего сложного, но есть нюанс. В разделе Файлы используется UITableView, а весь код написан на Obj-C кем-то 10 лет назад — то есть никто из текущей команды не вносил в него изменения. Таким образом, использовать эту связку было бы долго и дорого, поэтому стали искать альтернативные решения.

Какой был сетап на момент решения задачи? Нашим деплоймент таргетом была iOS 14, а ещё к этому моменту мы успели написать несколько фичей на SwiftUI: экраны в шаринге и настройках, поэтому решили присмотреться к нему в первую очередь. Среди кандидатов ещё был UIKit+Frames  — далее разберемся с плюсами и минусами каждого варианта.

Итак, у SwiftUI есть несколько значительных плюсов, например: 

  • Удобная и понятная верстка, еще и превью есть!

  • Простота внесения изменений — легко понять, какие изменения и зачем были внесены.

  • Отличное API для анимаций, которое позволяет создавать и комбинировать анимации в несколько строчек кода.

Но куда же без минусов

  • Нестабильность работы. Все-таки багов в нем больше, чем в UIKit. Например, достаточно нестабильное API — публичные интерфейсы часто меняются, из-за чего у одного и того же метода может быть несколько сигнатур, доступных для разных версий iOS. 

    Также на новых iOS поведение у системных компонентов иногда изменяется. Например, как у Text, который с iOS 14 стал по дефолту многострочным. Правда, начиная с 14 iOS изменения происходят сильно реже, если сравнивать с UIKit — там подобных изменений вообще не происходит.

UIKit+Frames

Теперь разберемся со вторым вариантом UIKit+Frames. Здесь в плане плюсов получилось поскромнее:

  • Полный контроль того, что происходит. Такой способ верстки подразумевает, что все размеры элементов и их расположение на экране вы будете высчитывать самостоятельно, — отсюда, конечно, много однотипного кода, понятность которого, зачастую, оставляет желать лучшего.

  • Как следствие, скорость работы. Если есть полный контроль — можно сделать много оптимизаций. Например, размеры текста можно рассчитывать в бэкграунд потоке, а на основном — только отображать результат. Из-за декларативной парадигмы SwiftUI не может похвастаться подобным.

Что по минусам

  • Верстка на фреймах сложно читаема, особенно в кейсах, где много subviews, или, где их число динамически изменяется.

  • Часто внесение изменений в layout влечет за собой изменение большого количества кода. И затем, понять на ревью, что именно изменилось, достаточно сложно.

  • Сложности с анимациями. И тут могут быть три решения:

    1) UIView.animate(withDuration:animations:) — удобное, но не очень практичное API. Не подходит для анимаций внутри ячеек коллекции, которые после переиспользования могут продолжить воспроизводить чужие анимации. Почему? Их нельзя отменить, а делать костыли с layer.removeAllAnimations() уж очень не хочется.

    2) UIPropertyAnimator — хорошее API для интерактивных анимаций. Здесь есть всё, что нужно: старт, пауза, остановка в любой момент времени. Но у него все еще есть один серьезный недостаток. Если у вас несколько анимируемых свойств, то на каждое из них потребуется свой аниматор, чтобы их можно было анимировать параллельно и избежать конфликтов. И если свойств много, то это превращается в кучу кода, за которой нужно следить: запускать/останавливать аниматоры в разные моменты жизненного цикла.

    3) CAAnimation — самое низкоуровневое API для анимаций. Вкратце, умеет все тоже самое что и UIPropertyAnimator, но имеет менее удобное API для использования. С теми же минусами при множестве свойств.

У вас может возникнуть естественный вопрос: почему мы не рассматривали Auto Layout? Они создают проблемы с перфомансом в сложных view (где много subviews), а также в коллекциях с большим объемом данных. В нашем случае — это и сложная view, и большая коллекция.

Оценив ситуацию, мы решили, что стоит попробовать SwiftUI: уверенности придал прошлый позитивный опыт. А также поняли, что в нашем кейсе это будет не так болезненно, как UIKit. Ну и хуже, чем легаси модуль на Obj-C, уж точно не получится.

Начало увлекательного путешествия в SwiftUI: помогите Xcode найти путь до динамических библиотек 

Немного про беды с CocoaPods на пути к конечной цели

И тут начались проблемы. На самом деле, они начались задолго до этого, и часть из них мы уже успели решить, когда делали экраны в настройках и шаринге.

Первая — нерабочие превью, они даже не собирались. 

Что было? Сначала нечитаемые логи о том, что Xcode не видит нужные библиотеки. В результате долгого гугления и поиска костылей, которые могут помочь, пришлось поприседать с графом зависимостей и попытаться изменить тип линковки библиотек со статического на динамический.

Затем возникли беды с CocoaPods, которые теряют пути до бандлов, и у вас просто перестает собираться проект, не говоря уже про превью. Снова гугление и теперь очень старые баг-репорты в CocoaPods, которые уже несколько лет не правят. Нашлось решение, и после всех танцев с бубнами проект собирается! Ура, и превью тоже. Но есть нюанс: превью не запускается. И все по новой…Упираешься опять в старые баги в CocoaPods. В конечном итоге все начинает работать.

С чем именно была проблемы? Сначала пришли к выводам, что что SwiftUI не умеет в статические библиотеки. Поэтому пришлось добавить динамическую линковку для всех внутренних библиотек, что оказалось не так просто из-за багов в CocoaPods с транзитивными зависимостями.

Второй причиной, оказался еще один баг с CocoaPods, из-за которого dyld не может найти путь до динамических библиотек. Но немного хаков в Podfile, и все готово:

post_install do |installer|
  installer.pods_project.build_configurations.each do |config|
    if config.name == 'Debug'
      # SwiftUI Previews Workaround.
      config.build_settings['LD_RUNPATH_SEARCH_PATHS'] ||= ['$(FRAMEWORK_SEARCH_PATHS)']
    end
  end
end

Борьба с крешами: библиотеки для аналитики и кастомные шрифты

Отлично, теперь превью собираются, но на этом пока всё: при запуске сразу получаем креши. Они возникают, потому что библиотеки для аналитики сходят с ума из-за странного билда, который Xcode 14 собирает для превью. Проблема в том, что этот билд не совсем «‎честный»: проект собирается со специальными флагами, которые никогда не встретятся на реальном устройстве.

А ещё мы по какой-то причине не можем использовать кастомные шрифты, лежащие в отдельной библиотеке, — в итоге снова получаем креш.

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

extension ProcessInfo {
  static var isRunningForPreviews: Bool {
#if DEBUG
    return processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
#else
    return false
#endif
  }
}

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

P.S. В Xcode 15 это исправили. Поэтому если вы уже переехали на него, то ресурсы, вроде наших кастомных шрифтов, будут подгружаться нормально, а вот проблема с библиотеками все еще осталась.

Делаем шаринг файлов на SwiftUI: топ-6 багов, с которыми вы столкнетесь на iOS 14

SwiftUI? Давай
SwiftUI? Давай

Первый раз мы попробовали SwiftUI в одном из экранов шаринга файлов.

Экраны шаринга файлов
Экраны шаринга файлов

И, честно говоря, это было достаточно больно — все дело в iOS 14. На ней в SwiftUI есть очень много багов, например, с анимациями, векторными картинками, и нет части важного функционала, например, для работы с клавиатурой и preferredContentSize.

Сначала пришлось бороться с показом клавиатуры, потому что API для нее доступна только в iOS 15. Поэтому пришлось встраивать UIKit в SwiftUI, показывая UITextField и делая ее firstResponder’ом, при помощи UIViewRepresentable, и конечно, со всеми любимым приседаниями вокруг DispatchQueue.main.async(group:qos:flags:execute:), чтобы не сломать SwiftUI.

func updateUIView(_ uiView: PasswordTextField, context _: Context) {
  /* Update uiView */ 
  
  guard uiView.isFirstResponder != isFirstResponder else { return }
  
  DispatchQueue.main.async {
    if isFirstResponder {
      uiView.becomeFirstResponder()
    } else {
      uiView.resignFirstResponder()
    }
  }
}

Однако не весь функционал из UIKit есть даже на iOS 15, например, если вам нужно управлять состоянием кнопки «‎Готово», включая и выключая ее по какому-то условию, то от обертки над UITextField никуда не деться.

Потом были беды с тем, чтобы достать prefferedContentSize из UIHostingController: нормальное API для него есть только в iOS 16. И если на iOS 15 все было не так плохо, то в случае iOS 14 все работало плачевно. По какой-то причине view периодически игнорировала размеры UIHostingController, из-за чего ее то растягивало за пределы экрана, то наоборот — слишком сильно сжимало.

Пример неправильного расчета размеров
Неправильный размер подсказкиПериодически при обновлении схлопывается в одну строчку
Неправильный размер подсказки
Периодически при обновлении схлопывается в одну строчку

Костыль для iOS 14, который пришлось написать для решения этой проблемы: рекурсивный лейаут view до получения стабильного результата:

final class Controller: UIViewController {
  private func onSUIViewSizeChange() {
    guard #unavailable(iOS 15.0) else { return }
    
    UIView.performWithoutAnimation { [hostingVC] in
      hostingVC.view.setNeedsLayout()
      hostingVC.view.layoutIfNeeded()
    }
             
    updatePreferredContentSize()
  }
}

Если вы вдруг захотите использовать SwiftUI на iOS 14, то вот краткий список багов, с которыми мы встретились:

  • Невозможность работы с векторными картинками, они становятся пиксельными. И единственный вариант обхода этой проблемы — это использование враппера над UIImageView:

    ResizableImage
    public struct ResizableImage: View, Equatable {
      private let name: String
      private let contentMode: ContentMode
      private let foregroundColor: Color?
    
      public var body: some View {
        if Constants.isIOS15 {
          SwiftUIImage(image: Image(name), contentMode: contentMode, foregroundColor: foregroundColor)
        } else {
          UIImageRepresentable(name: name, contentMode: contentMode, tintColor: foregroundColor)
        }
      }
    
      public init(name: String, contentMode: ContentMode = .fit, foregroundColor: Color? = nil) {
        self.name = name
        self.contentMode = contentMode
        self.foregroundColor = foregroundColor
      }
    }
    
    // MARK: - SwiftUI Image
    
    private struct SwiftUIImage: View, Equatable {
      let image: Image
      let contentMode: ContentMode
      let foregroundColor: Color?
    
      var body: some View {
        image
          .resizable()
          .renderingMode(foregroundColor == nil ? .original : .template)
          .aspectRatio(contentMode: contentMode)
          .foregroundColor(foregroundColor)
      }
    }
    
    // MARK: - UIImageRepresentable
    
    /// Backport for scalable images that are resized incorrectly in iOS 14.
    @available(iOS, introduced: 14.0, deprecated: 15.0, message: "Use `Image` instead.")
    private struct UIImageRepresentable: UIViewRepresentable, Equatable {
      let name: String
      let contentMode: ContentMode
      let tintColor: Color?
    
      init(name: String, contentMode: ContentMode, tintColor: Color?) {
        self.name = name
        self.contentMode = contentMode
        self.tintColor = tintColor
      }
    
      func makeUIView(context _: Context) -> UIImageView {
        let imageView = UIImageView()
        
        imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
        imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
        imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
        
        return imageView
      }
    
      func updateUIView(_ uiView: UIImageView, context _: Context) {
        uiView.contentMode = contentMode.uiKit
        
        if let tintColor {
          uiView.image = UIImage(named: name)?.withRenderingMode(.alwaysTemplate)
          uiView.tintColor = UIColor(tintColor)
        } else {
          uiView.image = UIImage(named: name)
        }
      }
    }
    
    // MARK: - Helpers
    
    private extension ContentMode {
      var uiKit: UIView.ContentMode {
        switch self {
        case .fit: .scaleAspectFit
        case .fill: .scaleAspectFill
        }
      }
    }
    
    // MARK: - Constants
    
    private enum Constants {
      static let isIOS15: Bool = if #available(iOS 15.0, *) { true } else { false }
    }

На iOS 14 иконки нечеткие
На iOS 14 иконки нечеткие
  • Еще один камень в огород resizable картинок, это то, что они теряют свой intrinsicSize  их фреймы приходится хардкодить. 

    Например, такой код не будет правильно растягивать картинку по ширине с учетом ее соотношения сторон:

    HStack {
        ResizableImage("folder")
                      
        Text("Hello World!")
    }
    .frame(height: 30)
  • Нет API работы с клавиатурой. Здесь помогут только врапперы вокруг UITextField или UITextView.

  • При интеропе с UIKit некоторые анимации багуют. В основном, те, что связаны с транзишенами между вью, поэтому большинство из них мы отключали для этой оси.

    Пример багующей анимации

  • У UIHostingController на iOS 14.4 не вызывается viewDidLoad(). Это неприятно, но хотя бы остальные методы жизненного цикла контроллера работают.

  • И самое веселое — это креши c iOS 14.0 по 14.1. Например, один экран решил показать другой, и случился креш. Почему — неясно, ко всему прочему он оказался флакающий, а его причины лежат где-то глубоко в SwiftUI. Полечить эти креши мне не удалось, но, начиная с iOS 14.2, их нет.

Допиливаем файлы: отсутствие коллекций, анимации, плавность скролла 

Проблемы нас не испугали, поэтому вернемся назад к задаче с Файлами. 

Отсутствие коллекций

Первое, с чем мы столкнулись — это отсутствие коллекций. Да, у нас есть LazyGrid, но lazy в нем не значит reusable, поэтому при большом числе файлов LazyGrid создаст кучу view и будет держать их в памяти.

LazyGrid встроенный в ScrollView держит в памяти именно View, а не его body. View.body и все, что связано с его отображением на экране, рассчитывается только для видимых элементов.

А еще начались проблемы с обновлениями. ForEach пытается посчитать различия между старым и новым массивом, и при обновлении большого списка файлов приложение зависает на несколько секунд. Это, конечно, можно исправить через View.id(_:), но тогда список будет перезагружаться при любом обновлении данных. Это не быстро, к тому же пользователь не видит, что именно произошло с его файлами.

Итак, понятно, что экрана, написанного целиком на SwiftUI не будет, но сдаваться мы не собирались. 

С iOS 16 Apple добавили возможность использовать SwiftUI в ячейках через UIHostingConfiguration. Нам это, к сожалению, было не актуально, так как таргетом была iOS 14, но дало надежду, что так делать все-таки можно, и оно даже будет нормально работать.

Стали исследовать, как это можно сделать без UIHostingConfiguration, и пришли к такому решению:

HostingCell
protocol HostingCell: UICollectionViewCell {
  var hostingController: UIViewController { get }
  var contentView: UIView { get }

  func setupHostingController(with parent: UIViewController)
  func releaseHostingController()
}

extension HostingCell {
  func setupHostingController(with parent: UIViewController) {
    guard hostingController.parent == nil else { return }

    parent.addChild(hostingController)

    hostingController.view.frame = contentView.bounds
    contentView.addSubview(hostingController.view)
    hostingController.didMove(toParent: parent)

    hostingController.view.backgroundColor = .clear
  }

  func releaseHostingController() {
    // This method can be called from `deinit'.
    // So any strong references to self result in the `EXC_BAD_ACCESS` error.
    let releaseActions = { [hostingController] in
      guard hostingController.parent != nil else { return }

      hostingController.willMove(toParent: nil)
      hostingController.view.removeFromSuperview()
      hostingController.removeFromParent()
    }

    if Thread.isMainThread {
      releaseActions()
    } else {
      DispatchQueue.main.async(execute: releaseActions)
    }
  }
}

Здесь есть два основных момента, про которые не стоит забывать:

  • Первый — это нужно добавлять контроллер как чайлда. Для того, чтобы его view правильно лейаутилась, а также получала некоторые типы ивентов, например, изменения safe area или показа клавиатуры.

  • Второй — не забывать откреплять контроллер от родителя при deinit’e ячейки. UICollectionView может создать слишком много ячеек, а после начать удалять лишние. Такое обычно происходит при смене UICollectionViewLayout или после быстрого скролла.

Проблемы с анимацией

Итак, все встроили, сверстали, немного попользовались и заметили странное поведение у анимаций. Из-за того, что SwiftUI не может отличить обновление контента ячейки от ее переиспользования, происходит такой глитч:

Когда идет скролл вверх и появляются папки - бейджики с них куда-то уезжают, а потом появляются новые
Когда идет скролл вверх и появляются папки - бейджики с них куда-то уезжают, а потом появляются новые

Первое, что приходит на ум, это в prepareForReuse() заменять UIHostingController.rootView на какой-то плейсхолдер, а при конфигурации ячейки заменять его на полноценную view. Но этот способ оказался нерабочим, потому что SwiftUI обычно леайутит view в конце ранлупа Main Thread. Поэтому при большой коллекции ячейка может быть переиспользована несколько раз в пределах одного ранлупа, из-за чего глитч с анимациями снова даст о себе знать.

Второй идеей было форсить layout SwiftUI в prepareForReuse() при помощи вызова layoutIfNeeded(). Но это не лучшее решение, потому что тем самым мы вмешиваемся в процесс layout'а SwiftUI и ломаем все оптимизации, которые он делает под капотом, из-за чего получаем множество неожиданных фризов.

Более подходящим решением будет подсказывать SwiftUI, когда view обновилась при помощи View.id(_:).

Что мы и сделали:

final class Cell: UICollectionViewCell {
  
  /* ... */
  
  private struct Content: View {
    @ObservedObject var state: State
  
    var body: some View {
      HStack {
        /* Animated Content */
      }
      .id(ObjectIdentifier(state))
    }
  }
  
  private func configure() {
    controller.rootView = Content(state: state)
  }
}

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

После того, как с анимациями все стало в порядке, пришла пора заняться плавностью скролла. Стало заметно, что с ним что-то не так. Даже медленный скролл иногда фризил, не говоря о паре fps, которые выдавал фастскролл.

Открыв TimeProfiller стало понятно, что с у нас слишком долго вычисляются View.body. А что именно на это повлияло — читайте дальше.

Вредные советы: чеклист, как ухудшить перформанс SwiftUI. Если им не следовать, ваши view ускорятся в 3 раза

Хочу отметить, что все лайфхаки, которыми я делюсь дальше, помогают ускорить перфоманс SwiftUI на всех версиях iOS. 

Все тесты и замеры скорости работы проводились на iPhone 15 Pro Max и iOS 17.4.1

Итак, то на что стоит обращать внимание и чего избегать при верстке на SwiftUI:

  1. Создание классов во View.body (например UIColor и UIFont) — это красный флаг, они очень сильно влияют на время расчета View.body.

  • В таком случае каждый раз при пересчете тела view будут создаваться новые классы для шрифтов и цветов:

    VStack {
      Text("Hello")
        .font(Font(UIFont.preferredFont(forTextStyle: .title1)))
        .foregroundStyle(Color(uiColor: .label))
    
      Text("World!")
        .font(Font(UIFont.preferredFont(forTextStyle: .title2)))
        .foregroundStyle(Color(uiColor: .secondaryLabel))
    }
  • Этого просто избежать при помощи использования нативных шрифтов и цветов, которые являются структурами и не требуют дополнительных аллокаций памяти:

    VStack {
      Text("Hello")
        .font(Font.title)
        .foregroundStyle(Color.primary)
    
      Text("World!")
        .font(Font.title2)
        .foregroundStyle(Color.secondary)
    }

    В результате использования нативных шрифтов мы ускорили view в 5 раз (48.29 µs -> 9.59 µs).

  1. Не оптимальный код во View.body или какие-то тяжелые вычисления в нем.

    Например, у нас была сортировка сета из трех элементов. Что с одной стороны достаточно быстро, а с другой — ничего не сортировать вовсе и использовать в этом месте массив было бы намного быстрее.

  • Такое решение будет каждый раз создавать новую строку, конкатенируя две другие:

    VStack {
      Text("Hello" + "World!")
        .font(Font.title)
        .foregroundStyle(Color.primary)
    }
  • Исправить это можно, например, сохранив строку в константы:

    VStack {
      Text(Constants.titleAndSubtitle)
        .font(Font.title)
        .foregroundStyle(Color.primary)
    }
     
    private enum Constants {
      static let titleAndSubtitle = "Hello" + "World!"
    }
  1. Тяжелые вычисления в инициализаторе view. Инициализаторы должны быть максимально дешевыми, а все тяжелые вычисления — ленивыми, то есть отложенными до отображения view на экране. Например, Image достает картинку из бандла только перед появлением на экране.

  • Не самый очевидный кейс дорогого init’a, потому что здесь создание строки находится в дефолтном значении константы, что на самом деле происходит каждый раз при создании этой view:

    struct BadExampleView: View {
      private let titleAndSubtitle = "Hello" + "World!"
     
      var body: some View {
        VStack {
          Text(titleAndSubtitle)
            .font(Font.title)
            .foregroundStyle(Color.primary)
        }
      }
    }
  • Исправить это можно, передав строку через инициализатор:

    struct GoodExampleView: View {
      private let titleAndSubtitle: String
    
      var body: some View {
        VStack {
          Text(titleAndSubtitle)
            .font(Font.title)
            .foregroundStyle(Color.primary)
        }
      }
    
      init(titleAndSubtitle: String) {
        self.titleAndSubtitle = titleAndSubtitle
      }
    }

    В результате оптимизации кода мы ускорили view в 2 раза (25.55 µs -> 12.71 µs).

  1. Слишком большие View.body. Об этом кто только не писал, но не включить это сюда нельзя. Потому что SwiftUI достаточно умный, чтобы не пересчитывать View.body у subviews, которые не изменились, за счет чего можно сильно уменьшить количество вычислений, которое произойдет при обновлении view.

  • Здесь, казалось бы, всего четыре view, и что может пойти не так:

    VStack {
      Text("Hello")
        .font(Font.title)
        .foregroundStyle(Color.primary)
    
      Text("World!")
        .font(Font.title)
        .foregroundStyle(Color.primary)
    
      Text("Hello")
        .font(Font.title2)
        .foregroundStyle(Color.secondary)
    
      Text("World!")
        .font(Font.title2)
        .foregroundStyle(Color.secondary)
    }
  • Но если их немного сгруппировать:

    VStack {
      TitleAndSubtitleView(title: "Hello", subtitle: "World!", font: Font.title, color: Color.primary)
     
      TitleAndSubtitleView(title: "Hello", subtitle: "World!", font: Font.title2, color: Color.secondary)
    }
     
    struct TitleAndSubtitleView: View {
      let title: String
      let subtitle: String
      let font: Font
      let color: Color
    
      var body: some View {
        Text(title)
          .font(font)
          .foregroundStyle(color)
    
        Text(subtitle)
          .font(font)
          .foregroundStyle(color)
      }
    }

    Получим двукратный прирост в скорости пересчета тела view (16.69 µs -> 8.08 µs).

    Это происходит потому, что SwiftUI теперь при пересчете тела view просто создает новые TitleAndSubtitleView, проверяет что они не изменились и не пересчитывает их View.body.

  1. Большое число if else: лучше не увлекаться ими, потому что они заново пересоздают view при переходе в разные ветки. По возможности в этом случае лучше менять параметры модификаторов.

  • Вот такое решение не очень хорошее, потому что постоянно будет переключаться между разными иерархиями:

    GeometryReader { proxy in
      Group {
        switch previewOrPlaceholder {
        case let .placeholder(name):
          Image(name)
            .resizable()
            .aspectRatio(contentMode: .fit)
    
        case let .preview(uiImage):
          Image(uiImage: uiImage)
            .resizable()
            .aspectRatio(contentMode: .fill)
        }
      }
      .frame(width: proxy.size.width, height: proxy.size.height)
      .clipped()
    }
  • А такое решение будет только обновлять view, не изменяя ее иерархию, поэтому работать будет быстрее. А еще оно более компактное:

    GeometryReader { proxy in
      previewOrPlaceholder.image
        .resizable()
        .aspectRatio(contentMode: previewOrPlaceholder.contentMode)
        .frame(width: proxy.size.width, height: proxy.size.height)
        .clipped()
    }
     
    private extension PreviewOrPlaceholder {    
      var image: Image { /* ... */ }
      var contentMode: ContentMode { /* ... */ }
    }

    Здесь без цифр, потому что на таком небольшом примере разница несущественная. Но если у вас более сложные view, то она станет очень заметна, как это было у нас. Смотрите итоговое сравнение перфоманса дальше.

  1. Явное присвоение identity равно полному пересозданию view при ее изменении. В этом месте мы поняли, что фикс анимаций через View.id(_:)  плохая идея, и переписали его на более легковесное решение через модификацию транзакции.

    Легковесность решения заключается в том, что оно никак не модифицирует иерархию view. А значит, не требует ни дополнительных пересчетов View.body, ни лишних обновлений графа зависимостей.

    При помощи модификатора View.transaction(:) мы выключаем анимации для всех view в иерархии в нужные моменты времени. Поэтому решение и работает максимально быстро.

    extension View {
      func disabledAnimations(_ disabled: @escaping () -> Bool) -> some View {
        self.transaction { (transaction: inout Transaction) in
          if disabled() {
            transaction.disablesAnimations = true
          }
        }
      }
    }
  2. Также во view, которые зависят только от констант и не имеют какого-то изменяемого состояния @State, @Bindable, @Environment), можно добавить конформанс Equatable, а сложные view стоит обернуть во View.equatable(). Подробнее о том, как SwiftUI сравнивает view, можно почитать тут.

  • Для примера, рассмотрим такую не оптимальную view:

    struct RepeatedTextView: View {
      let text: String
      let repeats: Int
    
      var body: some View {
        return Text(Array(repeating: text, count: repeats).joined())
          .font(Font.title)
          .foregroundStyle(Color.primary)
      }
    }
  • И начнем ее обновлять каждый второй фрейм:

    struct ContentView: View {
      private let repeats = [10, 10, 20, 20]
      @State private var index = 0
    
      var body: some View {
        TimelineView(.animation) { context in
          RepeatedTextView(text: "Hello World!", repeats: repeats[index])
            .onChange(of: context.date) { /* ... */ }
        }
      }
    }
  • И так как view не соответствует Equatable, SwiftUI начнет пересчитывать ее тело для того, чтобы понять изменилась ли view или нет.

    Здесь правда стоит уточнить, что это пример в вакууме, потому что SwiftUI умеет сравнивать структуры при помощи рефлексии и в этом конкретном случае все-таки сможет сравнить наши структуры не пересчитывая тело view. Но это не задокументированное поведение, на которое не стоит полагаться. Поэтому, если ваша view содержит что-то кроме простых свифтовых типов, то поведение будет ровно таким.

  • Однако если мы немного исправим код:

    struct RepeatedTextView: View, Equatable {
      /* ... */
    }
    
    TimelineView(.animation) { context in
      RepeatedTextView(text: "Hello World!", repeats: repeats[index])
        .equatable()
        .onChange(of: context.date) { /* ... */ }
    }

    То теперь у нас тело view как и ожидается, начнет пересчитываться каждый второй фрейм. А результате получили ускорение в 2 раза (25.55 µs -> 12.71 µs)!

  1. Еще из интересных наблюдений: View.animation(:value:) заметно утяжеляет вычисление тела view. И там, где это возможно, лучше использовать явные анимации через View.withAnimation(_:_:).

После всех этих улучшений все наши view стали считаться в среднем в три раза быстрее.

Почему мы боремся за микросекунды

А теперь давайте разберемся, почему микросекунды это важно? Казалось бы, цифры из примеров выше, это все еще слишком мало, чтобы вызвать какие-либо проблемы.

Но есть один нюанс: на то, чтобы закоммитить фрейм, у нас есть всего лишь 8.33 мс.(или 16.67 для устройств, которые не поддерживают 120 герц, но будем целиться в именно в 8.33 мс).

Время жизни фреймов
Время жизни фреймов

И за это время нужно:

  • Обработать пользовательские жесты

  • Обновить данные на экране (переконфигурировать ячейки)

  • Перерисовать наши view

Если с первыми двумя шагами все хорошо, то на последнем начинаются проблемы. Когда видишь в профайлере, что всё время, которое у тебя было на коммит транзакции, тратится на обновление SwiftUI-ных views.

Давайте посмотрим на примеры. Вот наша коллекция файлов, и мы ее достаточно медленно скроллим.

Обновление view внутри одной транзакцииМедленный скролл
Обновление view внутри одной транзакции
Медленный скролл

На скриншоте выше, видно что мы обновляли одну view, это заняло 188 мкс, и транзакция закоммитилась за 2.37 мс, а значит мы успели до дедлайна в 8мс. И пользователь будет наблюдать плавный скролл. 

Поэтому можно заметить, что на масштабе в несколько обновлений view, никаких проблем нет. SwiftUI все еще будет работать достаточно быстро и все успевать.

К сожалению, если в Xcode 15.3 добавлять в профайлер трек Display, который измеряет время жизни каждого фрейма, то ничего записать не удастся — Instruments будут просто крешиться при каждой попытке записи. Поэтому будем опираться на время коммитов транзакций, так как если мы не будем успевать их коммитить вовремя, то 120 fps точно не получим.

Но если мы начнем скроллить нашу коллекцию с файлами чуть быстрее, то проблема станет очевидной.

Обновление view внутри одной транзакцииБыстрый скролл
Обновление view внутри одной транзакции
Быстрый скролл

Итак, судя по тому что наша транзакция стала желтой, с ней что-то так. А проблема в том, что теперь за одну транзакцию мы обновляем двадцать одну view, и занимает это почти 21 мс. Что в 2.5 раза дольше, чем должно быть.

На скриншоте выше видно, что из 8 мс мы тратим 1.72 мс только на обновление наших view. Все дело в том, что теперь SwiftUI должен обновить больше двухсот view — обновление каждой занимает в среднем 12 мкс, а у некоторых отдельно взятых 20-30 мкс. Что в сумме как раз и дает такую большую цифру.

Все потому, что в идеале обновления view должны занимать единицы, максимум десятки мкс. Чтобы даже при таком большом числе обновлений за одну транзакцию — хитчей (hitch — время, на которое мы опоздали с коммитом транзакции) не возникало.

Более того, если мы чуть уменьшим масштаб, то станет ясно, что почти все транзакции теперь не успевают закоммититься в срок, из-за чего пользователь начнет видеть заметные подвисания. 

Среднее время коммита транзакцийБыстрый скролл
Среднее время коммита транзакций
Быстрый скролл

И так, ошибку поняли, приняли, по чеклисту прошлись и все исправили. Что у нас получилось в итоге:

Обновление view внутри одной транзакцииБыстрый скролл
Обновление view внутри одной транзакции
Быстрый скролл

Транзакция позеленела, теперь занимает 8.31 мс, что уже в 2.5 быстрее, а значит мы успеем ее закоммить в срок. 

Теперь при таком же быстром скролле наши view обновляются за 567 мкс или в три раза быстрее. А в среднем обновление одной view занимает 5мкс и среди них больше нет огромных обновлений по 20-30 мкс.

Снова уменьшим масштаб и станет понятно, что почти все транзакции, за исключением парочки, теперь зеленые, а значит успевают коммититься в срок, в среднем за 5.7мс. Что очень хорошо, потому что благодаря этому пользователь теперь не будет видеть зависаний, и скролл будет оставаться плавным всегда.

Среднее время коммита транзакцийБыстрый скролл
Среднее время коммита транзакций
Быстрый скролл

Заключение

Какие выводы можно сделать? UIKit и SwiftUI неплохо уживаются вместе, и для этого не обязательно иметь высокий таргет.
Если он iOS 15, то можно начинать постепенный переход, например, с небольших экранов и ячеек коллекций. Однако и с таргетом iOS 14 можно жить: в статье мы разобрали основные проблемы, с которыми вы можете столкнуться. Надеюсь, это было для вас полезно, и станет проще браться за более масштабные экраны при таких же вводных.

Ну и под конец хочу поделиться профитами, которые мы получили, переписав на SwiftUI раздел Файлов: 

  • Простота и удобство верстки очень сильно ускоряют разработку интерфейсов. А еще декларативный подход позволяет не думать о том, как и когда нужно обновлять view.

  • Разработчики меньше времени тратят на написание бойлерплейтного кода, например, по расчету фреймов. И могут больше покопаться в анимациях и транзишенах, сделав более красивый и качественный UI. Поэтому верстка становится менее рутинная и более творческая.

  • Код, написанный на современном фреймворке, проще поддерживать, а еще порог входа становится ниже. Все больше и больше стажеров приходят с уверенными знаниями в SwiftUI, и не очень в UIKit. Поэтому не приходится тратить кучу времени на онбординги.

Какие планы? В будущем мы планируем еще больше UI перевести на SwiftUI, и в этом нам поможет новая дизайн-система, развивать которую мы собираемся как раз на этом фреймворке.

Комментарии (10)


  1. Limansky
    22.05.2024 11:29
    +2

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


    1. VasilievVictor
      22.05.2024 11:29

      При чем тут язык программирования и среда( и прочее) разработки? Да и ios 14 устарела, даже на древнем iPhone SE стоит 15.4. Читаешь как экшен, борьба за сотые доли миллисекунд, глюки как головы гидры, крутяк(хотя на первый взгляд кажется, что проблема была сначала зачем-то создана, а потом героически решена, наверное, пока еще что нибудь не вылезет). А потом запускаешь приложение Яндекса умный дом, и не дождавшись его открытия за 10(а то и больше) секунд, идешь и выключаешь свет вручную.


      1. VasilievVictor
        22.05.2024 11:29

        Ставить молча минусы это прикольно, написали бы, с чем не согласны )). С тем что ios 15 не ставится на устройства выпущенные в 2015-начале 2016 годах. Это 9 лет. Согласно данным компании, на начало 2024 года, ios 15 и ниже, установлены на менее 4% устройств(про ios 14 и говорить нечего)…


        1. am10
          22.05.2024 11:29
          +3

          Я поставил минус за три момента

          1. Ты упомянул в качестве примера приложение, над которым автор не работает и не работал. Это не имеет связи с конкретной статьей и является демагогией, не более.

          2. Устаревшая iOS 14 на текущий момент поддерживается разработчиками, как было указано в самом начале. Это значит, что есть ненулевое число пользователей, для которых приложение стало работать быстрее - именно об этом написано в статье. Упоминание, что версия iOS устарела можно было заменить на "Солнце садится на западе", равное по ценности твоему очевидное утверждение.

          3. Ты указал, что согласно данным компании iOS 15 установлено на менее 4% устройств. Значит ли это, что можно эти данные экстраполировать на аудиторию приложения, о котором статья? Можно попробовать. Но ты не учел, что раскладка по девайсам в среднем по больнице вообще не равна раскладке аудитории конкретного приложения. Чем позже вышло - тем меньше аудитория старых версий, обратное тоже справедливо. Еще один твой аргумент в воздух.

          Этого достаточно?


          1. VasilievVictor
            22.05.2024 11:29

            Не совсем, поясню, то что ios 14 поддерживается, это хорошо, но насколько я понял из статьи , было принято решение внедрить визуальные улучшения на «новом фреймворке» в эту устаревшую версию ios и именно это решение ускорили в «3 раза». Насколько это решение работает быстрее старого, не совсем понятно. Насчет приложения «умный дом», над которым автор и команда не работает, согласен, не корректный пример. Просто задело, что в статье рассказывается про допуски по времени, про микро и миллисекунды, а тут на другие порядки никто внимания не обращает. Компания одна, требования к одному приложению видны(в статье можно прочитать), неужели настолько разный подход и отношение?


            1. am10
              22.05.2024 11:29
              +1

              Можно вместо ответа на вопрос

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

              я просто прикреплю скрин с Википедии?


              1. VasilievVictor
                22.05.2024 11:29

                Короче понятно, «к пуговицам претензии есть?». А вы не думаете, что для абсолютного большинства есть Яндекс, этакий монолит со своими стандартами и качеством, практически единые требования при приеме на работу, позиционирование компании мирового топ-уровня и такой разный результат )).

                Насчет основной темы статьи, ответил выше, можете и дальше интерполировать и натягивать сову на глобус, о огромном процентном отношении пользователей диска с ios 14, которым нужна другая визуализация )). Сама статья хорошая, работа проделана серьезная, применение решений из статьи очень ограничено.


            1. Kn1kt Автор
              22.05.2024 11:29
              +3

              было принято решение внедрить визуальные улучшения на «новом фреймворке» в эту устаревшую версию ios и именно это решение ускорили в «3 раза».

              Не совсем, если мы хотим сделать решение на новом фреймворке, то не можем просто так взять и не поддержать его одинаково хорошо на всех версиях iOS которые у нас есть. 

              А стали работать быстрее мы независимо от версии iOS, в частности, все тесты проводились на iPhone 15 Pro Max и iOS 17.4.1.


              1. VasilievVictor
                22.05.2024 11:29

                Спасибо за ответ, теперь гораздо понятней.

                Единственно: "... то не можем просто так взять и не поддержать его одинаково хорошо на всех версиях iOS которые у нас есть. " - это такое технологическое решение или принятый подход к внедрению новых фич(который тянет за собой огромную трудоемкость в отдельных случаях, как тот что в статье )


                1. Kn1kt Автор
                  22.05.2024 11:29
                  +2

                  Скорее принятый подход. У всех решений есть свои плюсы и минусы. 

                  Если бы мы остались на UIKit, то не нужно было бы сильно ухищряться с поддержкой старых версий, но при этом верстать красивый и качественный UI было бы гораздо сложнее. Для нас действительно важен хороший, стабильный UI. 

                  Со SwiftUI это делать куда проще, но и здесь есть свои недостатки, главный из которых это более трудоемкая поддержка старых версий iOS.

                  Спасибо за фидбэк, я понял, что не сразу считывается, что перфоманс улучшился и на других версиях iOS, дополню это в тексте.