UIWindow служит корнем иерархии вью сцены: подкласс UIView без superview, который держит rootViewController, получает события от UIApplication и передаёт их вью внутри себя.
С чего начинается: когда сцена подключается, scene delegate (или WindowGroup в SwiftUI) создаёт UIWindow для UIWindowScene, задаёт rootViewController и вызывает makeKeyAndVisible(). view корневого контроллера становится содержимым окна; все остальные вью добавляются под ним.
Чем отличается: окно привязано к экрану через свою windowScene, это первая остановка, когда UIApplication рассылает событие, а key-окно получает ввод с клавиатуры и другие события, не связанные с касаниями.
Сколько их: обычно одно на сцену. Дополнительное окно нужно для контента, который должен быть поверх всего; порядок задаёт windowLevel. По TN3187 от Apple, SDK после iOS 26 не запустит UIKit-приложение, которое не перешло на жизненный цикл сцен, поэтому создание окна должно быть в scene(_:willConnectTo:options:).
view.window остаётся nil, пока вью не окажется в иерархии какого-либо окна; это быстрый способ проверить, «на экране ли уже эта вью».
iOSJunior
Основы UIKit
UIView, жизненный цикл UIViewController, иерархия view, обработка касаний
Закрыто вопросов
0 / 25
- 0Повторить
- 6Часто спрашивают
Вопросы
loadView и viewDidLoad вызываются один раз на экземпляр контроллера; viewWillAppear, viewIsAppearing, viewDidAppear и два колбэка исчезновения вызываются каждый раз, когда экран показывается или скрывается.
init: контроллер существует, его вью ещё нет.
loadView() → viewDidLoad(): вью создаётся при первом обращении к view. Одноразовую настройку делают в viewDidLoad: сабвью, констрейнты, делегаты, первый запрос данных.
viewWillAppear(_:): экран вот-вот покажется. Обновите данные, которые могли измениться, пока экран был скрыт.
viewIsAppearing(_:): вью уже в иерархии с окончательным размером и трейтами. Здесь место настройке, зависящей от геометрии.
viewDidAppear(_:): переход завершён. Запуск анимаций, логирование показа экрана.
viewWillDisappear(_:) / viewDidDisappear(_:): сохранить черновик, поставить на паузу видео и таймеры.
viewWillLayoutSubviews() / viewDidLayoutSubviews() могут вызываться много раз за одно появление, поэтому должны быть дешёвыми.
Сетевую загрузку запускают в viewDidLoad, если достаточно одного раза, или в viewWillAppear, если данные должны быть свежими при каждом заходе. Тяжёлая синхронная работа во viewWillAppear задерживает анимацию push, поэтому делайте её асинхронно.
loadView создаёт корневую вью контроллера и присваивает её в view; viewDidLoad вызывается сразу после него и настраивает уже существующую вью. Переопределять loadView нужно только чтобы сделать корневой вью свой класс.
UIKit вызывает loadView лениво, при первом чтении view. Реализация по умолчанию загружает nib или сцену сториборда, если они есть, иначе создаёт обычный UIView.
В переопределении присвойте view и не вызывайте super: он построит вью, которую вы тут же выбросите.
Никогда не вызывайте loadView() сами. Чтобы принудительно загрузить вью, например в юнит-тесте, вызовите loadViewIfNeeded().
Слишком раннее чтение view (в init или из родителя до показа контроллера) загружает её раньше времени, и viewDidLoad срабатывает раньше, чем вы ожидаете.
frame задаёт положение и размер вью в системе координат superview; bounds описывает тот же прямоугольник в собственных координатах вью. bounds.origin не равен нулю, когда вью показывает смещённую часть своего содержимого: у scroll view bounds.origin совпадает с его contentOffset.
center тоже задаётся в координатах superview. Его изменение двигает вью, не трогая bounds.
Сабвью располагаются в bounds родителя, поэтому сдвиг bounds.origin двигает на экране все дочерние вью сразу; именно так скроллится UIScrollView.
При неединичном transform (поворот, масштаб) frame становится ограничивающим прямоугольником трансформированной вью, и его установка даёт неопределённый результат. Размер задавайте через bounds, положение через center, остальное через transform.
Все три способа дают одни и те же вью UIKit: сториборды и XIB предлагают визуальный редактор, а код даёт читаемые диффы и заметно меньше конфликтов при слиянии. Большинство команд сейчас делают новые экраны в коде или на SwiftUI.
Сториборды: много экранов и сегвеи между ними в одном файле; сегвеем называют переход, заданный в сториборде, а storyboard identifier представляет собой строку, по которой контроллер создаётся из кода. Быстро прототипировать, но XML конфликтует при мёрже, большие файлы медленно открываются, а идентификаторы сегвеев и контроллеров остаются строками, которые падают только в рантайме.
XIB: одна вью или один экран на файл. Меньше область влияния конфликтов; разумный выбор для переиспользуемой кастомной вью или ячейки.
Код: всё на Swift: зависимости передаются через init, работают инструменты рефакторинга, диффы читаемы. Больше набирать, и нет канваса, пока не добавите #Preview.
Launch screen: по-прежнему сториборд или ключ UILaunchScreen в Info.plist. Он статичный и не может выполнять код.
Контроллер из сториборда создаётся через init(coder:). instantiateViewController(identifier:creator:) (iOS 13+) всё равно позволяет передать в него зависимости.
@IBOutlet помечает свойство, которое Interface Builder заполняет ссылкой на вью, чтобы код мог её читать и менять; @IBAction помечает метод, который Interface Builder привязывает к событию контрола, чтобы контрол мог вызвать ваш код. Второе и есть target-action.
Аутлеты обычно объявляют как weak var titleLabel: UILabel!: вью уже принадлежит иерархии, а ! говорит, что значение будет установлено после загрузки вью. Обращение до viewDidLoad падает на nil.
Target-action: контрол хранит целевой объект, селектор и события и вызывает этот метод, когда событие происходит. В коде это addTarget(_:action:for:) с #selector(...).
Селектор ищется в рантайме через Objective-C runtime, поэтому метод должен быть @objc (@IBAction это подразумевает).
Переименованный аутлет, который всё ещё подключён в сториборде, падает с «not key value coding-compliant»; переименованный экшен падает с «unrecognized selector sent to instance».
С iOS 14 замыкания UIAction (addAction(_:for:), UIButton(primaryAction:)) позволяют вообще обойтись без селекторов.
UINavigationController управляет стеком view controller'ов: push кладёт контроллер наверх с кнопкой «Назад» и свайпом назад, pop снимает его. present показывает контроллер модально поверх текущего, вне всякого стека, пока его не закроют.
Push: переход вглубь внутри одного сценария (список → детали). Нужен navigation controller: без него navigationController равен nil, и push молча ничего не делает.
Present: отдельная задача (написать сообщение, войти, фильтры). С iOS 13 по умолчанию используется стиль .pageSheet, который пользователь может смахнуть; .fullScreen смахнуть нельзя. Закрывается через dismiss(animated:).
.pageSheet оставляет презентующий экран видимым, поэтому его viewWillDisappear и viewWillAppear вокруг такого показа не вызываются.
Модальный экран, которому нужна панель заголовка или собственные push, оборачивают в свой UINavigationController.
setViewControllers(_:animated:) заменяет весь стек: так диплинк открывает экран на три уровня вглубь.
UITabBarController стоит уровнем выше: каждая вкладка обычно держит свой navigation controller, поэтому у каждой вкладки независимый стек.
Таблица спрашивает у data source, сколько строк, и ячейку для каждой видимой строки, а delegate сообщает о выборе, размерах и прокрутке. Существуют только ячейки на экране; ушедшие за край попадают в очередь переиспользования и возвращаются из dequeueReusableCell(withIdentifier:for:).
Data source (UITableViewDataSource): numberOfRowsInSection и cellForRowAt. Отвечает на вопрос, что показывать.
Delegate (UITableViewDelegate): didSelectRowAt, высоты строк, свайп-действия и колбэки прокрутки, поскольку он наследует UIScrollViewDelegate.
Переиспользование: для 10 000 строк нужно примерно столько ячеек, сколько помещается на экране, поэтому память не растёт и прокрутка остаётся плавной.
Переиспользованная ячейка ещё показывает содержимое предыдущей строки, поэтому cellForRowAt должен выставлять каждое поле, а prepareForReuse() сбрасывает состояние вроде незавершённой загрузки картинки.
После изменения данных вызывают reloadData() или performBatchUpdates(_:completion:) либо применяют снапшот через diffable data source.
Scroll view представляет собой вью, у которой bounds.origin сдвигается, когда пользователь тянет пальцем: contentSize задаёт размер прокручиваемого содержимого, contentOffset показывает, какая его точка находится в левом верхнем углу, а contentInset добавляет дополнительное прокручиваемое пространство вокруг.
Прокрутка возможна, только если contentSize больше собственных границ. С Auto Layout contentSize вычисляется из констрейнтов к contentLayoutGuide.
contentOffset содержит то же значение, что и bounds.origin. Его установка или вызов setContentOffset(_:animated:) прокручивает из кода.
contentInset добавляет отступ, например место под тулбар. adjustedContentInset добавляет к нему safe area; это поведение задаёт contentInsetAdjustmentBehavior.
scrollViewDidScroll(_:) у делегата срабатывает при каждом изменении смещения (его используют для сворачивающихся хедеров и подгрузки следующей страницы), поэтому он должен быть дешёвым.
UIImage хранит данные изображения (пиксели и масштаб), которые не находятся на экране; UIImageView является вью, которая показывает их согласно contentMode. .scaleAspectFit показывает изображение целиком с пустыми полями, .scaleAspectFill заполняет вью и обрезает лишнее.
.scaleToFill, значение по умолчанию, растягивает картинку под форму вью и искажает её.
.scaleAspectFit показывает изображение целиком без искажений и оставляет пустые поля.
.scaleAspectFill заполняет всю вью без искажений и обрезает лишнее, но только при clipsToBounds = true; иначе лишнее рисуется за пределами вью.
Скругляйте через layer.cornerRadius и clipsToBounds, а тень задавайте через layer.shadowOpacity/shadowRadius/shadowOffset; обрезка отсекает тень, поэтому нужны две вью: внешняя с тенью и внутренняя, которая обрезает.
Один cornerRadius скругляет только фон и рамку слоя. Сабвью и содержимое картинки остаются с острыми углами, пока не включён clipsToBounds (то же, что layer.masksToBounds).
Почему работают две вью: внешний контейнер не обрезает, поэтому его тень видна; внутренняя вью обрезает содержимое с тем же радиусом.
Задайте layer.shadowPath. Без него Core Animation вычисляет форму тени по содержимому в offscreen-проходе на каждом кадре, и это бьёт по плавности прокрутки.
layer.cornerCurve = .continuous даёт плавную форму угла, как у Apple; layer.maskedCorners скругляет только выбранные углы.
Для круга cornerRadius = bounds.width / 2 нужно пересчитывать в layoutSubviews, потому что размер может измениться.
Completion handler у dataTask выполняется на фоновой очереди, а UIKit можно использовать только на главном потоке. Обновляйте UI на главном потоке: оберните его в DispatchQueue.main.async или используйте async/await из main-actor-контекста.
Main Thread Checker, включённый по умолчанию в debug-сборках, называет строку: «UILabel.text must be used from main thread only».
В языковом режиме Swift 6 это не скомпилируется: view controller'ы помечены @MainActor, а обработчик представляет собой @Sendable-замыкание без этой изоляции.
Исправление в колбэках: декодируйте на фоновой очереди, а в DispatchQueue.main.async оберните только обновление UI.
Исправление на async/await: вызывайте URLSession.shared.data(from:) в Task, запущенном из view controller'а. Задача наследует main actor, поэтому после await код снова на главном потоке.
Вне главного потока безопасно: сеть, декодирование JSON, декодирование изображений и рисование через UIGraphicsImageRenderer.
Используйте динамические цвета (системные семантические вроде .label и .systemBackground или цвета из asset catalog с тёмным вариантом), и UIKit сам пересчитывает их при каждой смене оформления. CGColor, заданный слою, вычисляется один раз, поэтому его нужно выставлять заново при смене трейта.
Семантические цвета: .label, .secondaryLabel, .systemBackground, .separator адаптируются сами.
Asset catalog: набор цвета или изображения с вариантами Any и Dark; UIColor(named:) возвращает динамический цвет.
В коде: UIColor { traits in ... } возвращает свой цвет для каждого userInterfaceStyle.
Слои: borderColor, shadowColor и цвета градиента имеют тип CGColor. Вычисляйте их через resolvedColor(with: traitCollection) и обновляйте в обработчике registerForTraitChanges (iOS 17+), который заменил устаревший traitCollectionDidChange(_:). С iOS 18 работает и установка в layoutSubviews: UIKit отслеживает прочитанные там трейты и вызывает его заново.
overrideUserInterfaceStyle = .dark закрепляет одно оформление за поддеревом вью или контроллера.
Поместите изменения свойств внутрь UIView.animate(withDuration:animations:). Для Auto Layout сначала измените constant констрейнта, затем внутри блока анимации вызовите layoutIfNeeded() у superview: анимируется именно проход layout.
Анимируемые свойства вью: frame, bounds, center, transform, alpha, backgroundColor. isHidden и text не анимируются; их плавно меняют через UIView.transition(with:duration:options:animations:) с .transitionCrossDissolve.
Под Auto Layout не анимируйте frame: следующий проход layout вернёт его обратно. Меняйте констрейнты или используйте transform, который layout игнорирует.
Вызовите layoutIfNeeded() один раз перед изменением констрейнта, чтобы анимация стартовала с устоявшегося layout, а не анимировала заодно посторонние отложенные изменения.
UIView.animate(springDuration:bounce:) (iOS 17+) описывает пружину «на ощущение». UIViewPropertyAnimator можно ставить на паузу, разворачивать и прокручивать, что нужно для анимаций, управляемых жестом.
Свойства только уровня слоя, такие как strokeEnd или shadowPath, требуют Core Animation (CABasicAnimation).
viewIsAppearing вызывается один раз за появление: после того как вью оказалась в иерархии с окончательным размером и трейтами, но до того как она на экране. viewWillAppear слишком ранний для геометрии, а viewDidLayoutSubviews срабатывает на каждом проходе layout.
Порядок при каждом появлении: viewWillAppear → viewIsAppearing → viewDidAppear. Метод добавлен в iOS 17 и работает начиная с iOS 13 при сборке в Xcode 15 и новее.
Во viewWillAppear вью может ещё не быть в окне, а bounds и traitCollection могут быть устаревшими, поэтому позиция прокрутки или расчёт размера оказываются не там.
У viewDidLayoutSubviews правильные числа, но он срабатывает при повороте, появлении клавиатуры и изменении размеров ячеек, отсюда старый флаг didScrollOnce. viewIsAppearing избавляет от него.
Типичные задачи: прокрутить список к текущему элементу, рассчитать хедер от реальной ширины, применить оформление, зависящее от трейтов.
Наблюдатель добавляется во viewWillAppear, который вызывается при каждом появлении экрана, и нигде не удаляется. NotificationCenter не отсеивает дубликаты регистраций, поэтому после трёх заходов одно .cartDidChange вызывает cartDidChange() три раза: три события аналитики, три перезагрузки.
Почему: viewDidLoad вызывается один раз на экземпляр контроллера, а viewWillAppear при каждом показе экрана: после возврата через pop, закрытия модального окна, переключения таба. Одноразовая настройка, помещённая туда, накапливается; то же касается addSubview, addGestureRecognizer или sink из Combine.
Исправление: регистрируйтесь один раз во viewDidLoad, а во viewWillAppear оставьте только обновление вроде reloadBadge(). Наблюдатель на селекторе удаляется автоматически при деаллокации контроллера (начиная с iOS 9).
Если слушать нужно только на экране: делайте вызовы парными: добавляйте во viewWillAppear, а во viewWillDisappear вызывайте removeObserver(self, name: .cartDidChange, object: nil).
Блочный addObserver(forName:object:queue:using:) ещё хуже: каждый вызов возвращает токен, который нужно хранить и удалять, а замыкание, сильно захватывающее self, вдобавок даёт утечку контроллера.
Как поймать: тест, который дважды вызывает viewWillAppear, один раз отправляет уведомление и проверяет, что событие аналитики ровно одно.
Вперёд: отдайте следующему контроллеру всё нужное до его показа, лучше всего через init. Назад: дочерний контроллер сообщает через колбэк, который задал родитель (замыкание или weak-делегат), но никогда не через прямую ссылку на тип родителя.
**Вперёд через init**: EditViewController(item: item) делает данные обязательными и неизменяемыми. Со сторибордами используйте instantiateViewController(identifier:creator:) или задайте свойство в prepare(for:sender:).
Назад через замыкание: var onSave: ((Item) -> Void)?. Родитель задаёт его и захватывает [weak self]. Подходит для одного-двух колбэков.
Назад через делегат: протокол с несколькими именованными колбэками, хранится как weak var delegate, чтобы дочерний контроллер не удерживал родителя.
Общее состояние: когда несколько экранов показывают одни и те же данные, все они наблюдают одну модель или стор, а не передают друг другу копии.
Не тянитесь назад через presentingViewController или navigationController?.viewControllers с приведением типа: это привязывает дочерний контроллер к одному конкретному родителю и ломается, когда меняется сценарий.
Containment позволяет одному view controller'у встроить вью другого в свою, при этом дочерний остаётся полноценным контроллером со своим жизненным циклом. Добавляют через addChild, затем добавляют его вью, затем вызывают didMove(toParent:); убирают в зеркальном порядке.
Зачем: разбить огромный экран на переиспользуемые части (хедер, список, пустое состояние) или построить свой контейнер (пейджер, переключатель «загрузка/контент»). UINavigationController и UITabBarController представляют собой контейнеры, устроенные так же.
Добавление: addChild(child) → добавить child.view и задать констрейнты → child.didMove(toParent: self).
Удаление: child.willMove(toParent: nil) → child.view.removeFromSuperview() → child.removeFromParent().
addChild сам вызывает willMove(toParent:), а removeFromParent сам вызывает didMove(toParent: nil), поэтому в каждой последовательности указан только второй.
Если добавить только вью без этих вызовов, дочерний контроллер останется без колбэков появления, без обновлений трейтов и safe area и с navigationController == nil.
UIKit сначала выполняет hit-testing от окна вниз, чтобы найти самую глубокую вью под пальцем, и доставляет касание ей; всё, что эта вью не обработала, поднимается по responder chain: superview, view controller, окно, приложение.
Hit-testing: hitTest(_:with:) проверяет point(inside:with:) у вью, затем опрашивает её сабвью спереди назад; побеждает самая глубокая вью, содержащая точку.
Пропускаются вью с isHidden, alpha меньше 0.01 или isUserInteractionEnabled == false. Часть сабвью за границами superview не может получить касание, даже если она там нарисована из-за выключенного clipsToBounds.
Responder chain: UIResponder служит базовым классом для UIView, UIViewController, UIWindow и UIApplication. Необработанные касания, клавиатурные команды и sendAction(_:to:from:for:) с target nil поднимаются по этой цепочке, пока кто-нибудь их не обработает.
Gesture recognizers видят касания раньше вью. Tap-рекогнайзер на контейнере может проглотить нажатия на строки таблицы, если не выставить cancelsTouchesInView = false.
First responder: другое понятие: объект, получающий ввод с клавиатуры; назначается через becomeFirstResponder().
Gesture recognizer представляет собой машину состояний, прикреплённую к вью: он следит за касаниями и вызывает свой action, когда распознаёт жест. По умолчанию побеждает только один рекогнайзер; require(toFail:) задаёт порядок, а метод делегата shouldRecognizeSimultaneouslyWith позволяет двум работать одновременно.
Встроенные типы: tap, long press, pan, swipe, pinch, rotation, screen-edge pan, hover.
Дискретные (tap, swipe): .possible → .recognized или .failed; action срабатывает один раз.
Непрерывные (pan, pinch, long press): .began → много раз .changed → .ended или .cancelled. Считайте translation(in:) относительно значения, сохранённого в .began, и убирайте за собой и в .cancelled, и в .ended.
single.require(toFail: double): одиночный тап ждёт, пока двойной не провалится, через короткую задержку.
gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:): верните true для panGestureRecognizer скролл-вью, чтобы работали и ваш pan, и прокрутка.
У UILabel и UIImageView по умолчанию isUserInteractionEnabled = false, поэтому добавленный к ним рекогнайзер не сработает, пока вы это не включите.
Каждый UIView опирается на CALayer: вью отвечает за касания, layout, responder chain и accessibility, а слой хранит содержимое, которое рисует и анимирует Core Animation. Слои легче, но не получают касаний.
Вью служит делегатом своего слоя. cornerRadius, borderWidth, shadowOpacity и mask относятся к свойствам слоя; backgroundColor и alpha, заданные у вью, передаются слою.
UIView.animate работает, добавляя анимации Core Animation к слою.
Во время анимации слой уже хранит конечное значение; layer.presentation() возвращает то, что сейчас на экране, что полезно для hit-testing движущейся вью.
Слой напрямую: для градиентов (CAGradientLayer), фигур (CAShapeLayer), видео (AVPlayerLayer) и частиц (CAEmitterLayer).
Переопределение layerClass делает нужного типа собственный слой вью, и он меняет размер вместе с ней сам, без ручной синхронизации frame сабслоя в layoutSubviews.
setNeedsLayout помечает вью для нового прохода layout, и вызывается layoutSubviews; setNeedsDisplay помечает её содержимое устаревшим, и вызывается draw(_:) для перерисовки. Ни один не действует сразу: оба откладываются до конца текущей итерации run loop.
Layout меняет положение и размеры сабвью; display перерисовывает пиксели самой этой вью. Перемещение сабвью никогда не требует setNeedsDisplay.
draw(_:) вызывает только UIKit: при первом показе вью, после setNeedsDisplay() и при изменении размера, если contentMode == .redraw. Никогда не вызывайте его сами.
Переопределение draw(_:) даёт вью backing-битмап размером с неё, умноженный на масштаб экрана, и перерисовка идёт на CPU. Для фигур, которые анимируются или часто меняются, CAShapeLayer или обычные сабвью обычно дешевле.
Свойство, влияющее на отрисовку, вызывает setNeedsDisplay() в своём didSet, и много изменений за один проход стоят одной перерисовки.
layoutIfNeeded() принудительно выполняет отложенный layout сразу, например прямо перед анимацией.
Главный run loop служит циклом событий главного потока: он обрабатывает касания, таймеры и блоки из очереди, затем коммитит layout и отрисовку. Запланированный Timer срабатывает только в режиме .default, а при прокрутке цикл работает в режиме .tracking, поэтому таймер ждёт.
Один проход: обработать источники ввода, запустить подошедшие таймеры, выполнить блоки из DispatchQueue.main, затем закоммитить отложенные изменения layout и отрисовки и уснуть. Поэтому setNeedsLayout и группирует изменения: всё, что поменяли за один проход, отрисовывается вместе.
Режимы: run loop работает в одном режиме за раз, и срабатывают только источники и таймеры, зарегистрированные для этого режима. Прокрутка переключает его в .tracking, чтобы ничто не мешало следовать за пальцем.
Исправление: добавьте таймер для .common (набора, в который входят и .default, и .tracking): RunLoop.main.add(timer, forMode: .common). CADisplayLink подчиняется тому же правилу.
Запланированный таймер удерживается run loop до invalidate(), а повторяющийся держит и свой target, поэтому инвалидируйте его, когда экран уходит.
Долгая работа на главном потоке блокирует весь цикл: ни касаний, ни таймеров, ни новых кадров.
Через 3 секунды ничего не напечатается. Через 5 секунд замыкание выполнится и напечатает «closure ran», затем отпустит self, и напечатается «deinit». Замыкание держит закрытый контроллер на 2 секунды дольше, но цикла сильных ссылок нет, так что это не утечка.
Замыкание захватывает self сильно, а очередь держит замыкание до его выполнения, то есть очередь косвенно владеет контроллером. Ничто из того, чем владеет контроллер, не ссылается обратно на замыкание, поэтому владение одностороннее.
Лишнее время жизни всё равно имеет цену: выполняется работа для экрана, которого никто не видит (сетевой запрос, событие аналитики, алерт из контроллера, которого уже нет на экране).
С [weak self] «deinit» напечатается через 3 секунды; через 5 секунд замыкание обнаружит, что self равен nil, и ничего не сделает.
Настоящая утечка появляется, когда что-то, живущее столько же, сколько контроллер, удерживает его и никогда не останавливается: хранимое свойство-замыкание, захватывающее self, повторяющийся Timer с target self (его удерживает run loop) или Task с бесконечным циклом, который никто не отменяет.
Task { } ведёт себя так же: держит self, пока не завершится, поэтому долгие задачи нужно отменять во viewDidDisappear, ведь deinit не вызовется, пока задача держит self.
Task захватывает ячейку, а не строку. Ячейки переиспользуются, поэтому, когда медленная загрузка завершается, ячейка может уже показывать другого пользователя и получает чужой аватар; к тому же старое изображение видно, пока не придёт новое.
Почему: dequeueReusableCell возвращает ячейку, которая только что ушла за экран, со старым image. Загрузки завершаются в любом порядке, поэтому побеждает та, что закончилась последней, а не та, что относится к строке, которую ячейка показывает сейчас.
Исправление: очищайте изображение при конфигурации (или в prepareForReuse()), запоминайте, какой элемент показывает ячейка, и проверяйте это после await перед присваиванием.
Более надёжное исправление: храните Task в ячейке и вызывайте cancel() в prepareForReuse(), чтобы загрузки для ушедших за экран строк тоже останавливались. Проверку id оставьте: загрузчик, игнорирующий отмену, всё равно вернёт изображение.
Альтернатива: вообще не трогайте ячейку из колбэка: кэшируйте изображение по URL, затем вызывайте reconfigureRows(at:) или reconfigureItems(_:) у снапшота diffable data source, чтобы таблица сама запросила ячейку, которая сейчас показывает этот элемент.
Как поймать: быстро скролльте при включённом Network Link Conditioner (3G); в тесте нужно дать загрузчику стаб с управляемыми задержками и сконфигурировать одну ячейку для двух строк.