引言
游戏上线才是问题的开始:玩家在哪一关流失、哪个机型闪退最多、新手引导第几步被卡住——没有数据就只能靠猜。Defold 本身不内置分析 SDK,所有埋点、缓冲、上报、崩溃捕获都要自己在 Lua 层搭起来。本文从事件模型设计讲起,覆盖客户端队列与本地缓冲、批量上报与网络请求、崩溃捕获与堆栈采集、会话与用户标识、隐私合规脱敏,最后给出线上监控与告警的落地方式。
前置阅读:/defold-network-http-websocket/(HTTP 请求与异步回调)、/defold-profiling-debugging/(调试与性能分析工具)。
目录
- 1. 埋点模型与事件设计
- 2. 客户端事件队列与缓冲
- 3. 批量上报与网络请求
- 4. 崩溃捕获与错误处理
- 5. 错误堆栈与上下文采集
- 6. 会话管理与用户标识
- 7. 隐私合规与数据脱敏
- 8. 线上监控与告警
- 9. 速查表
- 相关阅读
- 延伸阅读
1. 埋点模型与事件设计
1. 事件的四要素
一个可用的事件模型至少要有四个字段:
| 字段 | 说明 | 示例 |
|---|---|---|
| name | 事件名,全局唯一 | level_complete |
| ts | 时间戳(毫秒) | 1727769600000 |
| session | 会话 ID | s_8f3a2c |
| props | 自定义属性表 | { level = 3, time = 42.5 } |
2. 事件命名规范
对象_动作 推荐
level_start
level_complete
level_fail
item_purchase
shop_open
tutorial_step
不要用:StartLevel、start-level、开始关卡(中文名不利于聚合)
规则:全部小写、下划线分隔;名词在前动词在后,便于按前缀聚合;属性用 snake_case,嵌套不超过两层。
3. 埋点分级
| 级别 | 用途 | 上报时机 |
|---|---|---|
| 关键事件 | 付费、关卡完成 | 立即上报 |
| 一般事件 | 界面打开、按钮点击 | 批量上报 |
| 调试事件 | 内部测试用 | 仅开发包上报 |
-- analytics.lua 对外接口
local M = {}
function M.track(name, props)
local event = {
name = name,
ts = os.time() * 1000,
session = M.session_id,
props = props or {},
}
M.enqueue(event)
end
return M
踩坑:不要在埋点属性里塞大对象(比如整个背包表)。事件体积会迅速膨胀,而且序列化开销会拖慢帧率。属性只放标量和短字符串。
2. 客户端事件队列与缓冲
1. 为什么需要队列
网络请求是异步且可能失败的。如果每次埋点都直接发请求,弱网下会堆积大量失败请求,还会触发平台的请求频率限制。
埋点 → 内存队列 → 达到阈值或定时 → 批量上报 → 成功则清空
→ 失败则回退到本地存储
2. 内存队列实现
-- analytics/queue.lua
local M = {
items = {},
max_size = 100,
}
function M.push(event)
table.insert(M.items, event)
if #M.items > M.max_size then
table.remove(M.items, 1) -- 丢弃最旧事件,保护内存
end
end
function M.drain(n)
local batch = {}
for i = 1, math.min(n, #M.items) do
table.insert(batch, M.items[i])
end
for _ = 1, #batch do
table.remove(M.items, 1)
end
return batch
end
return M
3. 本地持久化缓冲
应用被杀进程时内存队列会丢失,用 sys.save 把队列落盘:
local FILE = "analytics_pending"
function M.persist()
local ok, err = pcall(sys.save, FILE, M.items)
if not ok then
print("[analytics] 落盘失败:", tostring(err))
end
end
function M.restore()
local data = sys.load(FILE)
if data and #data > 0 then
M.items = data
print("[analytics] 恢复待上报事件:", #data)
end
end
注意:
sys.save使用sys.get_save_file(application_id, filename)的路径规则。写入前要确认应用标识已配置,否则会写到临时目录,重装即丢。
3. 批量上报与网络请求
1. http.request 基础
local function post_batch(batch, on_done)
local body = json.encode({ events = batch })
local headers = {
["Content-Type"] = "application/json",
["X-Api-Key"] = ANALYTICS_KEY,
}
http.request("https://api.example.com/v1/events", "POST", function(self, id, response)
on_done(response.status == 200)
end, headers, body)
end
参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
| url | string | 上报端点,必须 https |
| method | string | GET 或 POST |
| callback | function | 回调签名 (self, id, response) |
| headers | table | 请求头 |
| post_data | string | 请求体,GET 时为 nil |
| options | table | 超时、重试等,可选 |
2. 定时批量上报
local FLUSH_INTERVAL = 10.0 -- 每 10 秒尝试上报
local BATCH_SIZE = 20
function update(self, dt)
self.timer = (self.timer or 0) + dt
if self.timer >= FLUSH_INTERVAL and not self.sending then
self.timer = 0
flush(self)
end
end
function flush(self)
if queue.size() == 0 then return end
self.sending = true
local batch = queue.drain(BATCH_SIZE)
post_batch(batch, function(ok)
self.sending = false
if ok then
print("[analytics] 上报成功:", #batch, "条")
else
-- 失败:把事件塞回队列头部,等待下次重试
for i = #batch, 1, -1 do
table.insert(queue.items, 1, batch[i])
end
queue.persist()
end
end)
end
踩坑:
http.request是异步的,回调可能在任意帧触发。不要在回调里直接操作 GUI 节点,先msg.post给对应脚本,让它在自己的上下文里处理。
批量策略:数量触发(攒够 20 条)、时间触发(每 10 秒)、混合(先满足者触发)、关键事件插队立即单独发。
4. 崩溃捕获与错误处理
1. sys.set_error_handler
Defold 允许注册全局错误处理器,捕获 Lua 运行时错误:
function init(self)
sys.set_error_handler(function(source, message, traceback)
-- source: 出错的脚本或模块路径
-- message: 错误描述
-- traceback: 调用栈字符串
print("[crash] source:", tostring(source))
print("[crash] message:", tostring(message))
report_crash(source, message, traceback)
end)
end
关键点:注册后引擎不会因为 Lua 错误直接崩溃退出,这给了你上报的时间窗口。
2. 崩溃上报的三段式
1. 捕获 → sys.set_error_handler 拿到 source/message/traceback
2. 持久化 → 立即写入本地文件(网络此时往往不可靠)
3. 延迟上报 → 下次启动时检查待发送崩溃报告并上传
local CRASH_FILE = "crash_pending"
local function report_crash(source, message, traceback)
local payload = {
source = tostring(source),
message = tostring(message),
traceback = tostring(traceback),
ts = os.time(),
app = sys.get_application_info(),
device = sys.get_sys_info(),
engine = sys.get_engine_info(),
}
sys.save(CRASH_FILE, payload) -- 同步落盘,确保退出前写入
print("[crash] 崩溃报告已写入本地")
end
踩坑:
sys.save在崩溃回调里必须是同步的。不要在里面发网络请求——进程随时可能被系统回收,异步回调没机会执行。
3. 启动时补报与原生崩溃
function init(self)
local pending = sys.load("crash_pending")
if pending and pending.message then
post_crash(pending, function(ok)
if ok then
sys.save("crash_pending", {}) -- 上报成功才清空
print("[crash] 历史崩溃补报成功")
end
end)
end
sys.set_error_handler(on_error)
end
Lua 层的 set_error_handler 只能捕获脚本错误。引擎层崩溃(段错误、OOM、显卡驱动问题)需要原生扩展支持:Sentry 扩展(社区维护的 defold-sentry)、Firebase Crashlytics(通过原生扩展接入),或自建符号化(收集 tombstone 与堆栈,服务端符号化)。
5. 错误堆栈与上下文采集
1. 手动采集堆栈
local function capture_context(extra)
local info = sys.get_sys_info()
local app = sys.get_application_info()
return {
platform = info.system_name,
os_version = info.system_version,
device = info.device_model,
manufacturer = info.manufacturer,
language = info.language,
app_id = app.identifier,
version = app.version,
engine = sys.get_engine_info().version,
extra = extra or {},
trace = debug.traceback("", 2),
}
end
字段说明:
| API | 返回内容 |
|---|---|
| sys.get_sys_info | 系统名、版本、机型、语言、网络类型 |
| sys.get_application_info | 应用标识、版本号 |
| sys.get_engine_info | 引擎版本、编译时间 |
| debug.traceback | 当前调用栈字符串 |
2. 业务上下文与面包屑
光有堆栈不够定位问题,还要带上业务状态:
function analytics.set_context(key, value)
analytics.context[key] = value
end
-- 进入关卡时记录
function on_level_start(self, level_id)
analytics.set_context("level", level_id)
analytics.set_context("player_hp", self.hp)
end
-- 面包屑:记录崩溃前最后 N 条操作
local MAX_BREADCRUMBS = 30
function analytics.add_breadcrumb(action, detail)
table.insert(analytics.breadcrumbs, { action = action, detail = detail, ts = os.time() })
while #analytics.breadcrumbs > MAX_BREADCRUMBS do
table.remove(analytics.breadcrumbs, 1)
end
end
崩溃时把 context 与 breadcrumbs 一并上报,就能看到「玩家在第 7 关、血量 12、背包 8 件、刚打开商店时崩了」。
6. 会话管理与用户标识
1. 会话定义与生命周期回调
冷启动 → 新会话
切后台超过 30 分钟再回来 → 新会话
切后台 30 分钟内回来 → 延续当前会话
local SESSION_TIMEOUT = 30 * 60 -- 秒
function analytics.check_session()
local now = os.time()
if not analytics.session_id or (now - analytics.last_active) > SESSION_TIMEOUT then
analytics.session_id = generate_session_id()
print("[analytics] 新会话:", analytics.session_id)
end
analytics.last_active = now
end
function on_message(self, message_id, message, sender)
if message_id == hash("window_focus_lost") then
analytics.track("app_background")
analytics.flush_now() -- 切后台是刷新的最佳时机
elseif message_id == hash("window_focus_gained") then
analytics.check_session()
end
end
说明:window_focus_lost / window_focus_gained 是 Defold 的内置消息,分别在切后台和回前台时触发。切后台时系统通常会给几秒钟时间让你把队列刷出去。
注意:匿名 ID 属于「设备标识」范畴,在 GDPR 下通常仍属个人数据,需要在隐私政策中披露并提供重置入口。生成方式很简单——首次启动时用
string.format造一个随机串并sys.save持久化,之后每次启动读回即可。
7. 隐私合规与数据脱敏
1. 上报前的白名单过滤
不要「先收集再脱敏」,而是只上报白名单字段:
local ALLOWED_PROPS = {
level = true, level_name = true, duration = true,
item_id = true, price = true, currency = true,
result = true, step = true, reason = true,
}
local function sanitize(props)
local out = {}
for k, v in pairs(props or {}) do
if ALLOWED_PROPS[k] then out[k] = v end
end
return out
end
2. 禁止上报的内容
禁止:真实姓名、邮箱、手机号、IP 全地址、设备序列号
禁止:玩家输入的聊天内容、自定义昵称、精确地理位置
允许:机型、系统版本、语言、应用版本、匿名 ID
3. 合规开关
function analytics.set_consent(granted)
analytics.consent = granted
if not granted then
queue.items = {}
sys.save("analytics_pending", {})
print("[analytics] 用户拒绝数据收集,已清空队列")
end
end
function analytics.track(name, props)
if not analytics.consent then return end
queue.push(build_event(name, sanitize(props)))
end
GDPR/CCPA 要求提供数据删除能力:至少要做到提供「重置匿名 ID」入口切断历史关联、服务端按保留期自动清理(如 14 个月)、提供数据导出接口。
8. 线上监控与告警
1. 关键指标
| 指标 | 计算方式 | 告警阈值示例 |
|---|---|---|
| 崩溃率 | 崩溃会话数 / 总会话数 | 大于 1% |
| 上报失败率 | 失败请求 / 总请求 | 大于 5% |
| 关卡流失率 | 未完成关卡人数 / 进入人数 | 单关大于 40% |
| 首帧耗时 | 冷启动到首帧的秒数 | P95 大于 5s |
| ANR 率 | 无响应次数 / 会话数 | 大于 0.5% |
2. 客户端自监控
local stats = { ok = 0, fail = 0 }
function track_result(ok)
if ok then stats.ok = stats.ok + 1 else stats.fail = stats.fail + 1 end
local total = stats.ok + stats.fail
if total >= 20 and (stats.fail / total) > 0.3 then
print(string.format("[analytics] 上报失败率过高: %.1f%%", stats.fail / total * 100))
end
end
崩溃聚类:服务端按「错误消息 + 堆栈首行」做指纹聚类,否则一个循环报错会瞬间淹没列表。
指纹 = hash(message + traceback 的前 3 行)
踩坑:性能埋点不要每帧上报,否则监控本身会成为性能瓶颈。
9. 速查表
| 需求 | API 或做法 | 备注 |
|---|---|---|
| 注册错误处理 | sys.set_error_handler(fn) | 签名 (source, message, traceback) |
| 采集堆栈 | debug.traceback(msg, level) | 崩溃时手动调用 |
| 系统信息 | sys.get_sys_info() | 机型、系统版本、语言 |
| 应用信息 | sys.get_application_info() | 应用标识与版本 |
| 引擎信息 | sys.get_engine_info() | 引擎版本、编译时间 |
| HTTP 上报 | http.request(url, "POST", cb, headers, body) | 异步,回调任意帧触发 |
| 本地落盘 | sys.save(file, table) / sys.load(file) | 崩溃路径必须同步写 |
| JSON 序列化 | json.encode(t) / json.decode(s) | 属性只放标量 |
| 切后台回调 | window_focus_lost 消息 | 刷新队列的最佳时机 |
| 内存占用 | collectgarbage("count") | 返回 KB |
| 匿名 ID | 生成后 sys.save 持久化 | 需提供重置入口 |
一句话记忆:埋点走「内存队列 + 本地落盘 + 定时批量上报」三段式;崩溃捕获靠 sys.set_error_handler 拿 source/message/traceback,先同步落盘、下次启动补报;上报前按白名单脱敏并尊重用户同意开关;线上用崩溃率、上报失败率、关卡流失率三个指标做告警。
相关阅读
- /defold-network-http-websocket/ — HTTP 请求、异步回调与错误处理
- /defold-profiling-debugging/ — 性能分析与调试工具
- /defold-save-serialization/ — 本地存储与数据序列化
- /defold-script-system-lua/ — Lua 错误处理与消息机制
延伸阅读
- /defold-native-extensions/ — 用原生扩展接入 Crashlytics 等 SDK
- /defold-cross-platform-publish/ — 多平台发布与版本管理
- /defold-hot-reload-updates/ — 通过热更新修复线上问题
- /defold-performance-optimization/ — 上报逻辑自身的性能开销控制
- 游戏开发专题 — 游戏数据运营与线上监控
-- ======================================
-- 完整示例:analytics.lua 埋点模块骨架
-- 放在 /scripts/analytics.lua
-- ======================================
local M = {
uid = nil, session_id = nil, last_active = 0,
consent = false, context = {}, pending = {}, sending = false,
}
local ENDPOINT = "https://api.example.com/v1/events"
local API_KEY = "your-api-key"
local BATCH_SIZE = 20
local ALLOWED = { level = true, duration = true, result = true, item_id = true, price = true }
local function sanitize(props)
local out = {}
for k, v in pairs(props or {}) do
if ALLOWED[k] then out[k] = v end
end
return out
end
function M.init()
M.uid = string.format("u_%x", os.time())
M.session_id = string.format("s_%x", math.random(1e8))
M.consent = sys.get_config_string("analytics_consent", "false") == "true"
sys.set_error_handler(function(source, message, traceback)
sys.save("crash_pending", {
source = tostring(source),
message = tostring(message),
traceback = tostring(traceback),
ctx = M.context,
sys = sys.get_sys_info(),
})
print("[analytics] 崩溃已记录:", tostring(message))
end)
end
function M.track(name, props, priority)
if not M.consent then return end
table.insert(M.pending, {
name = name, uid = M.uid, session = M.session_id,
ts = os.time(), priority = priority or "normal",
props = sanitize(props), ctx = M.context,
})
if #M.pending > 500 then table.remove(M.pending, 1) end
end
function M.flush(on_done)
if M.sending or #M.pending == 0 then
if on_done then on_done(false) end
return
end
M.sending = true
local batch = {}
for i = 1, math.min(BATCH_SIZE, #M.pending) do
table.insert(batch, M.pending[i])
end
for _ = 1, #batch do table.remove(M.pending, 1) end
local headers = { ["Content-Type"] = "application/json", ["X-Api-Key"] = API_KEY }
http.request(ENDPOINT, "POST", function(self, id, response)
M.sending = false
if response.status == 200 then
print("[analytics] 上报成功:", #batch)
else
for i = #batch, 1, -1 do
table.insert(M.pending, 1, batch[i])
end
sys.save("analytics_pending", M.pending)
end
if on_done then on_done(response.status == 200) end
end, headers, json.encode({ events = batch }))
end
return M
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。