前言:
-
最近抽了一些下班后的时间,把“埋点上报”这块从零散业务代码中抽离出来,做成了一个可复用的 Flutter / Dart 插件:
reliable_event_queue。一开始目标其实很简单,只是想解决“埋点别丢”这个问题。 -
但真正落到项目后会发现,埋点上报并不是一个简单的
track -> request。弱网、前后台切换、上传失败、瞬时高频事件、批量重试、排障导出,这些问题只要项目稍微复杂一点,就一定会遇到。 -
所以这个插件最终不只是“发事件”,而是补了一整套可靠队列能力:本地持久化、批量 flush、失败重试、死信队列、状态统计、导出调试,以及由业务侧自行接入网络和生命周期时机。
-
本篇文章更偏实战一些,重点讲的是设计思路和落地方式,而不是单独讲 API。老规矩,先从整体思路开始。
正文:
当前项目中的 reliable_event_queue 使用,主要从下面几个维度来理解:
-
为什么要做可靠埋点队列插件
-
插件能力模型怎么设计
-
Flutter 侧完整使用流程
-
队列、重试与持久化实现思路
-
如何在业务项目里落地
-
常见误区和实践建议
-
为什么要做可靠埋点队列插件
在很多项目里,埋点通常是一句:
-
点击按钮时直接调用一次接口
看起来简单,但真实业务里其实更复杂:
-
用户点击后立刻切后台,请求还没发完
-
弱网环境下接口超时,事件直接丢失
-
音频上传、蓝牙同步、AI 对话这类链路事件很多,瞬时并发高
-
某些错误类事件恰恰发生在网络最差的时候
-
排查线上问题时,想看失败事件却没有任何本地留痕
如果只是页面里散着写 await track(),你能拿到的只是“尽量发一下”。
而可靠埋点队列插件的目标是:
-
先把事件可靠地记下来
-
再在合适时机批量上传
-
失败后自动重试
-
超过阈值的事件进入死信队列
-
给业务和测试提供可观测、可导出的调试能力
也就是说:
-
插件解决的是“可靠送达”
-
业务层只关心“什么时候产生日志”
-
插件能力模型设计
当前插件核心对象是 ReliableEventQueue,围绕它拆成了几类基础模型:
-
TrackEvent:业务侧最原始的埋点输入 -
QueuedEvent:进入队列后的标准事件对象,包含状态、重试次数、时间戳等 -
QueueConfig:队列行为配置 -
FlushResult:一次 flush 的执行结果 -
QueueStats:当前队列统计信息 -
QueueStatus:pending / uploading / failed / success / deadLetter -
EventUploader:由业务实现的上传器接口
对应能力分为几类:
-
初始化:
initialize() -
写入事件:
track()/trackEvent() -
手动上传:
flush() -
生命周期配合:
onAppForeground()/onAppBackground()/updateNetworkState()/notifyUserInteraction() -
查询与统计:
getStats()/getPendingEvents()/getFailedEvents() -
导出调试:
exportAllEvents()/exportFailedEvents()/exportDeadLetterEvents() -
状态监听:
statsStream/debugStream
这种设计有个很大的好处:
-
插件只负责“可靠队列”
-
不强绑定任何具体埋点平台
也就是说:
-
你可以接自研后端
-
也可以接神策、GrowingIO、友盟之类的上报层
-
甚至可以一套队列对接多种 uploader
-
Flutter 侧完整使用流程
下面以“初始化队列 -> 写入埋点 -> 合适时机上传 -> 导出失败事件”这个常见场景举例。
import 'package:reliable_event_queue/reliable_event_queue.dart';
class MyUploader implements EventUploader {
@override
Future<BatchUploadResult> upload(List<QueuedEvent> events) async {
// 这里接你自己的后端接口
return BatchUploadResult(
results: events
.map(
(event) => EventUploadResult(
eventId: event.id,
success: true,
),
)
.toList(growable: false),
);
}
}
final queue = ReliableEventQueue.instance;
await queue.initialize(
config: const QueueConfig(
batchSize: 20,
flushInterval: Duration(seconds: 30),
idleDuration: Duration(seconds: 10),
maxRetryCount: 5,
),
uploader: MyUploader(),
storage: SqliteQueueStorage(
dbPath: '/your/path/events.db',
),
);
await queue.track(
eventName: 'page_view',
payload: <String, dynamic>{
'pageKey': 'login',
'timestamp': DateTime.now().toIso8601String(),
},
);
final result = await queue.flush(force: true);
final failedPath = await queue.exportFailedEvents(
filePath: '/your/path/failed_events.jsonl',
);
如果你是 ViewModel / Store 架构,建议这样组织:
-
页面只负责触发埋点
-
上报时机由应用层统一管理
-
uploader 放到 data/service 层
-
调试页读取
statsStream和失败事件列表 -
队列、重试与持久化实现思路
这个插件真正的核心,不在 track(),而在“事件进入队列后发生了什么”。
整体链路大致是:
-
业务生成
TrackEvent -
插件包装成
QueuedEvent -
写入内存队列
-
同步持久化到本地存储
-
到达上传时机后,选出一批可上传事件
-
调用
EventUploader.upload() -
根据返回结果更新状态
-
成功则删除或标记成功,失败则计算下次重试时间
这里面我比较在意的几个点是:
-
1. 持久化优先于上传
事件不是“准备上传时再存”,而是“进入队列时就先落地”。
这样做的价值很直接:
-
App 闪退不丢
-
切后台不丢
-
临时断网不丢
-
2. 批量 flush,而不是一条条发
单条即发的问题很多:
-
网络开销大
-
并发难控
-
服务端压力高
-
一旦高频埋点出现,容易把上报链路打爆
所以插件里默认是批量挑选候选事件,再一次性交给 uploader 处理。
-
3. 失败重试要有节制
插件里使用的是指数退避思路:
-
第一次失败,稍后重试
-
第二次失败,拉长间隔
-
第三次继续拉长
达到 maxRetryCount 后,就不再无限重试,而是标记成 deadLetter。
这类事件的意义在于:
-
不影响正常队列继续跑
-
又保留问题现场,便于后续导出排查
-
4. 队列状态必须可观测
如果一个队列插件只能“悄悄工作”,那出问题时会非常难查。
所以这里单独补了:
-
QueueStats -
statsStream -
debugStream -
导出能力
这样你可以知道:
-
当前有多少待上传
-
多少上传成功
-
多少失败
-
是否已经进入死信
-
最近一次 flush 到底发生了什么
-
存储层设计
当前插件支持两种存储:
-
FileQueueStorage -
SqliteQueueStorage
它们的定位不太一样:
-
文件存储更轻,适合快速接入和小规模场景
-
SQLite 更稳,适合正式业务和较大事件量场景
这也是为什么我把存储层抽成了 QueueStorage 接口。
这样做的好处是:
-
存储策略可替换
-
核心队列逻辑不依赖具体存储实现
-
后面如果要扩展 Hive / Isar / Drift,也不需要重写整套队列逻辑
-
业务项目里怎么落地
如果你想把这个插件接进真实项目,我更建议先把它当成“埋点基础层”,而不是一上来就替换所有统计代码。
比较稳的落地方式是:
-
先接关键链路事件
-
再接页面曝光与点击
-
最后补调试与导出能力
例如在业务里,可以优先接这些场景:
-
登录注册链路
-
支付结果链路
-
AI 对话开始/结束/异常
-
蓝牙同步成功/失败
-
音频上传成功/失败
-
OTA 升级结果
这些事件有个共同特点:
-
不是简单的“埋一下看看”
-
而是真的需要可靠上报
如果拿 wisenote 这种项目举例,reliable_event_queue 特别适合用来承接:
-
弱网下的关键行为事件
-
音频、同步、上传等过程型日志
-
AI 交互链路事件
-
需要后续导出排查的问题事件
-
生命周期配合建议
插件本身没有把网络监听、前后台监听写死在内部,而是提供了几个钩子:
-
onAppForeground() -
onAppBackground() -
updateNetworkState() -
notifyUserInteraction()
这是我刻意保留的边界。
因为不同项目对“什么时候上传”要求不一样。
例如:
-
App 回前台时 flush 一次
-
网络恢复时 flush 一次
-
用户空闲一段时间再 flush
-
批量积压达到阈值后立即 flush
也就是说:
-
插件负责提供时机接入点
-
项目负责决定触发策略
-
调试页为什么重要
做这类插件时,一个很容易被忽略的问题是:
-
开发阶段你以为事件发出去了
-
测试阶段你发现服务端没收到
-
线上阶段你根本不知道是没写入、没上传、还是上传失败
所以插件里我一直保留“调试能力优先”的思路。
至少要能看到:
-
当前队列总数
-
pending / failed / dead-letter 数量
-
最近一次 flush 的结果
-
失败事件导出文件
只有这样,这个插件在真实项目里才不是“黑盒”。
-
常见误区
-
误区1:把队列插件当成网络 SDK
这个插件解决的是“可靠缓冲与重试”,不是替你实现所有网络层能力。真正发请求的细节,还是应该交给 uploader。
-
误区2:所有事件都一视同仁
埋点也要分层。
例如:
-
支付成功、升级失败这种属于高优先级
-
页面点击属于普通优先级
-
某些调试类过程日志甚至可以采样
如果不分层,队列很容易被低价值事件淹没。
-
误区3:失败后无限重试
无限重试听起来“更可靠”,但实际上会带来:
-
电量消耗
-
存储膨胀
-
死循环问题
所以一定要有 deadLetter 兜底。
-
误区4:没有调试和导出能力
没有这层能力,插件出问题时几乎不可排障。
-
误区5:把业务策略写死在插件里
插件负责给通用能力,项目层负责决定:
-
什么时候 flush
-
哪些事件进队列
-
哪些事件允许丢弃
-
哪些事件必须保留成功记录
-
项目中的架构总结
这轮改造后,埋点可靠上报相关逻辑已经形成了比较清晰的链路:
-
插件层提供统一可靠队列能力
-
uploader 层负责对接真实后端
-
app 生命周期负责决定 flush 时机
-
业务层只负责产生日志对象
-
调试页和导出能力负责排障与验证
这样做的价值是:
-
多项目可复用
-
埋点逻辑不再散落在页面里
-
弱网和异常场景下更稳
-
排查问题时更有抓手
-
后续切换后端成本更低
结束:
埋点这件事,真正难的点从来都不是“把一条数据发出去”,而在于:
-
关键事件能不能先可靠记下来
-
弱网和前后台切换时能不能不丢
-
上传失败后能不能继续追踪和排障
-
业务代码能不能不被一堆上报细节污染
reliable_event_queue 这类插件真正有价值的地方,也不在“多了一个 API”,而在于它把埋点这件事从“页面零散请求”拉回到了“基础设施能力”。
这个插件目前已经具备基础可用能力,后续还可以继续做:
-
更完整的 isolate worker
-
平台级网络与生命周期自动适配
-
事件优先级调度
-
去重与采样能力
-
更完整的 debug widget
如果你项目里也有弱网、离线、上传、同步、实时链路这类场景,建议尽早把“埋点可靠性”从页面逻辑里抽出来,收益会比想象中大。
声明:
仅开源供大家学习使用,禁止从事商业活动,如出现一切法律问题自行承担。
仅学习使用,如有侵权,造成影响,请联系删除,谢谢。