全服喇叭喊话"恭喜 &config/MetaValueGetDef.lua 的函数表加 game/proxy/MetaValueProxy.lua 的替换引擎。全引擎的配置读取、公告模板、界面动态文案,全走这一个门。今天把"元变量"这套体系拆开,看一把钥匙怎么开全引擎的锁。
"元变量"这个词第一次听会懵,拆开就懂了:变量是"存值的名字",元变量是"存取值方法的名字"。普通配置表记"屏幕宽度是 960",元变量记"屏幕宽度去问 GetScreenSize"。这一字之差,让配置从快照变成了活物——快照会旧,活物常新。引擎里凡是"值会随环境变"的东西(屏幕尺寸、平台标志、玩家属性、物品名字),都登记成元变量而不是存副本。
MetaValue 系统的第一冲击是它的 GetDef 表——每个"配置项"都是一个函数:
MetaValueGetDef = {
-- 游戏基础信息
["SCREEN_WIDTH"] = function()
return GetScreenSize().width -- 现场算,不是存的
end,
["PLATFORM_ANDROID"] = function()
return global.isAndroid
end,
["NOTCH_PHONE_INFO"] = function()
return CheckNotchPhone() -- 刘海屏信息
end,
...
}
传统配置表存"值",这张表存"怎么取值"。要屏幕宽?现场量;要平台?现场查全局标志。取值函数化意味着每个配置项天然是"最新的"——屏幕旋转了、平台标志变了,下次取永远是新值,不存在过期问题。这和消耗格那篇"现查现显"的穷格子哲学同源:数据不落脚,落脚就有一致性麻烦。
GetScreenSize 里还有一个偷懒的细节值得学:
function GetScreenSize()
-- for 减少获取游戏屏幕尺寸的次数
if not G_GameScreenSize then
G_GameScreenSize = global.Director:getVisibleSize()
end
return G_GameScreenSize
end
注释原文就写着"减少获取游戏屏幕尺寸的次数"——屏幕尺寸这种东西开机后不会变,查一次存全局量,之后全走缓存。不可变的量查一次,可变的量每次查,MetaValueGetDef 表里两种策略并存,分界线就是"这个值会变吗"。一个简单的 if not 判断,省掉的是全引擎几百次 getVisibleSize 的跨层调用。
函数表还有个隐藏红利:取值函数可以带参数。ITEM_NAME/2001 这种"键加参数"的形态就是为它准备的——一个 ITEM_NAME 函数服务全游戏所有物品的名字查询。配置表的数据量从"物品数×字段数"压成"字段数",这是函数化相对数据表的数量级优势。
再列几个引擎里的真实键位感受一下覆盖面:SCREEN_WIDTH 屏幕宽、NOTCH_PHONE_INFO 刘海屏信息、PLATFORM_IOS 平台标志、DEVICE_UNIQUE_ID 设备唯一号——从渲染到设备到平台,一个系统全包。这也是为什么它叫 Meta(元)Value:它管的不是某类数据,是"数据该怎么取"这件事本身。界面适配要屏幕尺寸、登录要设备号、公告要物品名,全走同一个 GetValueByKey——调用方学一次,全引擎通用。
MetaValueProxy 的构造函数里有一段看着像咒语的循环:
for k, _ in pairs(MetaValueGetDef) do
local v = string.split(k, ".") or {}
local n = #v
if n == 1 then
_G[key1] = key1 -- 一级键:_G.PLATFORM = "PLATFORM"
elseif n == 2 then
_G[key1] = _G[key1] or {}
_G[key1][key2] = key2 -- 二级键:_G.GAME_DATA.itemGroundScale = ...
elseif n == 3 then
_G[key1] = _G[key1] or {}
_G[key1][key2] = _G[key1][key2] or {}
_G[key1][key2][key3] = key3 -- 三级同理再嵌一层
end
end
它把 GetDef 里每个键按点号拆开,注册成全局常量:键名本身成为 _G 里的值。为什么要把键名塞回全局?因为业务代码里写 SL:GetValue("PLATFORM") 时那个字符串是裸的,拼错一个字母运行时才炸;注册成全局常量后,配合静态检查工具(或简单的 _G[键名] 存在性校验),拼错的键名立刻暴露。把字符串变成符号,让错误从运行时挪到编写时——这一段循环的本质是把"约定"升级成"可检查的约定"。
按点号分层注册还顺手建了命名空间树:A.B.C 形态的键会自动生成 _G.A.B.C 的嵌套表,键多起来也不乱。GAME_DATA 组、PLATFORM 组各回各家,键数量涨十倍,_G 的顶层还是干净的。
_G[key1] = _G[key1] or {} 里的 or {} 也是个细节:多级键共享一级命名空间,后注册的键不覆盖先建好的表。or 模式在 Lua 里是"存在就用现成的、不存在才新建"的标准写法——和单例的 Inst()、对象池的 Alloc 一个血统。"or 默认值"是 Lua 的三元表达式的另一半,读引擎代码时满地都是,看惯了速度能快一倍。
公告模板的替换引擎是全系统最精彩的一段:
function MetaValueProxy:MetaValueFormat(str)
local source = str
local results = {}
while true do
-- 找 &<KEY/param>& 形态的标记
local sIdx, eIdx, oriStr, param = string.find(source, "(&<(.-)>&)")
if not (oriStr and param) then
break
end
if self._cacheFunc[oriStr] then
results[oriStr] = self._cacheFunc[oriStr] -- 缓存命中:直接用编译好的
else
-- 本地取元变量:load 现场编译一个取值函数
local tokens = G_GetSplitArray(param, "/")
local metaKey = tokens[1]
local successFunc = load(string.format(
[[
local metaValueProxy = global.Facade:retrieveProxy(global.ProxyTable.MetaValueProxy)
return metaValueProxy:GetValueByKey('%s', '%s') or ""
]], metaKey, tokens[2]))
if successFunc then
self._cacheFunc[oriStr] = successFunc -- 编译产物进缓存
end
end
...把标记从 source 里摘掉,继续找下一个...
end
-- 统一替换
for key, result in pairs(results) do
str = 替换标记为 result() 的返回值
end
return str
end
两个值得抄的工程决定。第一,标记的解析和取值的执行分离:先扫出全部标记,再统一替换——扫描循环里不做替换,替换循环里不做扫描,各干各的互不踩脚。第二,load 编译出的取值函数按原始标记串缓存:同一条公告模板跑一百次,只有第一次付编译成本,后面九十九次直接调函数。把字符串动态编译成函数再缓存,这是 Lua 特有的性能杠杆,用对了是神器,用忘了是内存漏(缓存无上限增长——好在这里标记种类有限,天然有界)。
用 load 而不是手写解析器的原因也要说清:标记的参数形态会进化(今天 KEY/param,明天可能 KEY/param/extra),手写解析器每种形态一段逻辑,load 方案把形态变化交给编译器消化——形态会变的解析,交给 load 比交给 if-else 便宜。当然 load 有执行任意代码的风险,所以前面那道 CheckJsonInvalid 的闸才那么重要,两者是配套工程。
替换循环里那句 string.find(str, key, 1, true) 的第四个参数 true(纯文本查找、关闭模式匹配)也别放过:标记串里含百分号、括号这些模式字符,开模式匹配会炸。引擎作者在这里被坑过一次才加上 plain 标记——凡是拼接出来的字符串做 find,一律开 plain,这条和日志篇的格式化防护是同族教训。
下面这个演示把替换引擎跑给你看:模板里写几个 &<键/参数>& 标记,点执行看输出实时生成;盯着缓存面板,第一次执行走编译、第二次同标记全 HIT;清缓存再跑,编译计数重新涨——Load 的成本被你亲手量出来:
skill-metavalue-1003g
演示里还能玩出一个进阶观察:把缓存勾掉连点三次执行,编译计数每次都涨;勾上缓存再连点,编译数不再动、命中数往上蹿——同一个模板,缓存开关一拨,成本结构完全不同。引擎默认开缓存的原因你现在能算出来:公告每播一次解析一遍标记,编译一次 load 的成本是命中路径的几十倍,不缓存才是真正的性能事故。
某服的多语言版要做海外发行,公告文案里硬编码的中文名词(物品名、地图名、称号)成了天堑——十七种语言,同一句话要配十七种物品名。接手的方案就是元变量:文案模板只写 &<ITEM_NAME/2001>&,各语言包里 GetDef 的函数查各自的字典表。改造后新增语言的成本从"翻译全部代码字符串"降到"翻译一份字典",两周的多语言工期压到三天。
改造中有个防注入细节值得单讲:模板来自配置表也可能来自运营输入,标记的参数如果被塞进代码就有执行风险。引擎的防线是 CheckJsonInvalid 的黑名单(function、_G、return 这些危险词直接拦)加 load 时的格式化拼接——动态编译的输入必须过闸,这条和安全篇的"链接白名单"是同一族纪律。上线五年,零注入事故。
黑名单的实现也值得看一眼它的分层:
local invalidStr = { "%", "(", ")", "_", "lib", "function", "then", "end", "_G", "return", "index", "set" }
local invalidStr2 = { "lib", "function", "then", "end", "_G", "return", "index", "set" }
两张黑名单内容不同:全量版带百分号和括号(防格式符号捣乱),轻量版只拦关键字。不同入口按风险等级配不同的闸——黑名单也是分级别的,一刀切的严格会让正常内容过不去,一刀切的宽松会让坏内容溜进来。分级的依据是入口的暴露程度:运营可编辑的用重闸,程序内部的用轻闸。顺带一提,黑名单是"穷举已知危险"的思路,永远有漏网可能,所以我们同时在 load 外面套了 pcall 兜底——黑名单挡常规攻击,pcall 接非常规漏网,双保险才敢把动态编译放进生产环境。
这次改造的通用结论:模板系统解决的是"内容里长出数据"的需求。凡是策划想在文案里嵌动态值的需求(公告、任务描述、邮件、跑马灯),元变量一套全管。你们项目里如果还有人在代码里拼公告字符串,把这篇甩给他。
改造的验收清单也留一份:一,所有标记能被正确解析(正则和转义都过);二,查不到的键显示 "undefined" 而不是崩溃或空串;三,危险词黑名单拦截生效(塞一段 function 进模板试试);四,同模板连跑一百次,编译计数只涨到标记种类数。四条全绿才上线——模板系统一旦带病上线,出错的是全服玩家都能看见的公告,脸丢得比任何系统都大。顺带一提这四条清单本身也是通用的:把"标记"换成"占位符"、"模板"换成"国际化文案",它同样适用于任何做字符串模板引擎的团队——底层机制不同,验收逻辑相通。
问:GetDef 的函数带副作用(顺手改全局)行不行?
行不通也行,但千万别。Get 的语义是"查询无副作用",Set 的写操作全部登记在 MetaValueSetDef 那张表里——读写分离在配置层的落地。混了之后,一次 innocent 的模板渲染可能改了游戏状态,这类 bug 的排查痛苦程度排进生涯前三。
问:GetValueByKey 查不到时为什么返回字符串 "undefined" 而不是 nil?
因为替换引擎的输出是字符串拼接,nil 会在 concat 时炸。返回 "undefined" 让替换流程永不中断,页面上顶多显示一个未定义标记——容错的返回类型要匹配使用场景,这个细节比返回什么值本身更重要。
问:键名按点号分层会不会和业务字符串冲突?
不会,注册的值就是键名字符串本身(_G.PLATFORM = "PLATFORM"),它只当常量记号用。真正的坑是有人手滑往 _G.PLATFORM 表里塞了别的东西,污染了命名空间——所以键的分层注册只建结构不存业务值,业务值永远走 GetValueByKey 现取。
问:模板替换的性能上限在哪?
单条公告三五个标记,替换微秒级;跑马灯每秒滚十条也毫无压力。真正的上限在 load 编译的缓存大小——标记种类是有限的(GetDef 就那些键),缓存天然有界。要警惕的是"参数动态拼进标记"的写法(&<ITEM_NAME/随机ID>&),参数一变就是新标记新编译,缓存就漏了——缓存键要收敛在有限集合,这条对所有编译缓存通用。
问:查不到的键为什么还要转一手 ConditionProxy?
GetValueByKey 的兜底逻辑是 GetDef 没有就去 ConditionProxy 查条件类取值——两个系统共用一套键空间,策划配置条件表达式时不用关心值来自哪边。这是门面模式的变体:一把钥匙进门,后面几个管家接力,调用方无感。
👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…
读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…
玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…
每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…
玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…
新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…