Desarrollo iOS con Swift

Guía técnica completa que cubre características del lenguaje Swift, UIKit y SwiftUI, gestión de dependencias, CI/CD con Fastlane, automatización de App Store Connect, notificaciones push, autenticación biométrica, Core Data, SwiftData, Combine, App Clips, WidgetKit, testing, Instruments y accesibilidad.

SwiftUIKitSwiftUICocoaPodsSPMFastlaneAPNsApp Store ConnectCore DataSwiftDataCombineApp ClipsWidgetKitXCTestInstrumentsAccessibility

1. Características del Lenguaje Swift

Protocolos y Programación Orientada a Protocolos

Swift favorece la programación orientada a protocolos sobre la herencia de clases. Los protocolos con implementaciones por defecto, tipos asociados y extensiones de protocolo proporcionan composición flexible.

protocol Cacheable {
    associatedtype Key: Hashable
    var cacheKey: Key { get }
    var ttl: TimeInterval { get }
}

extension Cacheable {
    var ttl: TimeInterval { 300 } // default 5 min
}

protocol NetworkService {
    func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T
}

// Combine protocols for composition
typealias AuthenticatedNetworkService = NetworkService & AuthProvider

struct APIClient: AuthenticatedNetworkService {
    func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        var request = endpoint.urlRequest
        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
        let (data, response) = try await URLSession.shared.data(for: request)
        guard let http = response as? HTTPURLResponse, 200...299 ~= http.statusCode else {
            throw APIError.invalidResponse
        }
        return try JSONDecoder().decode(T.self, from: data)
    }
}

Concurrencia en Swift (async/await, Actors)

El modelo de concurrencia estructurada de Swift con async/await, TaskGroup y actor proporciona seguridad contra data races en tiempo de compilación. La anotación @MainActor asegura que las actualizaciones de UI ocurran en el hilo principal.

// Actor for thread-safe state
actor ImageCache {
    private var cache: [URL: UIImage] = [:]
    private var inFlight: [URL: Task<UIImage, Error>] = [:]

    func image(for url: URL) async throws -> UIImage {
        if let cached = cache[url] { return cached }
        if let existing = inFlight[url] { return try await existing.value }

        let task = Task {
            let (data, _) = try await URLSession.shared.data(from: url)
            guard let image = UIImage(data: data) else {
                throw ImageError.decodeFailed
            }
            return image
        }
        inFlight[url] = task
        let image = try await task.value
        cache[url] = image
        inFlight[url] = nil
        return image
    }
}

// @MainActor for UI-bound code
@MainActor
class ProfileViewController: UIViewController {
    func loadProfile() {
        Task {
            let profile = try await apiClient.fetch(.profile)
            nameLabel.text = profile.name // guaranteed main thread
        }
    }
}

Genéricos y Tipo Result

Los genéricos con type constraints habilitan abstracciones reutilizables y type-safe. El tipo Result proporciona manejo explícito de errores como alternativa a try/catch.

// Generic network response wrapper
struct PaginatedResponse<T: Decodable>: Decodable {
    let items: [T]
    let total: Int
    let page: Int
    let hasMore: Bool
}

// Generic repository pattern
protocol Repository {
    associatedtype Entity: Identifiable & Decodable
    func getAll() async throws -> [Entity]
    func getById(_ id: Entity.ID) async throws -> Entity
    func save(_ entity: Entity) async throws -> Entity
    func delete(_ id: Entity.ID) async throws
}

// Result-based error handling
enum AppError: Error, LocalizedError {
    case network(URLError)
    case decoding(DecodingError)
    case unauthorized
    case serverError(statusCode: Int, message: String)

    var errorDescription: String? {
        switch self {
        case .network(let error): return "Network error: \(error.localizedDescription)"
        case .decoding: return "Failed to parse server response"
        case .unauthorized: return "Session expired. Please log in again."
        case .serverError(_, let msg): return msg
        }
    }
}

Property Wrappers

Los property wrappers encapsulan lógica reutilizable de propiedades. SwiftUI depende ampliamente de ellos (@State, @Binding, @StateObject, @EnvironmentObject), y los wrappers personalizados son poderosos para validación, persistencia y control de acceso.

// Custom property wrapper for UserDefaults persistence
@propertyWrapper
struct UserDefault<T> {
    let key: String
    let defaultValue: T
    let container: UserDefaults

    init(key: String, defaultValue: T, container: UserDefaults = .standard) {
        self.key = key
        self.defaultValue = defaultValue
        self.container = container
    }

    var wrappedValue: T {
        get { container.object(forKey: key) as? T ?? defaultValue }
        set { container.set(newValue, forKey: key) }
    }
}

// Clamped value property wrapper
@propertyWrapper
struct Clamped<Value: Comparable> {
    var value: Value
    let range: ClosedRange<Value>

    init(wrappedValue: Value, _ range: ClosedRange<Value>) {
        self.range = range
        self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }

    var wrappedValue: Value {
        get { value }
        set { value = min(max(newValue, range.lowerBound), range.upperBound) }
    }
}

// Usage in a settings model
class AppSettings: ObservableObject {
    @UserDefault(key: "onboardingComplete", defaultValue: false)
    var onboardingComplete: Bool

    @UserDefault(key: "preferredUnit", defaultValue: "kg")
    var preferredUnit: String
}

// Usage in SwiftUI with built-in wrappers
struct SettingsView: View {
    @AppStorage("notificationsEnabled") private var notifications = true
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        Toggle("Enable Notifications", isOn: $notifications)
    }
}

2. UIKit y SwiftUI

Vistas Declarativas con SwiftUI

SwiftUI usa una sintaxis declarativa donde describes la jerarquía de vistas y SwiftUI maneja el rendering, diffing y animación. La gestión de estado está integrada con property wrappers.

struct WorkoutListView: View {
    @StateObject private var viewModel = WorkoutViewModel()
    @State private var showingAddSheet = false

    var body: some View {
        NavigationStack {
            List {
                ForEach(viewModel.workouts) { workout in
                    NavigationLink(value: workout) {
                        WorkoutRow(workout: workout)
                    }
                }
                .onDelete { indexSet in
                    viewModel.delete(at: indexSet)
                }
            }
            .navigationTitle("Workouts")
            .navigationDestination(for: Workout.self) { workout in
                WorkoutDetailView(workout: workout)
            }
            .toolbar {
                Button("Add") { showingAddSheet = true }
            }
            .sheet(isPresented: $showingAddSheet) {
                AddWorkoutView(viewModel: viewModel)
            }
            .refreshable { await viewModel.refresh() }
        }
    }
}

Layout Programático con UIKit

Para layouts complejos y codebases legacy, UIKit con Auto Layout programático sigue siendo esencial. SnapKit o constraints manuales vía NSLayoutConstraint dan control total.

class WorkoutCell: UITableViewCell {
    private let titleLabel = UILabel()
    private let durationLabel = UILabel()
    private let iconView = UIImageView()

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setupViews()
    }

    required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") }

    private func setupViews() {
        [iconView, titleLabel, durationLabel].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            contentView.addSubview($0)
        }
        NSLayoutConstraint.activate([
            iconView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
            iconView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
            iconView.widthAnchor.constraint(equalToConstant: 40),
            iconView.heightAnchor.constraint(equalToConstant: 40),

            titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 12),
            titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
            titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),

            durationLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
            durationLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
            durationLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10)
        ])
    }
}

3. Gestión de Dependencias: CocoaPods y SPM

CocoaPods

CocoaPods está basado en Ruby y usa un Podfile para declarar dependencias. Modifica el workspace de Xcode, agregando un proyecto Pods junto al proyecto de tu app.

# Podfile
platform :ios, '15.0'
use_frameworks!
inhibit_all_warnings!

target 'MyApp' do
  # Networking
  pod 'Alamofire', '~> 5.9'
  pod 'Kingfisher', '~> 7.0'

  # Analytics
  pod 'Firebase/Analytics'
  pod 'Firebase/Crashlytics'

  # UI
  pod 'SnapKit', '~> 5.7'
  pod 'lottie-ios', '~> 4.4'

  target 'MyAppTests' do
    inherit! :search_paths
    pod 'Quick', '~> 7.0'
    pod 'Nimble', '~> 13.0'
  end
end

post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
    end
  end
end

Swift Package Manager (SPM)

SPM es el gestor de dependencias nativo de Apple, integrado directamente en Xcode. Usa archivos de manifiesto Package.swift y soporta dependencias binarias, plugins y compilación condicional.

// Package.swift
// swift-tools-version:5.9
import PackageDescription

let package = Package(
    name: "AppCore",
    platforms: [.iOS(.v15), .macOS(.v13)],
    products: [
        .library(name: "AppCore", targets: ["AppCore"]),
        .library(name: "AppNetworking", targets: ["AppNetworking"]),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
        .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.1.0"),
    ],
    targets: [
        .target(
            name: "AppCore",
            dependencies: [.product(name: "Algorithms", package: "swift-algorithms")]
        ),
        .target(
            name: "AppNetworking",
            dependencies: ["AppCore", .product(name: "Dependencies", package: "swift-dependencies")]
        ),
        .testTarget(name: "AppCoreTests", dependencies: ["AppCore"]),
    ]
)

4. Fastlane (match, gym, deliver, pilot)

match: Gestión de Code Signing

match almacena certificados de code signing y provisioning profiles en un repo Git compartido o almacenamiento cloud. Asegura que todos los miembros del equipo y máquinas CI usen las mismas credenciales.

# Matchfile
git_url("[email protected]:your-org/ios-certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["com.example.app", "com.example.app.widget"])
username("[email protected]")
team_id("ABC123DEF")

# Fastfile - sync certificates
lane :certificates do
  match(type: "development", readonly: true)
  match(type: "appstore", readonly: true)
end

gym: Automatización de Builds

gym (alias de build_app) maneja invocaciones de xcodebuild para archivado y exportación de IPAs. Gestiona detección de workspace, selección de scheme y opciones de exportación.

# Gymfile
workspace("MyApp.xcworkspace")
scheme("MyApp")
clean(true)
output_directory("./build")
export_method("app-store")
include_bitcode(false)
export_options({
  compileBitcode: false,
  uploadSymbols: true
})

# Fastfile - complete build lane
lane :build do
  increment_build_number(
    build_number: ENV["CI_PIPELINE_IID"] || latest_testflight_build_number + 1
  )
  certificates
  gym
end

deliver: Upload al App Store

deliver sube archivos IPA, screenshots, metadata y notas de release a App Store Connect. Puede auto-enviar para review y gestionar releases por fases.

# Deliverfile
app_identifier("com.example.app")
username("[email protected]")
team_id("ABC123DEF")
submit_for_review(true)
automatic_release(false)
phased_release(true)
force(true)
reject_if_possible(true)
submission_information({
  add_id_info_uses_idfa: false
})

# Fastfile - full deploy
lane :deploy do
  build
  deliver(
    ipa: lane_context[SharedValues::IPA_OUTPUT_PATH],
    skip_screenshots: true,
    precheck_include_in_app_purchases: false
  )
  slack(message: "iOS build submitted to App Store Review")
end

pilot: Distribución por TestFlight

pilot (alias de upload_to_testflight) gestiona builds de TestFlight, beta testers y grupos de testing externo. Sube IPAs y los distribuye a testers internos y externos.

# Fastfile - TestFlight beta distribution
lane :beta do
  build
  pilot(
    ipa: lane_context[SharedValues::IPA_OUTPUT_PATH],
    skip_waiting_for_build_processing: false,
    distribute_external: true,
    groups: ["QA Team", "Beta Testers"],
    changelog: "Bug fixes and performance improvements",
    beta_app_review_info: {
      contact_email: "[email protected]",
      contact_first_name: "Jose",
      contact_last_name: "Nobile",
      contact_phone: "+57 300 000 0000",
      demo_account_name: "[email protected]",
      demo_account_password: "your-secure-password"
    }
  )
  slack(message: "New TestFlight build available for testers")
end

# Manage beta testers
lane :add_testers do
  pilot(
    distribute_only: true,
    groups: ["External Beta"],
    notify_external_testers: true
  )
end
Experiencia de Jose: En producción, configuré el pipeline completo de Fastlane para la app iOS: match para gestión de certificados del equipo, gym para builds automatizados, deliver para submissions al App Store y pilot para distribución por TestFlight a QA y beta testers externos. El pipeline CI/CD redujo el tiempo de release de un día completo de trabajo manual a un solo trigger de merge-to-main.

5. Automatización de App Store Connect

API de App Store Connect

La API de App Store Connect proporciona acceso programático para gestionar apps, builds de TestFlight, beta testers y metadata de la app. La autenticación usa tokens JWT firmados con una clave privada .p8, y Fastlane envuelve esta API para flujos de trabajo comunes.

# Generate API key (stored in CI secrets)
# Key ID, Issuer ID, and .p8 private key file

# Fastlane API key configuration
lane :setup_api do
  app_store_connect_api_key(
    key_id: ENV["ASC_KEY_ID"],
    issuer_id: ENV["ASC_ISSUER_ID"],
    key_filepath: ENV["ASC_KEY_PATH"],
    duration: 1200,
    in_house: false
  )
end

# Direct API usage with Swift for custom tooling
# POST https://api.appstoreconnect.apple.com/v1/betaTesters
# Authorization: Bearer <jwt-token>
# Content-Type: application/json

Metadata y Localización

Fastlane gestiona la metadata del App Store como archivos locales: descripciones, keywords, notas de release y screenshots organizados por locale. Esto se integra naturalmente con control de versiones.

# Directory structure
# fastlane/metadata/en-US/
#   description.txt
#   keywords.txt
#   release_notes.txt
#   name.txt
#   subtitle.txt
# fastlane/metadata/es-MX/
#   description.txt
#   ...
# fastlane/screenshots/en-US/
#   1_home.png
#   2_workout.png

# Automated screenshot capture
lane :screenshots do
  capture_screenshots(
    workspace: "MyApp.xcworkspace",
    scheme: "MyAppUITests",
    devices: ["iPhone 15 Pro Max", "iPhone SE (3rd generation)", "iPad Pro (12.9-inch)"],
    languages: ["en-US", "es-MX"],
    clear_previous_screenshots: true
  )
  frame_screenshots(silver: true)
end

6. Notificaciones Push (APNs)

Configuración y Registro de APNs

Apple Push Notification service (APNs) requiere una clave de autenticación o certificado, configuración de entitlements y registro de device token. La autenticación basada en token (clave p8) es preferida sobre la basada en certificado.

// AppDelegate - Push registration
func application(_ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

    UNUserNotificationCenter.current().delegate = self
    UNUserNotificationCenter.current().requestAuthorization(
        options: [.alert, .badge, .sound, .provisional]
    ) { granted, error in
        guard granted else { return }
        DispatchQueue.main.async {
            application.registerForRemoteNotifications()
        }
    }
    return true
}

func application(_ application: UIApplication,
    didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
    Task { await APIClient.shared.registerPushToken(token) }
}

// Handle notification when app is in foreground
extension AppDelegate: UNUserNotificationCenterDelegate {
    func userNotificationCenter(_ center: UNUserNotificationCenter,
        willPresent notification: UNNotification) async
        -> UNNotificationPresentationOptions {
        return [.banner, .badge, .sound]
    }

    func userNotificationCenter(_ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse) async {
        let userInfo = response.notification.request.content.userInfo
        DeepLinkRouter.handle(userInfo: userInfo)
    }
}

Notificaciones Rich y Notification Service Extension

Las Notification Service Extensions permiten modificar el contenido de la notificación antes de mostrarse, habilitando imágenes adjuntas, desencriptación de payloads cifrados y tracking de analytics.

// NotificationService.swift (Notification Service Extension target)
class NotificationService: UNNotificationServiceExtension {
    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(_ request: UNNotificationRequest,
        withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        self.contentHandler = contentHandler
        bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent

        guard let content = bestAttemptContent,
              let imageURL = content.userInfo["image_url"] as? String,
              let url = URL(string: imageURL) else {
            contentHandler(request.content)
            return
        }

        downloadImage(from: url) { attachment in
            if let attachment = attachment {
                content.attachments = [attachment]
            }
            contentHandler(content)
        }
    }
}

7. Autenticación Biométrica

Face ID y Touch ID con LocalAuthentication

El framework LocalAuthentication proporciona Face ID y Touch ID con una API unificada. El sistema maneja la UI del prompt biométrico. Combínalo con Keychain para almacenamiento seguro de credenciales.

import LocalAuthentication

class BiometricAuthManager {
    enum BiometricType { case none, touchID, faceID }

    var availableBiometric: BiometricType {
        let context = LAContext()
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) else {
            return .none
        }
        switch context.biometryType {
        case .touchID: return .touchID
        case .faceID: return .faceID
        default: return .none
        }
    }

    func authenticate() async throws -> Bool {
        let context = LAContext()
        context.localizedCancelTitle = "Use Password"
        context.localizedFallbackTitle = "Enter PIN"

        var error: NSError?
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            throw AuthError.biometricUnavailable(error?.localizedDescription ?? "Unknown")
        }

        return try await context.evaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            localizedReason: "Authenticate to access your workouts"
        )
    }
}

// Keychain integration for secure token storage
class SecureTokenStore {
    func save(token: String, for account: String) throws {
        let data = Data(token.utf8)
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
            kSecValueData as String: data,
            kSecAttrAccessControl as String: try createAccessControl()
        ]
        SecItemDelete(query as CFDictionary)
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else { throw KeychainError.saveFailed(status) }
    }

    private func createAccessControl() throws -> SecAccessControl {
        var error: Unmanaged<CFError>?
        guard let access = SecAccessControlCreateWithFlags(
            kCFAllocatorDefault,
            kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
            .biometryCurrentSet,
            &error
        ) else {
            throw KeychainError.accessControlFailed
        }
        return access
    }
}

8. Core Data y SwiftData

Core Data Stack y NSManagedObject

Core Data proporciona persistencia de grafos de objetos con almacenamiento SQLite. NSPersistentContainer simplifica la configuración del stack, y las subclases de NSManagedObject definen el modelo de datos en código.

// Core Data stack setup
class PersistenceController {
    static let shared = PersistenceController()
    let container: NSPersistentContainer

    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "AppModel")
        if inMemory {
            container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
        }
        container.loadPersistentStores { _, error in
            if let error = error as NSError? {
                fatalError("Core Data load failed: \(error)")
            }
        }
        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }

    func save() {
        let context = container.viewContext
        guard context.hasChanges else { return }
        do {
            try context.save()
        } catch {
            let nsError = error as NSError
            print("Core Data save error: \(nsError)")
        }
    }
}

// Background context for heavy writes
func importWorkouts(_ data: [WorkoutDTO]) async {
    let context = PersistenceController.shared.container.newBackgroundContext()
    await context.perform {
        for dto in data {
            let workout = WorkoutEntity(context: context)
            workout.id = dto.id
            workout.name = dto.name
            workout.duration = dto.duration
        }
        try? context.save()
    }
}

SwiftData

SwiftData es el framework de persistencia moderno de Apple que reemplaza el boilerplate de Core Data con macros de Swift. La macro @Model auto-genera el esquema, y @Query proporciona fetches con actualización en vivo en SwiftUI.

import SwiftData

@Model
class Workout {
    var name: String
    var duration: TimeInterval
    var calories: Int
    var completedAt: Date
    @Relationship(deleteRule: .cascade) var exercises: [Exercise]

    init(name: String, duration: TimeInterval, calories: Int) {
        self.name = name
        self.duration = duration
        self.calories = calories
        self.completedAt = .now
        self.exercises = []
    }
}

@Model
class Exercise {
    var name: String
    var sets: Int
    var reps: Int
    var workout: Workout?

    init(name: String, sets: Int, reps: Int) {
        self.name = name
        self.sets = sets
        self.reps = reps
    }
}

// SwiftUI integration with @Query
struct WorkoutHistoryView: View {
    @Query(sort: \Workout.completedAt, order: .reverse)
    private var workouts: [Workout]
    @Environment(\.modelContext) private var context

    var body: some View {
        List(workouts) { workout in
            VStack(alignment: .leading) {
                Text(workout.name).font(.headline)
                Text("\(Int(workout.duration / 60)) min")
                    .foregroundStyle(.secondary)
            }
        }
    }
}
Experiencia de Jose: En producción, implementé Core Data para tracking de entrenamientos offline con importaciones en contexto de fondo para sincronizar datos del servidor. Cuando SwiftData maduró, evalué rutas de migración y lo adopté para módulos de nuevas funcionalidades, manteniendo Core Data para modelos estables existentes y evitar migraciones riesgosas.

9. Framework Combine

Publishers, Operadores y Subscribers

Combine es el framework reactivo de Apple para procesar eventos asincrónicos. Los publishers emiten valores a lo largo del tiempo, los operadores los transforman y los subscribers consumen los resultados. Se integra estrechamente con SwiftUI vía @Published y ObservableObject.

import Combine

class WorkoutViewModel: ObservableObject {
    @Published var searchText = ""
    @Published var workouts: [Workout] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    private var cancellables = Set<AnyCancellable>()

    init() {
        // Debounced search with Combine pipeline
        $searchText
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .removeDuplicates()
            .filter { !$0.isEmpty }
            .sink { [weak self] query in
                self?.search(query)
            }
            .store(in: &cancellables)
    }

    func search(_ query: String) {
        isLoading = true
        APIClient.shared.searchWorkouts(query: query)
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { [weak self] completion in
                    self?.isLoading = false
                    if case .failure(let error) = completion {
                        self?.errorMessage = error.localizedDescription
                    }
                },
                receiveValue: { [weak self] results in
                    self?.workouts = results
                }
            )
            .store(in: &cancellables)
    }
}

// Combine with NotificationCenter
NotificationCenter.default
    .publisher(for: UIApplication.didBecomeActiveNotification)
    .sink { _ in refreshData() }
    .store(in: &cancellables)

// Combine with Timer
Timer.publish(every: 30, on: .main, in: .common)
    .autoconnect()
    .sink { _ in pollForUpdates() }
    .store(in: &cancellables)

10. App Clips

Configuración e Invocación de App Clips

Los App Clips son versiones ligeras de tu app (menos de 15 MB) que se lanzan instantáneamente desde tags NFC, códigos QR, App Clip Codes o banners de Safari. Comparten código con el target de la app principal pero tienen su propio Info.plist y entitlements.

// App Clip entry point
@main
struct MyAppClip: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onContinueUserActivity(
                    NSUserActivityTypeBrowsingWeb
                ) { activity in
                    guard let url = activity.webpageURL else { return }
                    handleInvocation(url: url)
                }
        }
    }

    func handleInvocation(url: URL) {
        // Parse URL to determine which experience to show
        // e.g., https://example.com/clip/class/yoga-morning
        let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
        if let classID = components?.queryItems?.first(where: { $0.name == "id" })?.value {
            // Show class booking view for this specific class
            NavigationState.shared.showClassBooking(classID: classID)
        }
    }
}

// Shared code between main app and App Clip
// Use the active compilation condition to differentiate
#if APPCLIP
    // App Clip specific: prompt full app download
    func promptFullAppInstall() {
        guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
        let config = SKOverlay.AppClipConfiguration(position: .bottom)
        let overlay = SKOverlay(configuration: config)
        overlay.present(in: scene)
    }
#endif

11. WidgetKit

Widget Timeline Provider y Vistas SwiftUI

WidgetKit usa un TimelineProvider para suministrar snapshots y entradas de timeline que el sistema renderiza como vistas SwiftUI. Los widgets soportan múltiples familias (pequeño, mediano, grande) y usan AppIntents para interactividad.

import WidgetKit
import SwiftUI

struct WorkoutEntry: TimelineEntry {
    let date: Date
    let workoutName: String
    let calories: Int
    let streakDays: Int
}

struct WorkoutWidgetProvider: TimelineProvider {
    func placeholder(in context: Context) -> WorkoutEntry {
        WorkoutEntry(date: .now, workoutName: "Morning Run", calories: 350, streakDays: 7)
    }

    func getSnapshot(in context: Context, completion: @escaping (WorkoutEntry) -> Void) {
        let entry = WorkoutEntry(date: .now, workoutName: "HIIT Session", calories: 420, streakDays: 12)
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<WorkoutEntry>) -> Void) {
        // Fetch latest workout from shared App Group container
        let store = UserDefaults(suiteName: "group.com.example.app")
        let name = store?.string(forKey: "lastWorkout") ?? "No workout"
        let cals = store?.integer(forKey: "lastCalories") ?? 0
        let streak = store?.integer(forKey: "streak") ?? 0

        let entry = WorkoutEntry(date: .now, workoutName: name, calories: cals, streakDays: streak)
        let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: .now)!
        completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
    }
}

struct WorkoutWidget: Widget {
    let kind = "WorkoutWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: WorkoutWidgetProvider()) { entry in
            WorkoutWidgetView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .configurationDisplayName("Workout Tracker")
        .description("See your latest workout and streak.")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

12. Testing (XCTest y XCUITest)

Testing Unitario con XCTest

XCTest es el framework de testing integrado de Apple. Los tests unitarios validan lógica de forma aislada usando XCTestCase, assertions y expectations para código asincrónico. La inyección de dependencias basada en protocolos hace las clases testeables.

import XCTest
@testable import MyApp

final class WorkoutViewModelTests: XCTestCase {
    var sut: WorkoutViewModel!
    var mockAPI: MockAPIClient!

    override func setUp() {
        super.setUp()
        mockAPI = MockAPIClient()
        sut = WorkoutViewModel(apiClient: mockAPI)
    }

    override func tearDown() {
        sut = nil
        mockAPI = nil
        super.tearDown()
    }

    func testFetchWorkoutsSuccess() async throws {
        mockAPI.stubbedWorkouts = [
            Workout(name: "HIIT", duration: 1800, calories: 400)
        ]

        await sut.fetchWorkouts()

        XCTAssertEqual(sut.workouts.count, 1)
        XCTAssertEqual(sut.workouts.first?.name, "HIIT")
        XCTAssertFalse(sut.isLoading)
        XCTAssertNil(sut.errorMessage)
    }

    func testFetchWorkoutsNetworkError() async {
        mockAPI.shouldFail = true

        await sut.fetchWorkouts()

        XCTAssertTrue(sut.workouts.isEmpty)
        XCTAssertNotNil(sut.errorMessage)
    }

    func testCalorieCalculation() {
        let workout = Workout(name: "Run", duration: 3600, calories: 0)
        workout.exercises = [
            Exercise(name: "Squats", sets: 3, reps: 12),
            Exercise(name: "Lunges", sets: 3, reps: 10)
        ]

        let estimated = sut.estimateCalories(for: workout)
        XCTAssertGreaterThan(estimated, 0)
    }
}

Testing de UI con XCUITest

XCUITest controla la app a través de su UI para testing end-to-end. Lanza la app en un proceso separado, consulta elementos de accesibilidad y realiza taps, swipes e ingreso de texto.

import XCTest

final class WorkoutFlowUITests: XCTestCase {
    let app = XCUIApplication()

    override func setUpWithError() throws {
        continueAfterFailure = false
        app.launchArguments = ["--uitesting"]
        app.launchEnvironment = ["MOCK_API": "true"]
        app.launch()
    }

    func testAddWorkoutFlow() throws {
        // Navigate to workout list
        app.tabBars.buttons["Workouts"].tap()

        // Tap add button
        app.navigationBars.buttons["Add"].tap()

        // Fill in workout details
        let nameField = app.textFields["Workout Name"]
        XCTAssertTrue(nameField.waitForExistence(timeout: 3))
        nameField.tap()
        nameField.typeText("Morning HIIT")

        // Select duration
        app.buttons["30 min"].tap()

        // Save
        app.buttons["Save Workout"].tap()

        // Verify workout appears in list
        let cell = app.cells.staticTexts["Morning HIIT"]
        XCTAssertTrue(cell.waitForExistence(timeout: 5))
    }

    func testDeleteWorkout() throws {
        app.tabBars.buttons["Workouts"].tap()
        let cell = app.cells.firstMatch
        cell.swipeLeft()
        app.buttons["Delete"].tap()
        app.alerts.buttons["Confirm"].tap()
    }
}

13. Profiling con Instruments

Profiling de Rendimiento con Instruments

Instruments es la suite de profiling de Xcode para diagnosticar problemas de rendimiento. Los instrumentos clave incluyen Time Profiler para cuellos de botella de CPU, Allocations para memory leaks, Core Animation para rendering y Network para análisis de requests.

// Signpost API for custom performance measurement
import os.signpost

let log = OSLog(subsystem: "com.example.app", category: "Performance")

func loadWorkoutFeed() async {
    let signpostID = OSSignpostID(log: log)
    os_signpost(.begin, log: log, name: "LoadFeed", signpostID: signpostID)

    let workouts = try? await apiClient.fetchWorkouts()

    os_signpost(.end, log: log, name: "LoadFeed", signpostID: signpostID)
}

// MetricKit for production performance data
import MetricKit

class PerformanceManager: NSObject, MXMetricManagerSubscriber {
    func didReceive(_ payloads: [MXMetricPayload]) {
        for payload in payloads {
            // Analyze launch time, hang rate, disk writes
            if let launchMetric = payload.applicationLaunchMetrics {
                let avgLaunch = launchMetric.histogrammedTimeToFirstDraw
                    .bucketEnumerator
                analytics.track("launch_time", properties: ["data": avgLaunch])
            }
        }
    }

    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads {
            // Crash diagnostics, hang diagnostics, disk write exceptions
            if let crashes = payload.crashDiagnostics {
                for crash in crashes {
                    analytics.track("crash", properties: [
                        "signal": crash.signal,
                        "termination": crash.terminationReason ?? "unknown"
                    ])
                }
            }
        }
    }
}

// Common Instruments workflow checklist:
// 1. Time Profiler: identify hot paths and main thread blocking
// 2. Allocations: detect memory leaks and excessive allocations
// 3. Leaks: find retain cycles (especially in closures and delegates)
// 4. Core Animation: diagnose offscreen rendering and blending
// 5. Network: analyze request latency and payload sizes
Experiencia de Jose: En producción, usé el Time Profiler de Instruments para identificar un cuello de botella en el hilo principal del feed de entrenamientos causado por decodificación sincrónica de imágenes. Mover el procesamiento de imágenes a una cola en segundo plano y agregar el actor ImageCache redujo los hitches de scroll en un 80%. También integré MetricKit para monitorear tiempo de arranque y tasa de hangs en builds de producción.

14. Accesibilidad

VoiceOver, Dynamic Type y API de Accesibilidad

La accesibilidad en iOS cubre lectura de pantalla con VoiceOver, Dynamic Type para texto escalable, contraste de colores, movimiento reducido y la API de accesibilidad para elementos personalizados. Tanto SwiftUI como UIKit proporcionan modificadores y propiedades para hacer las apps usables para todos.

// SwiftUI accessibility
struct WorkoutRow: View {
    let workout: Workout

    var body: some View {
        HStack {
            Image(systemName: "flame.fill")
                .accessibilityHidden(true) // decorative
            VStack(alignment: .leading) {
                Text(workout.name)
                    .font(.headline)
                Text("\(workout.calories) cal")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(workout.name), \(workout.calories) calories")
        .accessibilityHint("Double tap to view workout details")
        .accessibilityAddTraits(.isButton)
    }
}

// Dynamic Type support
struct StatsView: View {
    @ScaledMetric(relativeTo: .title) private var iconSize = 24.0

    var body: some View {
        HStack {
            Image(systemName: "heart.fill")
                .frame(width: iconSize, height: iconSize)
            Text("Heart Rate")
                .dynamicTypeSize(...DynamicTypeSize.accessibility3)
        }
    }
}

// UIKit accessibility
class WorkoutDetailCell: UITableViewCell {
    func configure(with workout: Workout) {
        isAccessibilityElement = true
        accessibilityLabel = "\(workout.name), \(workout.duration) minutes"
        accessibilityTraits = .button
        accessibilityHint = "Opens workout details"

        // Support Dynamic Type
        titleLabel.font = UIFont.preferredFont(forTextStyle: .headline)
        titleLabel.adjustsFontForContentSizeCategory = true
    }
}

// Reduced motion support
struct AnimatedView: View {
    @Environment(\.accessibilityReduceMotion) var reduceMotion

    var body: some View {
        Circle()
            .animation(reduceMotion ? nil : .spring(), value: isExpanded)
    }
}

// Accessibility audit in tests
func testAccessibility() throws {
    let app = XCUIApplication()
    app.launch()
    try app.performAccessibilityAudit()
}
Experiencia de Jose: En producción, aseguré soporte de VoiceOver en todas las pantallas de entrenamientos, agregué Dynamic Type a cada elemento de texto e implementé alternativas de movimiento reducido para las animaciones de entrenamientos. La auditoría de accesibilidad en XCUITest detectó etiquetas faltantes antes de cada release.

15. Últimas Funcionalidades (2025-2026)

Swift 6: Concurrencia Estricta

Swift 6 introduce eliminación de data races en tiempo de compilación, un cambio de paradigma para la seguridad de concurrencia. El compilador ahora exige que el estado mutable nunca se comparta entre contextos de ejecución concurrente sin sincronización apropiada. Enforcement más estricto de @MainActor asegura que el código de UI corra donde debe. Esto elimina toda una clase de bugs de runtime que antes eran imposibles de detectar antes del despliegue.

Swift 6.2: Concurrencia Accesible

Swift 6.2 se enfoca en hacer la concurrencia más accesible. La opción de aislamiento por main-actor por defecto simplifica el caso común donde el código corre en el hilo principal. Los nuevos tipos InlineArray y Span proveen colecciones de tamaño fijo eficientes y acceso a buffers memory-safe. El soporte de WebAssembly permite a Swift compilar para la web. El nuevo paquete Subprocess ofrece una API amigable con concurrencia para procesos externos. Una API moderna de NotificationCenter usa tipos concretos en vez de strings. Debugging mejorado de async en Xcode muestra jerarquías de tareas y puntos de suspensión.

Framework Foundation Models (iOS 26)

El framework Foundation Models provee acceso al LLM on-device que potencia Apple Intelligence, con una API nativa de Swift. Disponible en iOS 26, iPadOS 26, macOS Tahoe y visionOS, habilita comprensión de lenguaje natural, generación de contenido y resumen -- todo ejecutándose enteramente on-device para privacidad. Las capacidades clave incluyen Generación Guiada (usando macros @Generable y @Guide para output estructurado), llamada a herramientas para ejecución autónoma de código, y respuestas en streaming. Funciona en cualquier dispositivo compatible con Apple Intelligence con tan solo tres líneas de código.@Generable and @Guide macros for structured output), tool calling for autonomous code execution, and streaming responses. Works on any Apple Intelligence-compatible device with as few as three lines of code.

Actualizaciones de SwiftUI

SwiftUI introduce Liquid Glass, un nuevo material de diseño que trae profundidad y translucidez a las interfaces. Charts 3D con Swift Charts e integración con RealityKit habilitan visualizaciones de datos en computación espacial. La nueva macro Animatable simplifica animaciones custom auto-generando conformance. SF Symbols recibió nuevas animaciones draw-on. Xcode 26.5 beta agrega nuevas APIs de StoreKit para suscripciones mensuales con planes de facturación con compromiso de 12 meses. Estas actualizaciones posicionan a SwiftUI aún más como el framework de UI primario para todas las plataformas Apple.

WWDC 2026: Swift 6.4, Xcode 27 e iOS 27

WWDC 2026 (8-12 de junio) entregó la siguiente ola de plataformas. Swift 6.4 refina el modelo de concurrencia reduciendo la carga de anotaciones, y agrega control de diagnósticos por bloque para ocultar advertencias específicas o promoverlas a errores en código crítico. Xcode 27 llega aproximadamente 30% más liviano con codificación agéntica integrada: un modelo local en Apple Silicon provee sugerencias en tiempo real, las tareas pesadas pueden delegarse a Anthropic Claude, Google Gemini u OpenAI, los Agent Skills escritos por Apple cubren guía de SwiftUI y modernización de código, y el nuevo Device Hub reemplaza al Simulator para pruebas en dispositivos virtuales y físicos. El framework Foundation Models gana un protocolo público LanguageModel para intercambiar proveedores de modelos cloud de terceros sin cambios de código, mientras SiriKit recibió un aviso formal de deprecación (remoción esperada alrededor del ciclo de iOS 29). iOS 27 y macOS 27 entraron en beta para desarrolladores, con lanzamiento público esperado en septiembre 2026. Apple saltó de iOS 18 a iOS 26 directamente en un ajuste único de numeración de versiones.

Desarrollo para visionOS

El desarrollo para visionOS introduce espacios inmersivos para experiencias espaciales completas, layout espacial para posicionar contenido en espacio 3D, volúmenes para contenido 3D delimitado y APIs de alineación 3D para posicionamiento preciso de objetos. SwiftUI es el framework primario, con RealityKit manejando rendering 3D. Las apps pueden mejorar progresivamente desde ventanas 2D hasta inmersión completa.

visionOS 26: Computación Espacial Mejorada

visionOS 26 (anunciado en WWDC 2025) agrega APIs volumétricas mejoradas que combinan SwiftUI, RealityKit y ARKit para apps y juegos espaciales más atractivos. Las entidades de RealityKit y sus animaciones ahora son observables y utilizables directamente en vistas SwiftUI. Los gestores de gestos escritos en SwiftUI pueden adjuntarse directamente a entidades de RealityKit. El seguimiento de manos más rápido y el soporte para accesorios espaciales amplían las posibilidades de entrada. El layout espacial de SwiftUI provee soporte integrado para animaciones, redimensionado y gestión de estado en 3D -- permitiendo a los desarrolladores construir apps espaciales con las mismas herramientas 2D que ya conocen.

Guía de Migración de Concurrencia

Migrar de Swift 5 a concurrencia Swift 6 requiere cambios sistemáticos: habilita chequeo estricto de concurrencia incrementalmente por target, anota tipos con conformance a Sendable, marca propiedades y métodos aislados por actor, reemplaza DispatchQueue.main con @MainActor, y audita todas las capturas de closures. Empieza con las advertencias-como-errores más estrictas en código nuevo mientras migras módulos existentes gradualmente.

Más Guías