
我先把前面五篇的进度简单对一下免得有读者是从这一篇才跟上来的目前这个幸运大转盘项目已经完成了转盘主界面、抽奖动画、奖品管理、中奖记录和中奖弹窗这几个核心模块。这一篇要补上的奖品详情其实是把用户点开某个奖品图标之后看到的那个说明页面做成一个完整的、能在OpenHarmony设备上稳定运行的页面。别觉得它只是展示几张图和几段文字奖品详情页涉及数据模型设计、UI布局、状态流转、平台差异适配一堆事稍不注意就会在真机上踩坑值得单独拆开好好写一写。1. 奖品详情页要承担什么角色1.1 它不只是展示信息那么简单很多开发者会把奖品详情做成一个静态页面固定写死几个文本控件奖品变了就改代码重新发版。这在演示Demo里能凑合但放到真实的OpenHarmony设备上配合前面几篇已经落地的奖项管理和转盘抽奖逻辑就完全说不过去了——用户在转盘上抽到奖品之后需要看到的是一个能根据当前奖项实时变化、能引导下一步操作比如填写收货地址、查看兑换码、联系客服的完整页面。我在设计这个模块时给奖品详情页定了三个核心职责承接转盘或中奖记录页面传过来的奖品ID根据ID从本地或远端拉取奖品完整数据。展示奖品的图片、名称、价值、规则说明、有效期、领取方式等字段按不同奖品类型动态渲染不同区块。根据领奖状态控制按钮的显示和点击行为把查看详情→立即领取→领取成功/已领取这条流程走通同时把状态回传给中奖记录模块保证数据一致性。简单说这个页面是用户对奖品这个概念的完整认知闭环。转盘上那个扇形区域空间有限只能展示图标和名称用户真正了解自己抽到了什么、怎么领、有什么限制全靠详情页。如果这个页面做得糊弄前面转盘做得多精致都会在用户体验上掉链子。1.2 在系列中的技术衔接点从前面几篇延续下来的技术路线是Flutter负责跨端UI和业务逻辑OpenHarmony端通过底层适配层提供系统能力调用。奖品的图片存储、数据持久化、路由跳转这些能力我们在前面几篇已经分别在奖品管理模块和主路由配置里处理过了。奖品详情页要做的是把这些能力组合起来同时承担一个中转站的角色——它接收到的不仅是一个ID还可能带有一个状态枚举表示用户是从转盘刚抽完奖跳过来的还是从历史记录里翻出来的这两个入口对应不同的交互预期。这个设计决策一开始就要定清楚不然后面加字段、改状态流的时候会非常痛苦。我用了一个简单的枚举加可选参数的构造方式来做页面传值enum PrizeDetailEntry { fromWheel, fromHistory } class PrizeDetailArgs { final String prizeId; final PrizeDetailEntry entry; final bool autoTriggerClaim; // 从转盘跳转时是否自动弹出领奖引导 const PrizeDetailArgs({ required this.prizeId, required this.entry, this.autoTriggerClaim false, }); }看起来只是多了两个字段实际用起来区别很大。从转盘跳转时用户刚经历完抽奖动画情绪是兴奋且急切的页面要直接把领取入口放在显眼位置从历史记录跳转时用户可能只是想再看看奖品规则甚至查看物流信息这时候页面顶部应该更偏信息展示按钮弱化为辅助操作。这两个场景不加区分地做成同一个布局总有一类用户会觉得别扭。2. 数据模型扩展奖品详情在底层要准备什么2.1 从基础字段到详情字段的演进前面奖品管理模块已经定义了一个基础的数据模型包含奖品ID、名称、图标路径、库存、概率等信息。但那些字段只够支撑转盘绘制和中奖判定。详情页需要的数据维度更多我在原有模型基础上做了一个扩展模型把展示性和流程性字段单独拆出来避免把主模型的职责搞得太重。扩展模型的核心字段分成四组基础展示组名称、副标题、主图URL、详情图列表、奖品类型实物/虚拟/优惠券、价值描述。规则说明组使用规则文本、有效期起止、适用平台或门店说明、是否支持转赠。领取流程组领取方式枚举自动发码/手动填写地址/线下核销、需要用户补充的信息项列表、客服联系方式。状态字段当前用户对该奖品的领取状态未领取/已领取/已过期/已使用、领取时间、关联的订单或兑换码。这里有一个容易忽略的点详情字段往往不是一次全部加载出来的。如果页面一进来就把所有字段请求完弱网环境下用户会盯着白屏干等。我参考了很多成熟电商App的做法把详情接口拆成了基础信息和补充信息两级。先加载基础信息页面能立刻渲染出图、名称和关键规则补充信息比如需要填写地址时动态拉取表单配置再异步加载填充到页面对应区块。2.2 数据解析与容错处理从OpenHarmony端通过通道传递过来的数据在Flutter侧会统一JSON解析。这里要特别做两类容错一类是字段缺失。同一套App可能在不同版本上运行老版本缓存的数据可能没有新增字段直接按强类型解析会抛异常。我用了一个安全解析工具方法对每个字段都提供类型判断和默认值兜底class SafeJson { static String readString(MapString, dynamic json, String key, {String fallback }) { final value json[key]; if (value is String value.isNotEmpty) return value; if (value is num) return value.toString(); return fallback; } static int readInt(MapString, dynamic json, String key, {int fallback 0}) { final value json[key]; if (value is int) return value; if (value is num) return value.toInt(); if (value is String) return int.tryParse(value) ?? fallback; return fallback; } }这类方法看起来没什么技术含量但项目里每一处解析都走它线上崩溃率能压下去不少。OpenHarmony端和Flutter端之间的消息通道偶尔会出现推送顺序问题数据到达时可能带了不完整的字段安全解析配合默认值能保证页面至少渲染出一个可用的状态而不是直接红屏。另一类是时间字段。有效期、领取时间这些时间值在通道传输时容易被转成不同的格式。我在解析时统一了一个静态工具方法处理多种格式支持标准ISO字符串、时间戳字符串和纯数字时间戳三类统一解析后转为DateTime再给UI层使用。这个统一很关键因为后续做剩余天数计算、过期置灰显示时都依赖这个时间对象宁可解析多花几行代码也不要在UI层到处做格式判断。3. 页面布局从草图到Flutter Widget树的落地3.1 布局拆解一个页面四个区我画奖品详情页的草图时把页面从上到下切成了四个视觉区顶部图片区、标题与价值区、规则说明区、底部操作区。用Flutter实现时整体骨架是一个CustomScrollView嵌套SliverAppBar和SliverToBoxAdapter这样做的好处是滚动时顶部图片可以跟随折叠视觉上比普通的SingleChildScrollView流畅很多也更贴合详情页该有的交互质感。顶部图片区用了一个宽高比约4:3的占位容器内部通过Image.network加载奖品主图。这里我加了一个缓存键的概念同一个奖品ID只允许存在一张主图缓存避免用户反复进出页面时图片闪烁。缓存键直接用prizeId拼接imageUrl的hash兼顾稳定性和唯一性。标题与价值区放了两行文本第一行是奖品名称用较大的字体加粗第二行是价值描述左对齐放价值 ¥XXX的标签。这个区域的背景色我从整体界面主题色中提取了一个浅色变体和顶部图片区形成视觉延展。规则说明区是这个页面里信息密度最高的区域。我把它做成了一个卡片列表每张卡片左侧放图标右侧放文本。卡片之间用Divider分隔而不是每张卡片独立带圆角边框这样视觉上更像连续的说明文档用户在阅读规则时注意力不会被切割得太碎。底部操作区使用了SafeArea包一层里面根据领取状态渲染不同按钮。正常状态下是宽底按钮立即领取已领取时变成描边按钮查看我的奖品已过期时按钮置灰并显示已过期。为了保证键盘弹起时操作区不被遮挡我在按钮外层加了resizeToAvoidBottomInset的配合处理。3.2 Widget拆分的颗粒度写Flutter页面很容易陷入一个build方法里堆几百行Widget的泥潭。一旦页面需要根据状态动态增删区块这种写法会让代码很快失控。我习惯把每个视觉区拆成独立的Widget类通过构造函数传入对应的数据对象。以规则说明区为例class PrizeRuleSection extends StatelessWidget { final PrizeDetailModel detail; const PrizeRuleSection({super.key, required this.detail}); override Widget build(BuildContext context) { return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( children: [ _buildRuleTile( icon: Icons.description_outlined, title: 使用规则, content: detail.usageRule, ), if (detail.validUntil ! null) _buildRuleTile( icon: Icons.schedule, title: 有效期至, content: _formatDate(detail.validUntil!), ), if (detail.supportTransfer) _buildRuleTile( icon: Icons.swap_horiz, title: 转赠, content: 该奖品允许转赠给好友, ), if (detail.redeemType PrizeRedeemType.store) _buildRuleTile( icon: Icons.storefront_outlined, title: 适用门店, content: detail.storeScope ?? 全部线下门店, ), ], ), ); } }这里用if去动态控制某个tile是否显示比在列表里拼接空对象要干净得多。页面有新的规则类型需要展示时只需要在这个区块里加一个if分支不会影响其他区块的代码。实测下来这个页面的Widget树从将近300行的单文件压缩到了5个职责清晰的类后续维护效率明显提升。4. 状态管理领奖流程的前后端协同4.1 本地状态机的设计奖品详情页里的状态其实可以抽象成一台小型状态机状态集合很固定只是不同入口的初始状态不一样待领取用户尚未领取展示领取按钮。待补充信息用户点击领奖后需要填写地址或手机号等信息才能完成。领取中请求正在处理中按钮变为loading态不可重复点击。领取成功后台已返回成功结果展示成功页或跳转下一步。已领取/已使用用户再次进入页面时的状态展示历史状态信息。已过期有效期已过按钮禁用。这个状态机我在一个ChangeNotifier的Model类里维护页面通过AnimatedBuilder监听变化。状态迁移只有四种合法路径待领取→待补充信息→领取中→领取成功以及待领取→领取中→领取成功不需要补充信息时。所有非法迁移在Model里直接拦截从根上避免因为快速点击按钮造成的状态错乱。void submitClaim() { if (!_canSubmitClaim()) return; _claimStatus ClaimStatus.claiming; notifyListeners(); _claimRepository.claimPrize(prizeId).then((result) { if (result.needSupplement) { _claimStatus ClaimStatus.needSupplementInfo; } else { _claimStatus ClaimStatus.claimSuccess; } notifyListeners(); }).catchError((error) { _claimStatus ClaimStatus.claimFailed; _errorMessage error.message; notifyListeners(); }); }注意_canSubmitClaim这个守卫方法它在内部判断了当前状态只有待领取和待补充信息两个状态允许触发提交。我在真机上测试时快速连点领取按钮没有出现重复请求的情况这比单纯在UI层disable按钮更可靠——因为UI层的disable可能因为重建而短暂失效。4.2 与OpenHarmony侧的数据回传奖品领取成功后数据不能只停留在详情页。中奖记录列表、首页转盘剩余机会、用户资产页都需要感知到这个变化。我在前面几篇已经搭了一个事件总线这次在领取成功时向总线发送了一个PrizeClaimedEvent载荷里包含奖品ID、领取时间和领取方式。中奖记录模块监听这个事件后刷新对应条目状态首页转盘模块根据奖品类型判断是否需要调整抽奖按钮的可用状态。这里有个我在实际联调中踩过的坑事件总线广播是同步的如果某个监听者在处理事件时抛了异常会影响同一事件的其他监听者。后来我在总线内部给每个订阅者包了一层try-catch让异常隔离到单个监听者。这个改动非常小但避开了一个模块报错导致所有模块都不刷新的连锁问题。另外领取成功后的详情页面不能立即销毁用户可能需要看到成功反馈再点返回。我用了Navigator的pop配合一个返回值来告诉上一级页面刷新final result await Navigator.pushbool( context, MaterialPageRoute(builder: (_) PrizeDetailPage(args: args)), ); if (result true) { // 刷新上一级页面中可能受影响的奖品状态 }如果返回值为true说明奖品状态发生了变化上一级页面无论是转盘还是中奖记录统一执行刷新逻辑。这个方法思路简单但在Flutter的页面栈通信里是相对清晰可靠的一种做法。5. 在OpenHarmony上适配的差异点与实操细节5.1 字体与排版差异不只是换个系统字体这么简单Flutter在OpenHarmony上的字体渲染和在其他移动平台上有一些肉眼可见的差异。OpenHarmony系统默认的字体栈没有包含苹方和Roboto中文字体在加粗、字重不匹配时容易出现假粗体——就是字体渲染引擎在原生字重不足时对字形做膨胀处理边缘看起来发虚尤其是小字号下特别明显。我在奖品详情页里对文本样式做了一个统一约束所有标题类文本奖品名称、区块标题使用fontWeight为w600实测在OpenHarmony设备上这个字重的渲染效果比w700更接近设计稿。正文字体统一使用系统默认不显式指定fontFamily避免强制绑定某个在OpenHarmony上不存在或映射错误的字体文件。文本高度用height参数控制为1.4而不是依赖默认行高这样中文按语段上下边距一致不会被截断。数字和金额的展示建议单独处理。我用了一个PriceText组件内部把¥符号和数字部分拆成两个Text放在一个Row里数字部分使用等宽字体的效果在OpenHarmony上比普通字体更整齐尤其是多位金额对齐时看起来专业很多。5.2 图片加载与缓存策略OpenHarmony上图片加载的底层渲染管线与标准Android不完全相同但Flutter的Image.network走的是自己实现的图片编解码链路所以跨平台表现基本一致。需要注意的坑集中在缓存和内存占用上。奖品详情页的主图通常是尺寸较大的网络图片如果在页面滑出后不及时释放内存占用会明显偏高。我在图片的loadingBuilder里增加了渐进式加载效果同时在页面销毁时通过imageCache.clear主动清理当前奖品ID对应的图片缓存条目。override void dispose() { if (_mainImageProvider ! null) { imageCache.evict(_mainImageProvider!); } _detailModel?.dispose(); super.dispose(); }evict会丢弃缓存里指定的ImageProvider释放其关联的内存位图。实测下来反复进出页面20次App内存增长从之前的35MB左右降到10MB以内效果非常显著。另外在弱网环境下大图加载会出现长时间白块。我自己的做法是用precacheImage在页面进入前提前缓存主图这个调用放在构造详情数据之后、页面转场动画之前的间隙利用转场动画的时间mask网络请求的延迟。5.3 物理返回键与页面状态OpenHarmony设备很多是带物理返回键的有些还支持手势侧滑返回。在填写领奖补充信息的页面里用户可能通过不同的返回方式退出半填状态。如果不对这个场景做处理用户填了一半的地址信息会因为返回而丢失体验很糟糕。我用PopScope包裹了补充信息表单页在canPop回调里判断当前表单是否有未提交内容。有内容时先弹一个确认对话框用户确认后才真正退出取消则保持停留。这个交互逻辑在Android、iOS和OpenHarmony上的表现一致Flutter的PopScope在三端都能正确拦截返回事件。另外在沉浸式状态栏适配方面OpenHarmony某些版本的刘海屏和挖孔屏安全区域计算和其他平台略有不同。我的解决办法是在详情页根部使用SafeArea配合MediaQuery.paddingOf手动计算顶部预留高度而不是依赖页面级AppBar自动适配。某款OpenHarmony平板设备在横屏时底部安全区会多出一截如果依赖默认适配底部操作按钮会被顶得过高视觉上飘在页面中间手动计算安全区后这个问题就消除了。6. 常见问题与排查技巧实录6.1 图片加载失败但页面整体无感奖品详情页的图片服务器偶尔会返回404或因为防盗链策略拒绝跨域请求。如果直接用Image.network失败时会显示一片空白用户以为页面坏了。我的做法是在图片外层包了一个_AdaptiveImage组件内部监听Image.network的错误回调。加载失败时组件自动切换为一个本地默认占位图这个占位图是一张本地Asset图片内容是简洁的奖品图片暂时无法显示插画。同时日志系统会记录一次图片加载失败事件开发期能据此定位数据源的异常问题。占位图方案还有一个额外的作用提升弱网环境下首帧渲染的完整感。即使网络图片还在加载中占位图先把视觉面积占住页面整体不会出现突然跳变。6.2 立即领取按钮点击无反应这是我在模拟器上最先遇到的问题。排查下来发现状态机里submitClaim方法在收到接口返回前状态被锁定为claiming按钮正常变灰但接口因为模拟器网络代理问题迟迟不返回导致按钮一直停在loading态。模拟器上这种情况多半是网络层代理配置没跟上。我的排查手段是先在页面里临时打印接口请求耗时和返回体确定是网络不通还是数据解析报错。如果返回体正常但解析失败优先检查字段名大小写和类型匹配问题OpenHarmony通道传过来的JSON字段和Flutter侧模型命名不一致的情况很容易出现。对于长时间loading的问题我还加了一个超时兜底请求发出8秒内没有返回就自动把状态重置为待领取并弹出网络开小差了请稍后重试的提示。这个兜底对真实用户的价值比想象中大弱网环境下它避免了用户卡死在一个无法操作的页面里。6.3 从转盘跳转过来时页面白屏转盘抽奖结束到详情页跳转之间需要传递一个奖品ID。我在调试中遇到过一次路由参数没传到位详情页拿到的prizeId为空字符串数据拉取接口返回异常页面因为空数据渲染直接白屏。定位后发现是转盘页的扇区点击回调里奖品ID字段名和详情页构造参数名不一致一边用的是rewardId一边用的是prizeId。这种字段命名不统一的问题在跨模块协作时非常常见我把项目里所有奖品相关字段统一命名后这类问题就很少再出现了。为了彻底兜住空参数问题详情页的构造方法里对prizeId做了空值校验一旦发现为空就弹出一个轻量提示并直接返回上一级。宁可让用户多点击一次回到列表也不要让他盯着一张白屏猜测发生了什么。6.4 状态回传导致页面重建时按钮闪烁有一次我在中奖记录页面里测试详情返回发现状态刷新时按钮会先闪一下立即领取再变成查看我的奖品。原因是中奖记录页监听事件总线刷新列表时列表里每个条目的详情预览区基于旧模型先渲染了一遍再用新数据重建。修复方法是在列表条目组件里对状态字段加了一个valueKeyValueKey(prize_item_${prize.id}_${prize.claimStatus})状态变化时Flutter会重建整个条目而不是复用旧的Element从根本上消除了闪一下的视觉问题。这个技巧在跨模块回传刷新的场景通用性很强凡是发现列表刷新时短暂展示过期状态优先检查有没有让Element复用旧数据的可能。7. 写在后面的一些细节建议这一篇把奖品详情页从数据模型到页面实现、从状态管理到平台适配完整过了一遍。回看整个开发过程我最深的感触是一个看起来只有几张图和几段文字的页面真正要做得扎实涉及的边界情况远比第一眼看到的要多。尤其是跨端开发同一个页面要在OpenHarmony、Android、iOS上表现一致靠的不是某个大而全的框架而是对每个平台的差异点逐一击破。如果大家按这篇的顺序自己实现一遍有几个建议可以重点确认数据模型里时间字段的格式统一、图片缓存的释放时机、状态机里非法迁移的拦截、以及物理返回键的防误触处理这几处是最容易返工的地方。先把这些地基打好后面再接客服模块或者多奖池扩展都会顺手很多。