花了好几个晚上把转盘抽奖的核心流程跑通之后,我以为这个 Flutter for OpenHarmony 幸运大转盘 app 就告一段落了。结果拿给朋友一测,反馈最多的不是“转盘动画不够顺”,也不是“中奖算法有 Bug”,而是——“我抽中这个保温杯,它到底长什么样?去哪里领?”这就很真实:抽奖只是起点,奖品详情才是把“抽中了”变成“拿到了”的关键闭环。这一篇就专门把奖品详情这个模块讲透,包括奖品数据模型怎么设计、跨页传参怎么避坑、详情页 UI 动效怎么落地、以及 OpenHarmony 真机上那些和 Android/iOS 明显不一样的适配细节。如果你已经跟我做完前几篇的转盘主体,这篇能让你把“能抽奖的 Demo”升级成“像一个正经产品的 app”。
1. 奖品详情要承载什么:从转盘结果到价值呈现的闭环
1.1 为什么奖品详情不只是“一个信息展示页”
大多数转盘 Demo 做到抽奖弹窗就结束了:弹窗里显示“恭喜获得三等奖”,用户点个“确定”,结束。但没有奖品详情的抽奖,本质上是在消耗用户信任感。用户抽中的不是一个抽象等级,而是一个具体的东西——他要知道奖品的图片、名称、使用方式、领取流程,才会觉得这次抽奖是有价值的。
所以奖品详情页在整个业务链路里的定位,不是“多余的美化页面”,而是抽奖业务的价值出口。它负责三件事:第一,把转盘上那个小小的扇形区域承载不了的信息,展开成一个完整的商品/奖品卡片;第二,承接后续的“领取/兑换/填写地址”动作,让用户完成闭环;第三,给运营留出展示空间,比如奖品的活动说明、使用期限、注意事项。
这个页面还有一个隐藏价值:它是很多共性开发技巧的试验场。奖品列表、详情、领奖状态这些都是任何 C 端 app 都会遇到的东西,做完这一篇,后面再做订单详情、商品详情、消息详情,都是同一套思路的迁移。
1.2 奖品数据模型:字段怎么加才够用又不冗余
前几篇我们用了一个很简单的 Prize 模型,基本只有 id、name、weight,够让转盘转起来。但到了详情页,这个模型必须扩展。我在实际开发中踩过一个坑:一开始图省事,把详情页要用的字段全部塞进 Prize 类里,结果转盘数据源里到处都是 description、imagePath 这种根本用不到的字段,耦合很重。后来拆成“基础字段 + 详情字段”,用可空或子对象承载,清爽很多。
我最终的模型长这样:
dart复制enum PrizeType { physical, coupon, points }
class Prize {
final String id;
final String name;
final String subtitle; // 转盘上展示的短文案
final String description; // 详情页完整描述
final String imagePath; // 主图资源路径
final List<String> detailImages; // 详情页轮播图
final PrizeType type; // 奖品类型
final int stock; // 剩余库存
final int weight; // 概率权重,转盘抽取用
final bool claimable; // 是否可领/已领
final Map<String, dynamic> extra; // 类型专属字段,比如券码、积分数量
const Prize({
required this.id,
required this.name,
this.subtitle = '',
this.description = '',
this.imagePath = '',
this.detailImages = const [],
this.type = PrizeType.physical,
this.stock = 0,
this.weight = 1,
this.claimable = true,
this.extra = const {},
});
}
单独解释几个容易被忽略的字段:
detailImages和imagePath分开。转盘扇形区域和小列表只需要imagePath,详情页的开屏大图则是detailImages里的第一张或者单独一张高清图。如果混在一起,转盘绘制时会把几张详情图全部加载进内存,转盘一启动就卡。type一定要结构化。实物、优惠券、积分这三类的详情页展示形态完全不同(后面 3.3 会展开),用枚举比用字符串判断靠谱得多,Dart 的 switch 也能覆盖全分支。extra放类型专属字段,比如优惠券的code、积分的points、实物的skuId。不要为每一种奖品类型都平铺加字段,那样模型会越来越肿。
1.3 数据从哪来:本地模拟数据与远程下发的取舍
详情页的信息量比转盘大得多,这直接牵扯到数据来源问题。纯 Demo 可以写死在代码里,但做成产品肯定要下发。我在这个项目里用的是“本地 JSON + Repository 接口”的方式,兼顾两者。
思路很简单:把奖品列表放进 assets/data/prizes.json,启动时读取到内存,UI 层不直接碰 JSON,而是通过一个 PrizeRepository 来取数据。接口设计成同步返回 List<Prize>,等以后接后端的时候,把 Repository 内部实现换成网络请求,UI 完全不动。
dart复制class PrizeRepository {
static final PrizeRepository instance = PrizeRepository._();
late List<Prize> _prizes;
Future<void> loadFromJson(String jsonString) async {
final list = jsonDecode(jsonString) as List<dynamic>;
_prizes = list.map((e) => Prize.fromJson(e as Map<String, dynamic>)).toList();
}
Prize? findById(String id) {
for (final p in _prizes) {
if (p.id == id) return p;
}
return null;
}
List<Prize> get all => _prizes;
}
这样做的另一个好处是:详情页不需要感知数据是本地还是远程,只需要 findById。后续如果要加缓存、加同步、加埋点,都只改这个仓库。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 转盘停稳后的数据交接:中奖索引到详情对象的链路设计
2.1 转盘角度计算后,怎么定位到底中了哪个奖品
转盘停稳后,第一个要做的动作是把旋转角度映射成奖品索引。前几篇我们用 AnimationController 驱动转盘旋转,_totalAngle 保存的是累计旋转角度。停稳时,通过取模运算找到当前指针落在哪个扇形区间。
计算公式大致是:
dart复制int getWonPrizeIndex(double totalAngle, double startOffsetAngle) {
final normalized = (totalAngle - startOffsetAngle) % 360;
final segment = 360 / prizeCount;
return (normalized ~/ segment) % prizeCount;
}
这里有三个比较容易出问题的细节:
startOffsetAngle是第一个奖品扇区的起始角度,取决于你绘制转盘时是怎么排布奖品顺序的。如果直接用totalAngle % 360去整除segment,大概率会偏一格。- 取模运算在 Dart 里对负数有坑,所以一定要先归一化成 0~360 的正数,我习惯写成
((value % 360) + 360) % 360。 - 确定奖品后,一定要再取一次
% prizeCount,防止边界情况刚好落在最后一个索引之外。
2.2 为什么强烈建议用“奖品 ID 跨页传参”,而不是直接传整个对象
抽中奖品后,接下来就是跳转详情页。很多 Flutter 开发者最自然的写法是 Navigator.push 时把 Prize 对象直接塞进 arguments。在纯 Flutter 项目里,这个写法大多数时候没问题。但到 OpenHarmony 上,我强烈建议你改掉这个习惯,改用“只传 ID,页面内部重新查表”。
原因有几个:
- OpenHarmony 的 Flutter 运行时和 Android 的 engine 实现有差异,跨页面传递自定义对象在部分版本上会出现序列化异常。尤其是对象里带了
List、Map这类容器时,报错信息非常晦涩,比如Could not convert the argument,排查起来很浪费时间。 - 只传 ID 还有一个工程上的好处:详情页永远可以通过 Repository 拿到最新数据。假设用户在详情页停留期间,库存被其他端改掉了,你刷新时拿到的一定是最新的;而如果传的是对象快照,那页面里展示的库存可能已经过期了。
- 从路由恢复的角度看,ID 是稳定可持久化的。以后做“杀进程恢复页面”时,只需要把 ID 存起来,就能重建详情页;传整个对象的话,恢复的时候还得再序列化一次。
所以沿用的套路是:
dart复制Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PrizeDetailPage(prizeId: wonPrize.id),
),
);
详情页里:
dart复制@override
void initState() {
super.initState();
_prize = PrizeRepository.instance.findById(widget.prizeId);
}
如果 _prize 为 null,说明数据源里压根没这个奖品,我会直接展示一个“奖品不存在”的空态,而不是白屏。
2.3 详情页数据获取:加载中状态、空状态与异常兜底
只传 ID 之后,详情页的数据获取就变成同步查表,一瞬间就完成了。但你在做真实项目时,一定要把“数据存在”和“数据不存在”两条分支都写清楚,不要默认永远有值。
我习惯在详情页维护一个小的状态枚举:
dart复制enum _LoadState { loading, success, empty }
虽然本地查表几乎瞬间完成,但为了以后切网络请求不用改 UI,这个状态机还是值得先搭好。加载中给一个简单骨架屏,成功正常渲染,空了给一个刷新按钮。这一小步在 OpenHarmony 上还有一个额外作用:避免在页面还没构建完成时就去操作 MediaQuery 或 Navigator,能省掉一部分生命周期相关的烦人异常。
3. 奖品详情页的 UI 落地:能看、能玩、不卡顿
3.1 页面骨架:头图下沉、信息上提、底部按钮固定
奖品详情页我建议直接用 CustomScrollView + SliverAppBar 来搭骨架,而不是 Scaffold + SingleChildScrollView + Stack。因为 SliverAppBar 可以原生支持头图折叠效果,用户往下滑动时,头图会平滑缩成顶栏标题,体验明显好一截。
核心布局代码结构大致是:
dart复制CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 280,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: Hero(
tag: 'prize-${prize.id}',
child: Image.asset(prize.detailImages.first, fit: BoxFit.cover),
),
title: Text(prize.name),
),
),
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitleSection(),
_buildMetaSection(),
SizedBox(height: 16),
Text('奖品介绍', style: ...),
SizedBox(height: 8),
Text(prize.description, style: ...),
],
),
),
),
SliverFillRemaining(
hasScrollBody: false,
child: Align(
alignment: Alignment.bottomCenter,
child: _buildBottomBar(),
),
),
],
)
底部领取按钮我用 SliverFillRemaining 的底部对齐来固定,这样即使描述文字很短,按钮也会稳稳钉在页面底部;描述很长时,它又不会挡住内容区,比 Stack 里手动算高度靠谱得多。
3.2 动效设计:Hero 共享转场与 AnimatedSwitcher 数字动画
详情页的动效我做了两个,都是低成本高感知的类型。
第一个是 Hero 共享转场。转盘中心其实已经展示了中奖奖品的小图标,把它和详情页头图用同一个 tag 连接起来,跳转时图片会从扇形中央“飞”到详情页顶部,整个视觉流转非常自然。
dart复制Hero(
tag: 'prize-${prize.id}',
child: Image.asset(...),
)
这里提醒一句:Hero 的 tag 必须在同一帧里唯一。如果你在产品列表和详情页同时都用这个 tag,要确保同一个页面里没有重复,否则 Flutter 会直接报 “There are multiple heroes that share the same tag”。
第二个是库存数字变化的动画。详情页里如果显示剩余库存,数据变化时直接刷新数字会显得很干。用 AnimatedSwitcher 包一层,数字变化时做一个“旧的淡出、新的淡入”的过渡,观感好很多。
dart复制AnimatedSwitcher(
duration: Duration(milliseconds: 300),
child: Text(
'$remaining',
key: ValueKey(remaining),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
)
记住一定要给 child 设置一个随数值变化的 key,否则 AnimatedSwitcher 会认为 child 类型没变,不会触发动画。
3.3 多奖品类型的 UI 适配:实物、优惠券、积分该怎么展示
不同类型奖品的详情页看起来应该不一样。实物要突出图片、规格、领奖方式;优惠券要突出券码、有效期、使用规则;积分要突出数量、到账说明。我用 switch (prize.type) 来决定中段展示区和底部按钮文案,代码结构清晰,后面加新类型也好扩展。
dart复制Widget _buildTypeSpecificSection() {
switch (prize.type) {
case PrizeType.physical:
return _PhysicalSection(prize: prize);
case PrizeType.coupon:
return _CouponSection(prize: prize);
case PrizeType.points:
return _PointsSection(prize: prize);
}
}
底部按钮的文案也会联动变化:
| 奖品类型 | 底部主按钮文案 | 中段核心信息 |
|---|---|---|
| physical | 立即领取 | 规格、领奖流程、地址填写 |
| coupon | 查看券码 | 券码卡片、有效期、使用规则 |
| points | 确认到账 | 积分数量、预计到账时间、明细 |
4. 在 OpenHarmony 上跑起来的适配细节:真机才是照妖镜
4.1 页面转场与返回手势:默认效果在鸿蒙设备上可能“用力过猛”
Flutter 默认的 MaterialPageRoute 在 Android 上是从底部滑入配合阴影渐变的转场,在 OpenHarmony 真机上我实测下来偶尔会出现转场时间过长、边缘阴影明显偏重的情况,观感不够“鸿蒙”。如果你想贴近 OpenHarmony 系统原生的页面切换感受,建议自己写一个轻量 PageRouteBuilder。
dart复制Route _buildDetailRoute(String prizeId) {
return PageRouteBuilder(
pageBuilder: (ctx, animation, secondaryAnimation) => PrizeDetailPage(prizeId: prizeId),
transitionsBuilder: (ctx, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic);
return FadeTransition(
opacity: curved,
child: SlideTransition(
position: Tween<Offset>(begin: Offset(0, 0.06), end: Offset.zero).animate(curved),
child: child,
),
);
},
transitionDuration: Duration(milliseconds: 280),
reverseTransitionDuration: Duration(milliseconds: 220),
);
}
再补充一个细节:OpenHarmony 设备普遍支持侧滑返回手势,Flutter 在接入 OpenHarmony 时对 CupertinoRouteTransitionMixin 的支持存在历史问题,如果你发现页面从右往左滑返回时出现黑屏或卡住,建议在页面根节点的 Scaffold 上显式设置 appBar: AppBar(leading: BackButton()),同时在系统侧保留返回手势,两条路都通,用户怎么操作都不会掉进 bug 里。
4.2 安全区域、刘海屏与键盘弹起
OpenHarmony 设备形态比 Android 碎片化还夸张,有带刘海的手机,有平板,还有各种带挖孔的设备。详情页头图如果是全屏沉浸式,一定要预留状态栏高度,否则头图的标题或返回按钮会被摄像头区域吃掉。
我之前栽过一跟头:在模拟器看没有任何问题,一到真机就发现 MediaQuery.of(context) 返回的顶部 padding 在某些设备上是 0,因为系统把页面默认设成了全屏模式。后来统一改成“手动读取并叠加”的写法,才稳下来:
dart复制final topPadding = MediaQuery.of(context).padding.top;
final extraAppBarHeight = topPadding > 0 ? 0 : 24.0;
详情页如果包含“填写收货地址”这类表单场景,还要提前处理键盘弹起。OpenHarmony Flutter 里 Scaffold.resizeToAvoidBottomInset 的行为和 Android 不完全一致,某些版本下键盘会把底部按钮顶到键盘上方,但页面底部会出现一块白条。我建议键盘弹出时主动 scrollController.animateTo(maxScrollExtent),把当前焦点字段滚到可见区域,同时给底部按钮加一个 SafeArea 包裹,双重保险。
4.3 图片加载、中文字体与 HAP 包体积的变化
图片这块,我项目里的奖品图都放在 assets/images/ 下,Image.asset 在 OpenHarmony 上基本正常。但有一个细节差异:OpenHarmony 的 Flutter 引擎在部分版本上对资源路径的大小写处理比 Android 严格,Assets/Images/Prize.png 和 assets/images/prize.png 这种大小写不一致,在 Android 上可能侥幸能跑,在 OpenHarmony 上就直接 Unable to load asset。建议项目里所有资源目录和文件名统一小写下划线风格,省得后续排查路径问题。
字体方面,OpenHarmony 设备自带的系统字体对中文覆盖还行,但如果你用了较特殊的中文字体,或需要保证所有真机显示一致,最好把字体文件打进包里,在 pubspec.yaml 里声明:
yaml复制fonts:
- family: AppFont
fonts:
- asset: assets/fonts/AlibabaPuHuiTi-Regular.ttf
代价是 HAP 体积变大。我做了一版带字体、带详情大图、带转盘全套素材的包,对比只跑通转盘逻辑的版本,体积从 31MB 涨到 47MB 左右(包含 debug 信息,release 会小一些)。如果你的用户对安装包大小比较敏感,图片尽量用 WebP 而不是 PNG,单张详情图能压掉 60% 左右。
4.4 常见异常日志速查:真机上最常出现的几个报错
因为跨页传参、资源加载、插件注册这些问题是在 OpenHarmony + Flutter 组合下最高频的坑,我把这阶段实测中遇到的报错整理成了速查表,排查时可以直接对照:
| 报错类型/现象 | 可能原因 | 常规处理 |
|---|---|---|
MissingPluginException |
插件没有随 HAP 注册,或者只 clean 了部分缓存 | 执行 clean 后重新构建 HAP,确认 ohpm 依赖已安装 |
Unable to load asset: assets/... |
资源路径大小写/层级与 pubspec 声明不一致 | 统一小写命名,用 flutter pub get 后检查 AssetManifest |
Could not convert the argument |
路由 arguments 传了无法序列化的自定义对象 | 改用传基础类型 ID,详情页自行查表 |
| 页面被键盘顶出白条/按钮跳动 | resizeToAvoidBottomInset 在 OpenHarmony 表现不一致 | 手控滚动偏移 + SafeArea 包裹底部按钮 |
| 中奖索引总偏一格 | 起始偏移角 startOffsetAngle 没处理 | 检查绘制转盘时的第一个扇形起始角度,统一后再做取模 |
| Hero 动画卡顿/掉帧 | 头图过大或 GPU 型号太老 | 详情页头图改用压缩后的 WebP,并把 Hero 的 flightShuttleBuilder 里尽量用静态图 |
5. 多走一步:领奖入口、库存联动与后续扩展思路
5.1 领奖按钮的防重复提交
详情页底部按钮点击后,最怕用户手速快连点两下,尤其是 OpenHarmony 低端机型触摸响应有延迟,用户更容易下意识多点。如果不做防重复,轻则产生两次领奖请求,重则库存扣两次。
我的做法很简单,用一个 _submitting 布尔标志位在函数入口拦住:
dart复制bool _submitting = false;
Future<void> _handleClaim() async {
if (_submitting) return;
setState(() => _submitting = true);
try {
// 调用仓库层领奖接口
await PrizeRepository.instance.claim(prize);
if (mounted) {
setState(() => _submitting = false);
}
} catch (e) {
if (mounted) {
setState(() => _submitting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('领取失败,请稍后重试')),
);
}
}
}
这里有两个细节:finally 块里如果直接 setState,在异步完成之前页面可能已经销毁,所以一定要用 mounted 做保护;按钮禁用态也要随 _submitting 联动,比如把按钮的 onPressed 置空,避免只拦截函数入口但视觉上没反馈。
5.2 库存联动:转盘概率、奖品列表与详情展示的数据一致性
当详情页支持领奖后,库存数据就有了三个消费方:转盘的概率计算、抽奖结果弹窗上的剩余量、详情页的库存展示。如果每一处都自己读一次本地数据,很容易出现转盘还能转到但详情页已经显示“售罄”的情况。
我前面把 PrizeRepository 设计成单一数据源的价值在这里就体现出来了。库存变更后,统一收口到 Repository,再用 ChangeNotifier 通知所有监听方刷新。这是一个非常轻量的状态管理方案,不需要引入 Provider 或者 Riverpod 这种重家伙,当前场景完全够用。
dart复制class PrizeRepository extends ChangeNotifier {
static final instance = PrizeRepository._();
void reduceStock(String prizeId) {
final prize = findById(prizeId);
if (prize == null || prize.stock <= 0) return;
prize.stock--;
notifyListeners();
}
}
转盘页和详情页各自用 ListenableBuilder 监听 Repository,库存一变,两边同时更新。这样做的最大好处是,以后如果转盘从 6 个奖品扩到 12 个,或者加了一个“每日库存重置”的规则,你只需要改 Repository 一处,所有页面自动同步。
5.3 后续还可以继续做的方向
做完奖品详情,这个抽奖 app 的核心链路才算真正闭合了。我接下来打算继续补的方向,也分享给你参考:
- 领奖状态机:抽中但没填地址、已填地址、已发货、已完成。这需要一个比
claimable更完整的状态枚举,并且要考虑同一用户重复抽中同一实物奖品的场景。 - 中奖记录列表页:用户可以回看历史中奖记录,甚至给每个奖品补一个“再抽一次”的入口,能显著提升活跃。
- 券码展示:优惠券类奖品可以加“复制券码”按钮,配合
Clipboard和查看次数的限制逻辑,实现起来不难但很实用。 - 埋点上报:记录从抽奖到详情页到领奖的转化漏斗,如果你打算上运营活动,这个数据是刚需。
这一篇的核心收获其实就两句话:跨页传参宁传 ID 不传对象,数据展示宁用单一数据源不用各自读取。这两条经验在 OpenHarmony 上尤其值钱,因为它们不是“风格偏好”,而是实打实能少踩几个真机坑的工程决策。你在做自己的转盘项目时,就算奖品只有三五个,也建议把 Repository、ID 传参、类型分支这套骨架打好,后面每次加需求都会感谢当时没偷懒的自己。
