📱

Cultiva Dashboard — Arquitectura SwiftUI

App iOS interna de Cultiva IA · Gestión de campañas de marketing con IA

iOS 17+ / macOS 14+ @Observable framework NavigationStack type-safe SwiftUI 5
🗺️
Arquitectura de la app Flujo de datos unidireccional
Navegación
Router @Observable
NavigationPath + enum Destination
Inyectado vía .environment(router)
Inyección global
AuthManager @Observable
Usuario actual · Sesión
Inyectado vía .environment(auth)
Vista raíz
RootView
NavigationStack(path: $router.path)
Inyecta router + auth al árbol
Vista lista
CampaignListView
@State viewModel
LazyVStack · searchable · .task{}
Vista detalle
CampaignDetailView
@State viewModel por campaignID
Subvistas aisladas
ViewModel
CampaignListViewModel
@Observable · tracking a nivel de propiedad
Re-render solo en propiedades leídas
Repositorio (protocolo)
CampaignRepository
LiveCampaignRepository (producción)
MockCampaignRepository (tests/previews)
@
Selección de property wrappers
Wrapper Cuándo usarlo
@State Valor local de la vista (toggles, campos de formulario)
@Binding Referencia bidireccional al @State del padre
@Observable + @State Modelo propio con múltiples propiedades (ViewModel)
@Observable (sin wrapper) Referencia de solo lectura pasada desde el padre
@Bindable Binding bidireccional a propiedad @Observable
@Environment Dependencias compartidas (Router, AuthManager)
@Observable ViewModel Observation framework
@Observable
final class CampaignListViewModel {
  private(set) var campaigns: [Campaign] = []
  private(set) var isLoading = false
  var searchText = ""

  // Protocolo: testable + mockeable
  private let repository: any CampaignRepository

  @MainActor
  func load() async {
    isLoading = true
    defer { isLoading = false }
    campaigns = (try? await
      repository.fetchAll()) ?? []
  }
}
¿Por qué @Observable y no ObservableObject?
Tracking a nivel de propiedad: SwiftUI solo re-renderiza la vista que leyó la propiedad que cambió. ObservableObject invalida toda la vista ante cualquier cambio.
🧭
Navegación type-safe NavigationStack + Router
enum Destination: Hashable {
  case detail(Campaign.ID)
  case settings
  case profile
}

@Observable
final class Router {
  var path = NavigationPath()

  func navigate(to dest: Destination) {
    path.append(dest)
  }
  func popToRoot() {
    path = NavigationPath()
  }
}

// RootView
NavigationStack(path: $router.path) {
  CampaignListView()
    .navigationDestination(
      for: Destination.self
    ) { dest in
      switch dest {
      case .detail(let id):
        CampaignDetailView(campaignID: id)
      case .settings:
        SettingsView()
      case .profile:
        ProfileView()
      }
    }
}.environment(router)
🧩
View composition + ViewModifier
// Subvistas pequeñas → solo re-render
// de la subvista que leyó la propiedad
struct CampaignListView: View {
  @State private var viewModel =
    CampaignListViewModel()

  var body: some View {
    VStack {
      SummaryBannerView(summary:
        viewModel.summary)
      StatusFilterView(selected:
        $viewModel.selectedStatus)
      LazyVStack { /* campañas */ }
    }
    .task { await viewModel.load() }
  }
}

// Equatable para evitar re-renders
struct CampaignRowView:
  View, Equatable {
  let campaign: Campaign
  static func == (lhs: Self, rhs: Self)
    -> Bool {
    lhs.campaign == rhs.campaign
  }
}

// ViewModifier reutilizable
struct CardModifier: ViewModifier {
  func body(content: Content)
    -> some View {
    content
      .padding()
      .background(.regularMaterial)
      .clipShape(RoundedRectangle(
        cornerRadius: 14))
  }
}
extension View {
  func cardStyle() -> some View {
    modifier(CardModifier())
  }
}
🚀
Optimización de rendimiento
// ✅ LazyVStack para listas largas
ScrollView {
  LazyVStack(spacing: 12) {
    ForEach(viewModel.filtered) { c in
      CampaignRowView(campaign: c)
    }
  }
}

// ✅ IDs estables (nunca índices de array)
ForEach(items, id: \.id) { ... }

// ✅ Trabajo async en .task{}
// (se cancela al desaparecer la vista)
.task { await viewModel.load() }

// ❌ NUNCA en body (bloquea render)
// var body: some View {
//   let data = apiCall() // ← PROHIBIDO
// }
  • LazyVStack/LazyHStack — crea vistas solo al hacerse visibles
  • IDs estables en ForEach para diff eficiente
  • .task{} para async — cancelación automática
  • Equatable en vistas con body costoso
👁️
Previews con mocks #Preview macro · iOS 17+
// Repositorio mock para previews
struct MockCampaignRepository:
  CampaignRepository {
  let items: [Campaign]
  func fetchAll() async throws
    -> [Campaign] { items }
}

// Previews con datos reales
#Preview("Lista activa") {
  NavigationStack {
    CampaignListView()
  }
  .environment(Router())
  .environment(AuthManager())
}

#Preview("Fila de campaña") {
  List {
    CampaignRowView(campaign:
      MockData.campaigns[0])
  }
}

#Preview("Detalle") {
  CampaignDetailView(
    campaignID: MockData
      .campaigns[0].id
  )
  .environment(Router())
  .environment(AuthManager())
}
Anti-patrones — código a evitar en código nuevo
  • ObservableObject / @Published / @StateObject / @EnvironmentObjectmigrar a @Observable; invalida toda la vista ante cualquier cambio
  • @EnvironmentObject para dependencias compartidas — reemplazar por @Environment(MyClass.self) con tipo explícito; falla en runtime sin mensajes de error claros
  • I/O, red o cálculo pesado en body o initusar .task{} o métodos explícitos de carga; bloquea el hilo principal
  • ViewModels como @State en subvistas que no poseen los datos — pasar datos desde el padre; rompe la fuente única de verdad
  • AnyView para borrado de tipos — preferir @ViewBuilder o Group; AnyView dificulta las optimizaciones de SwiftUI
  • Índices de array en ForEach(items.indices)usar id: \.id o Identifiable; los índices generan re-renders innecesarios al reordenar
  • Ignorar Sendable al pasar datos a actores — puede causar data races detectadas en runtime con Swift Concurrency