关于 roblox-dev
roblox-dev 是面向 Roblox 和 Luau 项目的开发 Skill,聚焦引擎运行机制与编码实践。内容涵盖复制、脚本、数据服务和性能,并提供深入主题的参考文件。
主要功能
- 01说明 Roblox 运行时行为
- 02指导脚本类型与存放位置
- 03遵循服务器权威设计原则
- 04介绍复制机制与远程调用校验
- 05提供进阶主题参考资料
适用场景
- 01审查 Roblox 游戏架构
- 02实现客户端与服务器交互
- 03处理复制对象与信号
- 04查阅数据存储或流式加载规则
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ afrxo/ roblox-agent-skills --skill roblox-dev安装后在 Claude Code 中输入/roblox-dev确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
examples/.gitkeepcharacter-lifecycle.luauconnection-tracker.luaudatastore-session.luauremote-handler.luauservices-header.luaureferences/buffer-library.mdcloud-services.mddatastore-rules.mdparallel-luau.mdperformance-optimization.mdquery-descendants.mdreplication-model.mdstreaming-enabled.md详细介绍
Roblox 引擎
Roblox 平台在运行时的行为。资料来源:create.roblox.com/docs(API 和概念);devforum.roblox.com(工程师的说明和 RFC)。
深入了解:
references/replication-model.md— 哪些内容会从何处复制、各属性的规则、延迟信号的时序references/cloud-services.md— 数据存储 / 内存存储 / 配置 / 密钥 / 内存中的数据选择指南references/datastore-rules.md— 速率限制、重试 / 退避、会话锁定references/streaming-enabled.md— 分块语义、ModelStreamingMode、客户端规则references/query-descendants.md—Instance:QueryDescendants选择器语法详解references/buffer-library.md—buffer库完整 APIreferences/parallel-luau.md— Actor 模型、ConnectParallel、SharedTable、线程安全references/performance-optimization.md— 完整的陷阱和缓解方法列表
集成政策
此技能介绍推荐模式;它不授权重写现有代码。将规则应用于新代码,以及用户明确要求重构的代码。应用技能默认设置前,请先匹配代码库现有风格(大小写、缩进、引号风格、模块结构)。不要引入项目尚未使用的工具;遇到冲突时指出一次,并默认采用现有模式。
完整规则:../../shared/integration-policy.md。
原则
- 服务器具有权威性。 客户端可能撒谎。任何会影响游戏状态的内容(货币、物品栏、伤害、可信的位置数据)都应由服务器负责。客户端表达意图;服务器负责验证并应用。
- 状态所有权是排他的。 每份状态都有唯一所有者——服务器、控制该状态的客户端,或复制机制。两个写入者就意味着竞争条件。
- 复制是异步的。 在服务器端创建的内容会在后续帧才出现在客户端,而非同一帧。在客户端创建的内容不会复制到服务器。
- 让出执行权会产生可观察影响。 事件处理程序中的让出执行权调用会在其间运行其他处理程序。实际顺序未必如代码所写。
- 引用 API 前先核实。 Roblox 的 API 范围庞大且不断变化。与其编造属性名称,不如说明不确定性。
服务 — 对所有服务使用 GetService
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
local Players = game:GetService("Players")
- 始终使用
game:GetService("Name")。不要使用game.ServiceName。- 有些服务默认无法在脚本运行时使用;
GetService会创建这些服务,而通过点索引访问会报错。 - 有些服务的类名与其访问器名称不同(例如,
RunService在内部名为“Run Service”)。
- 有些服务默认无法在脚本运行时使用;
- 优先使用
GetService获取Workspace,而不是小写的workspace全局变量。 两种方式都可用;这个全局变量早于GetService,为保持兼容而存在。统一使用GetService,可以让所有服务访问方式一致,并将拼写错误作为未定义变量错误暴露出来。 - 将
GetService调用放在文件顶部,按字母顺序排列,不要散落在后面的代码中。
DataModel 和实例
- DataModel 是以
game为根节点的树。服务是顶层子项,其他所有内容都是实例。 - 只要
Parent = nil且没有脚本持有引用,实例就仍然存活。设置Parent = nil不等于销毁。 使用:Destroy()断开事件连接、将实例标记为已销毁,并将Parent设为 nil。 :Destroy()不可逆。 已销毁的实例无法重新设置父级;继续访问其属性会报错。:Clone()要求Archivable = true(默认值)。克隆模型会包含其所有后代;兄弟实例之间的引用不会重新绑定。
查询后代
-
:GetChildren()— 仅直接子级。 -
:GetDescendants()— 完整子树,不过滤。对于小型树开销低,对于大型树开销高(例如流式加载的 Workspace)。 -
:FindFirstChild(name)/:FindFirstChild(name, true)— 第二个参数表示递归查找。 -
:FindFirstChildOfClass(class)/:FindFirstChildWhichIsA(class)— 按类筛选直接子级。 -
:QueryDescendants(selector: string)→{ Instance }。在引擎端执行经过筛选的后代查询,支持类似 CSS 的选择器语法(ClassName、.Tag、#Name、[prop = val]、[$attr]、[$attr = val]、组合符>>>,、伪类:not(...):has(...))。对于大型树,优先使用此方法,而不是GetDescendants+ Luau 端筛选。线程不安全。完整语法和示例见references/query-descendants.md。Workspace:QueryDescendants("MeshPart.SwordPart") -- MeshParts tagged SwordPart Workspace:QueryDescendants("Model >> [$OnFire = true]") -- attribute-matching descendants of any Model Workspace:QueryDescendants(":not(SpotLight, PointLight)") -- excluding lights
WaitForChild 规则
- 在客户端,复制容器(例如
ReplicatedStorage、Workspace)的子级在第一帧可能尚不存在。对于任何不是你自己放入其中的对象,都使用:WaitForChild("Name")。 - 在服务器,你在 Studio 中放置的每个 Instance 在脚本启动时都已存在;使用
:FindFirstChild即可。 - 生产代码中绝不要在不设置超时的情况下调用
WaitForChild——如果子级始终未到达,它会永久让出执行权。:WaitForChild("Name", 5)会在 5 秒后返回nil。
脚本类型和位置
三种脚本类型:
Script— 根据Script.RunContext和所在位置,在服务器或客户端运行。RunContext = Legacy(默认)→ 仅在服务器运行,只在服务器容器中运行(ServerScriptService、Workspace等)。RunContext = Server→ 在服务器运行,也可从ReplicatedStorage运行(但不要这样做——客户端可以读取源代码)。RunContext = Client→ 在客户端运行,从ReplicatedStorage运行。建议将其用于新的客户端脚本(比LocalScript更清晰)。
LocalScript— 仅在客户端运行。没有RunContext。只在客户端容器中运行:StarterPlayerScripts、StarterCharacterScripts、StarterGui、StarterPack、ReplicatedFirst。ModuleScript— 可由任一端通过require()调用。它在调用者的上下文中运行。
推荐布局
| 位置 | 内容 | RunContext |
|---|---|---|
ServerScriptService | 服务器脚本 + 仅限服务器的 ModuleScript | Server |
ServerStorage | 仅限服务器的数据(资源、表)——客户端看不到其中的内容 | 不适用 |
ReplicatedStorage | 共享的 ModuleScript(服务器 + 客户端)以及客户端脚本 | 客户端脚本使用 Client |
ReplicatedFirst | 最精简的客户端加载器(例如加载屏幕) | Client |
StarterPlayerScripts / StarterCharacterScripts / StarterGui / StarterPack | 按玩家运行的 LocalScript | 不适用 |
Workspace | 世界内容。用于对象行为的服务器端 Script。 | 如需明确指定,则使用 Server |
常见做法:将大部分代码作为 ModuleScript 放在 ReplicatedStorage(共享)或 ServerStorage/仅限服务器的位置,并在 ServerScriptService 中设置一个服务器入口 Script 来调用这些模块,再在 ReplicatedStorage 中设置一个客户端入口(RunContext = Client)。
规则
- **不要将服务器逻辑放在
ReplicatedStorage中。**客户端可以读取源代码;请假设其中的任何内容都会泄露。 - 明确设置模型或包内脚本的
RunContext——当模型重新设置父级或共享时,这样可以消除歧义。 - 在共享的 ModuleScript 中使用
RunService:IsServer()/:IsClient()进行运行时分支。不要根据script.Parent推断。 - **
Workspace中的LocalScript不会运行。**只有客户端可运行的容器才行。这是常见错误。 Workspace中的服务器脚本会运行,但将RunContext = Server设置在ServerScriptService中更清晰,并且在几何对象移动时更不容易令人意外。
来源:create.roblox.com/docs/scripting/locations。
复制
- 服务端 → 客户端会自动复制到已复制的容器中(
Workspace、ReplicatedStorage、Players[X].PlayerGui、克隆到PlayerGui的StarterGui)。属性更改和父级更改都会复制。 - **客户端 → 服务端不会复制。**客户端创建或修改的任何内容都只保留在本地,服务端永远看不到。例外包括:
Players[X].Character(过滤时代及角色所有权)、输入事件和显式的RemoteEvent调用。 - 复制是按属性进行的,并非全有或全无。有些容器两端都存在,但其中的内容仅在客户端本地(
Player.PlayerScripts、StarterGui初始复制后的Player.PlayerGui)。 - **先设置属性,再设置父级。**实例进入已复制的容器后,之后的每次属性更改都会作为独立更新进行复制,客户端可能会观察到中间状态。先完整构建实例,再设置
Parent。 - 信号行为很重要。
Workspace.SignalBehavior = Enum.SignalBehavior.Deferred(新场所中的现代默认值)会将信号触发批处理到下一个恢复点;假设ChildAdded会同步触发的代码很脆弱。请在启用延迟信号的情况下进行测试。 - 完整表格请参阅
references/replication-model.md。
RemoteEvents 和 RemoteFunctions
-- Server
local TakeDamage = ReplicatedStorage:WaitForChild("TakeDamage") :: RemoteEvent
TakeDamage.OnServerEvent:Connect(function(player, amount: unknown)
if typeof(amount) ~= "number" or amount < 0 or amount > 100 then return end
-- validated; apply
end)
-- Client
TakeDamage:FireServer(25)
- **
OnServerEvent的第一个参数始终是触发事件的Player。**它是真实可信的,由 Roblox 提供。其他任何内容都不要信任。 - 将其他所有参数都设为
unknown,并在边界处进行验证。设为number会让类型检查器接受利用者输入。(完整模式请参阅luau-expert技能。) RemoteFunction使用:InvokeServer()/:InvokeClient(),调用是同步的(会让出执行权)。尽可能避免客户端到服务端的InvokeClient;恶意客户端可以让调用一直挂起。优先使用RemoteEvent加回复事件。- **对每个 Remote 都进行速率限制。**跟踪每位玩家的调用次数;拒绝明显的垃圾请求。默认做法:使用有界队列,队列满时丢弃最旧的请求或直接拒绝。
- **每种逻辑操作使用一个 Remote。**不要通过
kind字段进行多路复用,这样类型更清晰,验证也更简单。
角色生命周期
local function onCharacterAdded(character: Model)
local humanoid = character:WaitForChild("Humanoid") :: Humanoid
-- ...
end
Players.PlayerAdded:Connect(function(player)
if player.Character then onCharacterAdded(player.Character) end
player.CharacterAdded:Connect(onCharacterAdded)
end)
- **触发
PlayerAdded时,玩家的第一个角色可能已经存在。**检查player.Character,并连接到后续的CharacterAdded。 - **每次重生都会生成一个新的
Model。**指向旧角色的引用会变得过时。在CharacterAdded中重新连接每个角色的信号;使用character.AncestryChanged或humanoid.Died清理上一组连接。 - 每个角色的
Humanoid.Died只会触发一次。死亡后,模型会继续存在一段时间(默认约 5 秒,由Players.RespawnTime控制),之后自动重生会将其销毁。 Player.CharacterRemoving会在模型离开Workspace之前触发,这是读取其状态的最后机会。
流式加载
现代场所会启用 Workspace.StreamingEnabled。世界中的部分区域会在每位玩家周围加载和卸载。
- 客户端可能看不到服务端可见的部件。切勿假设客户端上的几何体一定存在,应使用带超时的
WaitForChild,或检查是否为 nil。 - 每个
Model的流式加载由Model.ModelStreamingMode控制:Default—— 模型按常规方式进行流式加载,后代会独立加载。Atomic—— 整个模型作为一个整体加载或卸载(不会部分加载)。Persistent—— 所有客户端始终都会加载该模型。PersistentPerPlayer—— 通过相关Add/RemoveAPI 添加的玩家始终会加载该模型;其他玩家默认不会加载。
- 对于后代必须同时到达的对象(车辆组件、NPC 骨架),请使用
Atomic。谨慎使用Persistent,因为它会消耗每个客户端的内存。 - 即使部件已卸载,属性更改仍会复制;只是客户端不会渲染它们。不要更新已卸载对象的
BasePart.CFrame,这会浪费带宽。 - 关于区块语义以及
ModelStreamingMode的交互,请参阅references/streaming-enabled.md。
物理所有权
- 每个未锚定的
BasePart都有一个网络所有者。默认情况下,所有者是服务器。当玩家的角色触碰、踩到或持有一个部件时,所有权会转移到该玩家的客户端。 - 所有者负责模拟物理效果;模拟结果会同步回来。客户端拥有的部件,其位置并非由服务器权威决定——切勿将其
CFrame用于游戏状态判断。 - 在服务器端强制设置所有权:
part:SetNetworkOwner(player)——传入nil可将所有权交还给服务器。:GetNetworkOwner()可读取所有者。 - 锚定部件没有所有者。 对锚定部件调用
SetNetworkOwner会报错。如果不控制输入,请先使用BasePart:CanSetNetworkOwnership()检查。 - 默认启用自动转移。 对部件调用
:SetNetworkOwner(player)或:SetNetworkOwner(nil)后,该部件的自动转移会关闭——所有权会保持在你设置的位置。调用:SetNetworkOwnershipAuto()可重新启用自动转移。 - 对于玩家不得传送的任何部件(宝箱、NPC、投射物、反作弊相关部件),只需调用一次
SetNetworkOwner(nil),切勿调用SetNetworkOwnershipAuto()。
云服务(持久化与跨服务器)
Roblox 提供多种存储选项。应根据访问模式选择,而不是凭直觉。
| 服务 | 持久性 | 范围 | 用途 |
|---|---|---|---|
标准数据存储(DataStoreService:GetDataStore) | 永久 | 跨服务器 | 用户进度、物品栏、存档数据。支持数字、字符串、布尔值和表。类似 NoSQL。 |
有序数据存储(:GetOrderedDataStore) | 永久 | 跨服务器 | 全时排行榜(仅支持数字,可排序)。 |
内存存储(MemoryStoreService) | ≤ 45 天 | 跨服务器 | 匹配、每日/每月排行榜、临时跨服务器队列。速度比数据存储快。 |
| 配置(Creator Hub) | 永久 | 跨服务器 | 功能开关、可调参数。在游戏内只能读取。 |
| 密钥存储 | 永久 | 跨服务器 | API 密钥、第三方令牌。在游戏内只能读取。 |
| 内存中的 Luau 表 | 会话期间 | 单个服务器 | 临时状态(计时器、状态效果、当前生命值)。免费且即时。 |
云服务仅限服务器使用。 客户端无法读取 DataStore、MemoryStore 等服务。客户端需要访问时,需通过 RemoteEvent 转发。
数据存储核心规则
- 始终使用
pcall。 网络故障、请求节流和配额耗尽都可能导致读写抛出错误。 - 并发状态使用
UpdateAsync(而非SetAsync)——它会提供之前的值并以原子方式合并数据。在转换函数中返回nil可中止操作,且不会消耗配额。 - 会话锁定模式:在
PlayerAdded时读取一次,在会话期间修改内存中的数据,并在PlayerRemoving和BindToClose时保存。不要在每次数据变更时都读取。 game:BindToClose在所有玩家间共享约 30 秒的总时限。使用task.spawn并行保存。- 速率限制按键和游戏分别计算,并会随时间补充。此外还有一条硬性规则:每个键约 6 秒一次。
内存存储核心规则
- 临时性:数据最多在 45 天后过期。不要将存档数据放在这里。
- 可用的基本结构:队列、有序映射、哈希映射。写入吞吐量高于数据存储。
- API 仅限服务器使用。
完整的选型指南(包括何时使用哪种服务)请参阅 references/cloud-services.md;数据存储速率限制、重试/退避和会话锁定模式请参阅 references/datastore-rules.md。
来源:create.roblox.com/docs/cloud-services/data-stores-vs-memory-stores。
任务调度
task 库取代了经典的 wait/spawn/delay。请使用它。
| 调用 | 时机 |
|---|---|
task.spawn(f, ...) | 立即在新线程上运行 f(在当前恢复点之后恢复)。用于将会让出执行权的主体从事件处理程序中分离出来。 |
task.defer(f, ...) | 在当前恢复周期结束后运行 f——比 spawn 更晚。用于合并工作,且不会让出执行权。 |
task.delay(t, f, ...) | 在新线程上于 t 秒后运行 f。取代旧版 delay。 |
task.wait(t) | 让出当前线程 t 秒(若省略,则等待一帧)。取代 wait。 |
task.cancel(thread) | 终止由 task.spawn/task.delay 启动的线程。请配合显式清理使用。 |
task.synchronize() / task.desynchronize() | 用于 Parallel Luau Actor 协调。请参阅下方的 Parallel Luau 部分。 |
- 不要使用旧版全局函数
wait、spawn、delay。它们会限制执行频率(wait会限制到约 30 Hz),而且在一些使用方式下会造成泄漏,task版本则不会。 - 在事件处理程序中使用
task.spawn可以让快速触发的事件并发运行。如果主体会修改共享状态,请自行添加队列或锁。 - 需要在当前事件处理流程结束后执行工作时,使用
task.defer而非task.spawn——适用于会修改世界并触发更多信号的:Connect主体。 - 如果线程需要取消,请跟踪由
task.spawn/task.delay启动的线程;Instance 销毁时不会自动取消线程。
Buffer 库(二进制数据)
buffer 是 Luau 的定长可变字节块。来源:buffer.yaml in creator-docs。
适用于:
- 紧凑的 RemoteEvent 载荷(通过网络传输时,打包后的 buffer 比 Luau 表更小)。
- 为需要保持较小体积的 DataStore 值自定义二进制序列化。
- 读取/写入现有二进制格式。
- 替代
string.pack/string.unpack的使用场景。
local b = buffer.create(16) -- 16-byte buffer, zero-initialized
buffer.writeu32(b, 0, 0xCAFEBABE) -- 4 bytes at offset 0
buffer.writef32(b, 4, 1.5) -- 4 bytes at offset 4
buffer.writestring(b, 8, "abc") -- 3 bytes at offset 8
local magic = buffer.readu32(b, 0)
local payload = buffer.readstring(b, 8, 3)
主要规则:
- 所有偏移量都是从 0 开始的字节偏移量。 超出 buffer 范围读取或写入会引发错误。
- 所有数值读写都采用小端序。
- buffer 最大大小:1 GiB(
buffer.create会拒绝更大的大小;即使分配接近此上限也可能失败)。 - 通过 Roblox API(RemoteEvents 等)发送时会进行复制。 接收方不会保留对象标识。不要在 Actor 脚本之间共享 buffer(Parallel Luau)。
- 大小固定。 无法扩容或缩容——请分配合适的大小,或复制到新的 buffer 中(
buffer.copy)。
函数分为三组:
- 生命周期:
buffer.create(size)、buffer.fromstring(s)、buffer.tostring(b)、buffer.len(b)。 - 定宽数值的读写:
readi8/u8/i16/u16/i32/u32/f32/f64及对应的write*。 - 位级操作和批量操作:
readbits(b, bitOffset, bitCount)/writebits(每次调用 0–32 位)、readstring(b, offset, count)/writestring(b, offset, value, count?)、buffer.copy(target, targetOffset, source, sourceOffset?, count?)、buffer.fill(b, offset, value, count?)。
完整的函数签名参考见 references/buffer-library.md。
Parallel Luau(多线程)
默认情况下,Luau 是单线程的。Parallel Luau 通过 Actor 模型让互不依赖的工作在多个 OS 线程上运行。适用于可拆分的 CPU 密集型工作:逐玩家射线检测验证、程序化生成、大量逐实例计算。
Actor 模型
ActorInstance 下的脚本可以并行运行。- 每个 Actor 对应一个线程。 同一个 Actor 中的多个脚本仍会彼此串行运行。
- Actor 越多,负载均衡效果越好,即使设备的核心数少于 Actor 数量也是如此。
- 不要将 Actor 嵌套为其他 Actor 的后代——脚本所属的 Actor 是其最近的祖先。
阶段:串行与并行
代码默认以串行方式运行。切换方式:
-- Inside an Actor's script
RunService.Heartbeat:ConnectParallel(function()
-- parallel phase
local result = computeExpensive()
task.synchronize()
-- serial phase
applyResult(result)
end)
task.desynchronize()— 将当前线程切换为并行。让出执行权,并在下一个并行执行机会到来时恢复。task.synchronize()— 切回串行。在修改大多数 Instance 之前必须调用。Signal:ConnectParallel(callback)— 自动安排信号回调以并行方式运行;无需在其中调用desynchronize。- 并行阶段不允许使用
require()。 请先在串行阶段加载模块。
线程安全级别
API 成员带有线程安全标签:
| 级别 | 属性 | 函数 |
|---|---|---|
| Unsafe | 不允许并行读写 | 不允许并行调用 |
| Read Parallel | 可读取,不可写入 | 不适用 |
| Local Safe | 同一 Actor 内可执行任何操作;跨 Actor 只读 | 仅限同一 Actor |
| Safe | 可读写 | 可从任意位置调用 |
未标注时,默认为 Unsafe。大多数以读取为主的访问(几何查询、射线检测)都是安全的;大多数修改操作则不安全。请查看 API 参考中各成员的说明。
跨线程通信
Actor:SendMessage(topic, ...)+Actor:BindToMessage(topic, fn)/:BindToMessageParallel(topic, fn)— 异步单向消息传递。每个主题可以有多个绑定。SharedTable— 可在各 Actor 间共享、支持原子更新的类表结构。发送时不会复制。适用于“未存储在 DataModel 中的公共世界状态”。- 在串行阶段直接写入 DataModel也可行,但会导致频繁同步。
不适用的场景
- 长时间不间断的计算。 即使在线程中并行运行,不让出执行权的 100 ms 循环仍会阻塞该线程 100 ms。请拆分工作。
- 每帧开销很小的工作。 对于琐碎任务,Actor 的开销远大于节省的成本。
- 主要修改 DataModel 的代码。 大部分时间都会花在
task.synchronize上,从而失去并行的意义。
完整深入介绍:references/parallel-luau.md。来源:create.roblox.com/docs/scripting/multithreading。
性能
性能优化是一个循环:提前设计、使用 MicroProfiler 识别热点、进行优化、持续监控。以下是最值得关注的部分;完整的陷阱列表见 references/performance-optimization.md。
帧预算
- 目标:客户端达到 60 FPS = 每帧 16.67 ms。服务器没有帧率,但
RunService.Heartbeat扮演相同角色。 - Luau 侧任何持续每帧运行超过 1 ms 的操作都值得考虑优化。
常见错误
- 在
RunService.Heartbeat上执行大量工作(或PreSimulation/PostSimulation/PreRender/PreAnimation)。Heartbeat 每帧都会触发,应按间隔限制执行频率(if now - last < 0.1 then return end),或将工作移到频率较低的循环中。 - 在服务端使用
TweenService。 补间属性会每帧复制,遇到延迟时会卡顿。除非结果必须由服务端权威控制,否则应在客户端执行补间。 - 连接到
Player/ 角色后从不断开。 玩家离开时,Player实例不会自动销毁(参见Workspace.PlayerCharacterDestroyBehavior,可选择启用自动销毁)。应跟踪并显式断开连接,或在PlayerRemoving中手动销毁。 - 表无限增长。 玩家加入时设置
playerInfo[player] = ...,但离开时不执行playerInfo[player] = nil,是典型的服务端内存泄漏。 - 深度克隆大型表。 使用浅拷贝;仅修改发生变化的路径(参见
luau-expert技能中的交叉链接)。 - RemoteEvent 负载过大。 发送增量,而不是整个物品栏。对于高频状态,可打包到
buffer中。 - 过度创建和销毁实例。 在运行时克隆大型角色模型会将整个后代树复制给客户端。预先创建实例池优于每次事件都创建实例。
- 不会移动的未锚定部件。 设置
Anchored = true可将其完全移出物理模拟。 CollisionFidelity过高。Precise会消耗大量 CPU 和内存。对于玩家察觉不到差异的对象,应使用Box/Hull。- 资源重复。 同一网格/纹理多次上传 = 同一内容在内存中重复存储。应重复使用资源 ID。
- 已发布角色模型中的动画元数据。 克隆角色模型上的 Animation Editor 数据会随每个克隆一起复制。发布前应将其移除。
- 为了实现“无弹入”加载画面而对整个 Workspace 使用
PreloadAsync。 只预加载加载画面和起始区域所需的内容。 - 对高频且不让出执行权的计算路径使用原生代码生成(
--!native),这是轻松提升性能的办法——但应先确认该函数确实是性能热点。
值得了解的 MicroProfiler 范围
RunService.Heartbeat/PreRender/PreSimulation/PostSimulation— 你的脚本。physicsStepped/worldStep— 物理开销。ProcessPackets/Allocate Bandwidth— 网络开销。updateInvalidatedFastClusters— Humanoid/蒙皮网格的频繁变更。
使用 debug.profilebegin("Name") / debug.profileend() 为你自己的范围添加标记。
来源:create.roblox.com/docs/performance-optimization、/improve。
Bug 目录
重复连接
-- ❌ every CharacterAdded adds another connection; old ones never disconnect
Players.PlayerAdded:Connect(function(player)
player.CharacterAdded:Connect(function(char)
char.Humanoid.Died:Connect(function() ... end) -- leaks per respawn
end)
end)
按作用域跟踪并断开连接。一种常见模式是:用生命周期所有者(玩家、角色、实例)作为键,存储 { RBXScriptConnection };清理时遍历并调用 :Disconnect()。
过期引用
local character = player.Character
task.wait(5)
character.Humanoid.Health = 0 -- ❌ player may have respawned; old model is destroyed
每次让出执行权后重新获取 player.Character,或者捕获并验证:if character.Parent == nil then return end。
在事件处理程序中让出执行权
RemoteEvent.OnServerEvent:Connect(function(player)
task.wait(1) -- ❌ all subsequent fires from this player wait behind this one
grantReward(player)
end)
要么不要在处理程序中让出执行权,要么使用 task.spawn 执行主体,让每次触发都独立运行。使用 task.spawn 时要小心——并发状态更新需要加锁。
内存泄漏
Instance:Destroy()会断开事件并将实例从 DataModel 中移除,但不会解除 Lua 侧的引用。如果变量的生命周期较长,应将其设为nil。- 连接到非 Roblox 信号(自定义信号、库)时不会自动清理。应跟踪并断开这些连接。
:Connect(...)中捕获大型表的闭包会让这些表一直存活,直到连接断开。
CollectionService 清理
-- ❌ tag handler runs once per existing tagged instance, then once per new one
CollectionService:GetTagged("Door"):forEach(setupDoor)
CollectionService:GetInstanceAddedSignal("Door"):Connect(setupDoor)
始终配合 GetInstanceRemovedSignal 进行清理。使用 { [Instance]: State } 映射跟踪每个实例的状态,不要将状态保存在闭包中。使用 instance.AncestryChanged(当 parent == nil 时)或移除信号。
设置过程中有玩家加入的竞态
脚本启动时,PlayerAdded 会为服务器中已有的玩家触发(通过 :Connect 绑定只能捕获之后触发的事件)——请手动处理现有玩家:
for _, player in Players:GetPlayers() do onPlayerAdded(player) end
Players.PlayerAdded:Connect(onPlayerAdded)
架构规则
- 验证每个服务器边界。 RemoteEvent 参数、DataStore 读取结果、MessagingService 负载、HttpService 响应,以及任何受用户影响的内容。
- 服务器权威检查优于客户端检查。 客户端验证是为了改善用户体验(即时反馈);服务器验证是为了保障安全。两者缺一不可。
- 一个 ModuleScript 只负责一项职责。 不要将功能汇总到庞大的“Manager”模块中——应组合职责明确、所有权清晰的小模块。(模块和文件结构规则请参阅
luau-expert。) - 每项状态只由一个所有者负责。 一个字段要么由服务器所有,要么仅用于复制,要么由客户端所有。绝不能有多个写入方。如果多个参与方必须更新某项内容,就通过一个负责串行处理写入的模块来路由。
- 集中管理复制。 每个复制域(物品栏、货币、任务进度)使用一个模块,而不是将 Remotes 分散在代码库各处。这样更易审查,也更容易添加验证和速率限制。
- 不要信任客户端提供的 UserId。 从任何真实的
Player实例中读取的UserId(OnServerEvent的第一个参数、Players:GetPlayers()、Players:GetPlayerByUserId)都是真实可信的。客户端作为 RemoteEvent 参数发送的UserId只是一个数字——应进行验证或忽略。 - 服务器处理程序应具备幂等性。 网络卡顿导致的重放或利用程序造成的重放确实会发生。设计 RemoteEvent 处理程序时,应确保两次相同的触发产生相同的最终状态。
- 耗费资源的工作放在服务器端。 路径规划、大型查询以及任何 CPU 密集型任务都应在服务器端执行。客户端设备的性能差异很大,而服务器性能一致。
- 不要只依赖
MessagingService构建跨服务器状态。 它采用尽力交付机制,不保证送达,并且每个游戏都有速率限制。应将其用于发送“重要事件”通知,而不是同步状态。 - 脚本容器应与运行环境匹配。 服务器逻辑放在
ServerScriptService/ServerStorage;客户端逻辑放在StarterPlayerScripts/StarterCharacterScripts/StarterGui;共享模块放在ReplicatedStorage。不要将仅限服务器的逻辑放在ReplicatedStorage中——客户端可以读取其中的内容。
验证规则
- 不要凭记忆引用 API 签名,除非涉及最稳定的接口(
Instance.Parent、BasePart.CFrame、Humanoid.Health等)。Roblox 会重命名、弃用并新增 API。如有疑问,请引导用户查看 create.roblox.com/docs,并说明你不确定。 - 行为原则优先于 API。 应将规则表述为“服务器对……具有权威性”,而不是“使用
Workspace.StreamingMinRadius = 256”——属性可能会重命名,原则却不会。 - 区分稳定内容与变化内容。 DataModel 层级结构、服务、复制方向、角色生命周期:多年来都很稳定。新 API(例如近期新增的
Workspace属性、新的RunService方法、与脚本相关的测试版功能):推荐前应先核实。 - 不要混淆引擎与语言。 如果问题涉及类型或
--!strict的行为,请转交给luau-expert。如果涉及 Rojo 或文件系统布局,请转交给roblox-toolchain。如果涉及 UI 库,请转交给roblox-ui。
反模式
| ❌ 不要 | ✅ 应该 |
|---|---|
game.Workspace / workspace 全局变量 | game:GetService("Workspace") |
只使用 game.Players.PlayerAdded:Connect(f) | 先遍历 :GetPlayers(),再使用 :Connect 处理启动期间加入的玩家 |
在客户端使用 child = parent:FindFirstChild("X") | 使用 :WaitForChild("X", timeout) |
| 仅通过类型注解就信任客户端传来的 RemoteEvent 参数 | 将类型标注为 unknown,进行验证并缩窄类型 |
RemoteFunction:InvokeClient | 使用 RemoteEvent 往返通信 |
使用一个 Remote,通过 kind 字段复用多个操作 | 每个操作使用一个 Remote |
使用 SetAsync 保存并发状态 | 使用 UpdateAsync |
调用 DataStore 时不使用 pcall | 始终使用 pcall,并进行重试和退避 |
| 每次状态变化时都读取 DataStore | 使用会话缓存;在 PlayerRemoving 和 BindToClose 时写入 |
| 在让出执行后继续使用角色引用 | 每次让出执行后重新获取 player.Character |
| 重生时连接事件但不断开连接 | 跟踪每个角色的连接,并在角色移除时断开 |
使用 Instance.Parent = nil 清理 | 使用 :Destroy() |
使用 WaitForChild 时不设置超时 | 使用 :WaitForChild("X", 5) 并处理 nil |
| 只在客户端进行验证 | 始终在服务端验证 |
| 在频繁触发的事件处理程序中让出执行 | 使用 task.spawn 运行处理逻辑;通过自建队列进行串行处理 |
| 对敏感部件使用自动网络所有权 | 显式调用 SetNetworkOwner(nil) |
| 遍历标签集合时不配对移除信号 | 将 GetInstanceAddedSignal 与 GetInstanceRemovedSignal 配对使用 |
将服务端逻辑放在 ReplicatedStorage 中 | 使用 ServerScriptService / ServerStorage |
将 LocalScript 放在 Workspace 中 | 使用客户端容器(StarterPlayerScripts 等),或在 ReplicatedStorage 中使用 RunContext = Client 的 Script |
使用旧版全局函数 wait / spawn / delay | 使用 task.wait / task.spawn / task.defer / task.delay |
直接在 RunService.Heartbeat 上执行繁重工作 | 降低执行频率/移至运行频率较低的循环/使用 task.delay |
在服务端使用 TweenService 制作视觉动画 | 在客户端执行补间动画;仅同步目标 |
| 每次事件发生时都在运行时克隆大型装配体 | 使用对象池/预先实例化 |
对小型部件设置 CollisionFidelity = Precise | 使用 Box / Hull |
| 在并行阶段修改 Instances | 先调用 task.synchronize() |
| 在 DataStores 中存储 API 密钥 | 使用机密存储 |
| 在 MemoryStore 中保存数据 | 使用标准数据存储 |
示例
examples/services-header.luau— 规范的服务导入代码块。examples/character-lifecycle.luau— 玩家加入、角色添加/移除及逐角色清理。examples/remote-handler.luau— 带验证和速率限制的 RemoteEvent 服务端处理程序。examples/datastore-session.luau— 玩家加入时加载、使用会话缓存,并在移除时及 BindToClose 时保存。examples/connection-tracker.luau— 用于批量调用:Disconnect()的可释放作用域。