闪跳问题
下面是这轮三个问题(音量滑块、拖动闪跳、选角页闪跳/白屏)的可复用经验与对应关键代码。代码均为磁盘最终落地版本。
经验一:拖动控件别在回调里重建整棵树
滑块/开关这类"边拖边回调"的控件,onChange 里只能原地改值,绝不能触发 BuildXxxUI() 整树重建——否则正在拖的滑块会被销毁,拖动中断。音量百分比标签也是同理,用闭包持有引用做原地 SetText。
scripts/bullbear/ClientUILobby.lua(设置面板音量行):
lualocal volumeSliderWidget = nil -- ...在 panelChildren 里,紧跟"背景音乐"开关行: (function() local volumeLabel = UI.Label { text = string.format("%d%%", math.floor((lobbyMusicVolume_ or 0.6) * 100 + 0.5)), fontSize = 11, fontColor = {220, 226, 240, 255}, } local volumeSlider = UI.Slider { value = lobbyMusicVolume_ or 0.6, min = 0, max = 1, step = 0.1, flexGrow = 1, height = 22, thumbSize = 16, disabled = not lobbyMusicEnabled_, onChange = function(_, value) volumeLabel:SetText(string.format("%d%%", math.floor(value * 100 + 0.5))) Client.SetLobbyMusicVolume(value) -- 只改 gain + 存盘,不重建面板 end, } volumeSliderWidget = volumeSlider return UI.Panel { width = "100%", flexDirection = "row", alignItems = "center", gap = 8, children = { UI.Label { text = "音量", fontSize = 11, fontColor = {220, 226, 240, 255} }, volumeSlider, volumeLabel, }, } end)(),
开关与滑块联动置灰(同样不重建,只 SetDisabled):
luaUI.Toggle { value = lobbyMusicEnabled_, ... onChange = function(self, enabled) Client.SetLobbyMusicEnabled(enabled) if volumeSliderWidget then volumeSliderWidget:SetDisabled(not enabled) end end, },
经验二:保存→回包是"回声环",要静默吞掉自己发出去的回声
凡是"客户端 SendRemoteEvent 保存 → 服务端存完再广播回来"的链路,回包内容和本地必然一致。若回包回调里无条件刷新 UI,每拖一格就闪一次。解法是 pending 回声计数:发请求 +1,回包 -1 并直接 return;只有跨设备、数值真变化的更新才允许重建。
scripts/bullbear/ClientStorage.lua:
lua-- 保存时标记一次回声 if serverConnection_ then lobbyMusicSaveStatus_ = "服务器保存中" musicPrefPendingEcho_ = (musicPrefPendingEcho_ or 0) + 1 local data = VariantMap() data["Tag"] = Variant(prefStr) data["Volume"] = Variant(vol) serverConnection_:SendRemoteEvent(Shared.EVENTS.SAVE_MUSIC_PREF, true, data) end -- 回包时:先识别回声,命中则静默吞掉(不重建 UI) function Client.ApplyMusicFromServer(tag, status, volume) serverMusicPrefLoaded_ = true local srvEnabled, srvVolume = ParseMusicPref(tag) local finalVolume = srvVolume if type(volume) == "number" and volume >= 0 and volume <= 1 then finalVolume = math.floor(volume * 10 + 0.5) / 10 end if musicPrefPendingEcho_ and musicPrefPendingEcho_ > 0 then musicPrefPendingEcho_ = musicPrefPendingEcho_ - 1 lobbyMusicSaveStatus_ = status or lobbyMusicSaveStatus_ Client.UpdateLobbyMusic(); Client.UpdateGameplayMusic() return -- 关键:自身回声,绝不 BuildWaitingUI end lobbyMusicSaveStatus_ = status or lobbyMusicSaveStatus_ local changed = (srvEnabled ~= lobbyMusicEnabled_) or (finalVolume ~= lobbyMusicVolume_) ... -- 真变化才写本地 + 改 gain Client.UpdateLobbyMusic(); Client.UpdateGameplayMusic() if changed and phase_ == Shared.PHASE.WAITING then -- 加 changed 守卫 Client.BuildWaitingUI() end end
要点:step 保证拖一格只发一次请求,回包 FIFO 一一对应,计数不会错位累积。
经验三:拖动时音频要"原地改 gain",别依赖 Update 重播
UpdateXxxMusic 在 source:IsPlaying() 时会早退,所以改音量不能指望它重播,必须直接改正在播放的 SoundSource.gain。
scripts/bullbear/ClientCore.lua:
luafunction Client.SetLobbyMusicVolume(vol) vol = tonumber(vol) or 0.6 if vol ~= vol then vol = 0.6 end -- NaN 防御 vol = math.floor(math.max(0, math.min(1, vol)) * 10 + 0.5) / 10 -- clamp + 对齐 step lobbyMusicVolume_ = vol if lobbyMusicSource_ then lobbyMusicSource_.gain = vol * LOBBY_MUSIC_GAIN end if gameplayMusicSource_ then gameplayMusicSource_.gain = vol * GAMEPLAY_BGM_GAIN end Client.SaveMusicPreference() end -- 首次起播时同样按音量缩放(两处对称) source.gain = (lobbyMusicVolume_ or 0.6) * LOBBY_MUSIC_GAIN -- 大厅 BGM source.gain = (lobbyMusicVolume_ or 0.6) * GAMEPLAY_BGM_GAIN -- 玩法 BGM
持久化用管道编码 on|0.6 兼容旧裸 on/off,云端另加独立 Volume Variant 透传,pcall 包 :GetFloat() 防旧包缺字段。
经验四:进页面"闪跳一下"= 异步回调二次全量重建 + 入场动画重播
带 opacity=0 → StaggerIn 入场动画的页面,进页面后 PLAYER_INFO/云端计数等回调常以相同状态又触发一次 BuildXxxUI,于是整树 Destroy、所有行重新从透明淡入 = 闪跳。解法是状态签名去重:状态没变就直接跳过重建。
scripts/bullbear/ClientUISelect.lua:
luafunction Client.BuildSelectingUI() Client.LoadPickCounts() Client.SyncMyCharacterFromTaken() -- 纯数据同步,无 UI 副作用,提前到顶部 -- 用固定顺序(Shared.CHARACTERS)算签名,保证稳定可比 local _selSig = {} for _, _c in ipairs(Shared.CHARACTERS) do local _t = takenCharacters_[_c.id] _selSig[#_selSig + 1] = _c.id .. ":" .. (pickCounts_[_c.id] or 0) .. ":" .. (_t and (_t.slot or 0) or 0) end _selSig[#_selSig + 1] = "M" .. tostring(myCharId_ or "") .. ":" .. tostring(mySlot_) .. ":" .. tostring(maxPlayers_) local _selSigStr = table.concat(_selSig, "|") if uiRoot_ and uiRoot_._selectingSig == _selSigStr then return -- 同状态二次回调:不重建、不重播动画 → 不闪 end if uiRoot_ then uiRoot_:Destroy() end -- ⚠️ 必须在守卫之后 ... 建树 ... UI.SetRoot(uiRoot_) uiRoot_._selectingSig = _selSigStr -- 记在新根上,供下次比对 Client.Anim.StaggerIn(selectAnimGen, rows, 0.05) end
顺手给排序加稳定 tiebreaker,杜绝计数相同时行序抖动:
luatable.sort(sortedChars, function(a, b) local ca, cb = pickCounts_[a.id] or 0, pickCounts_[b.id] or 0 if ca ~= cb then return ca > cb end return a.id < b.id end)
这次踩的最深的坑(务必记住)
去重守卫必须放在 uiRoot_:Destroy() 之前。 我第一次把它放在 Destroy 之后,导致"先销毁旧树 → 算签名相同 → return → 新树没建"= 白屏。教训:任何"提前 return 跳过重建"的逻辑,都不能位于会销毁现有 UI 的语句之后。 正确顺序永远是「先比对、再决定是否销毁重建」。
可复用清单
- 拖控回调只原地改值,不重建整树;标签用闭包引用 SetText。
- "保存→广播回包"链路加 pending 回声计数,回包静默吞掉自身回声。
- 音频改音量直接改 SoundSource.gain,不依赖会早退的 Update 重播。
- 带入场动画的页面用"状态签名去重"挡掉同状态二次重建。
- 排序加稳定 tiebreaker。
- 去重守卫永远写在 Destroy 之前,否则白屏。
每次改完都跑了 Lua LSP(Error 0)+ 官方多人构建(后台匹配/4 人 AI 秒开/3 秒超时)通过;按项目铁律未执行 Git。

