
这个系列写到第十篇的时候幸运大转盘的基础功能其实已经跑通了。界面、动画、抽奖逻辑、音效反馈该亮的都亮了该转的也能转了。但站在产品角度它还活得比较“虚无”——用户抽了多少次中奖率是不是符合预设什么时候抽得最频繁大家更喜欢哪个奖品档位这些数据如果拿不到后续的优化方向就只能靠猜或者说全凭感觉调参数。这期我就把数据统计这块补上让这个 Flutter for OpenHarmony 的幸运大转盘从一个“会转的玩具”变成一个“可观察、可迭代”的功能模块。内容会包括统计维度怎么定、数据模型怎么设计、存储怎么选、埋点怎么接以及在 OpenHarmony 上实测时踩到的一些坑。对这个系列前几期跟下来的读者来说这一篇的代码量不大但设计上的取舍值得琢磨。如果你正打算给自己的跨平台 app 加一个类似的本地统计模块这篇文章可以直接“抄作业”。1. 数据统计模块的整体设计思路1.1 为什么选择纯客户端本地统计在动手写之前我先问了自己一个问题这个统计要不要往后端上报幸运大转盘这种功能在很多业务里确实是挂在服务端下发奖品、记录中奖流水。但这套系列教程做的场景是“单机娱乐型”的小应用没有登录体系也没有服务器。强行引入后端上报意味着要维护接口、做鉴权、处理离线队列工程量直接翻倍。对大多数人来说这属于过度设计。所以我的方案是全部走本地存储把统计当作客户端的一个内聚模块来做。这样一来有几个实际好处数据实时性高不依赖网络抽完立刻入库。实现成本低一个单例管理器加一套存储就够。后续如果想上报可以在这套本地统计的基础上做增量同步不需要推翻重来。我见过不少开发者一上来就想做“完整数据中台”结果卡在网络波动、数据一致性这些事上连基础统计都没跑起来。对于这种工具型的小应用先把本地统计做好让数据“有迹可循”比什么花架子都强。1.2 统计维度怎么定先满足三个核心问题定统计维度这步特别容易失控。有人会忍不住把用户每次点击、页面停留、手指滑动轨迹全记录下来最后得到一堆用不上的数据还占存储空间。我给自己圈了一个边界只要回答三个产品问题就行。第一个问题这个转盘到底被玩了多少次对应的是抽奖总次数、每日抽奖次数、单用户连续抽奖次数。这些数据能反映功能活跃度。第二个问题发放的奖品有没有偏离配置概率对应的是总中奖次数、各奖品中奖次数、未中奖次数。这个数据能验证转盘概率算法是否正常也能发现是否存在“永远抽不到某档奖品”的暗坑。第三个问题用户集中在什么时间段抽为什么要知道这个因为如果发现晚间活跃度明显偏高那后续的运营活动就可以考虑在晚间时段做加成如果半天才有人抽一次说明入口不够吸引人得考虑调整按钮露出。我最终确定的统计字段包括抽奖总次数、今日抽奖次数、未中奖次数、每个奖品的中奖次数、最后一次抽奖时间、每日抽奖时间分布用小时桶来聚合。这里没有做“连续未中奖次数”的实时记录原打算做防“晒”优化后来想了下这个维度更适合放服务端做风控本地统计做了意义不大。如果以后真需要我再加一个字段而已模型上留了扩展位。1.3 存储方案选型不要一上来就上数据库本地存储可选的方案有几类。第一类是 SharedPreferences 这种 key-value 轻量存储适合存简单配置和少量计数。第二类是 SQLite 这类关系型数据库适合存结构化历史记录能按时间范围查询。第三类是直接读写文件自己控制序列化格式。幸运大转盘这个场景数据量本身不会大就算用户一天抽几百次也就几百条记录。但如果只存一个聚合后的整数后续想看时间分布、奖品明细就得改存储结构重新刷数据。我的选择是折中方案聚合指标用 key-value 存例如total_spins、today_spins、prize_a_count读取快改动小。明细记录用结构化存储但我不直接引入数据库依赖而是用 JSON 文件追加写入按天分文件。为什么不直接上 SQLite因为在 OpenHarmony 生态上Flutter 插件适配参差不齐sqflite 这类依赖原生实现的插件不一定能直接跑起来。我试过在模拟器上用某个版本的原生数据库插件结果初始化时报一堆底层错误排查起来非常痛苦。文件存储只要路径对基本不依赖平台特殊能力兼容性更稳。这里也提一下OpenHarmony 上如果项目直接做的是标准 Flutter 应用很多纯 Dart 的 API比如dart:io、path_provider是可用的但path_provider这个插件本身也需要 OpenHarmony 侧的适配实现。我在实际工程里是自己获取应用沙箱路径来绕开的后续会有相关说明。2. 数据模型与核心统计逻辑拆解2.1 数据模型设计聚合字段和明细记录分开数据模型是统计模块的地基我分了两层来设计。第一层是实时聚合层。这一层保存当前累计值主要用于面板顶部那几张“指标卡”显示总次数、今日次数、中奖次数等。用 key-value 存就够了每次抽完奖只更新一个计数器性能开销可忽略。第二层是历史明细层。这一层保存每次抽奖的具体信息包括时间戳、是否中奖、奖品 ID、奖品名称、抽奖消耗的“幸运币”数量。考虑到后续可能要看“近7天走势”这一层必须按时间可查询。我用 JSON 数组文件来存文件名按日期区分例如statistics_2025_06_01.json这样想看哪天就加载哪天。字段定义上我用一个 Dart 类来表示单次抽奖记录class SpinRecord { final int timestamp; final bool isWin; final String prizeId; final String prizeName; final int costCoins; SpinRecord({ required this.timestamp, required this.isWin, required this.prizeId, required this.prizeName, required this.costCoins, }); MapString, dynamic toJson() { t: timestamp, win: isWin, pid: prizeId, name: prizeName, cost: costCoins, }; factory SpinRecord.fromJson(MapString, dynamic json) SpinRecord( timestamp: json[t], isWin: json[win], prizeId: json[pid], prizeName: json[name], cost: json[cost], ); }注意字段名我用了缩写比如t、win、pid纯粹是为了压缩 JSON 体积。单条记录省不了几个字节但记录多了、文件要反复读写时体积小一点还是有意义的。可读性方面因为只在文件里存不涉及跨端展示所以问题不大。聚合层的字段我保存在另一个 key-value 存储里大概长这样totalSpins: 157 todaySpins: 23 todayDate: 2025-06-01 winCount: 31 prizeCounts: {prize_001: 12, prize_002: 5, prize_003: 14} lastSpinTime: 1717228800000todayDate单独存一个字段用来判断是否需要把todaySpins清零——跨天时首次访问统计模块如果存的日期不是今天就直接重置今日次数。这样做比每次 get 时动态比较日期再算要省事也更直观。2.2 概率与统计模块的解耦写统计模块的时候最容易犯的错就是把统计代码直接塞进抽奖函数里比如void startSpin() { // 一堆转盘逻辑 // 统计逻辑混进来 statistics.total; statistics.prizeCount[xx]; statistics.save(); }这在功能上没错但后续会很痛苦。假设你要给转盘增加一个“十连抽”模式、加一个“保底机制”或者要调整统计字段都得回来改这个函数。如果埋点位置稍有偏差统计数据的准确性也会跟着出问题。更好的做法是把统计模块设计成事件的“观察者”。主流程只负责发出“抽奖完成”这件事统计模块自行决定怎么记、记多少。我在项目里定义了一个轻量的事件回调接口class SpinResultEvent { final bool isWin; final String prizeId; final String prizeName; final int costCoins; SpinResultEvent({ required this.isWin, required this.prizeId, required this.prizeName, required this.costCoins, }); }然后在抽奖动画播放完毕、结果真正确定后通过一个简单的总线把事件发出去eventBus.emit(SpinResultEvent( isWin: result.isWin, prizeId: result.prizeId, prizeName: result.prizeName, costCoins: config.spinCost, ));统计模块只负责订阅这个事件eventBus.onSpinResultEvent().listen((event) { statisticsManager.recordSpin(event); });这样做的好处是显而易见的。第一业务代码完全不感知统计的存在后续调整统计逻辑不会影响抽奖流程。第二新增统计维度时只需要在recordSpin方法里扩展不需要碰转盘代码。第三如果以后要加“抽奖后推送通知”“截屏分享”等额外动作都用同一套事件机制架构上保持统一。2.3 数据落盘时机内存更新与异步写文件并行数据落盘时机这个细节很容易被忽略但恰恰是最影响可靠性的地方。移动应用的进程随时可能被系统杀掉如果每次抽完奖只是把数据放在内存里等用户退出时才写文件那用户中途把 app 切到后台再杀掉这几次抽奖数据就丢了。我的策略是“内存快速更新、磁盘异步兜底”。每次抽奖完成后立即更新内存中的聚合值做到面板展示一定是实时的同时启动一个异步任务把最新的聚合值写入 key-value 存储并把当天的明细追加写入 JSON 文件。这个异步任务本身做了节流每秒最多触发一次写操作Futurevoid _scheduleFlush() async { if (_flushScheduled) return; _flushScheduled true; await Future.delayed(const Duration(milliseconds: 500)); _flushScheduled false; await _writeAggregates(); await _appendTodayRecord(); }单次抽奖之后用户可能立刻连续再抽这时候用 500 毫秒的节流窗口把多次写盘合并成一次能减少 IO 次数。如果进程被杀最多丢 500 毫秒内的增量实际抽奖记录通常已经写进去了。我还在 app 的生命周期回调里做了兜底切到后台或即将销毁时强制调用一次flush()。Flutter 侧在WidgetsBindingObserver的didChangeAppLifecycleState里监听paused或inactive状态OpenHarmony 上这个回调同样会触发。因为统计面板需要长时间保持最新数据这个兜底必须做扎实。3. 核心实现过程与关键代码3.1 路径获取OpenHarmony 上的沙箱目录处理前面提到path_provider在 OpenHarmony 上不一定有现成实现我实际处理方式是直接通过系统能力拿到应用沙箱路径。在 Flutter for OpenHarmony 工程里可以通过Platform.environment或拼接固定路径的方式获取可用目录。不同版本的系统可能在基线上有差异稳妥起见我封装了一个路径工具类class AppPaths { static String get statisticsDir { final base _resolveBaseDirectory(); final dir Directory($base/statistics); if (!dir.existsSync()) { dir.createSync(recursive: true); } return dir.path; } static String _resolveBaseDirectory() { // 优先使用环境变量获取沙箱目录 final envDir Platform.environment[HOME]; if (envDir ! null envDir.isNotEmpty) { return envDir; } // 兜底逻辑使用当前工作目录 return Directory.current.path; } }这里并没有使用任何三方插件纯 Dart 实现跨平台的时候也能编译通过。在 OpenHarmony 上这个路径最终会指向 app 的数据沙箱目录用户不可见但持久化没有问题。如果你用的 Flutter 版本已经适配了path_providerfor OpenHarmony那直接用官方插件自然更好少维护一套代码。我这边的项目里还有一些历史原因所以自己处理路径反而更省事。有一点必须强调不要硬编码/data/xxx这种绝对路径。不同版本的系统对沙箱路径的解析方式可能有区别用环境变量或者官方 API 返回的路径才是稳妥之道。我一开始图省事直接写死路径结果升级系统版本后目录结构变了统计文件找不到了所有历史数据“消失”排查了半天才定位到是路径问题。3.2 统计管理器核心方法实现统计管理器我用的是一个单例类StatisticsManager它内部持有聚合值和当天的记录缓存。初始化时从 key-value 存储中读取聚合值并判断是否跨天。class StatisticsManager { StatisticsManager._internal(); static final StatisticsManager instance StatisticsManager._internal(); int _totalSpins 0; int _todaySpins 0; int _winCount 0; MapString, int _prizeCounts {}; String _todayDate ; Futurevoid init() async { _totalSpins await _kv.getInt(totalSpins) ?? 0; _todaySpins await _kv.getInt(todaySpins) ?? 0; _winCount await _kv.getInt(winCount) ?? 0; _prizeCounts await _kv.getJson(prizeCounts) ?? {}; _todayDate await _kv.getString(todayDate) ?? ; _checkDateRollover(); } void _checkDateRollover() { final now _dateFormatter.format(DateTime.now()); if (_todayDate ! now) { _todaySpins 0; _todayDate now; _flushAggregates(); } } void recordSpin(SpinResultEvent event) { _totalSpins; _todaySpins; if (event.isWin) { _winCount; _prizeCounts[event.prizeId] (_prizeCounts[event.prizeId] ?? 0) 1; } _scheduleFlush(); } MapString, dynamic getPanelData() { ... } }recordSpin里的逻辑不复杂但有个细节值得说一下_todayDate的跨天判断。我把它放在init()和recordSpin()里各做一次。放在init()里是为了保证冷启动时面板展示的今日次数是准确的放在recordSpin()里则是为了处理“app 一直开着、跨了零点”这种边缘情况。如果不做第二次判断那用户从昨晚 23:59 用到今天 00:01今日次数会延续昨天的数据展示出来就是错的了。getPanelData()返回的是一个只读视图用来给 UI 层渲染。这里不做复杂的数据处理面板需要什么字段直接从这个方法拿。比如MapString, dynamic getPanelData() { final total _totalSpins; final winRate total 0 ? 0 : (_winCount / total * 100); return { totalSpins: total, todaySpins: _todaySpins, winCount: _winCount, winRate: winRate, prizeCounts: Map.of(_prizeCounts), lastSpinTime: _lastSpinTime, }; }这里有个小陷阱如果直接把_prizeCounts返回给 UI外部可能不小心修改了内部状态。所以我用Map.of()复制了一份再返回避免调用方改动到管理器的内部数据。这个习惯在写任何管理器类的时候都值得保持。3.3 明细记录按天追加写入与读取聚合值走 key-value明细记录走文件。我把每天的数据存成一个 JSON 数组文件名如下String _todayFilePath() { final date DateFormat(yyyy_MM_dd).format(DateTime.now()); return ${AppPaths.statisticsDir}/statistics_$date.json; }追加写入时不能先把整个文件读进来再写——那样文件大了以后每次写入都全量读写性能是硬伤。我的做法是打开文件句柄直接追加一行 JSON。为了区分多条记录我让每条记录单独占一行Futurevoid _appendTodayRecord(SpinResultEvent event) async { final file File(_todayFilePath()); final record SpinRecord( timestamp: DateTime.now().millisecondsSinceEpoch, isWin: event.isWin, prizeId: event.prizeId, prizeName: event.prizeName, costCoins: event.costCoins, ); final line jsonEncode(record.toJson()) \n; await file.writeAsString(line, mode: FileMode.append); }读取某一天的记录时按行读取并解析FutureListSpinRecord loadRecordsForDate(DateTime date) async { final fileName statistics_${DateFormat(yyyy_MM_dd).format(date)}.json; final file File(${AppPaths.statisticsDir}/$fileName); if (!file.existsSync()) return []; final lines await file.readAsLines(); return lines .where((line) line.trim().isNotEmpty) .map((line) SpinRecord.fromJson(jsonDecode(line))) .toList(); }这种逐行 JSON 的格式有几个好处第一追加操作不需要锁整个文件第二可以很方便地用tail之类的思路只读取最后 N 行第三如果某一行因为写入中断而损坏不会影响其他行读取时跳过空行就行。相比存在一个大 JSON 数组文件里这种格式的容错性好得多。但也要注意追加写的方式会让文件在没有达到“整理”条件时越来越大。我这个场景体量不大先不考虑分页等积累到一个月再做一个归档压缩把每天多个小文件合并成一个压缩包也不迟。3.4 数据可视化面板用 CustomPainter 绘制柱状图与饼图统计面板我设计成三个区域顶部指标卡、中部近7天抽奖趋势柱状图、底部奖品分布饼图。图表组件我没有引入第三方库直接用 Flutter 自带的CustomPainter画。原因一是第三方图表库在 OpenHarmony 上的兼容性不确定二是转盘这个场景的图表形态很固定自己画反而更可控。柱状图画近7天每日抽奖次数本质上就是一组矩形的绘制。核心逻辑在paint()方法里class BarChartPainter extends CustomPainter { final Listint values; final ListString labels; BarChartPainter({required this.values, required this.labels}); override void paint(Canvas canvas, Size size) { final barPaint Paint()..color const Color(0xFF3A7BFD); final axisPaint Paint() ..color const Color(0xFFCCCCCC) ..strokeWidth 1; canvas.drawLine( Offset(0, size.height - 20), Offset(size.width, size.height - 20), axisPaint, ); final maxValue values.reduce((a, b) a b ? a : b).toDouble(); final barWidth size.width / values.length * 0.6; final gap size.width / values.length; for (var i 0; i values.length; i) { final barHeight maxValue 0 ? 0 : (values[i] / maxValue) * (size.height - 40); final left gap * i (gap - barWidth) / 2; final rect RRect.fromRectAndRadius( Rect.fromLTWH(left, size.height - 20 - barHeight, barWidth, barHeight), const Radius.circular(4), ); canvas.drawRRect(rect, barPaint); // 文本绘制代码省略 } } override bool shouldRepaint(covariant BarChartPainter oldDelegate) { return oldDelegate.values ! values; } }这里做了两个细节处理一是柱子的圆角矩形视觉上不会显得生硬二是在shouldRepaint中判断数据是否变化避免每次 build 都触发重绘对低端机友好。如果直接把整个列表的引用传进来而列表还是同一个实例但内容变了可能不会触发重绘所以我会在数据更新时用一个新 List 实例来替换或者在shouldRepaint里比较listEquals。饼图的绘制稍微复杂一点但核心就是canvas.drawArc()void paint(Canvas canvas, Size size) { final rect Rect.fromLTWH(0, 0, size.width, size.height); final total prizeValues.values.fold(0, (a, b) a b).toDouble(); if (total 0) return; var startAngle -90.0; final colors [...]; // 奖品颜色序列 var index 0; for (final value in prizeValues.values) { final sweepAngle 360 * (value / total); canvas.drawArc(rect, startAngle * pi / 180, sweepAngle * pi / 180, true, Paint()..color colors[index % colors.length]); startAngle sweepAngle; index; } }注意drawArc的角度单位是弧度不是度数所以需要把度数换算一下。起始角度统一用 -90 度即 12 点钟方向符合阅读习惯。在面板的 UI 布局上我用了SingleChildScrollView包裹整个统计页避免小屏手机上内容溢出。每个区块是独立的Card组件数据刷新通过setState触发后图表 painter 收到新的数据列表自动重绘。这里不用做动画统计面板的信息密度为主动画反而干扰阅读。3.5 与转盘主流程的接线最后把统计管理器接入转盘主流程。我是在抽奖结果开始展示的那一步接入的。具体来说转盘的SpinController在动画结束时会回调一个onSpinCompleted(result)我在那个回调里发出SpinResultEvent。void _onSpinCompleted(SpinResult result) { setState(() { _lastResult result; }); eventBus.emit(SpinResultEvent( isWin: result.isWin, prizeId: result.prizeId, prizeName: result.prizeName, costCoins: _currentCost, )); }这个位置是我经过两次调整后确定的。第一次我想在用户“按下抽奖按钮”时就埋点但这样会把用户抽了一半、动画没播完就强退的情况也算进去会造成统计次数偏大。第二次想在中奖结果弹窗展示后再埋点但这个时机可能因为弹窗逻辑异常而漏掉。最终选动画结束回调这个位置正好是“一次抽奖真正完成”的语义边界。抽奖页也加了一个入口按钮跳转到统计页。跳转用的就是普通的 Navigator pushNavigator.of(context).push( MaterialPageRoute(builder: (_) StatisticsPage()), );StatisticsPage在initState里调用StatisticsManager.instance.init()并加载近7天的明细数据。init 过程如果文件较大可能稍微耗时但本地文件一般不大启动过程基本无感。4. 常见问题与排查实录4.1 路径不对导致统计文件丢失这是我在 OpenHarmony 上踩过的第一个大坑。最初我用了一个固定的路径来保存统计文件后来升级了某个系统组件版本应用沙箱的路径规则发生了变化读取时找不到了表现为统计面板全部归零。排查思路是先确认文件是否存在。可以在统计管理器初始化时把实际使用的路径打出来debugPrint(statistics dir: ${AppPaths.statisticsDir});然后再去看那个目录下有没有文件。如果目录不对就调整路径解析逻辑。我最终用的是从环境变量HOME读取的方案这在新版本上验证可用。如果你也遇到类似现象第一步先确认路径别急着怀疑统计逻辑。4.2 统计次数与真实抽奖次数不一致这个问题的根源通常有两个。第一埋点时机不对比如前面提到的在动画开始前埋点用户中途退出就会多记第二异步落盘被中断比如用户抽奖后立刻杀进程节流的 500 毫秒窗口还没到。对第一个原因把埋点移到事件回调里就能解决。对第二个原因虽然没有办法根治“进程被杀瞬间的数据丢失”但至少要在 App 生命周期切后台时强制 flush把丢失窗口缩小到几百毫秒内。如果你们的产品要求极端严格那只能每抽一次就同步写盘牺牲一点点性能换可靠性。我在调试这个一致性时加了一个自检方法统计管理器维护一个“本地真实次数”的计数器每次执行recordSpin都会自增同时聚合字段也更新。开发调试模式下拉起统计面板时面板上会显示两个数字一旦发现不一致立刻能定位是哪个环节丢数据。4.3 图表重绘导致界面卡顿近7天柱状图加奖品饼图理论上数据点很少不会造成卡顿。但如果每次页面 build 都重新创建 painter并且 painter 内部做了大量文本测量低端机上也会有掉帧感。我遇到过在某个 OpenHarmony 测试设备上点进统计面板时明显卡了一拍后来发现是因为我在 paint 方法里每次都重新计算所有文本样式和宽度。优化思路有两个。一个是在shouldRepaint里严格控制重绘条件数据没变就返回 false另一个是把文本样式这类不变的对象提取成 painter 的静态成员或 final 字段避免 paint 方法里重复创建。改完之后页面切换顺滑多了。4.4 概率配置与统计结果偏差较大后台配置了 50% 中奖率但统计面板显示的中奖率只有 30%。这种情况通常不是统计代码的问题而要看抽奖算法的实现。比如如果每次抽奖是先在奖品列表上生成一个随机索引但奖品列表里“未中奖”对应的分区过大那实际中奖率自然和配置参数不一致。我用一个模拟脚本验证过在纯 Dart 环境里模拟 10000 次抽奖把每次的中奖结果记录下来再和统计面板输出对比。这样做可以同时验证抽奖算法的数学正确性和统计模块的记录准确性。如果模拟结果符合配置概率但真实用户数据偏差大就要考虑是不是用户抽奖次数太少随机波动还没收敛。这时候不用急着改代码先攒数据再观察。4.5 shared_preferences 初始化时序问题在 OpenHarmony 上如果SharedPreferences插件要实现异步初始化稍有疏忽就会在读取统计值时拿到 null。我把统计管理器的init()设计成Future在main()里等它完成后再runApp保证 UI 第一次展示统计值时数据已经就绪。Futurevoid main() async { WidgetsFlutterBinding.ensureInitialized(); await StatisticsManager.instance.init(); runApp(const LuckyApp()); }这样做的坏处是会略微增加冷启动耗时但换来的稳定性值得。如果你的统计面板不在启动页面上当然也可以把 init 放到页面加载时再执行按需初始化。把数据统计做完之后这个幸运大转盘 app 的“感知”层面就闭环了。你能看到用户哪怕只是你自己什么时间在玩、奖品分布是什么样、中奖率和配置的偏差有多少这些数据能直接指导下一轮迭代——比如哪个奖档太小导致总不中、哪个时段活跃度低需要做活动刺激。我个人在实际操作中的体会是统计模块的难点从来不是写几个计数器而是想清楚“什么数据值得记、什么时机记最准、怎么存才靠谱”。把这三个问题想透代码量再少也是个能用、能扩展的模块。这期的实现保留了明细文件这一层后续你真的要上服务端、做跨端数据同步也可以基于这些本地记录做增量上传历史数据结构不用推倒重来。希望这篇对正在做相似功能的人有帮助下期我打算把数据这一块的“设置页”和“用户配置持久化”也补上到时候继续聊。