← Wszystkie wpisy

Pięć platform Apple, trzy wspólne pliki: jak naprawdę powstaje wieloplatformowe SwiftUI w aplikacji Return

Return, mój timer do medytacji, działa na pięciu platformach Apple: iPhone, iPad, Mac, Apple Watch i Apple TV.1 Baza kodu liczy 40 plików Swift (bez testów). Trzy z nich są współdzielone przez wszystkie pięć platform. Reszta rozdziela się na osobne targety w Xcode, które duplikują pojęcia takie jak TimerManager, AudioManager czy ContentView, zamiast współdzielić je przez kompilację warunkową #if os(...).

Współdzielenie sięga zatem około 7,5% — i jest to wybór zamierzony.

Ten esej opowiada o tym, jak w 2026 roku naprawdę wygląda wydanie wieloplatformowej aplikacji SwiftUI, dlaczego agresywne współdzielenie kodu bywa przeceniane oraz co łączy te trzy pliki, które rzeczywiście udało się współdzielić.

Kafelek platformy iOS 26 z Apple Developer Kafelek platformy iPadOS 26 z Apple Developer Kafelek platformy macOS 26 z Apple Developer Kafelek platformy watchOS 26 z Apple Developer Kafelek platformy tvOS 26 z Apple Developer

Pięć platform, na które celuje Return, w ujęciu samego Apple na developer.apple.com. Każda z nich to odrębny target platformowy w Xcode, a nie rozgałęzienie w czasie działania.

W skrócie

  • Return: 18 plików Swift w głównym targecie (iOS + iPadOS + macOS), 10 plików w targecie tvOS, 7 w targecie watchOS, 2 pliki widgetów (Live Activities) oraz 3 naprawdę wieloplatformowe pliki w Return/Shared/. Łącznie 40.
  • Te trzy wspólne pliki to elementy związane z trwałością danych: MeditationSession, SessionStore, SessionHistoryView. Stan, który wędruje przez iCloud, a nie interfejs, który dostosowuje się do platformy.
  • tvOS i watchOS mają osobne targety w Xcode, a nie gałęzie #if os(tvOS) w targecie głównym. Modele sterowania są zbyt odmienne, żeby zmieścić je w jednym ContentView.
  • Nawet w obrębie głównego targetu iOS/iPadOS/macOS bloki #if os mnożą się na potęgę: 10 w ContentView.swift, 8 w LiveActivityManager.swift, 8 w VideoBackgroundView.swift, 6 w AudioManager.swift.
  • Uczciwy wniosek: agresywne współdzielenie kodu między pięcioma platformami Apple to obciążenie utrzymaniowe. Niewielki wspólny rdzeń (warstwa trwałości) plus osobne interfejsy dla każdej platformy powstaje szybciej i psuje się rzadziej niż jeden potężny plik naszpikowany #if.

Uzupełnieniem tego tekstu są artykuły poświęcone poszczególnym platformom: macierz platform Apple, kontrakt środowiska wykonawczego watchOS oraz wzorce Liquid Glass w SwiftUI.

Liczby

Kształt bazy kodu, liczony w plikach Swift, po odjęciu testów jednostkowych i testów UI:

Return/                            18 files   (iPhone + iPad + Mac, single target)
├── Shared/                         3 files     cross-platform truth   ├── MeditationSession.swift   ├── SessionStore.swift   └── SessionHistoryView.swift
├── ContentView.swift              (10 #if os branches)
├── TimerManager.swift             (2 #if os branches)
├── AudioManager.swift             (6 #if os branches)
├── HealthKitManager.swift
├── LiveActivityManager.swift      (8 #if os branches, iOS-only)
├── ThemeManager.swift
├── VideoBackgroundView.swift      (8 #if os branches)
├── GlassTextShape.swift           (Liquid Glass, see prior post)
├── GlassTimerText.swift
└──  (settings, theme, audio assets, etc.)

ReturnTV/                          10 files   (tvOS, separate target)
├── TVContentView.swift
├── TVTimerManager.swift            duplicates main TimerManager
├── TVAudioManager.swift            duplicates main AudioManager
├── TVDurationPicker.swift
├── TVFocusModifier.swift           tvOS button styles for focus
├── TVSettingsView.swift
└── ReturnWatch Watch App/              7 files   (watchOS, separate target)
├── WatchContentView.swift
├── WatchTimerManager.swift         duplicates main TimerManager
├── WatchAudioManager.swift         duplicates main AudioManager
├── WatchHealthKitManager.swift     duplicates main HealthKitManager (mostly)
├── WatchSettingsView.swift
└── ReturnWidgets/                      2 files   (Live Activity + bundle)
├── ReturnLiveActivity.swift
└── ReturnWidgetsBundle.swift

Pięć platform, trzy wspólne pliki, dwa osobne targety platformowe plus target widgetu, a do tego intensywna kompilacja warunkowa wewnątrz targetu głównego. Wskaźnik współdzielenia to około 7,5%. Większość poradników o „wieloplatformowym SwiftUI” sugeruje coś odwrotnego: napisać jeden ContentView, który dostosuje się do każdej platformy dzięki @Environment(\.horizontalSizeClass) i #if os(...).2 Sprawdza się to przy dwóch platformach (iPhone + iPad). Przy pięciu przestaje działać.

Co łączy te trzy wspólne pliki

Return/Shared/MeditationSession.swift definiuje typ wartościowy blisko powiązany ze SwiftData:3

struct MeditationSession: Codable, Identifiable, Equatable {
    let id: UUID
    let startDate: Date
    let endDate: Date
    let durationSeconds: Int
    let sourceDevice: DeviceType
    var syncedToHealthKit: Bool

    enum DeviceType: String, Codable, CaseIterable {
        case iPhone, iPad, mac, appleTV, appleWatch
    }
}

Komentarz w nagłówku pliku pełni tu funkcję konstrukcyjną: // Add this file to: Return, ReturnTV, ReturnWatch Watch App targets. Ten sam plik źródłowy jest podłączony do wszystkich trzech targetów w Xcode — bez symlinków, bez pakietu Swift. System budowania Apple bez oporu kompiluje jeden plik do trzech plików binarnych.

SessionStore.swift to warstwa trwałości: cienka nakładka na NSUbiquitousKeyValueStore (magazyn klucz-wartość iCloud od Apple), która odczytuje i zapisuje tablice MeditationSession. Ten wybór ma znaczenie: synchronizacja przez magazyn klucz-wartość daje Return historię sesji na wielu urządzeniach bez zakładania kontenera CloudKit, kosztem limitu 1 MB na całą zawartość magazynu.12 Dla listy sesji medytacyjnych, z których każda waży średnio kilkaset bajtów, limit jest aż nadto wystarczający. SessionHistoryView.swift to lista SwiftUI renderująca te sesje. Obu plików targety iPhone, iPad, Mac, Watch i TV używają dokładnie tak samo.

Co łączy tę trójkę: opisują stan, a nie interakcję. MeditationSession oznacza to samo pojęcie na każdym urządzeniu. Lista minionych sesji czyta się tak samo wszędzie. Żaden z tych plików nie dotyka powierzchni sterowania, menedżera okien, decyzji o routingu dźwięku, silnika fokusu ani cyfrowej koronki. W momencie, w którym plik musi wiedzieć, na jakiej platformie działa, przestaje nadawać się do współdzielenia.

Dlaczego reszty nie udało się współdzielić

Weźmy TimerManager. Wersja dla iOS/iPadOS/macOS korzysta z Timer.publish(every: 1, ...) i kieruje powiadomienia przez UserNotifications. Wersja tvOS (TVTimerManager) obsługuje sytuację, w której użytkownik zapauzował timer pilotem Siri Remote i włącza się wygaszacz ekranu. Wersja watchOS (WatchTimerManager) deleguje pracę do WKExtendedRuntimeSession (przez WatchSessionManager), żeby system utrzymywał aplikację w gotowości mimo przygaszonego ekranu, a wejście obsługuje cyfrową koronką zamiast dotyku. Trzy platformy, trzy głęboko różne zachowania timera.

Dałoby się zunifikować je jako class TimerManager { #if os(watchOS) ... #elif os(tvOS) ... }. Efektem byłaby klasa z trzema trybami, każdy po czterdzieści linii kodu zamkniętego w #if, w której dotknięcie ścieżki iOS grozi zepsuciem ścieżki watchOS. To utrzymaniowy koszmar.

Trzy osobne klasy w trzech osobnych plikach to więcej kodu na dysku i mniej kodu w głowie. Duplikacja, którą da się przeczytać, bije abstrakcję, której nie sposób zrozumieć.

Ta sama logika dotyczy:

  • ContentView kontra TVContentView kontra WatchContentView: modele nawigacji są różne (na iPhonie oparty na stosie, na TV na fokusie, na Watchu na liście).
  • AudioManager kontra TVAudioManager kontra WatchAudioManager: kategorie sesji audio się różnią, watchOS ma ostrzejsze reguły odtwarzania w tle, tvOS inaczej kieruje dźwięk do AirPlay.
  • VideoBackgroundView ma w targecie głównym 8 gałęzi #if os(iOS) (z jedną towarzyszącą gałęzią #elseif os(macOS)), które obejmują różne zasoby wideo (fire_phone.mp4 kontra fire_mac.mp4), różne typy warstw i różne proporcje obrazu.4

Warto odnotować: główny target Return/ rzeczywiście zbiera iOS, iPadOS i macOS razem. Te trzy platformy współdzielą więcej kodu, niż nie współdzielą. NavigationStack ze SwiftUI działa na wszystkich trzech. .glassEffect() również. Różnice w zarządzaniu oknami są realne, ale dają się okiełznać w obrębie jednego targetu. Linię podziału na osobne targety wyznaczyły dopiero tvOS i watchOS.

Przypadek tvOS: dlaczego silnik fokusu wymusił osobny target

Nawigacja na Apple TV zbudowana jest wokół silnika fokusu.5 Każdy element interfejsu, z którym można wejść w interakcję, deklaruje, że może przyjąć fokus; strzałki na pilocie Siri Remote przesuwają fokus między elementami, a naciśnięcie przycisku wyboru aktywuje zafokusowany element. SwiftUI na tvOS udostępnia ten mechanizm przez .focusable(), .focusEffect oraz własne typy ButtonStyle, które reagują na @Environment(\.isFocused), dając efekt paralaksy znany z aplikacji samego Apple. Prawdziwy kod produkcyjny z TVFocusModifier.swift:6

struct TVCapsuleButtonStyle: ButtonStyle {
    var accentColor: Color = .white
    @Environment(\.isFocused) private var isFocused

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .colorMultiply(isFocused ? focusedTextColor : accentColor)
            .background(
                Capsule().fill(isFocused
                    ? AnyShapeStyle(accentColor)
                    : AnyShapeStyle(.ultraThinMaterial))
            )
            .clipShape(Capsule())
            .scaleEffect(isFocused ? 1.1 : 1.0)
            .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
            .shadow(color: .black.opacity(isFocused ? 0.3 : 0.1),
                    radius: isFocused ? 20 : 5, y: isFocused ? 10 : 2)
            .animation(.easeInOut(duration: 0.2), value: isFocused)
    }
}

Ten sam plik definiuje również TVCircleButtonStyle dla kontrolek kwadratowych i okrągłych. Oba style odwracają kolor i półprzezroczystość w reakcji na fokus: przyciski niezafokusowane leżą na .ultraThinMaterial, zafokusowane wypełniają się kolorem akcentu i zyskują na skali oraz cieniu. W tej aplikacji wzorzec jest strukturalnie specyficzny dla tvOS. @Environment(\.isFocused) jest dostępne na iOS, iPadOS, macOS, watchOS i tvOS,13 ale nawigacja sterowana fokusem stanowi podstawowy model interakcji wyłącznie na tvOS, gdzie Siri Remote nie generuje ani zdarzenia wskaźnika, ani dotyku. Na iPhonie czy iPadzie odpowiednia kontrolka reaguje na trafienie dotknięciem; na Macu — na najechanie kursorem lub kliknięcie. Style przycisków w TVFocusModifier.swift zakładają, że fokus jest głównym środkiem działania użytkownika, i wokół tego założenia budują całą reakcję wizualną. Nie ma dobrego sposobu, by napisać jeden ContentView obsługujący jednocześnie dotyk na iOS, kursor na Macu i nawigację fokusem na tvOS. Struktura widoku jest naprawdę inna: ContentView na tvOS to graf wierszy przyjmujących fokus, a ContentView na iOS to stos elementów działających na dotknięcie.

To samo dotyczy selektora czasu trwania. Na iPhonie wysuwa się od dołu i przyjmuje dotknięcia. Na Apple TV jest poziomym rzędem komórek przyjmujących fokus, po których użytkownik porusza się pilotem. TVDurationPicker.swift jest osobnym plikiem, bo projekt oparty na komórkach i fokusie nie ma odpowiednika na iPhonie. Wciśnięcie obu wariantów do jednego pliku oznaczałoby dwa niepowiązane interfejsy sklejone dyrektywą #if os(tvOS).

Przypadek watchOS: rozszerzone sesje wykonawcze, HealthKit i mniejsza powierzchnia

watchOS dokłada dwa ograniczenia strukturalne, których pozostałe platformy nie mają:

  1. WKExtendedRuntimeSession, żeby utrzymać aplikację w gotowości przy przygaszonym ekranie zegarka.8 Bez tego watchOS agresywnie usypia aplikację między kolejnymi taktami sekundnika, a timer zaczyna dryfować. Return deklaruje WKBackgroundModes: mindfulness w pliku Info.plist targetu watchOS, żeby system rozpoznał ten scenariusz i przydzielił budżet czasu wykonania; sama sesja tworzona jest domyślnym inicjalizatorem WKExtendedRuntimeSession().
  2. Synchronizacja przez iCloud za pomocą NSUbiquitousKeyValueStore, a nie WatchConnectivity.7 Historia sesji w Return synchronizuje się przez ten sam magazyn klucz-wartość, z którego korzystają targety iPhone, iPad i Mac, więc medytacja zapisana na zegarku pojawia się w historii na iPhonie bez żadnej bezpośredniej komunikacji zegarek-telefon. WatchConnectivity mógłby w przyszłości posłużyć do synchronizacji stanu na żywo, ale Return wybrał prostszy model: każde urządzenie zapisuje do tego samego magazynu iCloud, a kolejny odczyt na dowolnym urządzeniu widzi sumę wszystkiego.

WatchTimerManager.swift to timer po stronie zegarka; obsługę rozszerzonego czasu wykonania deleguje do WatchSessionManager, zdefiniowanego w ReturnWatchApp.swift jako final class WatchSessionManager: NSObject, WKExtendedRuntimeSessionDelegate. TimerManager z iOS nie ma odpowiednika tej logiki, bo aplikacje iOS pozostają responsywne na pierwszym planie bez jawnej sesji wykonawczej. Wepchnięcie logiki zegarkowej do TimerManager z iOS przez #if os(watchOS) oznaczałoby, że ścieżka iOS importuje symbole WatchKit, których nigdy nie używa, a ścieżka watchOS wymaga inicjalizacji, której ścieżka iOS nie potrzebuje.

WatchHealthKitManager.swift to mniejszy wariant głównego HealthKitManager. Zapisuje minuty uważności tak samo, ale UX prośby o zgodę jest inny (zegarek nie potrafi wyświetlić HealthKitPermissionSheet). Klasa zegarkowa jest z grubsza o połowę mniejsza od głównej.

Co dzieje się wewnątrz głównego targetu iOS/iPadOS/macOS

Nawet w obrębie targetu głównego współdzielenie nie przychodzi samo. ContentView.swift zawiera dziesięć bloków #if os(macOS) lub #if !os(macOS); LiveActivityManager.swift — osiem; VideoBackgroundView.swift — osiem; AudioManager.swift — sześć. Live Activities to funkcja dostępna wyłącznie na iPhonie, więc cały LiveActivityManager zamknięty jest w #if os(iOS). Selektor czasu trwania na iPhonie ma inny układ niż na iPadzie i Macu, więc ContentView prowadzi równoległe gałęzie układu.

Wzorzec, który się sprawdził: #if os(...) przy drobnych różnicach platformowych (inne zachowanie klawiatury, inne odstępy, brakujące API), osobny target przy dużych różnicach strukturalnych (fokus kontra dotyk, sesja treningowa kontra timer). Próg, przy którym ostatecznie się zatrzymałem, brzmi: „więcej niż jakieś 10 linii rozgałęzień”. Poniżej tego kompilacja warunkowa jest w porządku. Powyżej — plik wykonuje dwa zadania naraz, a to drugie należy do innego targetu.

Kiedy nie wydawać aplikacji na wszystkich pięciu platformach

Uczciwa ocena.

Lepiej odpuścić Apple Watch, jeśli aplikacja jest gęsta informacyjnie. Na 46-milimetrowym ekranie nie zmieści się 30-elementowa lista, selektor czasu trwania i ekran ustawień naraz. Return broni się na watchOS, bo trzon interakcji to jeden przycisk (start/stop timera). Aplikacja do produktywności, finansowa albo bogata w media tego nie wytrzyma.

Lepiej odpuścić Apple TV, jeśli aplikacja jest interaktywna. Telewizor służy doświadczeniom ambientowym (timer działający na ekranie po drugiej stronie pokoju, odtwarzanie muzyki). Wszystko, co wymaga częstych działań użytkownika, walczy z platformą. Return jest na tvOS, bo „ustaw dwudziestominutowy timer i patrz na ogień na ekranie” to dokładnie ten ambientowy przypadek, o który tu chodzi. Aplikacja do notatek byłaby udręką.

Lepiej odpuścić Maca, jeśli interfejs projektowano przede wszystkim pod telefon. SwiftUI na Macu działa, ale model nawigacji oparty na NavigationStack wygląda przy prawdziwym macowym pasku bocznym jak zabawka. Jeśli aplikacja miałaby na Macu sprawiać wrażenie niedokończonej, lepiej wydać ją przez Catalyst (który konwertuje wersję na iPada) albo pominąć Maca zupełnie, dopóki nie da się zbudować natywnego macowego interfejsu.

Lepiej odpuścić iPada, jeśli nie zrobiono adaptacji do klas rozmiaru. Aplikacja z iPhone’a rozciągnięta na cały ekran iPada wygląda tanio. iPad wymaga co najmniej NavigationSplitView z paskiem bocznym, a najlepiej prawdziwego układu dwupanelowego. Return używa widoków dzielonych na iPadzie i stosów na iPhonie. Kod siedzi w tym samym targecie, ale interfejs jest naprawdę inny.

Reguła, którą sobie wyznaczyłem: wydawać na danej platformie wtedy, gdy trzon interakcji aplikacji pasuje do sposobu sterowania na tej platformie. Timer do medytacji na Apple Watch — tak (jedno dotknięcie i start). Timer do medytacji na Apple TV — tak (ustaw i zapomnij). Tablicy kanban nie warto wydawać ani tu, ani tam.

Co przenosi się bez wysiłku

Trzy rzeczy, które w Return rzeczywiście trafiły na wszystkie pięć platform:

  1. Model danych (MeditationSession). Struktura jest identyczna na każdej platformie, synchronizuje się przez NSUbiquitousKeyValueStore, a każda platforma odczyta to, co zapisała dowolna inna.
  2. Widok historii sesji (SessionHistoryView). List z minionymi sesjami renderuje się identycznie na iPhonie, iPadzie, Macu, Apple Watch i Apple TV. List ze SwiftUI to jeden z nielicznych prymitywów, które czysto dostosowują się do wszystkich pięciu formatów urządzeń.
  3. Nakładka trwałości (SessionStore). Odczyty i zapisy są niezależne od platformy, a magazyn pod spodem (NSUbiquitousKeyValueStore) to wszędzie to samo API.

Trzy pojęcia. Stan, renderowanie listy i trwałość danych. Wszystko, co jest stanowe i prezentacyjne, a nie wiąże się ze sposobem sterowania zależnym od sprzętu, nadaje się do współdzielenia. Wszystko, co dotyka sterowania, fokusu, routingu dźwięku, rozmiaru ekranu albo wykonywania w tle — już nie.

Ten sam wzorzec wraca w przewodniku o tworzeniu aplikacji iOS z agentami, gdzie argumentowałem to samo innymi słowami: te fragmenty aplikacji iOS, które potrafi napisać agent, dzielą większość kodu z fragmentami pisanymi przez człowieka; te, które wymagają ludzkiego osądu (podpisywanie, dopracowanie wizualne, wydajność), to dokładnie te, które również kiepsko przenoszą się między platformami.9 Obie granice się pokrywają. Obie mówią o tym, w którym momencie zaczyna mieć znaczenie wiedza dziedzinowa.

Ile kosztuje wieloplatformowość

ROI rozkłada się nierówno. Dołożenie iPada do aplikacji iPhone’owej to może 20% kodu więcej (gałęzie klas rozmiaru, gdzieniegdzie widok dzielony). Dołożenie Maca do tego samego targetu to kolejne 15–20% (gałęzie #if os(macOS), pasek menu, zarządzanie oknami). Każdy większy target to przy niewielkiej aplikacji około 10 plików.

Apple Watch i Apple TV wypadają najdrożej. Dodanie watchOS do Return wymagało 11 nowych plików w osobnym targecie, w tym dedykowanych menedżerów audio, timera i HealthKit. Dodanie tvOS — 10 nowych plików w kolejnym osobnym targecie, w tym obsługi fokusu i własnego selektora czasu trwania. Razem niemal podwoiły powierzchnię kodu Swift dla czegoś, co na poziomie funkcji dla użytkownika jest tą samą aplikacją.

Decyzja o wydaniu na wszystkich pięciu platformach nie brzmiała: „chcemy być wieloplatformowi dla samej wieloplatformowości”. To była seria osobnych rozstrzygnięć: Apple Watch, bo timery do medytacji naprawdę należą do nadgarstka; Apple TV, bo format ambientowego ekranu pasuje do długich sesji w pokoju; Mac, bo część użytkowników medytuje przy biurku między spotkaniami. Każda platforma zasłużyła na swój target realnym zastosowaniem.

Jeśli funkcja nie zasługuje na własny target, taniej jest pominąć platformę i mocniej postawić na te, na których aplikacja jest znakomita.

Co to oznacza dla Państwa aplikacji

Trzy wnioski.

  1. Domyślnie jeden target na główną grupę platform. iOS + iPadOS + macOS w jednym targecie działa, bo trzon interakcji (dotyk + kursor) jest podobny. tvOS w osobnym targecie. watchOS w osobnym targecie. Każdy dodatkowy target to jakieś 10 plików, ale ratuje przed jedną klasą-bogiem z gałęziami #if, które rozrastają się bez końca.
  2. Współdzielić agresywnie stan, nie interakcję. Struktury modelu z Codable, nakładki na warstwę trwałości i renderowanie List przenoszą się niemal za darmo. Menedżery timera, menedżery audio i widoki treści — nie.
  3. Każda platforma musi zasłużyć. Nie warto wydawać na watchOS tylko dlatego, że się da. Warto wtedy, gdy trzon interakcji aplikacji pasuje do sposobu sterowania na danej platformie. Resztę pominąć.

Ten wzorzec współgra z trzema pozostałymi powierzchniami, które opisywałem przy tej samej rodzinie aplikacji: typowane App Intents dla Apple Intelligence, serwery MCP dla agentów działających w wielu LLM, Liquid Glass dla człowieka przy urządzeniu. Najbardziej zewnętrzną warstwą tego samego stosu jest platforma: to, na jakich ekranach aplikacja w ogóle działa. Wybierać ją trzeba równie świadomie, jak wybiera się powierzchnię AI.

FAQ

Dlaczego nie pakiet Swift do wspólnego kodu?

Rozważałem to. Przy trzech plikach pakiet Swift dokłada więcej ceremoniału, niż oszczędza. System budowania Xcode 26 bez oporu kompiluje jeden plik źródłowy do wielu targetów, wystarczy zaznaczyć pola Target Membership. Pakiet dokłada osobny Package.swift, osobny target testowy i warstwę pośrednią, przez którą trzeba się przebijać przy każdym refaktorze. Przy niewielkim wspólnym rdzeniu wygrywa prostsza odpowiedź.10

Czy SwiftData działa na watchOS i tvOS?

SwiftData jest dostępne na iOS 17+, macOS 14+, watchOS 10+ i tvOS 17+, a więc na każdej platformie, na którą celuje Return.11 Struktura MeditationSession jest zwykłym typem Codable, a nie @Model, ponieważ Return synchronizuje historię sesji przez NSUbiquitousKeyValueStore, a nie przez kontener SwiftData. Dla typów @Model wzorzec wygląda tak samo: plik modelu jest wspólny, a kontener trwałości różni się zależnie od platformy, jeśli musi.

Mac Catalyst czy natywny target macowy?

Catalyst to właściwe narzędzie wtedy, gdy aplikacja na iPada jest na tyle dobra, że przebudowana przez Catalyst wersja macowa czyta się jak natywna. Główny target Return jest prawdziwym targetem wieloplatformowym (nie Catalystem), zbudowanym w SwiftUI dla iOS, iPadOS i macOS w jednym pliku binarnym. Interfejs macowy używa #if os(macOS), żeby renderować się inaczej niż na iPadzie: pasek boczny zamiast arkusza, skróty klawiszowe przy przyciskach i tak dalej. Catalyst byłby prostszy, ale macowy interfejs wyglądałby jak aplikacja z iPada na Macu — a to najbardziej znany sposób, w jaki Catalyst zawodzi.

Czy przy małej aplikacji warto wydawać na Apple TV?

Raczej nie. Aplikacje na Apple TV mają bardzo konkretne zastosowania (ambientowe, medialne, proste gry casual). Jeśli aplikacja nie mieści się w żadnym z nich, publiczność platformy jest zbyt mała, by uzasadnić 10 plików Swift na aplikację. Return celuje w tvOS właśnie dlatego, że długie sesje medytacyjne na ekranie po drugiej stronie pokoju to jeden z nielicznych przypadków bliskich produktywności, który do tej platformy pasuje.

Ile czasu zajmuje wydanie na wszystkie pięć platform?

Trudno podać dokładną liczbę; zależy to od aplikacji. Return od pierwszego dnia powstawał jako wieloplatformowy, zamiast dokładać platformy stopniowo — a to szybsze niż doposażanie po fakcie. Z grubsza rzecz ujmując: MVP tylko na iPhone’a plus obsługa iPada plus obsługa Maca to mniej więcej 1,5x czasu potrzebnego na wersję wyłącznie iPhone’ową. Apple Watch dokłada kolejne 0,5x. Apple TV — jeszcze 0,5x. Pierwsze wydanie na pięć platform to zatem około 2,5x czasu wersji wyłącznie na iPhone’a, z zastrzeżeniem, że była to budowa wspierana przez agenta, w której większość zduplikowanego kodu masowo edytował Claude Code, a nie ludzka ręka.

Bibliografia


  1. Return autora, aplikacja z timerem do medytacji opublikowana w App Store 21 kwietnia 2026. Targety natywne: iOS 26+, iPadOS 26+, macOS 26+, watchOS 26+, tvOS 26+. SwiftUI w całości. NSUbiquitousKeyValueStore do historii sesji między urządzeniami. 

  2. Apple Developer, “Configuring a Multi-Platform App” oraz sesja “SwiftUI essentials” z WWDC 2024. Domyślne zalecenia Apple skłaniają się ku jednemu targetowi z adaptacją sterowaną środowiskiem; droga wielu targetów, którą opisuje ten artykuł, jest świadomym odstępstwem. 

  3. Kod produkcyjny w Return/Return/Shared/MeditationSession.swift, SessionStore.swift, SessionHistoryView.swift. Komentarz w nagłówku MeditationSession.swift brzmi: „Add this file to: Return, ReturnTV, ReturnWatch Watch App targets.” 

  4. Kod produkcyjny w Return/Return/VideoBackgroundView.swift (8 gałęzi #if os(iOS) plus jedna gałąź #elseif os(macOS)), Return/Return/ContentView.swift (10 gałęzi #if os), Return/Return/AudioManager.swift (6 gałęzi #if os), Return/Return/LiveActivityManager.swift (8 gałęzi #if os, plik wyłącznie dla iOS). Liczby gałęzi uzyskane poleceniem grep -Ec '^\s*#if os\\(' <file>

  5. Apple Developer, wytyczne “Focus interactions” Human Interface Guidelines. Silnik fokusu tvOS to model nawigacji fundamentalnie różny od dotyku na iOS czy wskaźnika na Macu. 

  6. Kod produkcyjny w Return/ReturnTV/TVFocusModifier.swift. Definiuje dwa typy ButtonStyle (TVCapsuleButtonStyle i TVCircleButtonStyle), które opakowują @Environment(\.isFocused), aby odwrócić kolor i półprzezroczystość przy fokusie oraz zastosować skalowanie i cień. 

  7. Apple Developer, “WatchConnectivity”. Framework do komunikacji między sparowanym iPhonem a zegarkiem; Return nie używa go do synchronizacji sesji, opierając się na magazynie klucz-wartość iCloud. 

  8. Apple Developer, “WKExtendedRuntimeSession” oraz klucz Info.plist “WKBackgroundModes”. Wartość mindfulness opisana jest jako: „Enables extended runtime sessions for silent meditation” — dokładnie to, czego potrzebuje timer do medytacji. Return tworzy domyślną WKExtendedRuntimeSession() i deklaruje WKBackgroundModes: mindfulness w pliku Info.plist targetu watchOS. Kod produkcyjny: Return/ReturnWatch Watch App/ReturnWatchApp.swift definiuje WatchSessionManager: NSObject, WKExtendedRuntimeSessionDelegate; WatchTimerManager.swift deleguje do niego pracę związaną z rozszerzonym czasem wykonania. 

  9. Analiza autora w Tworzenie aplikacji iOS z agentami AI, praktycznym przewodniku po tworzeniu aplikacji iOS z udziałem agentów na podstawie 8 aplikacji produkcyjnych. 

  10. Apple Developer, “Configuring a Multi-Platform App”. Przynależność do targetów pozwala skompilować jeden plik źródłowy do wielu targetów bez pakietu Swift. Właściwe narzędzie przy niewielkich wspólnych rdzeniach. 

  11. Apple Developer, “SwiftData” platform availability. Dostępne na iOS 17+, iPadOS 17+, macOS 14+, watchOS 10+, tvOS 17+, visionOS 1+, a więc na wszystkich pięciu rodzinach platform Apple. 

  12. Apple Developer, “NSUbiquitousKeyValueStore”. Magazyn klucz-wartość iCloud od Apple, służący do synchronizowania niewielkich porcji stanu między urządzeniami użytkownika. Zgodnie z opublikowanymi przez Apple limitami całkowity rozmiar magazynu jest ograniczony do 1 MB dla wszystkich kluczy łącznie. Kod produkcyjny: Return/Return/Shared/SessionStore.swift

  13. Apple Developer, EnvironmentValues.isFocused. Dostępne na iOS 14+, iPadOS 14+, macOS 11+, tvOS 14+, watchOS 7+. API jest wieloplatformowe; różni się to, czy fokus stanowi podstawowy sposób nawigacji użytkownika. 

Powiązane artykuły

Macierz platform Apple: które cele zasługują na którą aplikację

iOS, iPad, Mac, Watch, Vision, TV. Sześć platform, sześć zobowiązań. Wybór celów Apple jest decyzją produktową, zanim st…

15 min czytania

HealthKit + SwiftUI w iOS 26: autoryzacja, typy próbek i wzorce wieloplatformowe na podstawie dwóch wdrożonych aplikacji

Wzorce produkcyjne z Water (HKQuantitySample) i Return (HKCategorySample): UX uprawnień, opakowania async, warianty watc…

13 min czytania

Instalacja i aktualizacja Codex CLI: Mac, Linux, Windows

Każdy sposób instalacji, aktualizacji, przypinania wersji i odinstalowania OpenAI Codex CLI -- skrypt instalacyjny, npm,…

14 min czytania