Сразу скажу: я не программист
Вообще. Код на работе не пишу, пет-проектов на гитхабе нет, да и SwiftUI изнутри не знаю совсем. Просто захотелось понять, каково это — делать софт сейчас, когда под рукой есть ИИ. Тему взял такую, чтоб самому не скучно было: здоровье собственного Mac. Так и вышел MacDashboard — нативное приложение под macOS в одно окно, без внешних зависимостей и с лицензией MIT.
И это не пост в духе «смотрите какой я молодец». Это честный разбор, как оно писалось. Он про то, как я оркестрировал нескольких ИИ-агентов, про инструмент для скриншотов UI без окна, и про историю с CPU, где самое интересное, что оптимизировать в итоге оказалось нечего.
Одно число напишу сразу, потому что мимо него не пройти. Да, в какой-то момент мой скролл по CPU оказался на уровне, а то и ниже штатного Finder на одном и том же Mac. Нет, это не значит, что я написал SwiftUI лучше, чем Apple пишет AppKit, ха-ха. А почему — будет в главе про скролл, и это самая честная часть текста.
С чего все началось: Python, браузер и легкий стыд
Первая версия «дашборда» приложением не была вообще, это была пачка скриптов: один собирал отчет о системе в текстовый файл, второй рендерил его в статический HTML, а третий раздавал живые метрики в браузер через SSE. Да, это работало, но ощущалось дико: браузер плюс локальный сервер плюс питон, и все это ради пары графиков про мой же Mac.
Поэтому я решил переписать все с нуля в нативный SwiftUI: одно окно, ноль зависимостей, но главное, что в фоне ничего лишнего. И тут вылезла первая приятная деталь: собирать это можно без Xcode. build_app.sh работает на голых Command Line Tools — swiftc --triple по каждой архитектуре, lipo сшивает universal-бинарь, ad-hoc codesign подписывает. Полный Xcode на 12 гигов не нужен.
Обратная сторона честно написана в README: так как подпись ad-hoc и без нотаризации, скачанную копию при первом запуске блокирует Gatekeeper. Снимается это один раз командой xattr -dr com.apple.quarantine или через System Settings, «Open Anyway». Правый клик «Открыть» на macOS новее 15 уже не спасает, кстати. А если собрал из исходников сам, то и карантина нет вообще.
Как это вообще писалось: «Матрешка» из ИИ-агентов
Вот тут самое интересное, и об этом, насколько я могу судить, обычно молчат. Я не сидел в одном-единственном чате с фразой «напиши мне приложение и чтоб летало и красиво было». Работа шла через оркестрацию: главная сессия держит архитектуру и общается со мной, а само исполнение уходит на самый дешевый уровень, который справится без проблем, но не сожрет все лимиты.
-
легкая модель (Haiku) — механические правки по точной спеке, boilerplate
-
средняя (Sonnet) — обычная реализация
-
она же на высоком «усилии» — конкурентность, хитрые места, отладка
-
отдельные роли:
debuggerищет первопричину,reviewerсмотрит дифф против спеки и сам ничего не правит,testerсобирает, гоняет тесты, запускает и меряет.
Ну и венец творения Anthropic, модель Fable, работала только на планировании: писала спеки настолько подробные, что исполнителю не остается проектных решений: точные файлы, контракты, крайние случаи и команды проверки. Она самая дорогая, так что после готовой спеки уходит за кулисы.
Поверх этого всего дисциплина, которую я в шутку зову zero-context. Есть PLAN.md, единственный источник правды по текущему блоку. Готовые блоки уезжают в HISTORY.md, который моделью никогда не перечитывается — это экономит контекст, читай деньги. А блок закрыт только после свежей пересборки и моего явного «ок» после того, как я глазами прошелся по живому приложению. Зеленые тесты агентов для меня — условие необходимое, но не достаточное.
Забегая вперед: в цикле даже и сторонние модели оказывались. План одного из заходов по скроллу пришел от Grok, а способ ключевой проверки (об этом я расскажу ниже) подсказала модель посильнее. Такая честная многомодельная кухня.
И да, все это на личной подписке Claude Pro. Лимиты я жег регулярно, можно сказать, жил от лимита до лимита.
Инструмент, которым я «фоткал» невидимые экраны
Быстро выяснилось, что у приложения есть состояния, которые просто адски больно воспроизводить руками: ошибка чтения SMART у внешнего диска, живой прогресс brew upgrade, баннеры ошибок, спиннеры «занято». Каждый раз подгонять систему под нужное состояние казалось безумием.
Поэтому родился маленький инструмент, headless render harness. Он рендерит SwiftUI-вью в PNG оффскрин, вообще без окна. Пишешь короткий сценарий строк на двадцать, инжектишь в модель нужное состояние и получаешь картинку.
Внутри ни ImageRenderer, ни настоящего NSWindow, а связка AppKit:
let hosting = NSHostingView(rootView: rootView)hosting.frame = CGRect(origin: .zero, size: hosting.fittingSize)hosting.layoutSubtreeIfNeeded()let rep = hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)!rep.size = hosting.bounds.sizehosting.cacheDisplay(in: hosting.bounds, to: rep)let png = rep.representation(using: .png, properties: [:])
Скрипт компилирует сценарий как main.swift вместе со всеми исходниками (минус @main-точку входа) через swiftc. А дальше я несколько раз поймал грабли лбом и записал каждую прямо рядом с инструментом, чтобы не переучиваться каждый раз. Вот это, наверное, самое полезное для разработчика:
-
NavigationSplitViewрендерится пустой белой панелью. Сайдбару сListнужно настоящее окно и appearance-контекст, которых у оффскрин-рендера нет. Detail-панель при этом рисуется нормально. Значит сценарии скоупим на отдельные карточки, а сайдбары проверяем в живом приложении. -
Язык надо пиннить ПЕРВЫМ.
L10nStore.shared.language = .ru— самый первый оператор, идет до создания модели и вью. Иначе EN-first системная локаль отрендерит английский, и ты будешь долго тереть глаза. -
Все приложение
@MainActor, а верхmain.swift— нет. Поэтому тело сценария заворачивается вMainActor.assumeIsolated { … }. Без этого просто-напросто не собирается. -
По мелочи: имя файла обязано быть
main.swift; не линковать-framework Observation(это SDK-модуль Swift, а не фреймворк, линковка падает); в harness никогда не звать методы, которые запускают работу (start(),refreshReport()) — состояние инжектится прямо в поля.
Ничего революционного, но это именно тот класс знаний, который нигде не гуглится и добывается только через боль.
CPU-детектив, часть 1: анимация, которая жрала процессор (сильно)
Теперь про производительность. И главный поворот в том, что «злодеем» оказалась не тяжелая логика, а декоративные бесконечные анимации.
Улика раз (блок P). Диагностический агент с замерами (sample, счетчики спавнов top на переходах состояний) нашел, что радужная рамка-акцент RainbowBorder в .onAppear запускала withAnimation(.repeatForever) безусловно и навсегда, независимо от того, активно вообще окно или нет. Итог — постоянный фон ~5-6% CPU во всех состояниях, включая свернутое и скрытое окно. sample показывал анимацию доминирующим кадром, даже когда коллекторы уже стояли на паузе. Починка: завязать анимацию на активность окна, а бегущий repeatForever гасить транзакцией disablesAnimations = true (простое переприсваивание его надежно не отцепляет, это отдельная маленькая грабля SwiftUI). Плюс расширить сам детектор паузы, чтоб ловил еще и «окно закрыто, но приложение фронтовое», и «свернуто».
Улика два (блок PERF). Через день idle CPU снова пополз вверх, ~27% против базовой линии в ~5.1%. Изоляционные тесты по каждому месту с repeatForever указали почти целиком (~16 процентных пунктов из ~18-23, то есть почти 90% всего разрыва) на один источник: «дышащий» бейдж-предупреждение об осиротевших элементах автозапуска в AutostartCard.swift. Снова repeatForever, снова безусловный. Починка тут: перевести его на конечный repeatCount(5, autoreverses: true) (секунд десять, заканчивается на ярком состоянии, которое и так статически флажит проблему) и гейтить по видимости окна.
Результат замерил tester: средний CPU и во фронтовом простое, и под окклюзией — 0.01%. Падение примерно в 2000 раз. Это, если что, единственное число из всей статьи, которое я считаю железобетонным, комментарий с ним до сих пор живет в коде на AutostartCard.swift:70.
Мораль сей басни простая и полезная: любая repeatForever-анимация — тихий вампир. Она заставляет NSHostingView перекладываться каждый кадр вечно. Гейтите по активности окна и предпочитайте конечные repeatCount.
CPU-детектив, часть 2: как я не смог ускорить скролл (и почему это даже хорошо)
А вот теперь мое любимое: после 50-минутного стресс-теста я заметил: CPU при скролле вкладки «Обзор» скачет выше 20%. Ну непорядок же (в тот момент я подумал, что все к чертям сломал). Ну ладно, открыли отдельный блок. Дальше пять подходов, и спойлер: каждая гипотеза о причине оказалась ложной, а блок закрыт как «дефект не найден».
-
Подход 1. Тоггл
.allowsHitTesting(false)во время скролла — пустышка. Он гейтит AppKit-хиттест кликов, а не hover-диспетчеризацию SwiftUI. -
Подход 2. Заморозка кадра через
ImageRenderer. Доля упала почти в ноль, но я это отклонил: белые плитки в темной теме, курсор «not-allowed», реальный лаг ввода в начале жеста. Лечить симптом хуже болезни. -
Подход 3. Свести per-row
.onHoverв per-cardonContinuousHover(число респондеров 85-300 упало до 13-17). Реализация чистая, ревью APPROVE, а замер — плоско, без улучшения. Чтение call-tree: ~51% стоимости это обход дереваNSView.hitTest:вообще вне responder-системы SwiftUI. -
Подход 4 (диагностика). lldb показал, что живое дерево
NSViewкрошечное: 53 вью, глубина 11. Рычаг «упростить иерархию» мертв. Парныйsample(курсор над контентом против курсора вне окна) дал идентичные доли — стоимость не зависит от курсора. И, главное, я перестал смотреть на относительную долю и померил абсолютный CPU черезtop -pid: активный скролл это устойчивые 28-33% одного ядра, спад до нуля за секунду после остановки. Реально!
Дальше я поднял планку до «максимально нативно, чтоб не было стыдно» — и подход 5 пошел вширь.
-
Перевод строк процессов на
NSTableViewплюсNSTrackingArea— идентично базе. FAIL. -
Абляция до нуля. Я физически закомментировал каждый
.onHoverи.hoverTipна вкладке (проверил grep-ом иsample, где счетчик hover-апдейтов упал в 0). Скролл-CPU держался 35-41%, так же или чуть выше. Это доказало, что hover вообще никогда не был драйвером. А в верхушке стекаsampleдоминировала общая машинерия render-графа SwiftUI, AttributeGraph:AG::Graph::UpdateStack::update,AG::Graph::propagate_dirty,AGGraphGetValue. Одного «виноватого» кадра нет, стоимость размазана. -
Ядерный вариант. Заменить внешний SwiftUI
ScrollViewцеликом на тру AppKitNSScrollViewплюсNSHostingView, контент оставив как есть. Собралось с первого раза, проскроллилось идеально. Замер: база 24.5% против POC 25.4%, елки-палки, идентично. Гипотеза про контейнер опровергнута. Задним числом очевидно: SwiftUIScrollViewна macOS сам сделан поверхNSScrollViewиNSClipView. Я поменял контейнер на его же реализацию.
И вот тут я задумался: а не врет ли сама методика? Как это проверить — прогнать тем же синтетическим драйвером заведомо нативное приложение — подсказала модель посильнее. Я направил тот же драйвер инъекции событий на нативный Finder (кровь от крови AppKit NSTableView, ноль SwiftUI). Finder намерил медиану ~39.9%, выше моего приложения. А решающий ручной тест реальным трекпадом на обоих приложениях одной и той же командой захвата: MacDashboard ~32.3% медиана, Finder ~43.0%.
Вывод: специфичного для приложения, исправимого дефекта скролла в SwiftUI не существует. Каждый рычаг проверен и опровергнут в пух и прах, других механизмов в приложении нет, а на реальном вводе мой скролл по CPU на уровне или ниже полностью нативного AppKit-списка. Блок закрыт, кода не меняли.
И вот обещанная честность. Почему я тогда «не хуже Finder»? Не потому что SwiftUI победил AppKit, а потому что read-only дашборд из в основном статичных карточек делает на кадр гораздо меньше работы, чем живой файловый браузер, который рисует превью, иконки там, метаданные и QuickLook. Плюс стоимость скролла в SwiftUI, хоть и заметна, но не патологична. Вот и все: меньше работы на кадр и отсутствие патологии, и никакой магии.
Три метода, которые я унес из этой истории и рекомендую всем:
-
Абсолютный CPU важнее относительной доли в профайлере. Доля говорит «где», но не «сколько» и не «почему».
-
Физическая абляция честнее флага. Не «выключить hover настройкой», а вырезать целиком и посмотреть. Одно измерение закрыло целую ветку гипотез.
-
Эталонный контроль ловит вранье методики. Прежде чем гордиться числом, прогони той же линейкой заведомо нативное приложение.
Выложил в паблик, и CI сразу нашел то, что мой Mac не видел
Отдельная волна работы — это сделать репозиторий публичным и не сесть в лужу по приватности. ИИ-ассистент спрятан за compile-time флагом (в дефолтном бинаре нет ни сетевых символов, ни строк провайдеров — проверено), из тестовых фикстур вычищены реальные серийники и hostname, а я лично sudo lsof-ом убедился, что за сбор отчета приложение не открывает ни одного сетевого соединения. Историю из 74 коммитов я сначала заархивировал в отдельный приватный репозиторий, потом схлопнул публичный в один чистый коммит v1.0 «Cadia».
А дальше мой любимый урок. Первый же прогон CI на раннере GitHub macos-14-arm64 вскрыл то, что мой локальный тулчейн (Swift 6.3.3) пропускал присвистывая: ~36 ошибок actor-isolation. Лечилось точечными @MainActor по 12 файлам плюс один [weak self]. Еще CI поймал пару выражений в фикстурах, взрывавших бюджет тайпчекера, и несколько проверок, которые молча предполагали именно мой MacBook (наличие батареи, число ядер и атрибут SMART). Их сделали портируемыми.
Вывод, который я, не-программист, теперь считаю очевидным: «собирается и работает у меня» — это ну вот вообще не тест. CI на чужой машине это отдельная, очень отрезвляющая проверка.
И еще деталь, уже про раздачу. Готовый .app собирает отдельный релизный workflow и прикладывает к релизу. Но собрать его можно не на любом раннере: на Tahoe система Liquid Glass рендерит иконки и материалы в зависимости от мажорной версии SDK, поэтому workflow прибит к macos-latest с жестким ассертом sdk 26.*. Если GitHub однажды переедет на macOS 27, сборка релиза упадет. Причем намеренно и громко, чтоб я не отдал людям бинарь, который выглядит не так, как моя локальная сборка.
А сколько бы это стоило, если бы я платил за токены
Этот кусок я раскопал уже сильно после, когда приложение было готово. Напомню, что все делалось на личной подписке: я плачу фиксированную сумму в месяц и упираюсь в лимиты, а не в счет. Но мне стало любопытно, во что тот же самый объем работы обошелся бы по прайсу API, если платить за каждый токен. Транскрипты всех сессий лежат у меня локально в .jsonl, так что вопрос оказался чисто арифметическим.
Ответ: около 557$ за проект целиком, из них примерно 491 — основная фаза с 12 по 20 июля, от переписывания на SwiftUI до выкладывания в паблик. Это 45 сессий и 7 тысяч вызовов API. Средняя цена одного вызова 7,5 цента, средняя цена активного дня разработки около 62 долларов. Самый дорогой день — 17 июля, вышел в 129 баксов.
Дальше пошло интереснее, потому что по дороге я дважды чуть на порядок не ошибся.
Первая ловушка — а что вообще считать одним вызовом. Один ответ модели пишется в лог несколькими строками, по одной на блок контента: отдельно thinking, отдельно text, отдельно каждый tool_use. И во всех этих строках продублирован один и тот же объект usage. В среднем получается две строки на вызов. Если тупо просуммировать строки, счет удваивается. Правильно — группировать по requestId и брать запись с максимальным output_tokens. Кстати, встроенная сводная статистика (/usage, вкладка Overview) суммирует как раз построчно, поэтому завышает объем ровно в 2,11 раза. Число «10,4 миллиона токенов» на экране — это примерно 4,9 миллиона реальных.
Ловушка вторая, и вот она правда меняет картину мира. Смотрим, из чего вообще состоит трафик:
|
Тип токенов |
Доля |
|---|---|
|
Input, не из кэша |
0,04% |
|
Output, то есть ответы модели |
1,0% |
|
Запись в кэш |
3,6% |
|
Чтение из кэша |
95,3% |
То есть собственно вопросы и ответы — это один процент объема, а все остальное это prompt caching: каждый следующий запрос агента заново прочитывает весь предыдущий контекст сессии. Чтений из кэша в 96 раз больше, чем выданных моделью токенов, средний контекст на вызов — 72 тысячи токенов. И платится это все реальными деньгами: кэш дает 76% счета, а на сами ответы модели приходится меньше четверти.
Только вывод отсюда ровно обратный тому, который просится. Если бы кэша не было вовсе и каждый повторно прочитанный токен шел по обычной цене input, счет был бы 2737$. Кэш не раздул счет, он срезал его впятеро — круто.
И последнее, что меня порадовало отдельно. Помните матрешку из первой половины статьи и правило «Fable только на планировании»? Так вот, Fable сделала 15% вызовов и дала 44% счета: у нее 10$ за миллион входных токенов и 50$ за миллион выходных против 3$ и 15$ у Sonnet соответственно. Если пересчитать тот же самый трафик так, будто всю работу от начала до конца вела одна Fable, выходит около 1080$. То есть вдвое дороже. Правило я вводил из соображений качества, чтоб исполнителю не оставалось проектных решений, а по деньгам оно заодно срезало счет в два раза. Субагенты, если что, стоили 29% счета: у каждого свой контекст и свои вызовы.
Формулу я, само собой, не на глазок брал: она сверена с двумя официальными экранами /usage по конкретным сессиям, расхождение 0,1%. Если кому-то интересна методика подробно, спрашивайте в комментариях, я распишу. А вообще, интересно послушать, как еще можно порезать косты, тоже буду рад вашим сообщениям.
Что дальше
Приложение живое, я продолжаю его потихоньку пилить. Теперь его можно и просто скачать готовым — universal .app лежит в релизах. Правда, пока без нотаризации: платную Apple Developer лицензию, как и более крутой тариф Claude, я пока не потяну, так что скачанная копия попросит разовый шаг с Gatekeeper. Если возиться совсем не хочется, можно собирать из исходников, там карантина нет вовсе. Код под MIT, лежит на гитхабе, багрепорты и идеи очень даже приветствую (в шаблоне issue есть напоминание, что безопасно прикладывать, а что нет).
Если из всего текста и уносить одну мысль, то пусть это будет не «ИИ написал приложение», а вот что: самой ценной оказалась не строчка кода, а дисциплина измерений. Абсолютные числа, физическая абляция и эталонный контроль. Их, в отличие от синтаксиса SwiftUI, стоит знать даже тем, кто, как я, программистом себя отнюдь не считает.
ссылка на оригинал статьи https://habr.com/ru/articles/1063412/