Лектор CS193p: «В мобильном интерфейсе недопустимо зависание даже на полсекунды»

Stanford Online 3,7 тыс. 1 ч 10 мин 14 мин 18.12.2025
Главное

На очередной лекции курса Стенфордского университета CS193p «Разработка под iOS с использованием SwiftUI» (весна 2025 года) подробно разбирается одна из важнейших и одновременно сложных тем мобильной разработки — многопоточность. Лектор демонстрирует, как предотвратить зависание пользовательского интерфейса, объясняет базоческие концепции Swift Concurrency, такие как акторы, асинхронный контекст и протокол Sendable, а также показывает практические приемы работы со сложными предикатами в SwiftData. Главная идея занятия заключается в том, что современное приложение должно оставаться отзывчивым в любой ситуации, а среда Swift предоставляет мощные инструменты для статической проверки безопасности данных.

🛠️ Практические доработки интерфейса и проблемы с предикатами 0:05

В начале занятия лектор демонстрирует несколько небольших улучшений для разрабатываемого игрового приложения CodeBreaker. Первой задачей становится добавление анимации для функции поиска. В то время как варианты сортировки уже плавно анимированы благодаря привязке анимации к Binding, результаты поиска при вводе текста в поисковую строку меняются мгновенно и дискретно. Для исправления этого лектор применяет неявную анимацию с помощью модификатора .animation(), привязанного к значению текстового поля searchField. Теперь любое изменение поискового запроса плавно скрывает или отображает элементы списка.

Вторым улучшением становится добавление нового критерия фильтрации в перечисление SortOption — показ только завершенных игр (completed). Лектор решает использовать для них тот же порядок сортировки, что и для недавних игр (recent), предполагая, что пользователя в первую очередь интересуют игры, разгаданные совсем недавно. Однако добавление нового кейса в enum сразу вызывает ошибки компиляции «Switch must be exhaustive», напоминая о необходимости обработать новое значение во всех частях кода.

Для реализации логики фильтрации лектор пытается расширить макрос #Predicate, добавив проверку вычисляемого свойства game.isOver. Но при запуске приложение аварийно завершает работу. Лектор объясняет причину этого сбоя:

Макрос #Predicate преобразуется в SQL-запрос, который выполняется непосредственно на стороне базы данных. База данных не имеет доступа к коду на Swift и «не видит» вычисляемые свойства. Она способна оперировать только теми полями, которые физически сохранены в таблице и описаны в схеме модели @Model.

Чтобы решить эту проблему, лектору приходится отказаться от вычисляемого свойства isOver и сделать его обычным хранимым свойством (stored property) типа Bool. Это влечет за собой неприятную необходимость вручную изменять состояние переменной во всех местах, где игра может завершиться или начаться заново: при инициализации, отправке попытки угадать код и перезапуске. Лектор признается, что такой подход ему не нравится, поскольку вычисляемые свойства гарантировали актуальность данных в любой момент времени, тогда как ручное управление переменными повышает риск возникновения ошибок.

🗄️ Сложные предикаты и связи между таблицами в SwiftData 9:00

В качестве альтернативного решения проблемы с фильтрацией завершенных игр лектор предлагает не изменять модель данных, а составить более сложный предикат, который будет вычислять статус игры прямо в базе данных. Для этого можно проверить, содержит ли массив попыток игры такую, в которой комбинация штырьков (pegs) совпадает с секретным кодом (masterCode).

Код предиката принимает вид выражения с использованием метода contains(where:). Лектор обращает внимание на то, что этот запрос является очень комплексным, поскольку он затрагивает сразу несколько таблиц в базе данных — таблицу кодов и таблицу игр, связывая их между собой. SwiftData берет на себя всю сложную работу, автоматически генерируя SQL-запрос с необходимыми объединениями таблиц (joins).

Тем не менее, первая попытка запустить код снова приводит к критической ошибке: «Fatal error: Couldn't find attempts on CodeBreaker». Лектор призывает студентов всегда внимательно читать первую строку логов в консоли, так как она содержит точную причину сбоя. В данном случае ошибка произошла из-за того, что в предикате было использовано имя attempts, которое в модели являлось вычисляемым свойством, маскирующим реальное поле базы данных _attempts. После исправления имени свойства на _attempts код успешно компилируется и работает.

Чтобы определить оптимальный архитектурный подход, лектор проводит опрос среди студентов в аудитории. Он предлагает выбрать между двумя решениями:

  1. Использование сложного предиката со связями между таблицами.
  2. Превращение свойства isOver в хранимую переменную.

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

⏱️ Синхронизация времени и отслеживание автосохранений 13:40

Следующая проблема, которую разбирает преподаватель, связана с отслеживанием прошедшего времени игры (elapsedTime). В приложении реализован механизм, который запускает таймер при появлении экрана игры и останавливает его при закрытии или сворачивании приложения (используя ScenePhase). Само время начала игры намеренно не сохраняется в базу данных, так как если пользователь вернется к игре через несколько дней, счетчик не должен показывать трехдневную сессию. В базе сохраняется лишь чистая длительность игрового процесса в секундах.

Однако возникает тонкий баг: механизм автоматического сохранения SwiftData (autosave) периодически записывает состояние игры в фоновом режиме, но при этом переменная elapsedTime не успевает обновиться до актуального значения, если не произошло явных событий интерфейса вроде onDisappear. В результате в базе данных могут оказаться устаревшие показатели времени.

Для решения этой проблемы лектор переносит код логики отслеживания времени из основного представления в отдельный файл, создавая структуру ElapsedTimeTracker, реализующую протокол ViewModifier. Чтобы узнать, когда база данных собирается выполнить запись, необходимо использовать контекст модели данных @Environment(\.modelContext).

Лектор демонстрирует классический, хотя и редко используемый сейчас напрямую в SwiftUI механизм уведомлений — NotificationCenter. С его помощью создается издатель modelContextWillSavePublisher, который подписывается на событие ModelContext.willSave для конкретного контекста. У контекста модели есть два ключевых типа уведомлений:

Используя модификатор представления .onReceive(), лектор перехватывает сигналы этого издателя. Как только база данных готовится к сохранению, срабатывает замыкание, вызывающее метод модели game.updateElapsedTime(). Внутри этого метода лектор применяет простой и изящный трюк: он последовательно вызывает pauseTimer() и затем startTimer(). Поскольку функция паузы принудительно рассчитывает и фиксирует прошедшее время, это гарантирует, что в базу данных запишется самое актуальное число секунд. В ходе живой демонстрации лектор показывает логи консоли, подтверждающие, что фоновое автосохранение срабатывает примерно раз в минуту и успешно обновляет время.

🧵 Концепция многопоточности и плавность интерфейса 23:00

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

В интерфейсе мобильного приложения недопустимо зависание или замерзание UI даже на половину секунды. Если экран перестает реагировать на касания, у пользователя мгновенно возникает желание удалить это приложение.

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

Решением этой проблемы является использование потоков выполнения (threads). Они позволяют различным частям кода выполняться параллельно. Будет ли это происходить физически одновременно, зависит от архитектуры процессора: на многоядерных системах потоки могут работать параллельно на разных ядрах, а на одноядерных чипах операционная система применяет квантование времени (time slicing), поочередно выделяя ресурсы процессора каждому потоку. Для разработчика это выглядит как одновременное выполнение.

Главная сложность многопоточного программирования заключается в том, что человеку крайне трудно мыслить темпорально (во времени), контролируя параллельные процессы. Когда множество потоков одновременно работают с одними и теми же структурами данных, возникает состояние гонки (data races), приводящее к порче данных. Например, если два потока одновременно попытаются добавить элемент в один и тот же массив, структура данных может быть полностью разрушена.

В современном языке Swift разработчикам запрещено вручную создавать потоки или управлять ими. Вместо этого всю низкоуровневую работу берет на себя среда выполнения (runtime). Задача программиста — помочь компилятору понять, какие части кода могут быть безопасно изолированы или запущены параллельно. В этом и заключается суть модели конкурентности Swift Concurrency.

🎭 Акторы и изоляция данных в Swift 28:19

Фундаментальным инструментом защиты от гонок данных в Swift являются акторы (actors). Актор представляет собой ссылочный тип данных (reference type), во многом похожий на класс. Главное отличие заключается в том, что акторы не поддерживают наследование, а их ключевая суперсила — автоматическая синхронизация доступа ко всем своим внутренним переменным и функциям из разных потоков.

Если в приложении не используется многопоточность, разработчику не требуется знать про акторы. Например, в базовой версии CodeBreaker до этого момента акторы не применялись. Однако при работе с сетью или фоновыми задачами они становятся незаменимы.

Синхронизация внутри актора строится на двух незыблемых правилах:

  1. В один момент времени внутри актора может выполняться только одна функция или осуществляться доступ только к одной переменной. Даже если тысячи потоков попытаются вызвать методы актора одновременно, среда выполнения выстроит их в строгую очередь (сериализует доступ). Это полностью исключает ситуацию, когда два потока портят общие данные.
  2. Любая функция внутри актора гарантированно выполняется до своего победного конца либо до тех пор, пока не достигнет четко обозначенной точки приостановки (suspension point).

Понятие предсказуемости критически важно. Разработчик должен быть уверен, что пока функция работает с массивом или словарем, никакой другой поток не переключит контекст и не изменит эти данные посередине выполнения алгоритма. Код внутри актора называется изолированным (isolated).

Напротив, обычные классы и модели, помеченные макросом @Observable, по умолчанию являются неизолированными (nonisolated). Они не имеют встроенной защиты, и их нельзя напрямую использовать в тяжелых фоновых многопоточных вычислениях. Ключевое слово nonisolated можно использовать и внутри актора, если программисту требуется намеренно вывести конкретную функцию из-под ограничений очереди актора.

Пользовательский интерфейс приложения также жестко изолирован. Весь UI, включая вызовы функций body, управление состояниями @State и перерисовку экранов, работает на специальном глобальном акторе, который называется MainActor. Это гарантирует, что элементы интерфейса никогда не будут модифицироваться параллельно из разных потоков.

⏳ Асинхронный контекст: Async и Await 34:04

Для обеспечения непрерывной работы приложения функции актора не должны блокировать поток, если они выполняют длительную операцию (например, сетевой запрос). Вместо блокировки функция должна заявить о возможности своей приостановки, уступив очередь другим задачам актора. Для этого используется ключевое слово async, которое ставится сразу после сигнатуры функции.

Маркировка async служит официальным предупреждением для всей системы о том, что данная функция является потенциальной точкой приостановки (хотя физически приостановка может и не произойти в зависимости от логических ветвлений). Если функция не помечена как async, она считается синхронной, выполняется строго до конца и не имеет права вызывать внутри себя другие блокирующие операции.

При вызове любой async-функции программист обязан поставить перед ней ключевое слово await. Это своего рода аналог ключевого слова try при обработке ошибок. Слово await наглядно демонстрирует разработчику и компилятору: «В этом месте выполнение кода может быть заморожено на неопределенное время, пока мы ждем фоновый процесс». Кроме того, await обязательно используется при вызове методов других акторов, поскольку вызывающий код должен дождаться, пока тот актор освободится от текущих задач.

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

  1. Находиться внутри функции, которая сама объявлена как async.
  2. Обернуть вызываемый код в блок специальной структуры Task.

Лектор акцентирует внимание на опасности, о которой часто забывают программисты: во время приостановки функции по ключевому слову await управление передается другим методам актора, и они могут полностью изменить состояние приложения. Приостановка может длиться от доли секунды до нескольких минут (например, при плохом интернет-соединении). Когда функция возобновляет свою работу (resume), окружающий мир может стать совсем не таким, каким он был до await. Сохраненные индексы массивов могут указывать на несуществующие элементы, а кэшированные данные — устареть, поэтому после точки await необходимо заново валидировать состояние данных.

📦 Протокол Sendable и безопасная передача данных 40:50

Даже если акторы безупречно защищают свои внутренние данные, возникает вопрос: что происходит, когда один актор передает объект в качестве аргумента функции другому актору? Если этот объект можно изменять из обоих мест, защита рушится, и возникает риск гонки данных. Для контроля таких ситуаций в Swift Concurrency введен протокол Sendable.

Sendable является маркерным протоколом (marker protocol). Он не содержит никаких методов или свойств, а лишь сигнализирует компилятору, что данный тип данных можно безопасно передавать через границы изоляции акторов, не опасаясь разрушения структуры данных. Разработчик не реализует этот протокол, а лишь помечает им типы, удовлетворяющие критериям безопасности.

Лектор классифицирует различные типы данных с точки зрения их соответствия Sendable:

🚀 Инструменты Task и встроенные модификаторы SwiftUI 44:55

Для создания асинхронного контекста «на лету» в Swift используется блок Task. Его синтаксис включает указание приоритета выполнения (например, высокий приоритет для задач, влияющих на восприятие UI, или низкий фоновый приоритет) и замыкание с кодом. Вызов Task возвращает управление вызывающему потоку мгновенно и без задержек, а переданное замыкание запускается в неопределенный момент в будущем (это может занять как доли миллисекунды, так и секунды).

Объект задачи, возвращаемый конструкцией Task, можно использовать для отмены фоновой операции, если в ней отпала необходимость, или для получения возвращаемого значения. Важная особенность стандартного блока Task заключается в том, что он автоматически наследует изоляцию того актора, в контексте которого был вызван. Если запустить Task из кода пользовательского интерфейса, замыкание останется изолированным на главном потоке MainActor.

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

🛠️ Практический анализ файла words.swift и Swift 6 Mode 54:10

Во второй половине занятия лектор возвращается к практике и разбирает устройство файла words.swift, который используется студентами в домашних заданиях для скачивания словаря с сайта курса CS193p. В этом файле отчетливо видна конструкция for await word in url.lines.

Свойство lines, вызываемое у структуры URL, возвращает объект типа AsyncSequence (асинхронная последовательность). Этот инструмент позволяет порционно, по одной строке, читать текстовые данные, поступающие из сети, не дожидаясь скачивания всего огромного файла целиком. На каждой итерации цикла ключевое слово await сигнализирует о потенциальной приостановке: поток может замереть, ожидая прихода очередного пакета данных по протоколу HTTP. Асинхронный контекст здесь обеспечивается внешней оберткой Task.

Лектор демонстрирует эксперимент: он выносит код чтения строк в отдельную приватную функцию load(from:). Компилятор немедленно выдает ошибку, поскольку внутри функции используется await, но сама функция не объявлена как асинхронная. Проблема решается добавлением ключевого слова async в заголовок функции. Теперь ошибка перемещается на уровень выше — в место вызова функции load, где компилятор требует явно прописать await.

Далее лектор показывает еще более абсурдный, но поучительный пример. Он создает экземпляр View-компонента MatchMarkers и пытается прочитать его свойство body из неизолированного контекста фонового потока. Проект отказывается компилироваться, требуя поставить await перед обращением к body. Это происходит потому, что протокол View жестко привязан к MainActor. Фоновый поток обязан вежливо дождаться, пока главный поток освободится, чтобы безопасно прочитать значение этого вычисляемого свойства.

Изначально данный код в проекте не является строго потокобезопасным. В нем объявлен статический словарь shared, к которому теоретически могут одновременно обращаться разные потоки для чтения и записи, что способно разрушить структуру данных приложения. Однако компилятор молчит, поскольку в настройках проекта выставлен минимальный уровень проверки конкурентности.

Лектор заходит в настройки таргета Xcode и переключает параметр Strict Concurrency Checking с режима Minimal на режим Complete (этот режим является базовым для стандарта Swift 6). Проект мгновенно заполняется предупреждениями и ошибками компиляции, поскольку статический анализатор выявляет потенциальные риски состояния гонки данных.

Чтобы исправить ошибки и сделать код валидным для Swift 6, лектор применяет несколько макросов:

В завершение лекции преподаватель напоминает, что близится срок сдачи пятого домашнего задания, и призывает студентов не откладывать работу над финальными трехнедельными проектами. Он сравнивает программирование с разгадыванием кроссвордов: часто решение сложной проблемы приходит на следующий день после хорошего сна, так как подсознание продолжает анализировать задачу фоном. Лектор разрешает использовать искусственный интеллект для обучения при условии обязательного указания авторства в коде, и рекомендует студентам выходить за рамки лекционного материала, самостоятельно осваивая такие технологии, как Apple Maps и Swift Charts.

💬 Цитаты

«В интерфейсе мобильного приложения недопустимо зависание или замерзание UI даже на половину секунды. Если экран перестает реагировать на касания, у пользователя мгновенно возникает желание удалить это приложение.»

Преподаватель курса 23:15

«Макрос `#Predicate` преобразуется в SQL-запрос, который выполняется непосредственно на стороне базы данных. База данных не имеет доступа к коду на Swift и «не видит» вычисляемые свойства.»

Преподаватель курса 5:46
👥 Спикер
📖 Термины
Swift Concurrency
Встроенная в язык Swift модель для написания безопасного и читаемого многопоточного кода с использованием async/await.
Actor (Актор)
Ссылочный тип данных в Swift, обеспечивающий автоматическую защиту и синхронизацию доступа к своему внутреннему состоянию из разных потоков.
MainActor
Глобальный актор, на котором выполняются все задачи по отрисовке пользовательского интерфейса приложения.
Data Race (Гонка данных)
Ситуация в многопоточном программировании, когда два или более потока одновременно пытаются получить доступ к одной ячейке памяти, и как минимум один из них выполняет запись.
Sendable
Маркерный протокол в Swift, указывающий, что экземпляр данного типа может быть безопасно передан в другой поток выполнения.
📊 Цифры
⚖️ Другая сторона
Технологии и IT Swift Concurrency MainActor SwiftData SwiftUI