docs added
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
# Архитектура gui-client
|
||||
|
||||
## 1. Обзор проекта
|
||||
|
||||
Проект состоит из четырёх Gradle-модулей:
|
||||
|
||||
```
|
||||
common/ — общие модели (HumanBeing, Coordinates, Car, WeaponType)
|
||||
и сетевые типы (Request, Response, AuthRequest)
|
||||
client/ — низкоуровневый UDP-клиент (NetworkManager)
|
||||
server/ — сервер (ForkJoinPool, Exposed ORM, PostgreSQL)
|
||||
gui-client/ — JavaFX-клиент (наш модуль)
|
||||
gateway/ — UDP/TCP-шлюз
|
||||
```
|
||||
|
||||
Модуль `gui-client` — это толстый JavaFX-клиент, который:
|
||||
- аутентифицируется (login / register) через UDP
|
||||
- отображает коллекцию HumanBeing в таблице и на Canvas
|
||||
- позволяет добавлять, редактировать, удалять объекты
|
||||
- автоматически обновляет данные каждые 3 секунды (polling)
|
||||
- поддерживает 4 языка (ru, nl, hu, en_AU) без перезапуска
|
||||
- собирается в fat JAR (`shadowJar`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Почему JavaFX + Kotlin, а не что-то ещё
|
||||
|
||||
**JavaFX** выбран, потому что:
|
||||
- Это требование лабораторной работы — использовать JavaFX.
|
||||
- Даёт полноценный desktop-GUI: TableView, Canvas, модальные окна, CSS-styling.
|
||||
- Работает вне браузера, что важно для UDP-сокетов (браузеры не поддерживают произвольные UDP).
|
||||
|
||||
**Kotlin** выбран вместо Java, потому что:
|
||||
- Значительно короче (data class, scope functions, лямбды, trailing lambda).
|
||||
- Полная совместимость c JavaFX (прозрачная interop через Kotlin → JVM байткод).
|
||||
- Coroutines для асинхронной работы с UDP (без callback-hell).
|
||||
- Kotlin 2.2.0 с новым компилятором K2.
|
||||
|
||||
---
|
||||
|
||||
## 3. Структура gui-client
|
||||
|
||||
```
|
||||
gui-client/src/main/kotlin/
|
||||
├── Main.kt — точка входа
|
||||
├── localization/
|
||||
│ └── AppLocale.kt — i18n (4 локали, динамическое переключение)
|
||||
├── model/
|
||||
│ ├── Session.kt — глобальное состояние (singleton)
|
||||
│ └── HumanBeingProperty.kt — observable-обёртка над HumanBeing
|
||||
├── network/
|
||||
│ └── ClientProvider.kt — coroutine-обёртка над NetworkManager
|
||||
├── controller/
|
||||
│ ├── AuthController.kt — логика аутентификации
|
||||
│ └── MainController.kt — логика главного экрана (polling, фильтры, команды)
|
||||
├── view/
|
||||
│ ├── AuthView.kt — окно входа / регистрации
|
||||
│ ├── MainView.kt — главное окно (таблица + Canvas + тулбар)
|
||||
│ ├── EditDialog.kt — модальное окно добавления / редактирования
|
||||
│ └── CollectionCanvas.kt — Canvas-визуализация
|
||||
└── util/
|
||||
└── StreamUtils.kt — Streams API filter + sort
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Почему такая архитектура?
|
||||
|
||||
### 4.1. Разделение на View / Controller
|
||||
|
||||
Выбран **MVC-подобный паттерн** (не MVVM, не MVP):
|
||||
|
||||
- **View** (пакет `view/`) — только построение UI-дерева и обработка пользовательских событий. Никакой бизнес-логики, никаких прямых вызовов сети.
|
||||
- **Controller** (пакет `controller/`) — бизнес-логика, асинхронные операции, работа с Session и ClientProvider.
|
||||
- **Model** (пакет `model/`) — состояние и observable-свойства.
|
||||
|
||||
Почему не MVVM:
|
||||
- JavaFX TableView из коробки требует `ObservableList` + cell value factories, что естественно ложится на простые property-обёртки.
|
||||
- Добавление ViewModel-слоя (ещё один уровень абстракции) неоправданно усложнило бы код для desktop-приложения такого размера.
|
||||
- Контроллер в нашем случае — не JavaFX Controller (не `@FXML`), а обычный класс, который вызывается из View. Это разделение достаточно для тестируемости и переиспользования.
|
||||
|
||||
Почему `c` (controller) строчной, а `v` (view) строчной — Kotlin style (package names lowercase).
|
||||
|
||||
### 4.2. Session — singleton, а не dependency injection
|
||||
|
||||
```kotlin
|
||||
object Session {
|
||||
var client: ClientProvider? = null
|
||||
var token: String = ""
|
||||
var userId: UUID? = null
|
||||
var username: String = ""
|
||||
val collection: ObservableList<HumanBeingProperty> = FXCollections.observableArrayList()
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- Kotlin `object` — это потокобезопасный singleton на уровне JVM.
|
||||
- Все компоненты (View, Controller, Canvas, EditDialog) читают и пишут состояние через `Session.*`.
|
||||
- Альтернатива (DI: Guice, Koin, Dagger) неоправданна для такого масштаба — DI имеет смысл, когда есть десятки классов с глубокими графами зависимостей. У нас 4 view, 2 controller, несколько утилит.
|
||||
- `ObservableList<HumanBeingProperty>` — единственный источник истины (single source of truth). Парсинг ответа сервера (`show`) обновляет именно её, Canvas и TableView подписаны на её изменения.
|
||||
|
||||
### 4.3. HumanBeingProperty — observable-обёртка
|
||||
|
||||
```kotlin
|
||||
class HumanBeingProperty(humanBeing: HumanBeing) {
|
||||
val name = SimpleStringProperty(humanBeing.name)
|
||||
val coordinateX = SimpleObjectProperty<Long>(humanBeing.coordinates.x)
|
||||
val weaponType = SimpleObjectProperty<WeaponType?>(humanBeing.weaponType)
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Почему не использовать `HumanBeing` напрямую?
|
||||
- `TableColumn<HumanBeingProperty, String>` требует cellValueFactory, который возвращает `ObservableValue<String>`. Поле `SimpleStringProperty` это даёт.
|
||||
- Если бы мы использовали `HumanBeing` напрямую, пришлось бы оборачивать каждое чтение в `SimpleObjectProperty` внутри cellValueFactory, что создаёт временные объекты при каждом обновлении.
|
||||
- `HumanBeingProperty` — это value object, который живёт столько же, сколько и элемент в таблице. Его поля — это binding targets.
|
||||
- `toHumanBeing()` позволяет преобразовать обратно в модель для отправки на сервер.
|
||||
|
||||
### 4.4. ClientProvider — coroutine-обёртка над blocking NetworkManager
|
||||
|
||||
```kotlin
|
||||
class ClientProvider(host: String, port: Int) {
|
||||
private val net = ClientNetworkManager(host, port)
|
||||
|
||||
suspend fun auth(login: String, password: String, type: AuthType): AuthResponse? =
|
||||
withContext(Dispatchers.IO) {
|
||||
net.sendRequest(AuthRequest(login, md5Hash(password), type))
|
||||
}
|
||||
|
||||
suspend fun execute(commandName: String, ...): Response? =
|
||||
withContext(Dispatchers.IO) {
|
||||
net.sendRequest(Request(...))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `NetworkManager` (из модуля `client`) — blocking API. Он использует `DatagramChannel` с блокирующими вызовами `send`/`receive`.
|
||||
- `ClientProvider` оборачивает блокирующие вызовы в `withContext(Dispatchers.IO)` — стандартный Kotlin-паттерн для bridge между blocking и coroutine мирами.
|
||||
- Все контроллеры работают с `Session.client?.execute()`, который возвращает `suspend` и запускается в `scope.launch { ... }` контроллера.
|
||||
|
||||
Почему не сделать `NetworkManager` сам async:
|
||||
- `client` — отдельный модуль, его API жёстко задан (требование лабораторной — "модуль client"). Менять его нельзя без согласования.
|
||||
- Coroutine-обёртка изолирует изменения: если API `NetworkManager` изменится, правим только `ClientProvider`, а не все контроллеры.
|
||||
|
||||
### 4.5. Two-stage аутентификация
|
||||
|
||||
`AuthController` отделён от `AuthView`:
|
||||
- View собирает данные (host, port, username, password, login/register).
|
||||
- View вызывает `controller.authenticate(...)`.
|
||||
- Controller в `scope.launch` создаёт `ClientProvider`, отправляет `AuthRequest`, и результат передаёт через callback `(Boolean, String) -> Unit`.
|
||||
|
||||
Callback, а не возврат значения:
|
||||
- `authenticate` не может вернуть результат синхронно (сеть блокирует).
|
||||
- Она не может вернуть `suspend` — тогда View пришлось бы работать с coroutines (Parental ответственность View — показывать UI, не управлять потоками).
|
||||
- Callback безопасно вызывается через `Platform.runLater`, чтобы UI обновлялся из JavaFX Application Thread.
|
||||
|
||||
Почему `Session.userId = provider.getUUID()` устанавливается именно в AuthController:
|
||||
- `NetworkManager.setUUID(id)` хранит UUID внутри net-слоя.
|
||||
- `Session.userId` — это глобальное состояние, нужное для:
|
||||
- `EditDialog.onOk()` — установка `ownerId` для новых объектов.
|
||||
- `Session.isOwnObject()` — проверка владения для edit/delete.
|
||||
- Устанавливать его позже (в View) было бы поздно — к моменту открытия EditDialog userId уже должен быть известен.
|
||||
- `kotlin.runCatching { UUID.fromString(it) }.getOrNull()` — безопасный парсинг: если сервер вернёт кривой UUID, клиент не упадёт.
|
||||
|
||||
### 4.6. MainController — polling + команды
|
||||
|
||||
```kotlin
|
||||
class MainController {
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
private var pollingJob: Job? = null
|
||||
|
||||
fun start() {
|
||||
Session.collection.addListener(collectionListener)
|
||||
startPolling()
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
pollingJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(3000)
|
||||
refreshCollection()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Почему polling (pull), а не server-sent events (push):
|
||||
- Сервер работает поверх UDP. Server-sent events требуют TCP или WebSocket.
|
||||
- UDP не гарантирует порядок доставки. Если мы полагаемся на пуш-уведомления, можем пропустить обновление.
|
||||
- Polling каждые 3 секунды — разумный компромисс: UI обновляется достаточно быстро для desktop-приложения, нагрузка на сервер минимальна.
|
||||
- `delay(3000)` в корутине — не блокирует поток (в отличие от `Thread.sleep`).
|
||||
|
||||
Почему `SupervisorJob`:
|
||||
- Если `refreshCollection()` упадёт с исключением, это не отменит весь scope и не остановит polling.
|
||||
- `SupervisorJob` изолирует ошибку внутри одной корутины.
|
||||
|
||||
Как работает `refreshCollection()`:
|
||||
1. Выполняет `Session.client?.execute("show")` — блокирующий вызов, обёрнутый в `Dispatchers.IO`.
|
||||
2. Парсит текстовый ответ (строка → список HumanBeingProperty).
|
||||
3. Сравнивает ID текущей коллекции с ID из ответа:
|
||||
- `toRemove` — элементы, которых нет в ответе.
|
||||
- `toAdd` — новые элементы.
|
||||
- `toUpdate` — элементы, чьё name изменилось.
|
||||
4. Все изменения через `Platform.runLater`.
|
||||
|
||||
Парсинг ответа — почему не JSON:
|
||||
- Ответ от сервера — это строковое представление `toString()` объекта HumanBeing.
|
||||
- Формат: `HumanBeing(id=..., name=..., ...)`.
|
||||
- Парсинг через regex (extractValue) — да, это хрупко. Но:
|
||||
- Изменять формат на стороне сервера (переписывать show) — нельзя (требование лабораторной).
|
||||
- JSON-парсинг на клиенте потребовал бы координации схемой с сервером.
|
||||
- Regex-парсинг достаточно надёжен для стабильного формата вывода.
|
||||
|
||||
### 4.7. StreamUtils — функциональный filter/sort
|
||||
|
||||
```kotlin
|
||||
object StreamUtils {
|
||||
fun applyFiltersAndSort(
|
||||
items: List<HumanBeingProperty>,
|
||||
filters: Map<String, String>,
|
||||
comparators: List<Comparator<HumanBeingProperty>>,
|
||||
): ObservableList<HumanBeingProperty> {
|
||||
var stream = items.stream()
|
||||
for ((key, query) in filters) {
|
||||
if (query.isNotBlank()) {
|
||||
stream = stream.filter { prop ->
|
||||
val fieldValue = when (key) { ... }
|
||||
fieldValue.lowercase().contains(lower)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (comparators.isNotEmpty()) {
|
||||
val combined = comparators.reduce { a, b -> a.thenComparing(b) }
|
||||
stream = stream.sorted(combined)
|
||||
}
|
||||
return stream.collect(Collectors.collectingAndThen(
|
||||
Collectors.toList(),
|
||||
{ FXCollections.observableArrayList(it) }
|
||||
))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Почему `java.util.stream.Stream`, а не Kotlin Sequences:
|
||||
- Это прямое требование лабораторной — использовать Streams API.
|
||||
- `Stream.filter` / `sorted` — ленивые операции. Композиция нескольких .filter + sorted не создаёт промежуточных коллекций.
|
||||
- `collect(Collectors.collectingAndThen(toList(), FXCollections::observableArrayList))` — создаёт копию списка, которую можно безопасно вставить в TableView.
|
||||
|
||||
Почему перебираем `Map<String, String>`, а не строим Predicate для каждого поля:
|
||||
- Фильтры динамические: пользователь вводит текст в 11 полях, каждое применяется как `.contains(lowercase)`.
|
||||
- Использование рефлексии или property references могло бы быть элегантнее, но:
|
||||
- Рефлексия в JavaFX Property — избыточна (O(n) вызовов через MethodHandle).
|
||||
- `when (key)` — читаемый и однозначный диспатчинг. Да, это switch на 11 кейсов, но он стабилен: если добавится поле, компилятор не даст забыть кейс (в Kotlin нет exhaustiveness для when со String, но визуально заметно).
|
||||
|
||||
### 4.8. AppLocale — i18n без ResourceBundle
|
||||
|
||||
```kotlin
|
||||
object AppLocale {
|
||||
val currentLocale: ObjectProperty<Locale> = SimpleObjectProperty(Locale("ru"))
|
||||
|
||||
private val strings: Map<Locale, Map<String, String>> = mapOf(
|
||||
Locale("ru") to mapOf(...),
|
||||
Locale("nl") to mapOf(...),
|
||||
...
|
||||
)
|
||||
|
||||
fun bind(property: Property<String>, key: String) {
|
||||
property.value = get(key)
|
||||
currentLocale.addListener { _, _, _ ->
|
||||
property.value = get(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Почему `.properties` файлы не используются:
|
||||
- Требование лабораторной — "i18n должен храниться прямо в коде" (в классе).
|
||||
- Это упрощает сборку: не нужно копировать ресурсы в JAR (нет `processResources`).
|
||||
- Изменение строк не требует сборки проекта — просто правка Kotlin-файла.
|
||||
|
||||
Почему `ObjectProperty<Locale>`:
|
||||
- При смене языка нужно перерисовать ВСЕ UI-элементы, которые уже привязаны к строкам.
|
||||
- `bind(property, key)` регистрирует listener, который обновляет свойство при смене `currentLocale`.
|
||||
- Можно было бы пересоздавать все сцены при смене языка. Но `bind` — это точечное обновление: меняется только текст, без потери состояния полей ввода, выделения в таблице, позиции скролла.
|
||||
|
||||
Locale `en_AU`:
|
||||
- Выбран для юмора (Ryan Gosling + Australian English).
|
||||
- "Carn't" для car.cool, "Server's cactus", "Check ya credentials" и т.д.
|
||||
- Это демонстрирует кастомную локаль, не ограничиваясь стандартными en_US / en_GB.
|
||||
|
||||
### 4.9. EditDialog — модальное окно
|
||||
|
||||
```kotlin
|
||||
class EditDialog(private val owner: Window, private val existing: HumanBeing? = null) {
|
||||
fun showAndWait(): HumanBeing? {
|
||||
dialog.initModality(Modality.WINDOW_MODAL)
|
||||
dialog.initOwner(owner)
|
||||
...
|
||||
dialog.showAndWait()
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Почему return-значение через `result`, а не callback:
|
||||
- Диалог модальный — он блокирует ввод в родительском окне, пока открыт.
|
||||
- `showAndWait()` «замораживает» выполнение до закрытия диалога.
|
||||
- `result` записывается в `onOk()` при нажатии OK, диалог закрывается, и `showAndWait()` возвращает управление.
|
||||
- Код в `MainView.onAdd()` выглядит последовательно: `val humanBeing = dialog.showAndWait() ?: return; controller.executeCommand(...)` — нет вложенных колбэков.
|
||||
|
||||
Валидация:
|
||||
- Каждое поле проверяется при OK (а не по мере ввода).
|
||||
- Это проще: не нужно показывать/скрывать ошибки на каждое нажатие клавиши.
|
||||
- Если какое-то поле невалидно, показывается `Alert` (ошибка), диалог остаётся открытым.
|
||||
|
||||
Почему `Session.userId ?: return`:
|
||||
- При добавлении нового объекта `ownerId` должен быть установлен.
|
||||
- Если `Session.userId` == null (пользователь не залогинен или произошла ошибка), создавать объект бессмысленно.
|
||||
- `run { showError(...); return }` — явная диагностика вместо молчаливого игнора.
|
||||
|
||||
### 4.10. CollectionCanvas — анимация через AnimationTimer
|
||||
|
||||
```kotlin
|
||||
private val renderTimer: AnimationTimer = object : AnimationTimer() {
|
||||
override fun handle(now: Long) {
|
||||
animationTime = now / 1_000_000_000.0
|
||||
render()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Почему `Canvas` + `AnimationTimer`, а не JavaFX Shapes (Circle, Rectangle):
|
||||
- 50+ объектов на Canvas — один draw call на объект. Shapes создают отдельный Node для каждого — overhead для Scene Graph.
|
||||
- `AnimationTimer` вызывает `handle()` при каждом кадре рендера (60 FPS), что нужно для плавного свечения.
|
||||
- Glow-эффект — это полупрозрачный овал позади основного, чья alpha пульсирует по `sin(time * 3.0)`. Никаких JavaFX effects (DropShadow, Glow) — они бы грузили GPU без необходимости.
|
||||
|
||||
Почему ручное преобразование координат (world → screen):
|
||||
- `worldToScreenX(x) = x * scaleX + offsetX` — линейное преобразование из мировых координат в пиксели Canvas.
|
||||
- Автомасштаб: дельта между min и max по X/Y определяет `scaleX`/`scaleY`.
|
||||
- При одном элементе (min == max) используется `defaultRange = 200.0`, чтобы точка не уезжала в бесконечность.
|
||||
- Цвет владельца: `ownerColor(ownerId) = Color.hsb(hashCode % 360, 0.7, 0.9)`. Хэш UUID детерминирован — один и тот же владелец всегда одного цвета.
|
||||
|
||||
Почему `isResizable() = true` и `prefWidth/Height`:
|
||||
- Canvas в JavaFX по умолчанию не участвует в Layout (всегда сам задаёт размер).
|
||||
- Переопределение `isResizable`, `prefWidth`, `prefHeight` заставляет Canvas вести себя как Node, который можно растягивать через `widthProperty().bind(...)`.
|
||||
- Без этого Canvas бы не подстраивался под SplitPane при изменении размеров окна.
|
||||
|
||||
### 4.11. Парсинг `parseShowResponse`
|
||||
|
||||
```kotlin
|
||||
private fun parseShowResponse(message: String): List<HumanBeingProperty>? {
|
||||
val lines = message.lines().filter { it.isNotBlank() }
|
||||
var currentLines = mutableListOf<String>()
|
||||
var braceCount = 0
|
||||
for (line in lines) {
|
||||
currentLines.add(line)
|
||||
braceCount += line.count { it == '{' }
|
||||
braceCount -= line.count { it == '}' }
|
||||
if (braceCount == 0 && currentLines.isNotEmpty()) {
|
||||
val parsed = parseHumanBeingBlock(currentLines)
|
||||
if (parsed != null) items.add(parsed)
|
||||
currentLines.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Почему brace-счётчик, а не разбивка по строкам:
|
||||
- HumanBeing содержит вложенные объекты Coordinates и Car. Их `toString()` тоже содержит фигурные скобки.
|
||||
- Простое разделение по `\n\n` не сработает, если у Coordinates будет многострочный вывод.
|
||||
- Brace-счётчик отслеживает вложенность: открывающая `{` увеличивает счётчик, закрывающая `}` уменьшает. Когда `braceCount == 0` — блок завершён.
|
||||
|
||||
### 4.12. MainView — сборка UI
|
||||
|
||||
```kotlin
|
||||
private fun buildTablePane(): Node {
|
||||
val columns = listOf(
|
||||
"name" to { p: HumanBeingProperty -> p.name.value },
|
||||
"coordinates.x" to { p: HumanBeingProperty -> p.coordinateX.value.toString() },
|
||||
...
|
||||
)
|
||||
for ((i, col) in columns.withIndex()) {
|
||||
val headerLabel = Label()
|
||||
AppLocale.bindLabel(headerLabel, "table.$key")
|
||||
headerLabel.setOnMouseClicked { toggleSort(key) }
|
||||
...
|
||||
val tableCol = TableColumn<HumanBeingProperty, String>()
|
||||
tableCol.cellValueFactory = Callback { SimpleObjectProperty(extractor(it.value)) }
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Почему программное построение UI, а не FXML:
|
||||
- FXML удобен для крупных форм со сложной вложенностью. У нас — один BorderPane с ToolBar, SplitPane и HBox.
|
||||
- FXML требует `@FXML` аннотаций, отдельного контроллера, FXMLLoader с ClassLoader.
|
||||
- Для 300 строк UI проще писать на Kotlin: типизировано, нет магических строк `#onLogin`, компилятор проверяет вызовы.
|
||||
|
||||
Почему 11 отдельных TextField для фильтров, а не один поиск:
|
||||
- Требование: фильтр по каждому полю отдельно.
|
||||
- Пользователь может искать по имени И координате X одновременно.
|
||||
- `Map<String, String>`, где key = имя поля, value = строка поиска, передаётся в `StreamUtils.applyFiltersAndSort`.
|
||||
|
||||
Сортировка по клику на заголовок:
|
||||
- Клик по headerLabel переключает сортировку для колонки.
|
||||
- `toggleSort(key)` меняет `sortAscending` и создаёт `Comparator<HumanBeingProperty>`.
|
||||
- Компаратор сравнивает строковые представления значений — это универсально, работает для всех типов (String, Int, Double, Boolean).
|
||||
- `padStart` для чисел: `1.toString().padStart(20, '0')` = `"00000000000000000001"`. Это нужно, чтобы лексикографическая сортировка строк дала правильный числовой порядок.
|
||||
|
||||
### 4.13. MainView — команды add / edit / delete
|
||||
|
||||
```kotlin
|
||||
private fun onAdd() {
|
||||
val dialog = EditDialog(stage.scene.window)
|
||||
val humanBeing = dialog.showAndWait() ?: return
|
||||
controller.executeCommand(name = "add", humanBeing = humanBeing) { success, message ->
|
||||
if (success) controller.requestRefresh() else showError(message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`controller.requestRefresh()`:
|
||||
- При вызове `requestRefresh()` контроллер сразу запускает `refreshCollection()`.
|
||||
- После успешной операции (add/update/delete) новый объект появляется в таблице немедленно, без ожидания 3-секундного poll.
|
||||
- `Platform.runLater` внутри `refreshCollection` гарантирует, что обновление коллекции происходит в JavaFX Application Thread.
|
||||
|
||||
### 4.14. Сборка shadowJar
|
||||
|
||||
```kotlin
|
||||
// build.gradle.kts
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
id("org.jetbrains.compose")
|
||||
id("com.gradleup.shadow")
|
||||
}
|
||||
|
||||
tasks.shadowJar {
|
||||
archiveBaseName.set("Lab8-gui-client")
|
||||
archiveVersion.set("1.0")
|
||||
archiveClassifier.set("all")
|
||||
mergeServiceFiles()
|
||||
}
|
||||
```
|
||||
|
||||
- `com.gradleup.shadow` — создаёт fat JAR со всеми зависимостями (Kotlin stdlib, JavaFX, Exposed, Jackson, Log4j).
|
||||
- `mergeServiceFiles()` — объединяет META-INF/services из всех JAR-ов, иначе не работают JavaFX ServiceLoader и JDBC Driver.
|
||||
- JavaFX modules (javafx.controls, javafx.graphics) — добавляются в runtime через Gradle Compose plugin.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Flow
|
||||
|
||||
### 5.1. Аутентификация
|
||||
|
||||
```
|
||||
AuthView.onLogin()
|
||||
→ читает host, port, username, password, isLogin
|
||||
→ controller.authenticate(host, port, username, password, isLogin) { success, msg -> ... }
|
||||
→ scope.launch(Dispatchers.Default)
|
||||
→ Session.client?.close()
|
||||
→ ClientProvider(host, port) // создаётся новый провайдер
|
||||
→ provider.auth(login, password, type)
|
||||
→ withContext(Dispatchers.IO)
|
||||
→ NetworkManager.sendRequest(AuthRequest(...))
|
||||
→ DatagramChannel.send() / receive() (blocking)
|
||||
→ response.success?
|
||||
→ Session.client = provider
|
||||
→ Session.username = username
|
||||
→ Session.userId = provider.getUUID()
|
||||
→ Platform.runLater { onResult(true, "") }
|
||||
→ AuthView: stage.close(), onSuccess?.invoke()
|
||||
→ Main.Stage → MainView.show()
|
||||
```
|
||||
|
||||
### 5.2. Получение коллекции (polling)
|
||||
|
||||
```
|
||||
MainController.start()
|
||||
→ startPolling()
|
||||
→ scope.launch { while(isActive) { delay(3000); refreshCollection() } }
|
||||
|
||||
refreshCollection()
|
||||
→ Session.client?.execute("show")
|
||||
→ withContext(Dispatchers.IO)
|
||||
→ NetworkManager.sendRequest(Request("show", ...))
|
||||
→ parseShowResponse(response.message)
|
||||
→ lines → brace-счётчик → parseHumanBeingBlock → HumanBeingProperty
|
||||
→ Platform.runLater {
|
||||
Session.collection.removeAll(toRemove)
|
||||
Session.collection.addAll(toAdd)
|
||||
Session.collection[idx] = updated
|
||||
}
|
||||
→ TableView обновляется (items привязан к filteredList ← Session.collection + фильтры)
|
||||
→ Canvas перерисовывается (collectionListener → scheduleRender())
|
||||
```
|
||||
|
||||
### 5.3. Добавление объекта
|
||||
|
||||
```
|
||||
MainView.onAdd()
|
||||
→ EditDialog(stage.scene.window).showAndWait()
|
||||
→ модальный диалог → пользователь заполняет поля → OK
|
||||
→ onOk(): валидация → result = HumanBeing(...) → dialog.close()
|
||||
→ возвращает HumanBeing
|
||||
→ controller.executeCommand("add", humanBeing = humanBeing)
|
||||
→ scope.launch {
|
||||
Session.client?.execute("add", [], humanBeing)
|
||||
→ withContext(Dispatchers.IO)
|
||||
→ NetworkManager.sendRequest(Request("add", ..., humanBeing))
|
||||
}
|
||||
→ callback { success, message ->
|
||||
if (success) controller.requestRefresh()
|
||||
else showError(message)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4. Фильтрация и сортировка
|
||||
|
||||
```
|
||||
Пользователь вводит текст в filterField
|
||||
→ filterField.textProperty().addListener { _, _, _ → applyFilters() }
|
||||
→ controller.updateFilters(filters)
|
||||
→ currentFilters = filters
|
||||
→ reapplyFiltersAndSort()
|
||||
→ StreamUtils.applyFiltersAndSort(Session.collection, currentFilters, currentComparators)
|
||||
→ stream.filter(...).sorted(...).collect(collectingAndThen(...))
|
||||
→ Platform.runLater { filteredList.setAll(result) }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Почему именно такие решения: сводная таблица
|
||||
|
||||
| Решение | Альтернатива | Почему выбрано это |
|
||||
|---------|-------------|-------------------|
|
||||
| MVC (View → Controller) | MVVM, MVP | Простота, JavaFX TableView из коробки работает с ObservableList |
|
||||
| Session singleton | DI (Koin, Dagger) | 2 controller + 4 view — DI избыточен |
|
||||
| HumanBeingProperty | Прямое использование HumanBeing | Observable для TableView |
|
||||
| ClientProvider (coroutine wrapper) | Сделать NetworkManager suspend | Нельзя менять модуль client; wrapping = изоляция |
|
||||
| Polling 3s | Push / WebSocket | UDP, сервер не поддерживает push |
|
||||
| StreamUtils (Streams API) | Kotlin Sequences | Требование лабораторной |
|
||||
| i18n в коде (Map) | .properties файлы | Требование лабораторной |
|
||||
| ObjectProperty<Locale> + bind | Пересоздание Scene | Сохраняет состояние UI при смене языка |
|
||||
| Canvas + AnimationTimer | Shapes + Timeline | Производительность при 50+ объектах |
|
||||
| Regex-парсинг ответа show | JSON | Формат ответа сервера фиксирован и не-JSON |
|
||||
| Программный UI | FXML | Малый размер UI, типизация, без FXMLLoader |
|
||||
| shadowJar | jlink / jpackage | Простота, одношаговая сборка, без модульных рисков |
|
||||
|
||||
---
|
||||
|
||||
## 7. Известные дефекты и ограничения
|
||||
|
||||
1. **Regex-парсинг ответа `show` хрупок** — если сервер изменит формат вывода, парсинг сломается. Решение: согласование формата с сервером или JSON API.
|
||||
|
||||
2. **Polling каждые 3 секунды** — избыточная нагрузка при сотнях клиентов. Решение: увеличить интервал или перейти на push.
|
||||
|
||||
3. **Фильтры на стороне клиента** — если коллекция большая (100K+ объектов), фильтрация через Streams API занимает время. Решение: server-side filtering.
|
||||
|
||||
4. **Canvas не использует GPU-ускорение** — JavaFX Canvas рендерится на CPU. Для 500+ объектов могут быть просадки FPS. Решение: перейти на `Group` с `Circle` или OpenGL через `Node`.
|
||||
|
||||
5. **Нет обработки reconnect** — если сервер перезагрузился, client.close() не пересоздаёт подключение. Решение: таймаут + retry logic в ClientProvider.
|
||||
|
||||
6. **Session.client — var с nullable** — возможно race condition: два вызова `execute()` в разных корутинах пока client уже закрыт. Решение: `synchronized` или `Mutex`.
|
||||
|
||||
7. **Нет кэширования locale-строк** — при переключении языка каждый bind вызывает `get(key)` на каждый listener. Для 50+ bind-ов это O(n * m). Решение: кэш `MutableMap<String, String>` при смене локали.
|
||||
@@ -0,0 +1,556 @@
|
||||
# JavaFX — полное введение (с параллелями на PyQt6)
|
||||
|
||||
> У вас есть опыт PyQt6 — я буду постоянно проводить параллели, чтобы было проще.
|
||||
|
||||
---
|
||||
|
||||
## 1. Что такое JavaFX?
|
||||
|
||||
**JavaFX** — это фреймворк для desktop-приложений на Java/Kotlin. Аналог Qt (PyQt6) для мира JVM.
|
||||
|
||||
| PyQt6 | JavaFX |
|
||||
|---|---|
|
||||
| QApplication | Application |
|
||||
| QWidget | Node / Parent / Region |
|
||||
| QMainWindow | Stage + Scene |
|
||||
| QVBoxLayout / QHBoxLayout | VBox / HBox |
|
||||
| QPushButton | Button |
|
||||
| QTableWidget | TableView |
|
||||
| QGraphicsScene / QGraphicsView | Canvas / Group |
|
||||
| signals & slots | Event handlers + Properties + Bindings |
|
||||
| QTimer | AnimationTimer / Timeline |
|
||||
| Qt Designer (UI файлы) | Scene Builder (FXML файлы) |
|
||||
| QLocale / QTranslator | ResourceBundle / кастомный AppLocale |
|
||||
|
||||
JavaFX пришёл на смену Swing (аналог PyQt5 → PyQt6). Он:
|
||||
- Использует GPU-ускоренный рендеринг (через Prism).
|
||||
- Имеет встроенную поддержку CSS-стилизации.
|
||||
- Поддерживает FXML (XML-разметка UI, аналог .ui файлов Qt Designer).
|
||||
- Работает через **Property** + **Binding** — встроенная реактивность.
|
||||
|
||||
---
|
||||
|
||||
## 2. Структура JavaFX-приложения
|
||||
|
||||
### 2.1. Точка входа
|
||||
|
||||
**PyQt6:**
|
||||
```python
|
||||
app = QApplication(sys.argv)
|
||||
window = QMainWindow()
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
```
|
||||
|
||||
**JavaFX:**
|
||||
```kotlin
|
||||
class App : Application() {
|
||||
override fun start(stage: Stage) {
|
||||
stage.title = "Hello"
|
||||
stage.scene = Scene(Button("Click me"), 300.0, 200.0)
|
||||
stage.show()
|
||||
}
|
||||
}
|
||||
|
||||
fun main() {
|
||||
Application.launch(App::class.java)
|
||||
}
|
||||
```
|
||||
|
||||
`Application.launch()` запускает **JavaFX Application Thread** (FxAT) — аналог Qt Event Loop. **Весь UI-код обязан выполняться в этом потоке.** Если вы обновляете UI из другого потока — используйте `Platform.runLater { ... }` (аналог `QMetaObject.invokeMethod` с `Qt.QueuedConnection`).
|
||||
|
||||
### 2.2. Stage и Scene
|
||||
|
||||
**Stage** — это окно. Аналог `QMainWindow` / `QDialog`.
|
||||
|
||||
```kotlin
|
||||
val stage = Stage()
|
||||
stage.title = "My App"
|
||||
stage.isResizable = true
|
||||
stage.minWidth = 400.0
|
||||
stage.minHeight = 300.0
|
||||
stage.show()
|
||||
```
|
||||
|
||||
**Scene** — это содержимое окна. Аналог `centralWidget` / `setWidget()`.
|
||||
|
||||
```kotlin
|
||||
stage.scene = Scene(rootNode, 800.0, 600.0)
|
||||
```
|
||||
|
||||
У одного Stage может быть только одна Scene (но можно менять динамически).
|
||||
|
||||
### 2.3. Node — всё, что можно нарисовать
|
||||
|
||||
**Node** — базовый класс для любого визуального элемента. Аналог `QWidget`.
|
||||
|
||||
```
|
||||
Node
|
||||
├── Parent (может содержать дочерние Node)
|
||||
│ ├── Region
|
||||
│ │ ├── Control
|
||||
│ │ │ ├── Button, Label, TextField, ComboBox, TableView...
|
||||
│ │ │ └── ...
|
||||
│ │ └── Pane
|
||||
│ │ ├── VBox, HBox, BorderPane, GridPane, StackPane...
|
||||
│ │ └── FlowPane, AnchorPane, TilePane...
|
||||
│ └── WebView
|
||||
├── Canvas (рисование через GraphicsContext)
|
||||
├── Shape (Circle, Rectangle, Text...)
|
||||
└── ImageView
|
||||
```
|
||||
|
||||
Параллель с PyQt6:
|
||||
- `QPushButton` → `Button`
|
||||
- `QLabel` → `Label`
|
||||
- `QLineEdit` → `TextField`
|
||||
- `QComboBox` → `ComboBox`
|
||||
- `QVBoxLayout` → `VBox`
|
||||
- `QHBoxLayout` → `HBox`
|
||||
- `QGridLayout` → `GridPane`
|
||||
- `QTableWidget` → `TableView`
|
||||
- `QGraphicsView` → `Canvas`
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout-менеджеры (панели)
|
||||
|
||||
В PyQt6 вы добавляете виджеты в layout:
|
||||
```python
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(button)
|
||||
layout.addWidget(label)
|
||||
widget.setLayout(layout)
|
||||
```
|
||||
|
||||
В JavaFX layout'ы — это тоже Node (наследники Pane):
|
||||
```kotlin
|
||||
val root = VBox(8.0) // spacing = 8
|
||||
root.children.addAll(button, label)
|
||||
|
||||
val hbox = HBox(10.0) // горизонтальный ряд, spacing = 10
|
||||
|
||||
val grid = GridPane()
|
||||
grid.add(label, 0, 0) // column=0, row=0
|
||||
grid.add(field, 1, 0) // column=1, row=0
|
||||
grid.addRow(1, label2, field2) // row=1
|
||||
grid.hgap = 8.0
|
||||
grid.vgap = 8.0
|
||||
|
||||
val border = BorderPane()
|
||||
border.top = toolbar // север
|
||||
border.center = splitPane // центр (растягивается)
|
||||
border.bottom = statusBar // юг
|
||||
```
|
||||
|
||||
**BorderPane** — самый удобный layout для главного окна: top/center/bottom/left/right. Аналог `QMainWindow` с setMenuBar, setCentralWidget, addToolBar.
|
||||
|
||||
---
|
||||
|
||||
## 4. Properties и Binding — реактивность
|
||||
|
||||
Это **ключевое отличие JavaFX от большинства UI-фреймворков**. В PyQt6 вы обновляете UI так:
|
||||
|
||||
```python
|
||||
button.setText("New text")
|
||||
label.setText(str(counter))
|
||||
```
|
||||
|
||||
В JavaFX можно сделать то же самое:
|
||||
```kotlin
|
||||
button.text = "New text"
|
||||
label.text = counter.toString()
|
||||
```
|
||||
|
||||
Но можно и **привязать** одно свойство к другому:
|
||||
|
||||
```kotlin
|
||||
val name = SimpleStringProperty("John")
|
||||
val label = Label()
|
||||
label.textProperty().bind(name) // label автоматически обновляется при name.set(...)
|
||||
|
||||
name.set("Jane") // label.text теперь "Jane" — без явного setText!
|
||||
```
|
||||
|
||||
### 4.1. Виды Property
|
||||
|
||||
```kotlin
|
||||
SimpleStringProperty("text") // String?
|
||||
SimpleIntegerProperty(42) // Int
|
||||
SimpleBooleanProperty(true) // Boolean
|
||||
SimpleDoubleProperty(3.14) // Double
|
||||
SimpleObjectProperty<UUID?>(null) // Любой объект (nullable)
|
||||
SimpleListProperty<String>() // ObservableList
|
||||
```
|
||||
|
||||
Каждая Property — это `ObservableValue<T>` + `WritableValue<T>`:
|
||||
- Можно читать: `property.value` / `property.get()`
|
||||
- Можно писать: `property.value = x` / `property.set(x)`
|
||||
- Можно слушать: `property.addListener { _, old, new -> ... }`
|
||||
- Можно привязать: `a.bind(b)` — `a` всегда равно `b`
|
||||
- Можно привязать двусторонне: `a.bindBidirectional(b)`
|
||||
|
||||
### 4.2. Практический пример
|
||||
|
||||
**PyQt6** — подписка на изменение текста:
|
||||
```python
|
||||
line_edit.textChanged.connect(lambda text: label.setText(text.upper()))
|
||||
```
|
||||
|
||||
**JavaFX**:
|
||||
```kotlin
|
||||
lineEdit.textProperty().addListener { _, _, newValue ->
|
||||
label.text = newValue.uppercase()
|
||||
}
|
||||
```
|
||||
|
||||
Или через bind:
|
||||
```kotlin
|
||||
label.textProperty().bind(
|
||||
lineEdit.textProperty().map { it.uppercase() }
|
||||
)
|
||||
```
|
||||
|
||||
### 4.3. Почему это важно?
|
||||
|
||||
- **TableView** обновляется автоматически, если вы используете ObservableList и CellValueFactory с Property.
|
||||
- **Canvas** может подписаться на `Session.collection` и перерисовываться при изменениях.
|
||||
- **i18n**: при смене языка все textProperty, привязанные через `bind`, обновляются одной строкой `AppLocale.currentLocale.set(newLocale)`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Простое приложение №1: счётчик кликов
|
||||
|
||||
**Python (PyQt6):**
|
||||
```python
|
||||
import sys
|
||||
from PyQt6.QtWidgets import *
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.counter = 0
|
||||
button = QPushButton("Click me")
|
||||
button.clicked.connect(self.on_click)
|
||||
self.label = QLabel("0")
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(button)
|
||||
layout.addWidget(self.label)
|
||||
widget = QWidget()
|
||||
widget.setLayout(layout)
|
||||
self.setCentralWidget(widget)
|
||||
|
||||
def on_click(self):
|
||||
self.counter += 1
|
||||
self.label.setText(str(self.counter))
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = MainWindow()
|
||||
w.show()
|
||||
app.exec()
|
||||
```
|
||||
|
||||
**JavaFX:**
|
||||
```kotlin
|
||||
import javafx.application.Application
|
||||
import javafx.scene.Scene
|
||||
import javafx.scene.control.Button
|
||||
import javafx.scene.control.Label
|
||||
import javafx.scene.layout.VBox
|
||||
import javafx.stage.Stage
|
||||
|
||||
class CounterApp : Application() {
|
||||
override fun start(stage: Stage) {
|
||||
val counter = SimpleIntegerProperty(0)
|
||||
|
||||
val label = Label()
|
||||
label.textProperty().bind(counter.asString()) // автообновление
|
||||
|
||||
val button = Button("Click me")
|
||||
button.setOnAction { counter.set(counter.get() + 1) }
|
||||
|
||||
val root = VBox(8.0, button, label)
|
||||
|
||||
stage.scene = Scene(root, 300.0, 200.0)
|
||||
stage.title = "Counter"
|
||||
stage.show()
|
||||
}
|
||||
}
|
||||
|
||||
fun main() { Application.launch(CounterApp::class.java) }
|
||||
```
|
||||
|
||||
Ключевое отличие: label обновляется автоматически через `bind`. Не нужно писать `label.text = ...` в обработчике.
|
||||
|
||||
---
|
||||
|
||||
## 6. Простое приложение №2: список с удалением
|
||||
|
||||
**JavaFX TableView — это не QTableWidget.** В PyQt6:
|
||||
```python
|
||||
table = QTableWidget(0, 3)
|
||||
table.setItem(0, 0, QTableWidgetItem("value"))
|
||||
```
|
||||
|
||||
В JavaFX TableView работает через **CellValueFactory**:
|
||||
|
||||
```kotlin
|
||||
data class Task(val name: String, val done: Boolean)
|
||||
|
||||
class TaskProperty(task: Task) {
|
||||
val name = SimpleStringProperty(task.name)
|
||||
val done = SimpleBooleanProperty(task.done)
|
||||
}
|
||||
|
||||
val items: ObservableList<TaskProperty> = FXCollections.observableArrayList()
|
||||
|
||||
val table = TableView<TaskProperty>()
|
||||
|
||||
val nameCol = TableColumn<TaskProperty, String>("Name")
|
||||
nameCol.cellValueFactory = Callback { data ->
|
||||
SimpleObjectProperty(data.value.name.value) // возвращает ObservableValue
|
||||
}
|
||||
|
||||
val doneCol = TableColumn<TaskProperty, Boolean>("Done")
|
||||
doneCol.cellValueFactory = Callback { SimpleObjectProperty(data.value.done.value) }
|
||||
|
||||
table.columns.addAll(nameCol, doneCol)
|
||||
table.items = items
|
||||
|
||||
items.add(TaskProperty(Task("Buy milk", false))) // строка появляется в таблице
|
||||
|
||||
val deleteButton = Button("Delete")
|
||||
deleteButton.setOnAction {
|
||||
val selected = table.selectionModel.selectedItem
|
||||
if (selected != null) items.remove(selected)
|
||||
}
|
||||
```
|
||||
|
||||
Так работает и наша таблица HumanBeing: `TableColumn` + `cellValueFactory` извлекает значение из `HumanBeingProperty`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Canvas — рисование
|
||||
|
||||
Аналог `QGraphicsScene` + `QGraphicsView`, но без Scene Graph. Canvas — это растровое полотно:
|
||||
|
||||
```kotlin
|
||||
class MyCanvas : Canvas() {
|
||||
private val gc = graphicsContext2D
|
||||
|
||||
fun draw() {
|
||||
gc.clearRect(0.0, 0.0, width, height)
|
||||
|
||||
gc.fill = Color.web("#1a1a2e")
|
||||
gc.fillRect(0.0, 0.0, width, height)
|
||||
|
||||
gc.fill = Color.YELLOW
|
||||
gc.fillOval(50.0, 50.0, 100.0, 100.0) // круг
|
||||
|
||||
gc.stroke = Color.WHITE
|
||||
gc.lineWidth = 2.0
|
||||
gc.strokeLine(0.0, 0.0, width, height) // линия
|
||||
|
||||
gc.fill = Color.WHITE
|
||||
gc.font = Font(14.0)
|
||||
gc.fillText("Hello Canvas", 10.0, 20.0) // текст
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Canvas поддерживает: прямоугольники, овалы, линии, текст, изображения, rotate, scale, save/restore (стек трансформаций). Нет встроенных кнопок или обработчиков — всё рисуется вручную.
|
||||
|
||||
Обработка кликов — через `addEventHandler`:
|
||||
```kotlin
|
||||
canvas.addEventHandler(MouseEvent.MOUSE_CLICKED) { event ->
|
||||
val x = event.x
|
||||
val y = event.y
|
||||
// проверка попадания
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Модальные диалоги
|
||||
|
||||
Аналог `QDialog.exec()`:
|
||||
|
||||
```kotlin
|
||||
val dialog = Stage()
|
||||
dialog.initModality(Modality.WINDOW_MODAL) // блокирует родительское окно
|
||||
dialog.initOwner(parentWindow)
|
||||
|
||||
val button = Button("OK")
|
||||
button.setOnAction {
|
||||
result = "some value"
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
dialog.scene = Scene(VBox(button), 300.0, 200.0)
|
||||
dialog.showAndWait() // блокирует выполнение до закрытия
|
||||
return result
|
||||
```
|
||||
|
||||
`showAndWait()` аналог `QDialog.exec()`:
|
||||
- Метод не возвращает управление, пока диалог не закрыт.
|
||||
- После закрытия продолжается выполнение сразу после `showAndWait()`.
|
||||
- Пока диалог открыт, родительское окно не реагирует на ввод.
|
||||
|
||||
---
|
||||
|
||||
## 9. Простое приложение №3: Todo-лист
|
||||
|
||||
Полный пример, который использует TableView, Property, Layout, модальный диалог:
|
||||
|
||||
```kotlin
|
||||
import javafx.application.Application
|
||||
import javafx.beans.property.SimpleStringProperty
|
||||
import javafx.collections.FXCollections
|
||||
import javafx.collections.ObservableList
|
||||
import javafx.geometry.Insets
|
||||
import javafx.scene.Scene
|
||||
import javafx.scene.control.*
|
||||
import javafx.scene.layout.VBox
|
||||
import javafx.stage.Stage
|
||||
|
||||
class TodoApp : Application() {
|
||||
|
||||
private val items: ObservableList<TodoItem> = FXCollections.observableArrayList()
|
||||
|
||||
override fun start(stage: Stage) {
|
||||
val table = TableView<TodoItem>()
|
||||
table.items = items
|
||||
|
||||
val col = TableColumn<TodoItem, String>("Task")
|
||||
col.cellValueFactory = javafx.util.Callback {
|
||||
SimpleObjectProperty(it.value.task.value) // ObservableValue для каждой ячейки
|
||||
}
|
||||
|
||||
val addBtn = Button("Add")
|
||||
addBtn.setOnAction { showAddDialog() }
|
||||
|
||||
val deleteBtn = Button("Delete")
|
||||
deleteBtn.setOnAction {
|
||||
val selected = table.selectionModel.selectedItem
|
||||
if (selected != null) items.remove(selected)
|
||||
}
|
||||
|
||||
val root = VBox(8.0, table, addBtn, deleteBtn)
|
||||
root.padding = Insets(8.0)
|
||||
|
||||
stage.scene = Scene(root, 400.0, 300.0)
|
||||
stage.show()
|
||||
}
|
||||
|
||||
private fun showAddDialog() {
|
||||
val dialog = Stage()
|
||||
dialog.initModality(Modality.WINDOW_MODAL)
|
||||
dialog.initOwner(stage.scene.window)
|
||||
|
||||
val field = TextField()
|
||||
val okBtn = Button("OK").apply {
|
||||
setOnAction {
|
||||
if (field.text.isNotBlank()) {
|
||||
items.add(TodoItem(field.text.trim()))
|
||||
dialog.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialog.scene = Scene(VBox(8.0, field, okBtn).apply {
|
||||
padding = Insets(8.0)
|
||||
}, 280.0, 100.0)
|
||||
dialog.showAndWait()
|
||||
}
|
||||
}
|
||||
|
||||
class TodoItem(val task: SimpleStringProperty) {
|
||||
constructor(task: String) : this(SimpleStringProperty(task))
|
||||
}
|
||||
|
||||
fun main() { Application.launch(TodoApp::class.java) }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Потоки: Platform.runLater
|
||||
|
||||
**Критическое правило:** любой код, который читает или пишет UI-свойства, должен выполняться в JavaFX Application Thread (FxAT).
|
||||
|
||||
```kotlin
|
||||
// НЕПРАВИЛЬНО: вызов из фонового потока
|
||||
thread {
|
||||
Thread.sleep(1000)
|
||||
label.text = "Done" // throws IllegalStateException!
|
||||
}
|
||||
|
||||
// ПРАВИЛЬНО: через Platform.runLater
|
||||
thread {
|
||||
Thread.sleep(1000)
|
||||
Platform.runLater { label.text = "Done" }
|
||||
}
|
||||
|
||||
// ПРАВИЛЬНО (Kotlin coroutines):
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) { networkCall() }
|
||||
Platform.runLater { label.text = result }
|
||||
}
|
||||
```
|
||||
|
||||
Аналог в PyQt6: `QMetaObject.invokeMethod(lambda: ..., Qt.ConnectionType.QueuedConnection)` или сигналы через跨线程.
|
||||
|
||||
---
|
||||
|
||||
## 11. Самая частая ошибка новичков
|
||||
|
||||
```kotlin
|
||||
val field = TextField()
|
||||
field.text = "initial" // OK — в Application Thread
|
||||
// ... позже:
|
||||
thread { field.text = "bad" } // CRASH — не в Application Thread
|
||||
```
|
||||
|
||||
В PyQt6 это тоже крашит, но PyQt6 иногда «прощает» случайные вызовы из других потоков. JavaFX строже — будет `IllegalStateException: Not on FX application thread`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Стилизация через CSS
|
||||
|
||||
JavaFX поддерживает CSS (аналог Qt Stylesheets):
|
||||
|
||||
```kotlin
|
||||
label.style = "-fx-text-fill: red; -fx-font-size: 16px;"
|
||||
|
||||
button.styleClass.add("primary-button")
|
||||
```
|
||||
|
||||
```css
|
||||
/* styles.css */
|
||||
.primary-button {
|
||||
-fx-background-color: #0078d4;
|
||||
-fx-text-fill: white;
|
||||
-fx-font-weight: bold;
|
||||
}
|
||||
```
|
||||
|
||||
Подключается через `scene.stylesheets.add("styles.css")`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Итог: что нужно запомнить
|
||||
|
||||
| Концепция | Суть |
|
||||
|-----------|------|
|
||||
| **Stage** | Окно (аналог QMainWindow) |
|
||||
| **Scene** | Содержимое окна (аналог centralWidget) |
|
||||
| **Node** | Любой UI-элемент (аналог QWidget) |
|
||||
| **Pane** | Layout-контейнер (аналог QVBoxLayout, QHBoxLayout) |
|
||||
| **Property** | Observable-значение с возможностью bind |
|
||||
| **Binding** | Автосинхронизация свойств (аналог сигналов, но декларативно) |
|
||||
| **ObservableList** | Список, за которым можно наблюдать |
|
||||
| **Platform.runLater** | Единственный способ обновить UI из фонового потока |
|
||||
| **AnimationTimer** | Вызов каждый кадр (60 FPS) для анимации |
|
||||
| **Canvas** | Растровое полотно для рисования (аналог QGraphicsScene без Scene) |
|
||||
| **TableView** | Таблица с колонками, каждая через cellValueFactory |
|
||||
|
||||
**Коротко:** JavaFX = PyQt6, где всё завязано на Properties. Если ты привык писать `widget.setX(value)` — забудь. Пиши `a.bind(b)` и подписывайся на изменения через listeners. Это декларативнее и избавляет от тонны ручных обновлений.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
1. Интерфейс должен быть реализован с помощью библиотеки JavaFX
|
||||
2. Графический интерфейс клиентской части должен поддерживать русский, нидерландский, венгерский и английский (Австралия) языки / локали. Должно обеспечиваться корректное отображение чисел, даты и времени в соответстии с локалью. Переключение языков должно происходить без перезапуска приложения. Локализованные ресурсы должны храниться в классе.
|
||||
|
||||
В функционал клиента должно входить:
|
||||
|
||||
1. Окно с авторизацией/регистрацией.
|
||||
|
||||
2. Отображение текущего пользователя.
|
||||
|
||||
3. Таблица, отображающая все объекты из коллекции
|
||||
|
||||
3.1. Каждое поле объекта - отдельная колонка таблицы.
|
||||
|
||||
3.2. Строки таблицы можно фильтровать/сортировать по значениям любой из колонок. Сортировку и фильтрацию значений столбцов реализовать с помощью Streams API.
|
||||
|
||||
4. Поддержка всех команд.
|
||||
|
||||
5. Область, визуализирующую объекты коллекции
|
||||
|
||||
5.1. Объекты должны быть нарисованы с помощью графических примитивов с использованием Graphics, Canvas или аналогичных средств графической библиотеки.
|
||||
|
||||
5.2. При визуализации использовать данные о координатах и размерах объекта.
|
||||
|
||||
5.3. Объекты от разных пользователей должны быть нарисованы разными цветами.
|
||||
|
||||
5.4. При нажатии на объект должна выводиться информация об этом объекте.
|
||||
|
||||
5.5. При добавлении/удалении/изменении объекта, он должен автоматически появиться/исчезнуть/измениться на области как владельца, так и всех других клиентов.
|
||||
|
||||
5.6. При отрисовке объекта должна воспроизводиться согласованная с преподавателем анимация.
|
||||
|
||||
6. Возможность редактирования отдельных полей любого из объектов (принадлежащего пользователю). Переход к редактированию объекта возможен из таблицы с общим списком объектов и из области с визуализацией объекта.
|
||||
|
||||
7. Возможность удаления выбранного объекта.
|
||||
Reference in New Issue
Block a user