Aplikasi desktop yang berhasil di dunia nyata jarang berhenti pada satu jendela: formulir bertambah, validasi menumpuk, dan data harus tersimpan di berkas atau database. Bila seluruh kode menempel di satu ActionListener raksasa, setiap perubahan menyentuh tampilan, aturan bisnis, dan penyimpanan sekaligus — dan pengujian otomatis mustahil karena logika hanya bisa dipanggil lewat klik mouse. Obatnya adalah arsitektur berlapis: pisahkan model (data dan aturan bisnis), view (tampilan), dan satu perantara yang mengatur keduanya. Chapter terakhir section ini merangkum seluruh material Swing dan JavaFX ke dalam kerangka yang bisa diuji tanpa membuka jendela.

Tiga Lapisan Aplikasi Desktop #

Seluruh varian arsitektur UI — MVC, MVP, hingga MVVM — berdiri di atas pembagian tiga peran yang sama. Tabel berikut memetakan perannya:

LapisanIsi seharusnyaTidak boleh berisi
Modeldata, validasi, aturan bisnis, penyimpanankode JFrame, Scene, listener
Viewkomponen UI, penataan letak, penampilan datakeputusan bisnis, akses database
Perantara (controller/presenter/viewmodel)alur “apa terjadi bila pengguna melakukan X”menggambar piksel, menyimpan data

Aturan praktisnya satu kalimat: model tidak mengenal view, dan view tidak mengambil keputusan. Selama aturan itu terjaga, bagian terbesar kode (model + perantara) bisa diuji sebagai kelas Java biasa lewat JUnit — tanpa EDT, tanpa layar, tanpa toolkit.

MVP dengan View Pasif #

Pola yang paling sesuai untuk Swing adalah MVP (Model-View-Presenter) dengan passive view: view direduksi menjadi interface penampil, dan presenter memegang seluruh alur. Program berikut membangun aplikasi pencatat nilai siswa — tiga lapisan, dua jalur error — tanpa satu pun import Swing:

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/** Arsitektur MVP (Model-View-Presenter) dengan view pasif berupa interface. */
public class ArsitekturMvp {
    public static void main(String[] args) {
        // Lapisan model: penyimpanan data, tidak mengenal UI
        DaftarSiswa model = new DaftarSiswa();

        // Lapisan view: implementasi konsol, hanya mencatat baris
        TampilanKonsol view = new TampilanKonsol();

        // Lapisan presenter: logika aplikasi, hanya mengenal dua interface di atas
        PresenterSiswa presenter = new PresenterSiswa(model, view);

        presenter.muatUlang();                // daftar awal masih kosong
        presenter.tambah("Budi", 85);
        presenter.tambah("Siti", 92);
        presenter.tambah("Budi", 85);         // nama duplikat, jalur error
        presenter.tambah("Dewi", -5);         // nilai di luar rentang, jalur error
        presenter.muatUlang();

        System.out.println("== keluaran TampilanKonsol ==");
        for (String b : view.baris()) {
            System.out.println(b);
        }

        // View kedua membuktikan presenter tidak terikat satu tampilan
        TampilanJson viewJson = new TampilanJson();
        PresenterSiswa presenterJson = new PresenterSiswa(model, viewJson);
        presenterJson.muatUlang();

        System.out.println("== keluaran TampilanJson ==");
        for (String b : viewJson.baris()) {
            System.out.println(b);
        }
    }
}

// ===== lapisan MODEL =====
class DaftarSiswa {
    private final Map<String, Integer> nilai = new LinkedHashMap<>();

    boolean ada(String nama) {
        return nilai.containsKey(nama);
    }

    void simpan(String nama, int nilaiUjian) {
        nilai.put(nama, nilaiUjian);
    }

    Map<String, Integer> semua() {
        return new LinkedHashMap<>(nilai); // salinan terurut sesuai pendaftaran
    }
}

// ===== lapisan VIEW (kontrak pasif, tanpa logika) =====
interface TampilanSiswa {
    void tampilkanDaftar(Map<String, Integer> data);

    void tampilkanInfo(String pesan);

    void tampilkanError(String pesan);
}

class TampilanKonsol implements TampilanSiswa {
    private final List<String> baris = new ArrayList<>();

    @Override
    public void tampilkanDaftar(Map<String, Integer> data) {
        baris.add("Daftar nilai (" + data.size() + " siswa):");
        data.forEach((nama, nilai) -> baris.add("  " + nama + " -> " + nilai));
    }

    @Override
    public void tampilkanInfo(String pesan) {
        baris.add("[INFO] " + pesan);
    }

    @Override
    public void tampilkanError(String pesan) {
        baris.add("[ERROR] " + pesan);
    }

    List<String> baris() {
        return baris;
    }
}

class TampilanJson implements TampilanSiswa {
    private final List<String> baris = new ArrayList<>();

    @Override
    public void tampilkanDaftar(Map<String, Integer> data) {
        StringBuilder sb = new StringBuilder("{\"siswa\": {");
        boolean awal = true;
        for (Map.Entry<String, Integer> e : data.entrySet()) {
            if (!awal) {
                sb.append(", ");
            }
            sb.append("\"").append(e.getKey()).append("\": ").append(e.getValue());
            awal = false;
        }
        baris.add(sb.append("}}").toString());
    }

    @Override
    public void tampilkanInfo(String pesan) {
        baris.add("{\"info\": \"" + pesan + "\"}");
    }

    @Override
    public void tampilkanError(String pesan) {
        baris.add("{\"error\": \"" + pesan + "\"}");
    }

    List<String> baris() {
        return baris;
    }
}

// ===== lapisan PRESENTER =====
class PresenterSiswa {
    private final DaftarSiswa model;
    private final TampilanSiswa view;

    PresenterSiswa(DaftarSiswa model, TampilanSiswa view) {
        this.model = model;
        this.view = view;
    }

    void muatUlang() {
        view.tampilkanDaftar(model.semua());
    }

    void tambah(String nama, int nilaiUjian) {
        if (nama == null || nama.isBlank()) {
            view.tampilkanError("nama tidak boleh kosong");
            return;
        }
        if (nilaiUjian < 0 || nilaiUjian > 100) {
            view.tampilkanError("nilai harus 0-100, diterima: " + nilaiUjian);
            return;
        }
        if (model.ada(nama)) {
            view.tampilkanError("siswa " + nama + " sudah terdaftar");
            return;
        }
        model.simpan(nama, nilaiUjian);
        view.tampilkanInfo("siswa " + nama + " tersimpan");
    }
}

Output yang Dihasilkan:

text
== keluaran TampilanKonsol ==
Daftar nilai (0 siswa):
[INFO] siswa Budi tersimpan
[INFO] siswa Siti tersimpan
[ERROR] siswa Budi sudah terdaftar
[ERROR] nilai harus 0-100, diterima: -5
Daftar nilai (2 siswa):
  Budi -> 85
  Siti -> 92
== keluaran TampilanJson ==
{"siswa": {"Budi": 85, "Siti": 92}}

Mari kita bedah tiga kelas intinya:

  1. interface TampilanSiswa (lapisan view): kontrak pasif dengan tiga method — tampilkanDaftar, tampilkanInfo, tampilkanError. Tidak ada method validasi() atau simpan() di sini; view hanya menerima instruksi menampilkan.
  2. class DaftarSiswa (lapisan model): memegang LinkedHashMap nama → nilai. Lapisan ini tidak mengenal view; kode yang sama bekerja untuk aplikasi konsol, Swing, maupun JavaFX.
  3. class PresenterSiswa (lapisan perantara): method tambah(...) memeriksa tiga aturan berurutan — nama kosong, nilai di luar rentang 0-100, nama duplikat — dan setiap kegagalan memanggil view.tampilkanError(...) lalu berhenti. Bila seluruh pemeriksaan lolos, presenter menyimpan ke model dan memerintahkan view.tampilkanInfo(...) — seluruh keputusan terkumpul di satu kelas yang bisa diuji.
  4. main: menyusun tiga lapisan lewat konstruktor, lalu mengemudikan skenario: dua tambahan sukses, satu duplikat, satu nilai tidak valid, satu pemuatan ulang.

Perhatikan baris [ERROR]: kedua kegagalan muncul dari presenter, bukan dari view — presenter tidak mencetak apa pun, hanya berbicara lewat interface.

Tampilan Bisa Ditukar Logika Tetap #

Bagian kedua main memasang presenter baru dengan TampilanJson — view yang merender data sama sebagai JSON — hasilnya satu baris {"siswa": {"Budi": 85, "Siti": 92}}. Presenter tidak berubah satu karakter pun; itulah alasan kontrak view berupa interface.

Pada aplikasi Swing sungguhan, view konkrit memegang komponen dan hanya meneruskan kejadian ke presenter:

java TampilanSwing.java — potongan implementasi view Swing (menampilkan pola, tidak dieksekusi)
import java.awt.BorderLayout;
import javax.swing.*;

class TampilanSwing implements TampilanSiswa {
    private final JFrame jendela = new JFrame("Data Siswa");
    private final JTextArea area = new JTextArea();
    private final PresenterSiswa presenter = new PresenterSiswa(new DaftarSiswa(), this);

    TampilanSwing() {
        JButton tambah = new JButton("Tambah Budi");
        tambah.addActionListener(e -> presenter.tambah("Budi", 85)); // teruskan, jangan putuskan
        jendela.add(new JScrollPane(area), BorderLayout.CENTER);
        jendela.add(tambah, BorderLayout.SOUTH);
    }

    @Override public void tampilkanDaftar(java.util.Map<String, Integer> data) {
        area.setText(""); // view hanya menampilkan
        data.forEach((nama, nilai) -> area.append(nama + " -> " + nilai + "\n"));
    }
    @Override public void tampilkanInfo(String pesan)  { area.append("[INFO] " + pesan + "\n"); }
    @Override public void tampilkanError(String pesan) { area.append("[ERROR] " + pesan + "\n"); }
}

Listener tombol hanya berisi satu baris terusan presenter.tambah(...) — tanpa if, tanpa validasi. Bila listener mulai memanjang, kode tersebut biasanya berada di lapisan yang salah.

MVC MVP dan MVVM #

Tiga nama yang sering dipakai bertukar punya beda nyata pada titik siapa yang menghubungkan model dan view:

AspekMVC klasikMVP (passive view)MVVM
Penghubungcontroller memperbarui viewpresenter memberi perintah eksplisitdata binding otomatis
View tahu model?kadang (observer)tidak sama sekalitahu lewat property
Khas diSwing (TableModel, observer)Swing, aplikasi dengan banyak pengujianJavaFX properties dan bindings

JavaFX lahir dengan mesin MVVM: SimpleIntegerProperty serta bind(...) dari chapter JavaFX Properties dan Bindings adalah viewmodel dalam bentuk property — perubahan model mengalir ke view tanpa kode antara. Swing tidak punya mesin itu, sehingga MVP view pasif menjadi pilihan yang paling bisa diuji.

Dengan kerangka tiga lapisan ini, aplikasi desktop kita punya tulang yang jelas: model yang bisa diuji, view yang bisa ditukar, dan perantara yang memegang alur. Prinsip yang sama akan kembali muncul saat aplikasi kita pindah dari jendela desktop ke halaman browser, pada Section Web Development berikutnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

ArsitekturMvp.java

Unduh berkas
java
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/** Arsitektur MVP (Model-View-Presenter) dengan view pasif berupa interface. */
public class ArsitekturMvp {
    public static void main(String[] args) {
        // Lapisan model: penyimpanan data, tidak mengenal UI
        DaftarSiswa model = new DaftarSiswa();

        // Lapisan view: implementasi konsol, hanya mencatat baris
        TampilanKonsol view = new TampilanKonsol();

        // Lapisan presenter: logika aplikasi, hanya mengenal dua interface di atas
        PresenterSiswa presenter = new PresenterSiswa(model, view);

        presenter.muatUlang();                // daftar awal masih kosong
        presenter.tambah("Budi", 85);
        presenter.tambah("Siti", 92);
        presenter.tambah("Budi", 85);         // nama duplikat, jalur error
        presenter.tambah("Dewi", -5);         // nilai di luar rentang, jalur error
        presenter.muatUlang();

        System.out.println("== keluaran TampilanKonsol ==");
        for (String b : view.baris()) {
            System.out.println(b);
        }

        // View kedua membuktikan presenter tidak terikat satu tampilan
        TampilanJson viewJson = new TampilanJson();
        PresenterSiswa presenterJson = new PresenterSiswa(model, viewJson);
        presenterJson.muatUlang();

        System.out.println("== keluaran TampilanJson ==");
        for (String b : viewJson.baris()) {
            System.out.println(b);
        }
    }
}

// ===== lapisan MODEL =====
class DaftarSiswa {
    private final Map<String, Integer> nilai = new LinkedHashMap<>();

    boolean ada(String nama) {
        return nilai.containsKey(nama);
    }

    void simpan(String nama, int nilaiUjian) {
        nilai.put(nama, nilaiUjian);
    }

    Map<String, Integer> semua() {
        return new LinkedHashMap<>(nilai); // salinan terurut sesuai pendaftaran
    }
}

// ===== lapisan VIEW (kontrak pasif, tanpa logika) =====
interface TampilanSiswa {
    void tampilkanDaftar(Map<String, Integer> data);

    void tampilkanInfo(String pesan);

    void tampilkanError(String pesan);
}

class TampilanKonsol implements TampilanSiswa {
    private final List<String> baris = new ArrayList<>();

    @Override
    public void tampilkanDaftar(Map<String, Integer> data) {
        baris.add("Daftar nilai (" + data.size() + " siswa):");
        data.forEach((nama, nilai) -> baris.add("  " + nama + " -> " + nilai));
    }

    @Override
    public void tampilkanInfo(String pesan) {
        baris.add("[INFO] " + pesan);
    }

    @Override
    public void tampilkanError(String pesan) {
        baris.add("[ERROR] " + pesan);
    }

    List<String> baris() {
        return baris;
    }
}

class TampilanJson implements TampilanSiswa {
    private final List<String> baris = new ArrayList<>();

    @Override
    public void tampilkanDaftar(Map<String, Integer> data) {
        StringBuilder sb = new StringBuilder("{\"siswa\": {");
        boolean awal = true;
        for (Map.Entry<String, Integer> e : data.entrySet()) {
            if (!awal) {
                sb.append(", ");
            }
            sb.append("\"").append(e.getKey()).append("\": ").append(e.getValue());
            awal = false;
        }
        baris.add(sb.append("}}").toString());
    }

    @Override
    public void tampilkanInfo(String pesan) {
        baris.add("{\"info\": \"" + pesan + "\"}");
    }

    @Override
    public void tampilkanError(String pesan) {
        baris.add("{\"error\": \"" + pesan + "\"}");
    }

    List<String> baris() {
        return baris;
    }
}

// ===== lapisan PRESENTER =====
class PresenterSiswa {
    private final DaftarSiswa model;
    private final TampilanSiswa view;

    PresenterSiswa(DaftarSiswa model, TampilanSiswa view) {
        this.model = model;
        this.view = view;
    }

    void muatUlang() {
        view.tampilkanDaftar(model.semua());
    }

    void tambah(String nama, int nilaiUjian) {
        if (nama == null || nama.isBlank()) {
            view.tampilkanError("nama tidak boleh kosong");
            return;
        }
        if (nilaiUjian < 0 || nilaiUjian > 100) {
            view.tampilkanError("nilai harus 0-100, diterima: " + nilaiUjian);
            return;
        }
        if (model.ada(nama)) {
            view.tampilkanError("siswa " + nama + " sudah terdaftar");
            return;
        }
        model.simpan(nama, nilaiUjian);
        view.tampilkanInfo("siswa " + nama + " tersimpan");
    }
}