Разработка / ROBLOX
Подписки на события Roblox: как не добавлять лишний обработчик при открытии меню
Разберите жизненный цикл одной подписки: подключение, повторное создание, отключение и новое открытие. Оригинальный тест в Studio показывает разницу между тремя слушателями и одним управляемым подключением, а также проверяет Once.
Выберите одну проблему для проверки #
Представьте временную панель помощи, которая реагирует на событие. При каждом открытии её контроллер подключает новую функцию, но старую подписку оставляет. После нескольких открытий одно событие может вызвать несколько функций. Это возможный механизм ошибки, а не диагноз любой существующей кнопки. Сначала определите, какая функция создаёт подписку и кто обязан завершить её работу.
В упражнении мы не создаём настоящее меню и не нажимаем его кнопку. Функции openPanel и closePanel обозначают жизненный цикл условного контроллера; сигналом служит отдельный BindableEvent. Так можно проверить владение подпиской без оформления интерфейса и без изменения опубликованных игр. Последующая проверка реальной панели остаётся самостоятельным этапом, даже если пример уже прошёл assertions.
Разделите событие и подписку #
Официальная документация описывает Connect как способ подключить функцию к событию. Результат подключения — объект RBXScriptConnection. Сохранённая ссылка на него позволяет отключить именно эту подписку через Disconnect. Сигнал, функция-обработчик и возвращённое подключение — разные сущности. Переменная с названием функции сама по себе не является ссылкой на подключение.
Для нашей панели владельцем currentConnection становится контроллер. Он знает, где создаётся подписка, и использует эту же ссылку при закрытии. Не разбрасывайте создание по нескольким несвязанным функциям без понятного владельца. В учебной задаче достаточно одного поля: либо оно хранит текущее подключение, либо равно nil после очистки. Этот простой договор позволяет проследить всю цепочку.
Воспроизведите три лишних слушателя #
Первая часть собственного теста создаёт три подключения к одному BindableEvent. Каждое увеличивает общий счётчик hits. Затем событие вызывается один раз. После ожидания обработки счётчик должен стать равным трём. Проверка показывает, что три активные подписки действительно выполняют три callbacks в этом изолированном сценарии. Она не моделирует ошибочную кнопку в конкретной игре.
Сохраните каждое подключение в таблице duplicates, чтобы затем отключить все созданные слушатели. Этот этап нужен как воспроизводимое сравнение: без него нельзя уверенно объяснять разницу между старой схемой и управляемой. Не называйте число подписок количеством кликов игрока. Здесь был один вызов события и три отдельно подключённые функции — два разных счётчика.
-- Original isolated engine experiment, not an existing-game script.
local signalOwner = Instance.new("BindableEvent")
local hits = 0
local duplicates = {}
for i = 1, 3 do
duplicates[i] = signalOwner.Event:Connect(function()
hits += 1
end)
end
signalOwner:Fire()
task.wait()
assert(hits == 3, "Three live subscriptions must make three callbacks")
for _, connection in duplicates do
connection:Disconnect()
assert(connection.Connected == false)
end
signalOwner:Fire()
task.wait()
assert(hits == 3, "Disconnected listeners must not receive a new fire")
local currentConnection
local function closePanel()
if currentConnection then
currentConnection:Disconnect()
currentConnection = nil
end
end
local function openPanel()
closePanel()
currentConnection = signalOwner.Event:Connect(function()
hits += 1
end)
end
openPanel()
openPanel()
openPanel()
signalOwner:Fire()
task.wait()
assert(hits == 4, "Reopening must leave only one listener")
closePanel()
closePanel()
signalOwner:Fire()
task.wait()
assert(hits == 4, "Repeated cleanup must be safe and stop new callbacks")
openPanel()
signalOwner:Fire()
task.wait()
assert(hits == 5, "A fresh panel must work after cleanup")
closePanel()
local onceHits = 0
local onceConnection = signalOwner.Event:Once(function()
onceHits += 1
end)
signalOwner:Fire()
signalOwner:Fire()
task.wait()
assert(onceHits == 1, "Once must handle only the first invocation")
assert(onceConnection.Connected == false)
signalOwner:Destroy()
print("GUIDEBOOK_CONNECTIONS_ENGINE_PASS hits=5 once=1")Проверьте явное отключение #
После первого вызова тест выполняет Disconnect для всех трёх сохранённых объектов. Для каждого проверяется Connected==false. Следующий Fire с ожиданием не должен менять hits: значение остаётся равным трём. Так мы проверяем отсутствие реакции на новое событие после очистки. Просто присвоить nil переменной, забыв отключить объект, было бы другим действием.
Сначала отключите принадлежащую контроллеру подписку, затем освободите её сохранённую ссылку. Не отключайте произвольные чужие подключения, которые создала другая система. В реальном интерфейсе полезно записать владельца рядом с местом создания. Задача очистки — закончить конкретную работу, а не глобально запретить всем слушателям получать события.
Сделайте повторное открытие управляемым #
В оригинальной openPanel сначала вызывается closePanel. Только после этого создаётся новое подключение и сохраняется в currentConnection. Три последовательных вызова openPanel поэтому оставляют одну активную подписку. Следующий Fire увеличивает hits с трёх до четырёх. Проверяется именно один дополнительный callback, а не число всех callbacks за весь тест.
Такое поведение подходит для нашей договорённости «один текущий контроллер панели». Оно не является универсальным решением для любой системы: несколько независимых панелей могут требовать разных владельцев и отдельных ссылок. Сначала определите желаемое количество активных подписок. Затем проверяйте его через наблюдаемый результат, а не только через то, что код выглядит коротким.
Повторная очистка должна быть безопасной #
closePanel проверяет наличие currentConnection. Если оно есть, функция отключает подписку и устанавливает поле в nil. Второй вызов не пытается использовать уже освобождённую ссылку. В тесте closePanel вызывается дважды, после чего новое событие не меняет счётчик. Это проверка повторного завершения нашего контроллера, а не всех возможных ошибок жизненного цикла интерфейса.
Затем openPanel вызывается снова. Fire должен увеличить hits до пяти, после чего выполняется заключительная очистка. Эта дополнительная проверка важна: остановить обработку недостаточно, если после закрытия контроллер больше нельзя создать заново. Проверяйте оба направления — очистка прекращает реакцию, а новый экземпляр снова реагирует ровно один раз по выбранной договорённости.
Используйте Once для первого срабатывания #
Официальное руководство предлагает Once, когда функция нужна только при первом срабатывании события. В отдельной части теста создаётся onceConnection и новый счётчик onceHits. Событие вызывается дважды; после ожидания onceHits должен быть равен одному, а Connected — false. Этот результат получен в настоящем Studio с отдельным BindableEvent.
Первое срабатывание сигнала не обязательно означает первое подходящее событие вашей предметной задачи. Например, если callback проверяет дополнительное условие, заранее решите, должен ли он слушать дальше после неподходящего входа. Не подменяйте эту договорённость названием Once. Для одноразового успешного действия и для первого любого сигнала могут понадобиться разные схемы.
Учитывайте порядок обработки событий #
При чтении результата тест ждёт через task.wait после Fire. Мы не предполагаем, что callback уже завершён на следующей строке. Официальная документация об отложенных событиях отдельно объясняет очередь и точки продолжения. Синхронное увеличение переменной в самодельной модели не доказывало бы такое же поведение движка; поэтому для этого материала выполнен изолированный тест Roblox API.
Также не считайте Destroy и Disconnect одинаковыми во всех ситуациях с ожидающими callbacks. Официальный раздел описывает различие между явным отключением и уничтожением объекта при наличии отложенных вызовов. Наш тест проверяет новые Fire после отключения и поведение Once; он не охватывает все очереди, уже выполняющиеся функции или параллельную обработку. Эти случаи требуют отдельных примеров.
Проверьте подключение к настоящей панели #
Для дальнейшей интеграции запишите матрицу: создать контроллер, открыть повторно, вызвать событие, закрыть дважды, вызвать событие после закрытия и открыть заново. Отдельно учитывайте уничтожение UI-объекта и завершение уже начавшейся работы. Если количество reactions отличается, сравните количество созданий подписки с ожидаемым жизненным циклом, прежде чем добавлять задержку или debounce.
Ограничитель частоты отвечает на вопрос, сколько действий допускается за время; владение подпиской отвечает на вопрос, сколько слушателей существует и когда они прекращают работу. Один приём не заменяет другой. В нашем примере нет сетевых запросов, наград, сохранений или покупки. Подключая их позже, добавляйте собственные проверки результата и не переносите доказательство локального теста на всю игру.
| Этап | Ожидание |
|---|---|
| Три слушателя | hits = 3 |
| После Disconnect | hits остаётся 3 |
| Повторное открытие | hits = 4 |
| Двойная очистка | hits остаётся 4 |
Сохраните подтверждённый результат #
Исправленный тест завершился строкой GUIDEBOOK_CONNECTIONS_ENGINE_PASS hits=5 once=1 в окне Output Studio. Эта строка идёт после assertions о трёх слушателях, их отключении, управляемом повторном открытии, безопасной очистке и Once. В учебном файле сохранены точные операции, а в записи проверки — его контрольная сумма. Счётчик пяти относится к последовательным этапам всего эксперимента.
Для передачи другому разработчику сохраните исходник, ожидаемую строку, порядок вызовов и границы проверки. Не называйте это испытанием готовой панели: в примере её нет. Тестовый проект отделён от опубликованных игр, а проверка реального интерфейса остаётся следующей задачей. Такая запись помогает воспроизвести механизм ошибки и применить решение осознанно, сохраняя собственный жизненный цикл.
| Граница | Что проверено |
|---|---|
| Новая панель | hits = 5 |
| Once | Один callback |
| Интерфейс | GUI отдельно |
| Очереди | Полный охват не заявлен |
Первоисточники
Roblox Creator Hub — EventsRoblox — RBXScriptConnection
Roblox — RBXScriptSignal
Roblox — Deferred engine events