«Баг починили, а он остался» — так это выглядело со стороны. Жалоба владельца звучала просто: «данные не обновляются, когда заходишь в приложение — надо передёргивать, чтобы обновились и погода, и всё остальное». Один симптом, одна фраза, ожидание одной правки.
MeteoHealth (карточка проекта) — приложение в App Store, и такая жалоба обычно и правда стоит одной строчки не в том месте. Я нашёл причину, исправил, тесты позеленели — и жалоба ушла. На двенадцать дней.
22 августа она вернулась почти дословно, и на этот раз за симптомом «не обновляется» обнаружились четыре независимые причины, плюс пятая, которую породил уже сам первый фикс. Ниже — не история одного бага, а история того, что делать, когда починка не держится.
Причина 1: .task живёт дольше, чем кажется#
Первая находка была почти очевидной постфактум. Экран «Сегодня» грузил данные в .task, а .task внутри TabView отрабатывает один раз за жизнь процесса: вкладки остаются смонтированными, и ни переключение табов, ни возврат из фона его не перезапускают.
scenePhase на экранах никто не слушал вообще — на уровне приложения он был занят только уведомлениями и deep-link. Комментарий в коде фиксирует это прямо:
// Повод: «данные не обновляются, когда заходишь в приложение — надо
// передёргивать». Причина была в том, что загрузка висела на `.task`, а он в
// `TabView` отрабатывает один раз за жизнь процесса: вкладки остаются
// смонтированными, и ни переключение табов, ни возврат из фона его не
// перезапускают.Фикс от 10 августа (952975c) добавил модификатор .refreshOnForeground на четыре вкладки — «Сегодня», «Прогноз», «Дневник», «Аналитика» — завязанный на переход scenePhase в .active. Это закрыло «ушёл в фон и вернулся». Не закрыло «переключился на вкладку и обратно, ни разу не уходя в фон» — то же самое .task продолжало молчать.
Эта половина причины всплыла только 22 августа, отдельным пунктом того же симптома: «возврат на вкладку не обновлял ничего: ворота срабатывали только после ухода в фон». Один механизм, два триггера, закрыты в два захода.
Причина 2: кеш без чёрного хода#
Вторая причина в том же первом фиксе: WeatherService держал жёсткий десятиминутный кеш без обхода. Даже если экран честно перезапускал загрузку, updateWeatherData() тихо отдавал те же значения из кеша — pull-to-refresh, вызванный руками, ничего не менял. Экран перерисовывался, колесо загрузки крутилось, данные оставались вчерашними — для пользователя это неотличимо от «вообще не работает».
/// - Parameter force: обойти десятиминутный кэш. Ставится там, где обновления
/// просит человек (pull-to-refresh) или возврат приложения из фона: иначе
/// «передёргивание» внутри окна кэша молча переприсваивало те же значения,
/// и данные выглядели свежими, не будучи ими.
func updateWeatherData(force: Bool = false) async {
...
let lifetime = force ? Self.forcedCacheLifetime : Self.cacheLifetime
if let entry = cache.object(forKey: cacheKey as NSString),
Date().timeIntervalSince(entry.value.timestamp) < lifetime {Параметр force не отменяет кеш целиком — его срок сокращается с десяти минут до одной, чтобы не выстрелить пачкой запросов в общий лимит API-ключа. Коалесинг параллельных вызовов через inFlightUpdate остался как был — и это оказалось важно: он же и спровоцировал причину 3.
Причина 3: force, растворившийся в толпе#
К 22 августа обе причины выше были закрыты, а жалоба вернулась. На старте приложения обновление запускают одновременно два места: MainTabView.task и TodayViewModel.loadData — и оба без force. Если в это же окно человек тянул экран руками, его принудительный запрос просто присоединялся к уже идущему обычному через inFlightUpdate и получал те же кешированные данные. Жест pull-to-refresh физически срабатывал — и не делал ровным счётом ничего.
/// Что делать с новым запросом обновления, когда один уже идёт.
///
/// На старте `MainTabView.task` и `TodayViewModel.loadData` запускают
/// обновление одновременно, и оба — без `force`; если в это окно человек тянул
/// экран, его принудительный запрос вливался в чужой обычный, тот отдавал
/// данные из десятиминутного кэша, и жест не делал ничего.
enum WeatherUpdateCoalescer {
enum Decision: Equatable {
case start
case join
case joinThenForce
}
static func decide(incomingForce: Bool, inFlightForce: Bool?) -> Decision {
guard let inFlightForce else { return .start }
return incomingForce && !inFlightForce ? .joinThenForce : .join
}
}Коалесинг сам по себе был правильной идеей — без него параллельные вызовы плодили бы дублирующие сетевые запросы. Ошибка была в том, что он не различал силу запроса: слабый и сильный проход схлопывались в один слабый результат.
Причина 4: погода ждала переезда#
Четвёртая причина жила отдельно от первых трёх, в резолвинге геолокации. При медленном GPS-фиксе resolveLocation() укладывался в восьмисекундный таймаут и выходил ни с чем — а повторной попытки не было, потому что didUpdateLocations перезапрашивает погоду только при переезде дальше 5 км. Если человек никуда не переезжал (а он обычно и не переезжал — просто открыл приложение утром на том же месте), обновление молча гасло насовсем. Экран оставался с вчерашним снимком до ручного передёргивания — жеста, который сам был съеден причиной 3.
/// Холодный старт на медленном фиксе укладывался в восьмисекундный таймаут
/// и выходил ни с чем; сама по себе попытка больше не повторялась —
/// `didUpdateLocations` перезапрашивает погоду только при переезде дальше
/// 5 км. Две попытки — потолок: если фикса нет и после них, проблема не во
/// времени.
private func scheduleLocationRetryIfNeeded(force: Bool) {
guard authorizationStatus != .denied, authorizationStatus != .restricted else { return }
guard locationRetryCount < Self.maxLocationRetries else { return }
locationRetryCount += 1
Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(Self.locationRetryDelay * 1_000_000_000))
await self?.updateWeatherData(force: force)
}
}Плюс fallback на системный кеш локации (location ?? currentLocation ?? locationManager.location) — если свежего фикса нет, последняя известная позиция для погоды обычно достаточна.
Четыре причины — и все закрыты. Оставался пятый слой, который эти четыре не считали, потому что его завёл сам первый фикс.
Причина 5: фикс, который обогнал сам себя#
Первый фикс (952975c) сам внёс отдельный баг, найденный уже на следующий день при живой проверке на симуляторе. Гейт RefreshOnForegroundGate считал троттлинг «по времени последнего обновления» — и сцена, ставшая .active сразу после холодного старта, устраивала вторую загрузку экрана вдогонку первой. В логах это выглядело так: 01:15:23 и 01:15:26 — две загрузки подряд с разницей в три секунды, хотя приложение только что открылось.
Правило переписали на то, как звучала сама жалоба: не «когда обновляли», а «сколько приложение пробыло в фоне»:
mutating func shouldRefresh(on phase: ScenePhase, now: Date = Date()) -> Bool {
switch phase {
case .background:
backgroundedAt = now
return false
case .active:
guard let wentAway = backgroundedAt else { return false }
backgroundedAt = nil
return now.timeIntervalSince(wentAway) >= minimumBackgroundTime
default:
return false
}
}Побочный эффект переписывания оказался приятным: заодно отвалились обе ложные сработки — холодный старт (backgroundedAt просто nil, обновлять нечего) и системный диалог поверх приложения, который даёт .inactive, а не .background. Проверено в тех же логах: 01:19:50 холодный старт — одна загрузка; 32 секунды в фоне; 01:20:26 возврат — вторая. Ровно две, без дубля.
Гейт как чистый тип#
Что мне нравится в этой правке — не сам факт починки, а то, что решение «обновлять сейчас или нет» с самого начала вынесли из View в отдельный тип RefreshOnForegroundGate, без единой зависимости от SwiftUI внутри логики. Это и позволило проверить троттлинг, .inactive vs .background и повторный дубль юнит-тестами, а не глазами на симуляторе.
Различение .inactive и .background — не случайная деталь API, а прямое следствие жалобы: системный диалог permission-запроса или control center поверх приложения даёт .inactive, и данные там объективно не успевают устареть. Обновлять на каждый такой диалог значит дёргать сеть без причины.
Порог в 30 секунд живёт в двух местах — в самом гейте и, продублированный, в UI-тесте с реальным циклом «свернуть — подождать — вернуть» на симуляторе (XCUIDevice.shared.press(.home) → Thread.sleep дольше порога → app.activate()). Дублирование зафиксировано в комментарии:
/// Порог из `RefreshOnForegroundGate`. UI-тесты не могут `@testable import`
/// приложение (оно запускается отдельным процессом), поэтому значение
/// продублировано. Разойдётся — тест начнёт возвращаться слишком рано и
/// покраснеет, а не соврёт.
private enum RefreshOnForegroundThreshold {
static let seconds: TimeInterval = 30
}Худший вариант для константы, продублированной в двух процессах — молчаливое расхождение. Здесь расхождение ломает тест, а не проходит незамеченным.
Стоит сказать и о честности процесса вокруг всего этого. В коммите 952975c прямо написано: «ПРОВЕРЕНО НЕ ДО КОНЦА: интерактивный цикл «свернуть — вернуть» на симуляторе прогнать не удалось». А в 22b8068, про регрессию скролла после pull-to-refresh: «на симуляторе исходную регрессию воспроизвести не удалось — тест зелёный и на откаченном коде, — так что подтвердить исправление можно только на устройстве». Фиксировать границу проверенного — часть той же дисциплины, что и сам фикс: тест, который не может доказать баг закрытым, честно называется сторожем от будущих поломок, а не доказательством.
Чем закончилась эта история#
Когда про баг говорят «починили, а он остался» — это почти никогда значит «фикс не сработал». Это значит, что причин больше одной, и они не связаны логикой, только симптомом. Здесь их было четыре независимых слоя — lifecycle SwiftUI (.task в TabView), кеш сервиса без обхода, коалесцирование параллельных запросов и гео-триггер по расстоянию — и каждый маскировал остальные: почини .task, а pull-to-refresh всё равно молчит из-за кеша; почини кеш, а на старте его съедает коалесинг; почини коалесинг, а без фикса локации экран всё равно не обновится, пока GPS не отработает. Плюс отдельно — баг, который завёл сам первый фикс, найденный только когда до симулятора дошли руки проверить вживую, а не по логам сборки.
Практический вывод конкретнее, чем «пиши больше тестов»: решение вынести логику из View в чистый тип себя окупило — именно оно сделало троттлинг проверяемым без снимков экрана. И второй: если симптом описан одной фразой пользователя, это не значит, что причина одна — каждый слой стоит проверить в изоляции, прежде чем закрывать тикет.


