幸运大转盘项目做到第七篇终于轮到最核心的业务环节了——抽奖次数管理。在Flutter for OpenHarmony这套技术栈下转盘动画、奖品列表这些偏UI的事情都不难处理但一涉及抽奖次数的存储、扣减、跨天重置就会牵扯出一堆平台细节和设计取舍。这篇文章我会把次数管理的完整实现拆开来讲数据怎么设计、存储怎么选、状态如何在UI中流转、跨天刷新怎么处理才不出bug以及我在OpenHarmony真机上踩过的几个和Android不一样的坑。适合正在用Flutter做OpenHarmony业务型Demo、或者想给自己的App加上“每日任务/抽奖次数”这类功能的开发者参考。1. 先把业务规则讲清楚抽奖次数远不只是“减一”1.1 这个模块到底要管哪几件事很多新手拿到“抽奖次数管理”这个需求第一反应就是“一个int变量每次抽奖减一嘛”。如果你只是做一个纯本地玩具Demo这么干确实没问题。但稍微有点运营思路的产品次数管理一定会包含这些规则每位用户每天有固定次数的免费抽奖机会比如3次用户每次抽奖消耗1次剩余次数不能变成负数用户可以通过看广告、签到、活动奖励等方式获得额外次数跨天之后免费次数恢复到当日的赠送上限但额外次数不清零应用杀掉重启之后数据仍然保留。我见过不少项目把这些规则散写在页面按钮的onClick里结果就是改一个需求要翻三个文件还经常出现“登录页显示3次、弹窗里显示2次、转盘运行时又扣了1次”这种状态不一致。这一篇我建议你先别急着写代码把规则梳理清楚再进行设计。1.2 字段设计要从用户视角出发而不是从“剩余值”出发我先给出一份聚合数据模型后面所有逻辑都围绕这组字段展开/// 抽奖次数聚合数据 class LotteryQuota { /// 今日赠送的免费次数上限通过 DailyGiftLimit 配置 final int todayGiftQuota; /// 额外获得的次数广告、签到、活动、购买 final int extraQuota; /// 今日累计已消耗次数 final int consumedCount; /// 上次赠送免费次数的日期Key如 2025-05-18 final String lastGiftDateKey; const LotteryQuota({ this.todayGiftQuota 0, this.extraQuota 0, this.consumedCount 0, this.lastGiftDateKey , }); /// 当前可用总次数 int get totalQuota todayGiftQuota extraQuota - consumedCount; }注意这里没有直接存一个“剩余次数”而是拆成了“今日赠送次数 额外次数 - 累计已消耗”。这看起来绕了一下但背后是两种完全不同的记账思路。如果用“剩余次数”一个字段跨天重置时你得想一想用户昨天剩了2次今天是给他加3次变成5次还是覆盖回3次如果昨天送的那2次没花完今天应该清零吗这些问题在单一剩余值下没有天然答案只能靠一堆if-else硬推。用我今天这套聚合模型就不一样了跨天之后只需要判断“上次赠送日期”是不是今天。如果不是就把todayGiftQuota重置为每日上限、把consumedCount清零extraQuota原封不动。昨天没花完的免费次数因为consumedCount清零、todayGiftQuota重新赋值为当日上限昨天的剩余免费次数自然就被“清掉”了不需要额外逻辑。1.3 把所有业务场景列成规则表再开始写代码我建议你在正式实现之前先画一张这样的规则表拿给产品或者自己确认完再动手场景字段变化边界条件每日首次进入todayGiftQuota 3consumedCount 0仅当 lastGiftDateKey ! 今天用户点击抽奖consumedCount 1totalQuota 必须 0看广告加次数extraQuota 1无上限但需防刷连续签到奖励extraQuota 2由签到模块传入用户跨天但没打开App不做任何操作下次打开时按“每日首次进入”规则处理用户改系统时间回拨以“日期字符串”判断不允许重置频率超过每日一次后端可加时间戳校验本地以防御为主这张表就是后面所有代码的行为契约。我实际开发中见过太多人省略这一步结果写到跨天逻辑的时候反复返工。规则前置永远比代码前置省时间。2. 存储选型OpenHarmony上能用的方案以及我的取舍2.1 先说结论我选了shared_preferences整个抽奖次数模块需要持久化的数据量撑死了也就几百字节一个用户一天产生一次写入操作。这种量级如果上SQLite、甚至是上文件数据格式那一套属于典型的杀鸡用牛刀。我在Flutter for OpenHarmony项目里最终选的是shared_preferences理由有三个它是KV型键值存储读写都是毫秒级完全匹配“次数”这种标量数据shared_preferences在Flutter生态里属于最基础的插件OpenHarmony适配进度靠前接口足够简单团队成员拿到就能上手不需要理解数据库事务、游标闭合这些东西。当然“能用”和“要确认适配”是两码事。我在OpenHarmony工程里接入shared_preferences时还是做了两步确认后面第5节会展开讲。这里先给结论只要你的OpenHarmony SDK版本和Flutter适配版本匹配shared_preferences直接跑没有大问题。2.2 三个候选方案的对比我也把另外两种方案拉进来做过对比这里把决策过程分享出来省得你再纠结方案优点缺点适合场景shared_preferences接口简单、加载快、社区适配成熟只适合KV数据多字段频繁写时要自己拼key用户设置、次数计数、开关状态本地文件JSON/ProtoBuf数据格式可控便于迁移和备份要自己管理路径、序列化、并发写锁日志导出、离线缓存快照SQLite/sqflite能力强支持索引和联表查询引入成本高跨平台适配需要额外关注native层消息记录、玩法全埋点等结构化历史数据对于抽奖次数这种“只需要读出来、加一、减一”的简单数据第一行就够了。别让存储方案成为阻碍项目推进的瓶颈。2.3 为什么一定要套一层Storage抽象很多教程会直接在Controller里写SharedPreferences.getInstance()这在小工程里没什么问题。但一旦你后续想换存储方案或者要增加“按用户隔离数据”的多账号逻辑所有调用点都要改。我的习惯是先定义一个小而稳的存储类把字段名和读写方法集中管理import package:shared_preferences/shared_preferences.dart; /// 抽奖次数存储层 class QuotaStorage { QuotaStorage(this._prefs); final SharedPreferences _prefs; static const _kTodayGift quota_today_gift; static const _kExtra quota_extra; static const _kConsumed quota_consumed; static const _kLastGiftDateKey quota_last_gift_date; int get todayGiftQuota _prefs.getInt(_kTodayGift) ?? 0; int get extraQuota _prefs.getInt(_kExtra) ?? 0; int get consumedCount _prefs.getInt(_kConsumed) ?? 0; String get lastGiftDateKey _prefs.getString(_kLastGiftDateKey) ?? ; Futurevoid save(LotteryQuota quota) async { await _prefs.setInt(_kTodayGift, quota.todayGiftQuota); await _prefs.setInt(_kExtra, quota.extraQuota); await _prefs.setInt(_kConsumed, quota.consumedCount); await _prefs.setString( _kLastGiftDateKey, quota.lastGiftDateKey); } }这套抽象的好处是业务代码永远不知道底层是SharedPreferences还是别的什么。将来要换数据库只需要重写QuotaStorage一个类Controller和UI一行都不用动。字段名也全部集中在类顶部不会出现“字符串常量遍天飞”的隐患。3. 数据流转链路不要在三个地方各写一套3.1 抽奖按钮、顶部角标、弹窗提示都读同一份数据次数这个数据会被多个组件同时用到转盘页顶部的剩余次数角标、抽奖按钮的可用状态、次数不足时的弹窗文案。如果每个组件各自去读Storage、自己管理状态那一定会出现“页面A显示3次、弹窗B显示2次、实扣1次”的灵异现象。我在这套项目里引入了一个轻量级的QuotaController用Flutter自带的ChangeNotifier做状态分发。不引入重量级状态管理框架是因为这个项目的状态范围很单一用官方原语就够了import package:flutter/foundation.dart; class QuotaController extends ChangeNotifier { QuotaController(this._repository) { _init(); } final QuotaRepository _repository; LotteryQuota _quota const LotteryQuota(); LotteryQuota get quota _quota; Futurevoid _init() async { _quota await _repository.loadAndResetIfNeeded(); notifyListeners(); } /// 尝试消耗一次次数。 /// 返回 true 表示扣减成功false 表示次数不足。 Futurebool tryConsumeOne() async { if (_quota.totalQuota 0) { return false; } _quota await _repository.consumeOne(); notifyListeners(); return true; } /// 增加额外次数source 用来做埋点/来源统计 Futurevoid addExtraQuota(QuotaSource source, int count) async { _quota await _repository.addExtraQuota(source, count); notifyListeners(); } }在UI层所有地方都通过ListenableBuilder绑定同一个QuotaController实例数据源只有一个显示自然就一致了ListenableBuilder( listenable: context.readQuotaController(), builder: (context, child) { final quota context.readQuotaController().quota; return Text( 剩余 ${quota.totalQuota} 次, style: const TextStyle(fontSize: 16), ); }, )3.2 Repository让“读、判断、扣减、写回”成为一个原子操作Controller不直接操作Storage中间再加一个QuotaRepository专门负责组装业务动作。这样做的好处是方便测试——测试时可以注入一个内存版Repository不碰真实磁盘。class QuotaRepository { QuotaRepository(this._storage, {this.dailyGiftLimit 3}); final QuotaStorage _storage; final int dailyGiftLimit; /// 读取数据并判断是否需要执行跨天重置 FutureLotteryQuota loadAndResetIfNeeded() async { final current LotteryQuota( todayGiftQuota: _storage.todayGiftQuota, extraQuota: _storage.extraQuota, consumedCount: _storage.consumedCount, lastGiftDateKey: _storage.lastGiftDateKey, ); final today dateKey(DateTime.now()); if (current.lastGiftDateKey ! today) { final reset LotteryQuota( todayGiftQuota: dailyGiftLimit, extraQuota: current.extraQuota, consumedCount: 0, lastGiftDateKey: today, ); await _storage.save(reset); return reset; } return current; } /// 扣减一次次数调用前必须先检查 totalQuota 0 FutureLotteryQuota consumeOne() async { final current await loadAndResetIfNeeded(); final next LotteryQuota( todayGiftQuota: current.todayGiftQuota, extraQuota: current.extraQuota, consumedCount: current.consumedCount 1, lastGiftDateKey: current.lastGiftDateKey, ); await _storage.save(next); return next; } /// 增加额外次数 FutureLotteryQuota addExtraQuota(QuotaSource source, int count) async { final current await loadAndResetIfNeeded(); final next LotteryQuota( todayGiftQuota: current.todayGiftQuota, extraQuota: current.extraQuota count, consumedCount: current.consumedCount, lastGiftDateKey: current.lastGiftDateKey, ); await _storage.save(next); return next; } }这里有一个我特别想强调的细节consumeOne开始之前先调用loadAndResetIfNeeded。这是为了防止用户在某天23:59:59进入页面、00:00:00刚好点了抽奖结果因为上一轮的次数还没重置而白白损失当天的免费次数。每次写操作都把“跨天校验”前置比依赖调用方自觉更稳妥。3.3 抽奖按钮的完整闭环按钮侧的逻辑应该是这样的用户点击抽奖调用tryConsumeOne()返回true开始转盘动画动画结束弹结果弹窗返回false弹“次数不足”引导弹窗引导用户看广告加次数无论结果如何顶部角标都会通过ListenableBuilder自动刷新。Futurevoid _onSpinPressed() async { final controller context.readQuotaController(); final success await controller.tryConsumeOne(); if (!success) { _showNoQuotaDialog(); return; } _runWheelAnimation(); }这个闭环看起来简单但好处是按钮不需要关心次数到底存在哪里、也不需要关心跨天重置它只需要问一句“我能抽吗”。把复杂逻辑沉淀到Repository和Controller里UI代码才能保持可读性和可维护性。4. 跨天刷新与时间边界最容易写出bug的地方4.1 需求看起来很简单每天0点恢复免费次数这句话听起来一目了然但落地时坑非常多。最典型的错误就是用“最后一次赠送时间到现在是否超过24小时”来判断// 错误示例不要这么写 final expired DateTime.now() .difference(lastGiftTime) .inHours 24;这个写法有一个致命问题如果用户每天晚上8点打开App那么他晚上8点之后得到的赠送次数到第二天晚上7点59分之前都不会重置。也就是说用户连续几天固定晚上8点玩他永远只能用“第一晚”的次数跨天重置永远不生效。因为重置条件被写成了“间隔24小时”而不是“经历了一个自然日”。4.2 正确姿势用日期字符串比较而不是时间差正确的判断依据应该是“上次赠送日期的年月日”和“今天的年月日”是否相同。只要不同说明已经跨了一个自然日就重置。我封装了一个工具方法/// 把日期转成 yyyy-MM-dd 格式的字符串作为“日期身份ID” String dateKey(DateTime dt) { final local dt.toLocal(); final month local.month.toString().padLeft(2, 0); final day local.day.toString().padLeft(2, 0); return ${local.year}-$month-$day; }用字符串比较即可不需要解析成时间戳也不需要处理时区偏移带来的浮点误差final todayKey dateKey(DateTime.now()); if (quota.lastGiftDateKey ! todayKey) { // 跨天了执行重置 }这个方法在loadAndResetIfNeeded里已经用到了第3节代码里能看到。4.3 定时刷新让跨午夜呆在页面里的用户也能恢复次数光靠“进入页面时检查”还不够因为用户可能挂机在当前页面跨越午夜。凌晨零点那一刻页面还开着但次数刷新动作不会自己发生。所以需要一个Timer把刷新动作精确排到下一个0点Timer? _midnightTimer; void _scheduleMidnightCheck() { final now DateTime.now(); final nextMidnight DateTime(now.year, now.month, now.day 1); _midnightTimer?.cancel(); _midnightTimer Timer( nextMidnight.difference(now), () { _checkDailyReset(); _scheduleMidnightCheck(); }, ); } Futurevoid _checkDailyReset() async { await _repository.loadAndResetIfNeeded(); notifyListeners(); }注意这里计算nextMidnight.difference(now)用的是本地时间所以天然适配设备时区。执行完一次之后再递归调用_scheduleMidnightCheck排下一个0点不会出现“只刷新一天、第二天失效”的问题。4.4 防时间回拨用户改系统时间怎么办本地应用做纯前端计数理论上防不住刻意篡改但可以加一道防御逻辑只在“今日日期Key”和“上次赠送日期Key不一致”时才重置重置后立即把新日期写回。这样即使用户把系统时间从今天改回昨天也不可能触发两次重置因为存储里的日期Key已经变成“今天”了。如果要做得更严谨可以再加一个字段记录“最后重置时间戳”当服务端下发时间基准时做二次校验。但纯本地项目做到日期Key这一层已经足够再往上就属于反作弊的范畴不是入门项目该背的包袱。5. 实测中撞上的OpenHarmony特性问题与规避方案5.1 现象切换后台再回来Timer“睡”过了头我在OpenHarmony真机上测试时发现一个诡异现象把转盘App切到后台隔一个小时再回前台剩余次数没有恢复。明明设置了0点Timer为什么没有触发第一次排查我以为是Timer被GC了。后来加了日志发现App回前台那一刻Timer回调压根没有执行。查了OpenHarmony的后台任务调度策略后才明白OpenHarmony对后台进程做了严格的资源冻结普通Timer在App进入后台后会被挂起不会按预期时间触发。这不只是OpenHarmony的问题很多移动系统都有类似策略只是Android和OpenHarmony的具体策略和触发时机不一样你不能假设“系统会帮你把Timer补执行”。5.2 完整排查链路从“没恢复”到“修复”如果你也遇到“跨天没恢复次数”的问题按这个顺序排查先确认重置方法有没有被调用。加一行日志判断最低层的loadAndResetIfNeeded有没有执行如果没执行说明触发源有问题。检查是“进前台触发”还是“Timer触发”如果Timer没触发检查App生命周期。在Flutter里监听WidgetsBindingObserver的AppLifecycleState.resumed在resumed回调里补一次_checkDailyReset并重新排定下一轮Timer再想一层App恢复前台时系统时间可能已经跨了好几天不能只靠“和0点差多久”来判断而是每次恢复都执行一次日期Key对比。修复后的关键代码class WheelPageState extends StateWheelPage with WidgetsBindingObserver { override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _quotaController.scheduleMidnightCheck(); } override void didChangeAppLifecycleState(AppLifecycleState state) { if (state AppLifecycleState.resumed) { // 回到前台立即校验跨天然后重新排定0点Timer _quotaController.checkDailyResetOnResume(); _quotaController.scheduleMidnightCheck(); } } override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } }5.3 其他几个值得注意的平台差异除了Timer休眠我再列几个Flutter for OpenHarmony项目里更容易踩到的差异点都是我这个项目亲自撞过的差异点Android上的表现OpenHarmony上的表现我的处理SharedPreferences初始化getInstance可以放在异步上下文偶发在冷启动阶段拿到空实例在main入口先await getInstance再runApp模拟器时区通常跟随宿主机部分镜像默认UTC和真机不一致调试时强制设置时区暴露前统一用真机验证路径插件path_provider可用版本差异较大有的API返回空路径次数模块不依赖外部文件路径规避风险第5节的5.2我很详细讲了链路这是这篇里的“避坑核心”。读者如果遇到跨天没恢复可以照这个链路查。6. 给后续功能留好接口广告加次数、签到送次数怎么接6.1 不要写死加次数入口用来源枚举运营一定会给你加“看广告1次”“连续签到2次”“活动红包3次”这些需求。如果你在Controller里写addExtraQuota()调用方传个数字那埋点、审计都很难做。我先定义了一个来源枚举enum QuotaSource { adReward, // 广告奖励 dailySign, // 每日签到 activity, // 活动奖励 purchase, // 购买/内购 }Controller增加来源参数Futurevoid addExtraQuota(QuotaSource source, int count) async { _quota await _repository.addExtraQuota(source, count); // 这里可以把 source 和 count 上报到统计平台 notifyListeners(); }将来不管从哪里加次数调用代码都会长得一样// 广告回调里 await controller.addExtraQuota(QuotaSource.adReward, 1); // 签到模块里 await controller.addExtraQuota(QuotaSource.dailySign, 2);统一入口的好处是审计“用户今天从哪些途径获得了多少次”只需要在Controller的addExtraQuota里加一行日志不需要去各个业务方翻代码。6.2 次数不足时的弹窗也建议用统一方法当tryConsumeOne返回false时不一定只能弹一个固定弹窗。弹窗可以带一个“看广告获取次数”的按钮由外部注入回调这样UI保持统一业务又灵活void _showNoQuotaDialog() { showDialogvoid( context: context, builder: (context) AlertDialog( title: const Text(今日次数已用完), content: const Text(观看一段广告即可获得1次抽奖机会), actions: [ TextButton( onPressed: () async { Navigator.pop(context); await context .readQuotaController() .addExtraQuota(QuotaSource.adReward, 1); }, child: const Text(看广告领次数), ), ], ), ); }这里外部广告SDK的回调可以先返回一个true模拟成功等真正接广告时再替换成真实回调不影响整体逻辑。6.3 关于“次数来源”埋点的一个小建议在把这个模块接入埋点系统时建议把以下信息记下来用户ID、日期Key、动作consume/add/reset、来源枚举、变化前后值。有了这些字段你才能回答“今天广告加次数转化率是多少”“平均每人每天消几次”这类运营问题。没有埋点的次数管理就像一个没装水表的水管你知道它在流水但不知道流了多少。最后分享一点我在这个模块里反复体会到的经验抽奖次数管理看似是几行加减法真正考验的是数据设计和边界场景的处理。把“剩余次数”换成“赠送 额外 - 已消耗”的记账模型之后这个模块一下子清爽了把跨天判断从“时间差”换成“日期字符串比较”之后凌晨边界也稳了。Flutter for OpenHarmony这个技术栈目前还在快速演进很多插件和平台行为都在变化我的建议是每次升级SDK后都重新跑一遍“切后台、跨夜、回前台”这三个用例比看什么Release Notes都管用。这个系列后面我还会继续写转盘的动画优化和奖品概率配置先把次数这个根扎稳。