MVC em Swing e os models

TL;DR

O Swing não implementa um MVC clássico de três objetos separados; usa uma separable model architecture: cada componente tem um model que guarda os dados, enquanto view e controller ficam fundidos num único objeto — o UI delegate (a classe *UI do look-and-feel). Você troca e customiza o model livremente; raramente toca na view+controller. Os models centrais são TableModel (para JTable), ListModel/ComboBoxModel (para JList/JComboBox), Document (texto) e TreeModel (JTree). A regra de ouro: o componente “does not contain or cache data; it is simply a view of your data” — então a view só repinta quando o model dispara o evento certo (fireTableRowsInserted, fireTableDataChanged, etc.). Mutar a List interna sem disparar o evento deixa a tela dessincronizada. Em uso real, prefira um AbstractTableModel tipado a um DefaultTableModel cru de Object[][].

O que é

“MVC em Swing” é uma expressão imprecisa, e numa entrevista vale corrigir o termo: o Swing usa uma variação chamada separable model architecture (arquitetura de model separável). No MVC clássico há três objetos distintos — Model, View, Controller. O Swing fundiu dois deles:

  • Model — objeto separado, que guarda os dados (e o estado de seleção). É o que você customiza no dia a dia.
  • View + Controller — fundidos num único objeto, o UI delegate (a classe terminada em UI, ex.: BasicTableUI), fornecido pelo look-and-feel. Ele desenha o componente e interpreta a entrada do usuário.

A razão da fusão é pragmática: separar pintura de tratamento de input rendia pouco e acoplava muito (ambos precisam de acesso íntimo ao mesmo estado de baixo nível). Separar o model, ao contrário, rende muito — é o eixo que permite plugar seus próprios dados num JTable ou JList sem reescrever a renderização.

O ponto-chave, na formulação da Oracle para JTable: “JTable does not contain or cache data; it is simply a view of your data.” O componente é uma janela sobre o model; quem detém a verdade dos dados é o model. Disso decorre tudo o mais nesta nota.

Como funciona

Separable model (por que separar dados de apresentação; um model por componente)

A separação dados/apresentação é o que dá ao Swing flexibilidade real:

  • Seus dados, sua estrutura. O tutorial é explícito: “Your model might hold its data in an array, vector, or hash map, or it might get the data from an outside source such as a database. It might even generate the data at execution time.” O componente não impõe um formato de armazenamento.
  • Troca de view sem tocar nos dados. O mesmo TableModel pode alimentar um JTable com qualquer look-and-feel; o model não sabe como é pintado.
  • Notificação por eventos. Como o componente não cacheia dados, ele depende do model para avisar quando algo muda — via eventos. É a espinha dorsal da reatividade da view (detalhada adiante).

Cada componente orientado a dados tem seu próprio tipo de model, com uma interface, uma classe abstrata de conveniência e uma implementação default:

ComponenteInterface do modelClasse de conveniênciaDefault pronto
JTableTableModelAbstractTableModelDefaultTableModel
JListListModelAbstractListModelDefaultListModel
JComboBoxComboBoxModel(estende ListModel)DefaultComboBoxModel
JTreeTreeModelDefaultTreeModel
JTextArea/JTextFieldDocumentAbstractDocumentPlainDocument

Models de UI vs models de seleção

Cada componente tem na verdade dois models: o de dados (ex.: TableModel) e o de seleção (ex.: ListSelectionModel). Esta nota cobre os dois — a seleção aparece na seção de selection models.

TableModel / AbstractTableModel / DefaultTableModel

A interface TableModel “specifies the methods the JTable will use to interrogate a tabular data model.” Os métodos essenciais:

int     getRowCount();                              // quantas linhas
int     getColumnCount();                           // quantas colunas
String  getColumnName(int columnIndex);             // título da coluna
Class<?> getColumnClass(int columnIndex);           // tipo da coluna (renderer/editor default)
boolean isCellEditable(int rowIndex, int columnIndex);
Object  getValueAt(int rowIndex, int columnIndex);  // valor de uma célula
void    setValueAt(Object aValue, int rowIndex, int columnIndex);
void    addTableModelListener(TableModelListener l);
void    removeTableModelListener(TableModelListener l);

getColumnClass merece destaque: ele “is used by the JTable to set up a default renderer and editor for the column” — devolver Boolean.class faz a coluna virar checkboxes; Integer.class/Number alinha à direita. (Como a view escolhe o renderer a partir desse tipo é o tema de Renderers e editors.)

Os três níveis de implementação:

  • TableModel (interface) — você implementa tudo, inclusive a gestão de listeners. Raro.
  • AbstractTableModel“provides default implementations for most of the methods… It takes care of the management of listeners and provides some conveniences for generating TableModelEvents.” Você só precisa fornecer getRowCount(), getColumnCount() e getValueAt(...). É a escolha idiomática para models customizados. Para uma tabela editável, é preciso também sobrescrever isCellEditable (que por padrão retorna false) e setValueAt.
  • DefaultTableModel — armazenamento pronto baseado em Vector/Object[][]. Conveniente para protótipos, mas perde type-safety (tudo é Object) e não modela um domínio.

ListModel / AbstractListModel / DefaultListModel e ComboBoxModel

O JList segue o mesmo padrão de três níveis. O tutorial descreve as opções:

  1. DefaultListModel“everything is pretty much taken care of for you.” Mutável: addElement, remove, insertElementAt.
  2. AbstractListModel“you manage the data and invoke the ‘fire’ methods… you must subclass AbstractListModel and implement the getSize and getElementAt methods inherited from the ListModel interface.”
  3. ListModel“you manage everything.”

Uma armadilha sutil na construção: “If you initialize a list with an array or vector, the constructor implicitly creates a default list model. The default list model is immutable — you cannot add, remove, or replace items in the list.” Ou seja, new JList<>(new String[]{...}) gera um model que você não pode mutar depois.

O ComboBoxModel estende ListModel, acrescentando o conceito de item selecionado (getSelectedItem / setSelectedItem). O default é DefaultComboBoxModel.

Document (modelo de texto) e TreeModel (menção)

Nem todo model é uma lista de linhas:

  • Document — é o model dos componentes de texto (JTextField, JTextArea, JEditorPane). Modela o conteúdo textual como uma sequência de caracteres com atributos e estrutura (elementos), não como uma String simples. Por isso edições de texto disparam DocumentEvent, e validação/máscara de entrada se faz no Document (via DocumentFilter), não no componente.
  • TreeModel — model do JTree, que expõe dados hierárquicos (um nó-raiz, e para cada nó seus filhos). O DefaultTreeModel com DefaultMutableTreeNode cobre a maioria dos casos. Mencionado aqui para completar o mapa; o foco desta nota é TableModel/ListModel.

Model listeners (TableModelListener) e como a view atualiza via eventos

Como o componente não cacheia dados, ele se registra como listener do model. Quando o model muda, ele dispara um evento; a view o recebe e repinta a região afetada. O tutorial: “Whenever items are added to, removed from, or modified in a list, the list model fires list data events.”

Para tabelas, AbstractTableModel oferece métodos fire* que constroem e despacham o TableModelEvent certo:

MétodoQuando usar
fireTableRowsInserted(firstRow, lastRow)linhas [firstRow, lastRow] inseridas
fireTableRowsDeleted(firstRow, lastRow)linhas [firstRow, lastRow] removidas
fireTableCellUpdated(row, column)uma célula mudou
fireTableRowsUpdated(firstRow, lastRow)valores de linhas mudaram
fireTableDataChanged()todos os dados podem ter mudado (estrutura igual)
fireTableStructureChanged()colunas mudaram (recria os column headers)

A precisão importa: fireTableRowsInserted(2, 2) informa à view exatamente qual linha apareceu, permitindo repaint incremental. fireTableDataChanged() é o martelo grande — repinta tudo e perde a seleção/posição. Use o evento mais específico que descreve a mudança.

// dentro do model: ao editar uma célula, notifique
@Override
public void setValueAt(Object value, int row, int col) {
    data.get(row).update(col, value);
    fireTableCellUpdated(row, col);   // a view repinta só essa célula
}

Para ouvir mudanças do model (ex.: recalcular um total ao editar), registre um TableModelListener:

table.getModel().addTableModelListener(e -> {
    int row = e.getFirstRow();
    int col = e.getColumn();
    // reagir à mudança (e.getType() é INSERT, UPDATE ou DELETE)
});

Selection models (ListSelectionModel)

A seleção é um model à parte, compartilhado por JList e JTable: o ListSelectionModel. “A list uses an instance of ListSelectionModel to manage its selection.” Os três modos:

ModoComportamento
SINGLE_SELECTIONum item por vez; selecionar um deseleciona o anterior
SINGLE_INTERVAL_SELECTIONum intervalo contíguo
MULTIPLE_INTERVAL_SELECTIONdefault — qualquer combinação de itens
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);

Mudanças de seleção disparam ListSelectionEvent, ouvido por um ListSelectionListener. O detalhe que pega quem não conhece: “Many list selection events can be generated from a single user action such as a mouse click. The getValueIsAdjusting method returns true if the user is still manipulating the selection.” Processe apenas o evento final:

list.addListSelectionListener(e -> {
    if (!e.getValueIsAdjusting()) {           // ignora eventos intermediários
        int idx = list.getSelectedIndex();
        // reagir à seleção definitiva
    }
});

Na prática

Em uso real, modele o domínio: um AbstractTableModel tipado sobre uma List<Order> é muito melhor que um DefaultTableModel de Object[][]. Ele dá type-safety (cada coluna conhece seu tipo), expõe os objetos de domínio diretamente (sem copiar para um array) e deixa explícito o que cada coluna significa.

import javax.swing.table.AbstractTableModel;
import java.util.ArrayList;
import java.util.List;
 
public class OrderTableModel extends AbstractTableModel {
 
    private static final String[] COLUMNS = { "ID", "Customer", "Total", "Paid" };
 
    private final List<Order> orders = new ArrayList<>();
 
    public OrderTableModel(List<Order> initial) {
        orders.addAll(initial);
    }
 
    @Override
    public int getRowCount() {
        return orders.size();
    }
 
    @Override
    public int getColumnCount() {
        return COLUMNS.length;
    }
 
    @Override
    public String getColumnName(int column) {
        return COLUMNS[column];
    }
 
    @Override
    public Class<?> getColumnClass(int column) {
        // dirige renderer/editor default: Boolean -> checkbox, etc.
        return switch (column) {
            case 0 -> Integer.class;
            case 2 -> java.math.BigDecimal.class;
            case 3 -> Boolean.class;
            default -> String.class;
        };
    }
 
    @Override
    public Object getValueAt(int row, int column) {
        Order o = orders.get(row);
        return switch (column) {
            case 0 -> o.id();
            case 1 -> o.customer();
            case 2 -> o.total();        // BigDecimal cru — formatação fica no renderer
            case 3 -> o.paid();
            default -> null;
        };
    }
 
    // Mutação que MANTÉM a view em sincronia: muda os dados e dispara o evento certo.
    public void addOrder(Order order) {
        int newRow = orders.size();
        orders.add(order);
        fireTableRowsInserted(newRow, newRow);   // view repinta só a linha nova
    }
}
// Uso:
//   OrderTableModel model = new OrderTableModel(carregarPedidos());
//   JTable table = new JTable(model);
//   panel.add(new JScrollPane(table));   // cabeçalhos só aparecem dentro do JScrollPane
//
//   model.addOrder(novoPedido);   // a tabela ganha a linha automaticamente

Por que isso supera o DefaultTableModel com Object[][]:

  • Type-safety e clarezagetColumnClass devolve tipos reais; getValueAt lê de um Order, não de uma célula Object sem nome.
  • Fonte única de verdade — os dados continuam na List<Order> do domínio; a tabela é só uma view sobre ela.
  • Eventos corretos de graçafireTableRowsInserted vem de AbstractTableModel; você só dispara no ponto certo.

Armadilhas

(1) Mutar a List interna sem disparar fireTable... — a view fica dessincronizada

Descrição. Como o componente não cacheia dados, ele só sabe que algo mudou quando o model dispara o evento. Se você muta a List interna e esquece o fire*, a tela continua mostrando o estado antigo até um repaint acidental (redimensionar a janela, trocar de aba). O bug parece intermitente: “às vezes a linha nova aparece”.

// RUIM — adiciona o dado mas não notifica a view
public void addOrder(Order order) {
    orders.add(order);   // a JTable não repinta: nenhum evento foi disparado
}

Fix (1 linha): dispare o evento que descreve a mudança — fireTableRowsInserted(newRow, newRow); após o add (ou fireTableRowsDeleted após remover).


(2) Usar DefaultTableModel com Object[][]/Vector cru onde um AbstractTableModel tipado serviria melhor

Descrição. new DefaultTableModel(Object[][] data, Object[] cols) é tentador, mas tudo vira Object: sem getColumnClass decente, números viram texto alinhado à esquerda, booleanos não viram checkbox, e a lógica de domínio fica espalhada em índices mágicos de array. Numa entrevista, sinaliza desconhecimento da separable model.

// RUIM — dados crus, sem tipos, sem domínio
Object[][] rows = { { 1, "Acme", "199.90", false } };
JTable table = new JTable(new DefaultTableModel(rows, COLUMNS));
// coluna "Total" é String; coluna "Paid" não vira checkbox

Fix (1 linha): modele o domínio com um AbstractTableModel tipado sobre List<Order> (como em Na prática), com getColumnClass retornando os tipos reais.


(3) Colocar formatação/lógica de negócio no model (getValueAt) em vez de no renderer

Descrição. É tentador formatar moeda, data ou status dentro de getValueAt (devolver "R$ 199,90" em vez de BigDecimal). Isso quebra a separação: o model passa a guardar String de apresentação, perde-se a ordenação numérica correta, e o getColumnClass deixa de fazer sentido. Apresentação é trabalho da view, não do model.

// RUIM — formatação dentro do model
@Override
public Object getValueAt(int row, int col) {
    if (col == 2) {
        return "R$ " + orders.get(row).total();  // String formatada no model
    }
    // ...
}

Fix (1 linha): devolva o valor cru (BigDecimal total) e formate na view com um TableCellRenderer — ver Renderers e editors.

Em entrevista

Frase pronta (inglês)

“People often say Swing follows MVC, but it’s more precise to call it a separable model architecture. Classic MVC has three separate objects — model, view, and controller — whereas Swing merges the view and the controller into a single UI delegate provided by the look-and-feel, and keeps only the model as the separable, pluggable piece. That’s a deliberate trade-off: separating painting from input handling wasn’t worth the coupling, but separating the data model is hugely valuable, because it lets you back a JTable or JList with your own data — an array, a list of domain objects, or even a database query — without touching how it’s painted. The crucial consequence is that the component, in Oracle’s words, does not contain or cache data; it is simply a view of your data, so the view only repaints when the model fires the right event — fireTableRowsInserted, fireTableDataChanged, and so on. In real code I extend AbstractTableModel over a typed List<Order> rather than using a raw DefaultTableModel of Object[][], because that gives me type-safety through getColumnClass and keeps formatting in the renderer instead of the model.”

Vocabulário

Termo PTTermo EN
arquitetura de model separávelseparable model architecture
model separado / plugávelseparable / pluggable model
view e controller fundidosmerged view and controller
delegate de interfaceUI delegate
disparar um evento do modelfire a model event
ouvinte do modelmodel listener (TableModelListener)
model de seleçãoselection model (ListSelectionModel)
repintar incrementalmenterepaint incrementally
segurança de tipotype-safety
fonte única de verdadesingle source of truth

Veja também

Referências