Skip to content

📙 Clase 29 — Arquitectura de una app de escritorio

Fase 6 · Especialización: Apps de escritorio 🎯 ⬅️ Volver al índice de clases

🎯 Qué aprendí

  • Las 3 capas de una app profesional: datos ↔ lógica ↔ interfaz (MVC ligero).
  • La estructura de carpetas estándar y la regla de dependencias.
  • Cómo fluye un clic de principio a fin por las capas.

📖 PARTE TEÓRICA

🏛️ 1. Las 3 capas (MVC ligero)

Todo lo que construiste en las Fases 2–5 apuntaba aquí. Una app seria se divide en:

┌─────────────────────────────────────────────────────┐
│  VISTA (ui.py)                                      │  🎨 widgets, layouts,
│  "muestra y captura"                                │     callbacks
├─────────────────────────────────────────────────────┤
│  LÓGICA (logica.py)          ← el "controlador/     │  🧠 validaciones, reglas,
│  "decide"                       modelo de negocio"  │     funciones puras
├─────────────────────────────────────────────────────┤
│  DATOS (datos.py)                                   │  🗄️ SQLite / JSON / API
│  "guarda y recupera"                                │
└─────────────────────────────────────────────────────┘
        Las flechas de import apuntan SIEMPRE hacia abajo:
        ui → logica → datos   (jamás al revés)
CapaResponde aPuede importarPROHIBIDO
Vista"¿cómo se ve y qué tocó el usuario?"logica, datosreglas de negocio
Lógica"¿es válido? ¿qué hacemos?"datosimport ui, tocar widgets
Datos"¿dónde y cómo se guarda?"conocer la lógica o la vista

📁 2. Estructura de carpetas estándar

mi_app/
├── main.py            🚪 punto de entrada (10 líneas máx)
├── ui/
│   ├── app.py         class App(ctk.CTk) — arma la ventana
│   └── paneles.py     frames-clase reutilizables (Clase 15)
├── logica/
│   └── contactos.py   validar_contacto(), reglas
├── datos/
│   └── bd.py          conectar(), CRUD (Clases 30-31)
└── requirements.txt   customtkinter==6.0.0 … (pip freeze)
python
# main.py — TODO el arranque:
from ui.app import App

if __name__ == "__main__":
    App().mainloop()

📌 Una carpeta con módulos es un paquete; se importa con puntos: from ui.app import App. Para apps pequeñas (tus ejercicios), la versión plana ui.py / logica.py / datos.py en la raíz es perfectamente válida — misma regla de capas, menos ceremonias.

🔄 3. La vida de un clic

Un clic en "Guardar" recorre las capas bajando y subiendo:

usuario ── clic ──▶ VISTA    callback _guardar(): lee entries


                    LÓGICA   validar_contacto(nombre, tel) → (ok, msg)
                      │            │
                      │ ok=False   │ ok=True
                      ▼            ▼
                    VISTA        DATOS    insertar_contacto(con, dict)
                  muestra msg      │

                    VISTA  ◀── vuelve ok → repintar lista + status "✅"

🧪 Tip de entrevista: "¿Qué es MVC?" → Separar Modelo (datos/estado), Vista (presentación) y Controlador (coordinación) para poder cambiar/probar cada parte sin romper las otras. En apps de escritorio pequeñas se usa una versión ligera: vista / lógica / datos.

🧪 4. El premio: probar sin abrir ventanas

Como la lógica y los datos no conocen la GUI, se prueban en terminal (¡o con pytest!):

python
# prueba rápida sin ventana:
from logica.contactos import validar_contacto
assert validar_contacto("", "999") == (False, "El nombre está vacío")
assert validar_contacto("Ana", "999")[0] is True
print("lógica OK ✔")

💻 PARTE PRÁCTICA — tu misión

Toma tu To-Do (ya refactorizado como clase en la Clase 18) y divídelo en 3 archivos. Pasos:

  1. Crea logica.py y mueve ahí validar() y cualquier regla (funciones puras).
  2. Crea datos.py y mueve ahí _cargar()/_guardar() (JSON por ahora; en la Clase 30 lo cambiarás por SQLite sin tocar las otras capas — esa es la gracia).
  3. app.py (la vista) queda solo con widgets y callbacks que llaman a las otras dos.
  4. main.py con el guardián y 3 líneas.
  5. Checklist: ¿logica.py importa customtkinter? ❌ No debe. ¿datos.py sabe qué es un label? ❌ Jamás.

🔐 Solución de referencia (ábrela solo DESPUÉS de intentarlo)

Verificada en el .venv: las capas logica/datos pasan sus pruebas sin GUI, y la vista compila e importa. Compárala con tu versión capa por capa.

Ver solución · logica.py
python
"""Reglas puras: sin widgets, sin archivos."""

def validar_tarea(texto: str) -> tuple[bool, str]:
    if not texto.strip():
        return False, "La tarea no puede estar vacía"
    return True, ""
Ver solución · datos.py (JSON)
python
"""Capa de datos: lo único que sabe CÓMO se guarda."""
import json
from pathlib import Path

RUTA = Path.home() / ".mi_todo" / "tareas.json"

def cargar() -> list[dict]:
    try:
        with open(RUTA, encoding="utf-8") as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return []

def guardar(tareas: list[dict]) -> None:
    RUTA.parent.mkdir(exist_ok=True)
    with open(RUTA, "w", encoding="utf-8") as f:
        json.dump(tareas, f, ensure_ascii=False, indent=2)
Ver solución · app.py (la vista)
python
"""Vista: SOLO widgets y callbacks; delega en logica y datos."""
import customtkinter as ctk
import logica
import datos

class App(ctk.CTk):
    def __init__(self):
        super().__init__()
        self.title("Mi To-Do")
        self.geometry("420x520")
        self._tareas = datos.cargar()          # restaurar al abrir
        self._crear_widgets()
        self._pintar()

    def _crear_widgets(self):
        self.entry = ctk.CTkEntry(self, placeholder_text="Nueva tarea…")
        self.entry.pack(padx=12, pady=12, fill="x")
        ctk.CTkButton(self, text="Agregar", command=self._agregar).pack()
        self.lista = ctk.CTkScrollableFrame(self)
        self.lista.pack(fill="both", expand=True, padx=12, pady=12)
        self.status = ctk.CTkLabel(self, text="")
        self.status.pack()

    def _agregar(self):
        ok, msg = logica.validar_tarea(self.entry.get())    # ← capa lógica
        if not ok:
            self.status.configure(text=f"⚠️ {msg}")
            return
        self._tareas.append({"texto": self.entry.get().strip(), "hecha": False})
        datos.guardar(self._tareas)                          # ← capa datos
        self.entry.delete(0, "end")
        self._pintar()

    def _alternar(self, tarea):
        tarea["hecha"] = not tarea["hecha"]
        datos.guardar(self._tareas)
        self._pintar()

    def _pintar(self):
        for w in self.lista.winfo_children():
            w.destroy()
        for tarea in self._tareas:
            icono = "✅" if tarea["hecha"] else "⬜"
            ctk.CTkCheckBox(self.lista, text=f"{icono} {tarea['texto']}",
                            command=lambda t=tarea: self._alternar(t)
                            ).pack(anchor="w", pady=2)
        pendientes = sum(1 for t in self._tareas if not t["hecha"])
        self.status.configure(text=f"Pendientes: {pendientes}")
Ver solución · main.py
python
from app import App

if __name__ == "__main__":
    App().mainloop()

❓ Preguntas y respuestas (autoevaluación)

1. ¿En qué capa vive entry.get() y en cuál "el email debe tener @"?

entry.get() en la vista (widget); la regla del email en la lógica.

2. ¿Hacia dónde apuntan los imports entre capas?

Siempre "hacia abajo": ui → logica → datos. Nunca al revés.

3. ¿Qué ganas concretamente con la separación?

Probar lógica/datos sin GUI, cambiar el almacenamiento (JSON→SQLite) sin tocar la vista, y reusar la lógica en otra interfaz (CLI, web).

4. ¿Por qué main.py debe ser mínimo?

Es solo el punto de entrada: si acumula lógica, vuelve a mezclar capas y no se puede importar limpio (rompe el guardián __main__).


📎 Apuntes relacionados

  • Módulos y la regla de dependencias → Clase 11
  • La App-clase que ahora se reparte → Clase 18

➡️ Siguiente

Clase 30 · SQLite I — la capa de datos de verdad: crear la BD e insertar.