下载 App
10/161 浏览案例分享
【案例分享】SDK 未就绪时调用只返回 false:TapTap SDK 接入流程与启动资源时序实录
投稿方向:星火编辑器论坛「案例分享」专区 / 生态贡献度
类型:图文案例
素材来源:《别停下》开发实录 —— engine-docs/recipes/sdk.md、recipes/preload-and-build-refs.md,对照 scripts/Ads.lua(256 行)与 scripts/main.lua 的 Start()
一句话结论
这套 SDK 的失败方式全是静默的。 sdk 不在时你访问它拿到 nil,接口不可用时它返回 false 而不抛错,宿主注入晚于首次探测时,探测结果会被永久缓存成"不可用"——整局乃至整个进程都不再出现广告入口,日志里一个字都没有。
另一半是启动时序:.ttf 不在 render-blocking 名单里,字体没下完,标题页一个字都画不出来。
一、sdk 是宿主注入的全局对象
它不是 require 进来的模块。宿主(TapTap 手机内嵌容器)在启动过程中把它挂到全局表上——它在不在,取决于你跑在哪。
接口:签名 桌面/Web 行为
ShowRewardVideoAd:(callback) -> boolean 返回 false
VibrateShort / VibrateLong:(type?) -> boolean 返回 false
GetNativeExitMenuRect:`() -> table\ nil` 返回 nil
LogEvent:(name, props?, cb?) -> boolean 返回 false + 错误码 1002
四个接口,没有一个会抛错。本地调试不被打断,代价是你没法靠报错发现接错了。
二、能力探测:探一次不够
我第一版是这么写的:
-- ❌ 探一次,永久缓存
function Ads.Available()
if probed_ then return available_ end
probed_ = true
local ok, sdkObj = pcall(function() return rawget(_G, "sdk") end)
-- ...
end
rawget 加 pcall 那两行没问题,问题在缓存。宿主把 sdk 注入进来的时机晚于我第一次调用 Ads.Available(),于是 available_ 被钉死成 false,一路到进程结束。
-- ✅ 每局开始重探一次
function Ads.ResetRun()
adRevivesThisRun_ = 0
probed_ = false -- 代价:一次 rawget + 一次字段访问
end
探测本身也要防住访问不存在的全局:
function Ads.Available()
if probed_ then return available_ end
probed_ = true
local ok, sdkObj = pcall(function() return rawget(_G, "sdk") end)
if not ok or (type(sdkObj) ~= "table" and type(sdkObj) ~= "userdata") then
available_ = false
return false
end
-- 有 ShowRewardVideoAd 方法才算可用
local ok2, fn = pcall(function() return sdkObj.ShowRewardVideoAd end)
available_ = ok2 and fn ~= nil
return available_
end
rawget 绕开元表,pcall 兜住"访问不存在的全局"。
三、返回值管"受理",回调管"结果"
官方文档那句话我抄在注释里了:返回值不代表广告是否完整观看。
- 返回 true:请求已受理,最终结果看回调的 result.success
- 返回 false:没受理。可能是命中 3 秒防抖、冷却未结束、已有请求进行中、宿主或 JNI 不可用、缺广告位配置
麻烦的是后半句:返回 false 时,失败回调可能在当前调用栈内同步触发,也可能完全不触发。 两条通道都要接,还得用一次性完成状态:
local completed = false
local callbackFired = false
local function finish(result)
if completed then return end -- 关键:只结算一次
completed = true
busy_ = false
if result.success then onReward() else onFail(result.msg) end
end
local ok, accepted = pcall(function()
return rawget(_G, "sdk"):ShowRewardVideoAd(function(result)
callbackFired = true
finish(result)
end)
end)
if not ok then
busy_ = false
return false
end
-- 没受理,且回调也没同步来 —— 必须自己结算
if accepted == false and not callbackFired then
finish({ success = false, msg = "广告暂不可用" })
return false
end
少了那个 if,busy_ 会永远停在 true,之后所有请求都被"广告正在播放中"挡掉。
result.msg 按文档整理成一张表:
msg:success 处理
"embed success":true 发奖
"embed manual close":false 不发奖,提示"需完整观看"
"unsupported platform":false 非嵌入式环境或平台不支持
其他字符串:false TapSDK 内部错误,原样透出
embed manual close 只在 iOS Embed 模式出现——玩家提前关掉广告,不能发奖。
还有个我自己加的时间戳陷阱:
-- ❌ 初值 0
local lastShowAt_ = 0
os.clock() 返回的是进程启动以来的 CPU 时间。进程刚起来时它 < 3.0,初值 0 会让启动瞬间的首次请求被算成防抖命中,错误信息还是"操作太快"。
-- ✅ 初值取足够小的负数
---@type number
local lastShowAt_ = -1e9
lastShowAt_ 要标 ---@type number。不标会被 = 0 推成 integer,赋值时报 Cannot assign number to integer。
四、埋点:code == 0 只代表写进本地了
sdk:LogEvent(name, properties, callback) 的错误码是分层的:
code:含义 实际场景
0:本地持久化成功 不代表已上传 TapDB 服务器
1001:TapDB 上下文不可用 TapOpenSDK 未初始化、宿主未实现桥
1002:TapDB 已禁用 本地调试、PC、Web
1003:参数校验失败 空事件名、properties 里有嵌套 table
1004:持久化失败 回调超时(30 秒)、SDK 销毁
properties 的 value 只收字符串、有限数字、布尔,嵌套 table 会被拒。1004 里有一类不要重试:TapDB context was destroyed,那是 SDK 销毁或脚本重载导致的。
五、启动时序:Start() 里的九步
顺序:做什么 为什么在这个位置
1:nvgCreate(1) 后面所有绘制都依赖它
2:加载字体(不 assert) 没就绪也要继续跑,逐帧重试
3:挂 ObserveDownloads 遮罩 必须在字体尝试之后、绘制之前
4:Game.New() / Render.New() 纯逻辑,不依赖资源
5:建临时 Scene 承载音频源 SoundSource 必须挂节点
6:平台探测 → 选性能档 移动端降到保守档
7:Meta.Load() / Settings.Load() 读存档
8:Layout() 需要 graphics:GetDPR()
9:SubscribeToEvent × 12 放末尾,避免回调早于状态就绪
第 3 步的位置最容易搞错。遮罩观察挂在字体尝试之前,就会漏掉字体那一次下载。
第 5 步有个 GC 陷阱:临时 Scene 建完要持有引用,否则被回收,音频源跟着失效。
六、字体:一个 .ttf 引发的启动失败
.ttf 属于 DWP 媒体资源,跟纹理、音效一类,不是 render-blocking。脚本跑起来的时候它大概率还没下完。
我原来写的是:
-- ❌ 启动瞬间就断言
assert(nvgCreateFont(vg_, "sans", "Fonts/NotoSansSC.ttf") >= 0)
启动那一刻字体没就绪,断言直接失败。实机表现是 1000+ 次 XHR 报错。
-- ✅ 不 assert,失败就转为异步下载 + 逐帧重试
local ok, h = pcall(nvgCreateFont, vg_, "sans", "Fonts/NotoSansSC.ttf")
fontReady_ = ok and type(h) == "number" and h >= 0
if not fontReady_ then
pcall(function()
local cache = GetCache()
if cache and cache.DownloadResource then
cache:DownloadResource("Fonts/NotoSansSC.ttf", nil)
end
end)
end
重试放在 HandleUpdate 最前面,每 30 帧一次——字体没好,后面画什么都是空白:
if not fontReady_ then
fontRetry_ = fontRetry_ + 1
if fontRetry_ % 30 == 0 then
local ok, h = pcall(nvgCreateFont, vg_, "sans", "Fonts/NotoSansSC.ttf")
if ok and type(h) == "number" and h >= 0 then
fontReady_ = true
end
end
end
这里有个我付出过代价的教训。 我一度把 default 组(glob 是 `,即全部资源)加进 preload_groups,想一次性把字体下好。结果成了一次全量并发预取,19 个资源各失败一次**(commit d1fa29b,已在 f291981` 回退)。要预下载字体,就单独建一个只含字体的组。
七、首屏遮罩:为什么它一个字都不能写
字体没就绪的那几秒,标题页全是文字。一个字画不出来,玩家看到深色背景,直接判定游戏坏了。兜底是全局下载观察:
loadObserveId_ = cache:ObserveDownloads(
function(completed, total, bytes)
loadDone_, loadTotal_, loadBytes_ = completed or 0, total or 0, bytes or 0
end,
function(bytes)
loading_ = false -- activeCount 归零,收遮罩
end)
传了 onComplete,它在 activeCount 归零时触发一次就结束观察。
遮罩只能用几何图形画——转圈加进度条,不能写字。它出现的时刻,恰好就是字体还没下好的时刻。
再加一层超时兜底:
local LOAD_TIMEOUT = 12.0
-- dt 可能是 0(同一帧内多次 Update),所以用累加而不是取绝对值
loadElapsed_ = loadElapsed_ + dt
if loadElapsed_ >= LOAD_TIMEOUT then
loading_ = false -- 宁可放行,也不能永久挡住游戏
end
拿不到 ObserveDownloads 也要立刻放行——遮罩是锦上添花,不能因为没 API 就把玩家挡在外面。
踩过的坑
坑 1:SDK 未就绪就调用
-- ❌ 直接调用
sdk:ShowRewardVideoAd(function(r) end)
-- sdk 为 nil 时报 "attempt to index a nil value"
-- ✅ 先探测,再调用,且整个调用包在 pcall 里
if not Ads.Available() then
onFail("当前环境不支持广告")
return false
end
local ok, accepted = pcall(function()
return rawget(_G, "sdk"):ShowRewardVideoAd(cb)
end)
坑 2:探测结果永久缓存
-- ❌ 探一次存一辈子
if probed_ then return available_ end
-- ✅ 每局开始清一次
function Ads.ResetRun() probed_ = false end
前者是"整个进程都没有广告入口,还没有日志",后者只是"这一局开头多花一次 rawget"。
坑 3:只处理返回值,不处理回调
-- ❌ 以为 accepted == true 就稳了
local accepted = sdk:ShowRewardVideoAd(cb)
if accepted then grantReward() end
accepted == true 只说明请求被受理。玩家可能在广告播到一半手动关掉。
-- ✅ 只在 result.success 为 true 时发奖
local function finish(result)
if completed then return end
completed = true
if result and result.success then onReward() else onFail(result and result.msg) end
end
坑 4:资源没加载完就用
-- ❌ 启动瞬间断言字体
assert(nvgCreateFont(vg_, "sans", "Fonts/NotoSansSC.ttf") >= 0)
-- ✅ 不 assert,失败也不中断启动,逐帧重试,遮罩兜住这几秒
fontReady_ = tryLoadFont()
if not fontReady_ then
print("[Font] 首次加载未就绪,转为异步下载 + 逐帧重试")
end
坑 5:为了一个资源开全量预取
// ❌ 想给字体开小灶,结果开了全场
{ "preload_groups": ["default"], "groups": { "default": ["**"] } }
// ✅ 要么不开,要么单独建一个只含字体的组
{ "preload_groups": [], "groups": { "default": ["**"] } }
坑 6:开发工具留在 scripts/ 里
.lua 是 render-blocking,引擎会在启动前预加载目录下所有脚本。我那两个自测与平衡对照工具原本放在 scripts/ 下,等于发布包里白带两个开发工具。现在移到了 tools/dev/。
适用边界
这套写法覆盖的范围: 移动端内嵌宿主(只有这里 sdk 才真的存在);启动期有 DWP 媒体资源需要兜底的场景(自定义字体、首屏大图、开场音频);玩法不依赖媒体资源的项目。
不适用或要另外处理的:
情况:原因
桌面 / Web 调试:四个接口全返回 false 或 nil,广告入口不该显示
玩法依赖媒体资源的数值:服务端和未下载完成时读到的是占位值
首次启动就想零等待:字体是 DWP 资源,首帧必然不在,只能靠遮罩或预下载组
需要精确广告归因:LogEvent 的 code == 0 只代表本地落盘,不代表已上传
GetNativeExitMenuRect 是这套接口里唯一"只读一次就够"的——胶囊位置运行期不变,在 Start() 里调一次缓存下来。它返回归一化坐标(0~1),返回 nil 就是没有胶囊,不需要做平台分支。
可迁移的经验
1. SDK 的失败模式决定了你要写多少防御。 这套接口不抛错只返回 false,那就意味着每次调用都得自己判返回值、自己兜回调、自己防重复结算。
2. 能力探测的结果有保质期。 凡是"宿主注入"的能力,探测缓存就不能永久化——清缓存的代价要和"静默永久失效"放在一起算。
3. 启动阶段的顺序不是风格问题。 遮罩挂在字体之前还是之后,决定你会不会漏掉那一次下载。
4. 兜底逻辑必须有出口。 遮罩要有超时,探测要有重探,重试要有上限。没有出口的等待,等于把玩家锁在门外。
(完)
星火编辑器-新手教程-关于多游戏模式的配置和触发的获取截图
星火编辑器-新手教程-关于多游戏模式的配置和触发的获取
HELLO 大家好,我是阿木木牧。最近第一版的内部小范围(几人)第一个图的测试已经结束了。 整体游戏以装备数值的崩坏而告终~ (虽然大家都很欢快。。。) 现在准备开始开发下个阶段的内容啦,就是将游戏模式拆分出来。分成单人模式,多人组队模式(合作PVE),多人匹配模式(对抗模式)。那么,怎么配置呢?说来也很简单哈~ 1,在游戏编辑器中,数据编辑器,找到地图设置。 2,编辑地图设置。比如我希望最大数量
14 赞
3 回复
04:10
自定义飘字教程截图
自定义飘字教程
之前编辑器更新的飘字,可能有不少同学还不会使用,所以我稍微写了下这篇教程,希望对大家有所帮助,更好的制作游戏
14 赞
1 回复
不支持当前设备截图
不支持当前设备
荣耀400pro
2 赞
3 回复
00:18
修复一下这个挂起过。有概率用不了的bug吧截图
修复一下这个挂起过。有概率用不了的bug吧
太绝望了[表情_豹哭][表情_豹哭][表情_豹哭][表情_豹哭]
1 赞
1 回复
05:19
1截图
1
1.完成后,获得信用点的增益依旧有效,可以卡无限信用点。理论上 2.部分天赋和在一起可以实现永续,那些天赋,我下一个发,因为只能传一个视频。 3.卡,人多点就卡,知道卡死,但是手机不会卡就游戏卡
2 赞
无须申请,星火编辑器现已公测开放下载截图
无须申请,星火编辑器现已公测开放下载
—————————————————— 各位关注星火编辑器的小伙伴: 感谢大家一直以来对编辑器支持! 破晓已去,曙光降临! 星火编辑器现已开启公测! · 关于下载和资格申请 现已无需申请,请去下面的官方首页直接下载! 缘起测试是星火编辑器自诞生以来第一次对外测试! 从2022年八月开始到十一月,历时三个月。 缘起之后缘聚测试,历时又七个月! 缘聚之后,破晓降临,历时又十二个月! 曾经微弱的星火现已渐
官方
331 赞
375 回复
00:11
人物有小概率会卡着不往前走截图
人物有小概率会卡着不往前走
3 赞
1 回复