Стендап Сьогодні
📢 Канал в Telegram @stendap_sogodni
🦣 @stendap_sogodni@shevtsov.me в Федиверсі

🤖🚫 AI-free content. This post is 100% written by a human, as is everything on my blog. Enjoy!

Пости з тегом #Swift

10.06.2024

Модель рівночасності у Swift

🐇🐢 Рівночасність — це не тільки для серверних застосунків з тисячами паралельних запитів. В розробці застосунків з GUI рівночасність теж має критичну важливість; якщо все робити в одному потоці, довготривалі задачі будуть помітно гальмувати інтерфейс. Тому у Swift нікуди не дітися без розуміння моделі рівночасності, яка тут ускладнена історичною спадщиною та шарами переосмислювань. Сьогодні як раз починається WWDC, де обіцяють ще нововведення, тому саме час розібратися з тим, що є.

📨 Попри те, що, як і всюди, у Swift є потоки, в коді застосунків використовують не їх, а модель задач — фрагментів синхронного коду, який виконується асинхронно на чергах. Черги є послідовні або паралельні; з різними пріоритетами, замками та семафорами тощо. Ця система планування називається Grand Central Dispatch та існує ще з 2009 року, тобто ще до Swift. Як і з запитами HTTP, логіка виконання задач перекладається на операційну систему. Одна з черг є “головною” та обслуговує UI; от з неї ми й хочемо прибирати важку роботу.

🚦 Проблемою є питання власності та спільного доступу; код з різних черг повинен синхронізувати доступи до даних. Найпопулярнішим способом того є перенесення коду на іншу чергу, наприклад на головну: DispatchQueue.main.async { ... }. Але в цілому керування доступом потребувало уважної перевірки вручну.

🎭 Щоб спростити життя, ввели модель акторів — це сучасне бачення рівночасності у Swift. Актори мають ізольований стан, тобто до нього можна звертатись тільки через async/await, та такі звернення будуть послідовними, тобто можна бути впевненим у цілісності даних.

🍱 Сенс існування актора: ми відокремлюємо частину коду (та його дані) за обмеженим інтерфейсом та кажемо: “Цей код ізольований від іншого коду та його можна виконувати паралельно з іншим.” Коли навіть в айфоні вже є 6 ядер, це важлива відзнака. Ось така незвична мені, “декларативна” модель рівночасності є у Swift.


11.06.2024

Ізоляція даних у Swift

Зміни у Swift 6 дійсно доторкнулись саме рівночасності. Якщо коротко, то гарантії ізоляції тепер будуть примусово встановлені ще компілятором.

Кожне значення в програмі отримує домен ізоляції. Це або конкретний актор, або глобальний актор, або неізольованість. Важливо, що неізольовані значення не значить вільні для використання; як тільки ми вступаємо в домен актору, то втрачаємо прямий доступ до неізольованого (часто спадкового) коду — доступ залишається тільки через await.

Особливо цікаво, в новому SwiftUI всі компоненти автоматично належать @MainActor. (MainActor - це абстракція для коду, який виконується на головному потоці; але не всього коду, а який добровільно входить в модель акторів.) Це значить, що в типовому застосунку SwiftUI ми не можемо більше ігнорувати питання ізоляції, що дуже добре.

До речі, офіційне рішення для простих випадків: долучати більше коду до @MainActor; єдине що зміниться, це домен ізоляції. Проте якщо весь код у @MainActor, то ми приходимо до рівня JavaScript: все виконання відбувається в одному потоці, обчислення гальмують інтерфейс.

Тому нам і потрібні додаткові актори, щоб прибрати повільні обчислення з головного потоку до підсистем — наприклад, завантаження з інтернету, чи перекодування GIF у MP4, як я зараз роблю. Приємно, що компілятор Swift 6 буде автоматично гарантувати ізоляцію таких модулів, та нам достатньо буде виправити помилки. Зазначу, що поки не весь SDK готовий до такої радикальної перевірки всюди. А тому я поки не можу у власних проєктах досягти чистоти ізоляції. Втім, до офіційного виходу Swift 6 ще місяці три.


12.06.2024

Кооперативна рівночасність

З цього відео зрозумів фундаментально важливий аспект рівночасності у Swift.

Рівночасність з Grand Central Dispatch була витискальною. Це стандартна модель рівночасності, де операційна система планує потоки та перемикає виконання як їй зручно. На ній побудовані всі сучасні ОС, та рівночасні аспекти більшості мов є абстракцією над потоками операційної системи.

Витискальна рівночасність чудово працює, поки потоків не так багато, та вони незалежні один від одного. Втім, з приходом рівночасного програмування в маси зʼявилися нові потреби. Що, якщо ми хочемо завантажити 1000 файлів, та в кожного з них буде рівночасний обробник? Робити 1000 потоків? Всі, хто так робив, швидко дізнаються, що у кількості потоків є технічні обмеження, та варто користуватися пулом, щоб на 1000 задач було лише декілька потоків (дехто каже, стільки, скільки ядер в процесора.)

Але пул потоків — то суто технічна необхідність, ускладнення програми. Тому деякі мови програмування вносять пул потоків у власну платформу та надають нам абстракції вищого рівня — це називається кооперативна рівночасність. Кооперативною, бо планувальник тепер знаходиться на рівні мови, та здатний перемикати задачі згідно з мовними конструкціями. Наприклад, з гарантією, що перемикання не відбудеться посередині синхронного коду. Або, що актор ніколи не матиме дві паралельні задачі.

От як раз у Swift async/await це механізм кооперативної рівночасності. Всі задачі з async/await виконуються на пулі потоків, та перемикання між ними відбувається лише там, де є ключове слово await. Ба більше, перехід async/await мало відрізняється від звичайного виклику функції — тільки тим, що стек замінюється новим. Та якщо синхронізація готова відбутися в момент виклику — то виконання просто продовжиться, без жодних пауз.

Це дуже крута та сучасна система. Сходу не знаю інших мов, де async/await може прямо передавати контроль (пишіть, якщо знаєте.) Наприклад, в JavaScript кожний await повертає нас у цикл подій; в Golang синхронізація через канали ніби завжди блокує одну сторону, бо це не виклик функції; в Ruby з ракторами так само.


21.09.2024

Оновлення до Swift 6 та його моделі рівночасності

Оновлював свої застосунки на Swift до свіжого Swift 6. Це вимагає дотримання моделі рівночасності. Досвід був цікавий. Простий застосунок майже не потребує змін. Але деякі функції вимагають переписування, навіть якщо в моєму коді немає акторів.

Мій застосунок з HealthKit був найбільш неприємним. HealthKit отримав новий набір API з асинхронністю, що чудово. От тільки старі API викликають непрозору помилку в асемблерному коді та ще й без стека. Суть помилки, як я зрештою зрозумів — що колбек був викликаний не в очікуваній “черзі”. А саме, в черзі @MainActor, а не в фоновій. Довелось переписати весь код з колбеками на API з async, причому розшукувати його вручну. Результатом я задоволений, але досвід розробки максимально вражий.

Ще в мене є застосунок для перегляду світлин, в ньому є фонове завантаження. Тут модель акторів дійсно корисна. Вирішив для себе, що найзручніше це зробити глобальний актор для менеджера завантажень, та призначити йому всі класи, що стосуються завантажень. Тоді внутрішні операції в цій області відбуваються синхронно, а на стику з інтерфейсом, тобто @MainActor, зʼявляється асинхронність.

Взагалі ментальна модель безпеки Swift така, що кожна змінна належить одному з акторів. Тому ми думаємо не про послідовність та синхронізацію операцій, а про власність над даними. Та цікаво, що власність не залежить від розташування змінних, наприклад, посередині нашого актора може бути атрибут чи функція з власністю @MainActor.

Та другий аспект — це типи Sendable - які дозволено передавати з актора в актора, або захопити в замиканнях з іншого актору. З цим доводиться помізкувати. Наприклад, мав проблеми з AVAssetWriterInput. Там API з колбеком “дай мені ще кадрів”. Цей колбек важко уявити без зовнішнього стану. Але ось проблема: якщо я викликаю метод всередині актору, то колбек все одно буде викликаний в іншій черзі — та в ньому не дозволено змінювати захоплені змінні. Знайшов цікаве рішення, де будь-яку змінну можна зробити Sendable, якщо додати до неї обгортку та замок.

Принаймні це оновлення є хорошою вправою та новим для мене способом думати про безпеку рівночасних операцій.


22.09.2024

Ще про ізоляцію даних у Swift

Ох і Swift! Такої суворої системи ізоляції я ще не бачив (ну, хіба в Rust, але там я далеко не заходив.) Не очікував такого від мови, яка для екосистеми Apple значить те ж саме, що JavaScript для браузерів — тобто це перша та головна мова, з якою стикаються всі.

Так, я звик до async/await в JavaScript. Як і до “розфарбовування функцій” - тобто обовʼязкового маркування функцій, де є await. Але якщо в JS async/await зʼявляється практично тільки там, де є ввід/вивід — то у Swift будь-яка заявка на рівночасність потребує async/await та розфарбовування.

Причому рівночасність у Swift нам потрібна, щоб робити програми, де не гальмує інтерфейс. Втім, я б радив почати з @MainActor всюди, а потім впроваджувати акторів обережно та обмежено, бо вони досипають в код несподіваних ускладнень. Наприклад:


22.12.2024

Дев-адвент 22: дрібні, але важливі виправлення

Хоч мій трекер збудований на швидкому введенні тегів в несподіваний момент — мушу зізнатися, що вже довгий час (може, з пів року чи більше) сповіщення не відпрацьовували. Тобто, вони зʼявлялися, але перехід в застосунок відбувався без відкриття форми додавання проби. Ба більше, довгий час відкриття застосунку зі сповіщення взагалі викидувало через критичну помилку. 😳

З помилкою я вже цього місяця розібрався. Це була одна з тих бісячих ситуацій у Swift, коли застосунок отримує EXC_BREAKPOINT в асемблерному коді (тобто системному, без вихідного тексту) - та піди зʼясуй, в чому справа. Виявилося, що версія метода делегату didReceive з async не працює з моделлю рівночасності. (Класична проблема з офіційними API, на жаль.) Тому, контрінтуїтивно, повернувся до старої версії — з колбеком — та це мене врятувало. (На жаль, щоб це дізнатися, довелося, як в пазлі, пробувати всі комбінації по черзі.)

Але форма все ще не відкривалася. Причому код, що її відкриває зі сповіщення, був той самий, що й зі списку — тобто проблема була десь посередині. Думав, що в бізнес-логіці — ну, може, я погано шукаю пробу, яку треба відкрити. Може, не встигаю створити (бо створюються тільки проби за минуле, тому по відкриттю на сповіщення про зараз проба створюється “just-in-time”.)

Та виявилося, ні. Це була ще одне непорозуміння життєвого циклу, цього разу — всього застосунку. Бо застосунок у SwiftUI - це нащадок структури App. А в ній, як і в компонентах, в конструкторі стан @State ще не має привʼязки. Та якщо в конструкторі створити делегат сповіщень та передати йому той стан, що є — то він не зможе редагувати справжній стан.

Розвʼязалося все просто (коли вже знаєш, як воно працює!) Переніс створення делегату до обробника onAppear - тобто з конструктора в body. І все! Форма відкривається вчасно.


01.02.2025

Робота з iCloud Drive та резервне копіювання

🛟 Сьогодні намагався (та зробив!) виконати ще одне прохання користувачів: додати резервне копіювання бази (таймтрекеру.) Функція очевидна, та ще й врятує мене, якщо раптом випущу збірку, що псує дані. Звісно, як місце для збереження копій я обрав iCloud Drive. Бо він вже доступний для всіх без зайвої авторизації. Отже.

З погляду застосунку, iCloud це майже звичайна файлова система. На iOS найскладніша на моїй памʼяті модель роботи з файлами — доступ порізаний на окремі теки, все відбувається через URL тощо. Але принаймні, коли ти вже знаєш URL контейнера, то далі все як звичайно.

А от процес налаштування заплутаний та погано задокументований. Ось стаття, з якою все нарешті вийшло. Спочатку ми оголошуємо “повсюдний контейнер” - це як скибка сховища користувача, яка буде належати нашому застосунку (та яку він буде бачити). Контейнер існує окремо від застосунку, та навіть декілька застосунків можуть ділити один контейнер.

Потім… потім воно працює з коду (ну, не на симуляторі, якщо тільки не увійти там у свій iCloud). Але в iCloud Drive файлів я не бачив. Було важко зрозуміти — що саме не так. Для iCloud Drive цей повсюдний контейнер потрібно додатково оголосити публічним та призначити йому імʼя - після чого… ні, все ще нічого не відбувається.

З вище вказаної статті збагнув, що щоб файли зʼявилися у Drive, вони повинні бути в піддиректорії Documents, а не прямо в контейнері. Тоді… ще потрібно було збільшити версію збірки, і ось нарешті тека з файлом зʼявилася і в Files на айфоні, і невдовзі на макбуці теж. 😮‍💨

Решта - справа техніки; експорт у ZIP в мене давно вже є. Трохи порефакторив, та додав виклик на відкриття застосунку: якщо з попереднього експорту минув день, створюємо ще один. Можна ще потім додати чистку старих файлів, але то менш критично - мій експорт майже за рік займає 277 Кб.


21.04.2025

Резервне копіювання для Ping

Три місяці тому писав про резервне копіювання даних мого стохастичного таймтрекера Ping. Але нарешті тільки сьогодні вивів його в бету.

На жаль, забув випустити раніше. Але ось що допомогло. Моя власна копія почала зависати на запуску. В XCode дуже легко зрозуміти. що зависає — достатньо поставити застосунок на паузу. Тому коли дістався до XCode, то відразу побачив, що висне — не сама резервна копія — а її завантаження до iCloud, метод setUbiquitous.

Як мені здається, проблема була в тому, що моя альфа-версія робила копію на кожний запуск, та їх накопичилося дві з гаком тисячі в одній директорії в iCloud. Як мені здається, така кількість файлів уповільнює роботу iCloud. Бо більше нічого не змінювалося, а сам файл розміром лише 300 Кб.

Так що довелося закінчити фічу — робити копіювання раз на день, додати вимикач та допомогу. Завдяки використанню @globalActor виніс роботу з головного потоку. Тут у Swift цікаво: async/await роблять код рівночасним, але залишають виконання в головному потоці (тобто гальмують інтерфейс застосунку.) Щоб зробити код ще й паралельним, потрібно винести його в актора.

Також зазначу. що бета-версії застосунків в Apple TestFlight згоряють за 90 днів. Тобто кожні 90 днів потрібно викладати нову версію, щоб бета-користувачі не втратили доступ. Та в мене сьогодні минуло 79! Радий, що через цю ситуацію встиг згадати та скоригувати курс.