前阵子接了个跑在 OpenHarmony 上的盲盒抽奖App需求,花了大概两周时间从环境搭建干到上线,中间把地址管理也一起做了。用 Flutter for OpenHarmony 来写,最直接的收获是不用维护两套界面代码,同一条业务逻辑既能跑在普通移动端,也能跑在开源系统上。这里我把整个过程中的项目拆解、核心代码思路、适配踩坑和地址管理实现都整理一遍,给后面要碰这类场景的人做个参考。要是你也想在一个 OpenHarmony 设备上做抽奖、开盲盒或者类似的 C 端功能,这篇应该能帮你少走不少弯路。
1. 项目拆解:盲盒抽奖+地址管理到底在做什么
1.1 一个正经抽奖应用的业务闭环
很多人一听到盲盒抽奖,第一反应就是做个转盘或者抽卡动画,点一下按钮弹个结果就行。但真把一个功能变成“应用”,远不止动画这么简单。我当时梳理下来,业务闭环起码要覆盖四个环节:奖池配置、抽奖动作、结果记录、奖品发放。用户抽到奖之后,虚拟奖品可以直接入账,但实物奖品就必须填写收货地址。地址管理和抽奖流程是强绑定的,不是独立功能。
所以我把应用拆成了两个大的模块:一个是抽奖主链路,负责奖池、抽奖、结果弹窗和记录;另一个是地址管理模块,负责收货地址的新增、编辑、删除、默认地址设置,以及抽奖结果页跳转选择地址。抽奖模块更多考验动画和随机策略,地址模块更多考验表单校验和数据持久化。两者合在一起,才是一个能被真正使用的 App,不是那种“只能截个图发朋友圈”的 Demo。
用户场景也不复杂:进入首页看到盲盒列表,选择一个盲盒,点击开启,播放动画,弹出中奖结果。如果是实物奖励,页面会引导用户选择或者新增收货地址,提交后生成订单记录。整套流程在移动端很常见,但放到 OpenHarmony 上,很多原以为“随便用用”的组件就得重新验证一遍,这也是我坚持要写这篇文章的原因。
1.2 技术选型:Flutter如何落到OpenHarmony
项目一开始其实有两个做法摆在面前。第一套是用 OpenHarmony 原生能力写 UI,再配合底层服务实现;第二套就是标题里写的 Flutter for OpenHarmony。我最终选了 Flutter,核心原因有三个:
第一,团队里已经有现成的 Flutter 业务代码,抽奖逻辑和地址相关页面在其他平台都已经做过一轮验证。平移到 OpenHarmony,只需要处理平台差异,不用把页面推翻重写。
第二,盲盒抽奖这种场景太吃 UI 动画了。Flutter 自带的动画体系、自定义绘制、过渡效果都比原生重新实现要快。尤其是转盘、飞入、缩放这些动效,直接在 Flutter 层就能搞定,不需要跟平台 UI 反复交互。
第三,OpenHarmony 上做应用,当前生态还处于快速迭代阶段。使用 Flutter 作为跨端框架,社区能力可以平滑迁移,遇到问题能参考的资料也多。用 Flutter 不是逃避原生,而是把精力集中在业务和交互上。
不过也得说句实话,Flutter for OpenHarmony 并不是官方默认就能跑的,需要把 Flutter 的 SDK 和 OpenHarmony 的工程结构做一层桥接。这个后面的环境配置会详细讲。简单理解就是,Flutter 负责画界面和跑业务,OpenHarmony 负责系统能力和包管理,两者之间通过一层插件机制通信。
1.3 整体代码结构与状态管理
项目代码我按功能拆了四个目录:pages 放页面,widgets 放公共组件,models 放数据模型,services 放业务逻辑和本地存储。抽奖和地址管理的代码没有混在一起,抽奖相关的随机逻辑单独放一个 lottery_service.dart,地址相关的增删改查单独放一个 address_service.dart。这样后面哪个模块出问题,直接进对应文件定位就行。
状态管理用的是 Provider,抽奖页面里维护当前中奖状态,地址页面里维护地址列表和默认选中项。之所以不引入太重的东西,是因为这个应用的状态流比较直接:首页选盲盒 -> 抽奖页 -> 结果页 -> 地址页,都是单向数据流,用 Provider 的 ChangeNotifier 足够,代码也好读。如果一开始就上 Bloc 或者 Riverpod 一类的复杂方案,反而把简单业务绕晕了。
// 简单的状态模型示意 class LotteryState extends ChangeNotifier { List<BlindBox> _boxes = []; Prize? _currentPrize; bool _isDrawing = false; List<BlindBox> get boxes => _boxes; Prize? get currentPrize => _currentPrize; bool get isDrawing => _isDrawing; }这个设计有个好处:后续要加“多盲盒联抽”或者“积分兑换抽奖次数”,都只需要在 LotteryState 里扩展字段,页面层改动很小。地址模块我也做了同样的事,AddressModel 复用到列表、选择器、表单三个页面,没有各写一套。
2. 环境与工程配置:Flutter在OpenHarmony上跑起来
2.1 工具链与SDK版本要点
这个环节是很多人第一道坎。OpenHarmony 应用开发需要的 IDE 和普通 Flutter 开发不是一套东西。我用的组合是 OpenHarmony SDK 5.x + Flutter 的 OpenHarmony 适配分支,开发工具是 OpenHarmony 应用开发专用的 IDE,里面可以配置三方 SDK 路径。配置好之后才能把 Flutter 工程识别成 OpenHarmony 工程,最终构建出 HAP 包。
版本匹配是最容易翻车的地方。Flutter 适配 OpenHarmony 的能力是跟着 OpenHarmony SDK 走的,不是越新越好。一开始我直接装了最新版 Flutter,结果构建工具链对不上,各种接口报错。后来老老实实换成配套版本,一次编译通过。
# 工程里需要明确定义版本的地方 flutter: 3.10.x (ohos 适配分支) compileSdkVersion: 5.x targetSdkVersion: 5.x如果你准备上手,建议先确认三件事:OpenHarmony SDK 的版本号、Flutter 适配分支对应的提交版本、IDE 工具的版本号。三者必须对齐。别指望随便拉个包就能跑,我因为这个浪费了差不多一个下午。
2.2 创建并改造工程:从普通Flutter搬到OpenHarmony
正常创建一个 Flutter 工程之后,并不能直接变成 OpenHarmony 应用。需要在工程目录下执行适配命令,把 ohos 相关目录生成出来。生成之后,项目根目录会多出一个 ohos 文件夹,里面包含类似 entry、signature、配置文件等 OpenHarmony 工程内容。Flutter 代码还是写在 lib 目录下,但最终构建入口由 ohos 工程接管。
改造过程有几个关键点:
- 包名和签名要提前定好。OpenHarmony 应用打包 HAP 需要签名配置,没有签名装不进真机。
- 资源文件要放在 Flutter 层使用 assets 声明,依赖原生资源的话注意路径映射。
- 权限声明在 ohos 目录下的配置文件中,网络请求、存储这类权限要在这里加。
我在做地址管理时用到了本地数据持久化,就涉及到存储权限和数据库文件路径。Flutter 层的 shared_preferences 或者数据库插件在 OpenHarmony 上能不能直接用,取决于适配层是否提供对应实现。我当时测试下来,纯 Dart 层面的 API 没问题,但涉及系统能力的部分需要走 platform channel 或者找适配插件。这个下文在地址管理再展开。
2.3 真机调试与HAP产物
OpenHarmony 应用调试不像安卓那样一插线就能直接跑,需要设备进入开发者模式,然后在 IDE 里面配置设备连接。连接成功之后,项目列表会出现目标设备,点击运行会先构建 napi 模块,再打包生成 HAP,最后自动安装启动。
我一开始栽了个跟头: IDE 里只能看到模拟器,看不到真机。后来发现是 USB 调试开关和设备信任没配对。这个步骤每个品牌设备略有差异,但绕不开“开发者模式 -> USB 调试 -> 设备授权”这部曲。如果你连上设备后一直没反应,先查这三项,大概率能解决。
真机调试还有个好处,就是能看到 Flutter 层的性能帧率。抽盲盒动画在模拟器上丝滑,一到真机就开始掉帧,这种问题不真机跑根本发现不了。所以我的建议是:环境搭好后别只盯着模拟器,尽早接真机,后面所有动画和存储调优都靠真机数据说话。
3. 盲盒抽奖核心实现:从随机算法到交互动画
3.1 奖池构建与加权随机抽取
抽奖系统最核心的地方就是奖池和随机算法。盲盒场景下,奖池里的每个奖品通常有不同概率。如果直接使用普通随机数,概率难以控制。更合理的做法是使用加权随机算法。我在项目里维护了一个奖池列表,每个奖品都带有 weight 权重值,所有奖品权重总和为 100 或 1000。前端抽奖时生成一个 0 到总权重之间的随机数,按权重区间命中奖品。
class LotteryPool { final List<LotteryItem> items; Prize draw() { int totalWeight = items.fold(0, (sum, item) => sum + item.weight); int randomValue = Random().nextInt(totalWeight); int cumulative = 0; for (var item in items) { cumulative += item.weight; if (randomValue < cumulative) { return item.prize; } } return items.last.prize; } }这段逻辑看起来简单,但有几个坑。第一,权重在服务端和客户端要一致。如果只在客户端做随机,用户抓到请求包就能篡改概率,所以我用的方案是客户端先请求一次服务端生成的随机种子,再在本地做展示用的随机计算,最终中奖结果以服务端记录为准。第二,连续抽奖需要做“保底”处理。比如用户连续没抽到高价值奖品,要触发保底机制。这个不能写死在抽奖页面,应该放在奖池逻辑层统一维护,每次抽完记录次数。
3.2 抽奖动画的实现细节
盲盒抽奖的动画一般有两种风格:一种是转盘旋转后停在某个奖品上,另一种是卡片翻盖、盒子晃动后弹出结果。我用的后者,因为盲盒的“开箱感”更强。动画拆成三段:盲盒抖动、盒子展开、结果弹出。三段之间用 AnimationController 串联。
抖动阶段用 Flutter 的 Transform.rotate 和 Transform.translate 组合,让盒子在短时间内左右摆动,配合逐渐变快的频率,模拟“正在开箱”的感觉。展开阶段用 ScaleTransition 把盲盒盖子放大并向下淡出,同时背景光斑用 Opacity 做闪烁。结果弹出阶段用 Curves.elasticOut 模拟奖品弹出来的阻尼效果。
AnimationController _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 1800), ); // 三段动画通过 TweenSequence 连接 TweenSequence<double>( [ TweenSequenceItem(tween: Tween(begin: 0, end: 10), weight: 30), TweenSequenceItem(tween: Tween(begin: 10, end: 0), weight: 20), TweenSequenceItem(tween: Tween(begin: 0, end: 1), weight: 50), ], ).animate(_controller);动画过程中最需要注意的是“动画状态和业务状态分离”。比如动画播放中,用户连点按钮,结果还没落库就触发下一次抽奖,很容易出现状态错乱。我在 LotteryState 里用 _isDrawing 字段做了锁定,动画播放中直接忽略抽奖事件。抽奖结果落库成功后才解锁。这个操作看似简单,但真机测试时好几次因为没加锁导致重复弹窗,后来才意识到问题的根源是状态和动画不同步。
3.3 请求防抖与结果落库
盲盒抽奖还有一个容易被忽略的点:抽奖请求的重复提交。移动端网络慢或者动画卡顿时,用户会下意识再点几次,如果接口没有幂等处理,就可能出现一个用户抽到两次相同奖励的情况。我在客户端做了三层防护:按钮级别在一秒内禁止第二次点击;业务状态级别在抽奖中锁定状态;最终结果唯一 ID 由服务端生成,客户端只负责展示。
抽奖结果落库也是地址管理的前置条件。中奖记录表至少要包含:奖品 ID、用户标识、中奖时间、实物标记、地址 ID。用户抽到实物奖后,地址 ID 一开始是空的,等他填写或者选择地址之后再回写。我在本地的 address_service.dart 里维护了一个中奖记录和地址的关联关系,保证用户退出应用重进后还能看到“待填写地址”的订单。
class PrizeRecord { final String recordId; final String prizeId; final bool isPhysical; String? addressId; bool get needAddress => isPhysical && addressId == null; }这个落库逻辑看起来啰嗦,但实际使用频率很高。我见过不少抽奖 App 抽中实物奖后直接弹文本框让用户输地址,页面一重启地址就丢了。把中奖记录和地址管理打通,才是完整闭环。
4. 地址管理功能:表单校验、持久化与默认地址
4.1 地址列表与选中逻辑
地址管理模块从用户视角看很简单:进入地址列表,能看到已经保存的地址,可以选择一条作为收货地址。但从代码角度,至少包含列表加载、单选高亮、默认地址标识、侧滑删除、下拉刷新这些交互。我用一个 AddressListPage 统管列表,通过 Provider 监听 AddressState 的变化,新增或删除后自动刷新。
列表里面有几个细节值得说。第一,默认地址要把“当前选中地址”和“默认地址”区分开。默认地址是用户设置的一条收件地址,选中地址是本次抽奖要使用的地址。两者不一定相同。第二,列表项要支持“设为默认”操作,默认地址在新增地址时自动置顶。第三,列表为空时要显示空态提示,不然用户从抽奖结果页跳过来一片白,很容易以为出 bug 了。
选中逻辑我放在 AddressState 里维护,抽奖结果页需要传递地址时,直接通过 Provider 读取 selectedAddress。这样页面之间的通信不需要构造函数层层传参,代码干净很多。
4.2 新增/编辑表单的实现
地址表单看起来简单,实际是地址管理里最需要花时间的部分。基础字段包括收货人、手机号、省市区、详细地址。再加上默认地址开关和标签(家/公司/学校)。我用一个 AndressFormPage 同时处理新增和编辑,进入页面时通过参数判断是“新增”还是“编辑”,如果是编辑就把已有地址数据回填到表单。
校验规则我列在下面:
- 收货人不能为空,长度控制在 20 个字以内。
- 手机号必须要做格式校验,用正则判断 11 位数字及常见号段。
- 省市区至少要选择到区级,不然物流没法配送。
- 详细地址不能为空,长度控制在 100 字以内。
- 默认地址开关默认关闭,但如果当前列表为空,应自动设为默认地址。
final _formKey = GlobalKey<FormState>(); autovalidateMode: AutovalidateMode.onUserInteraction, String? validatePhone(String? value) { if (value == null || value.isEmpty) return '请输入手机号'; if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(value)) return '手机号格式不正确'; return null; }这个表单要注意的坑是键盘弹出遮挡底部按钮。Flutter 默认会有 resizeToAvoidBottomInset 处理,但如果你外面套了滚动视图或者底部安全区,按钮还是可能被顶到屏幕外。我用的方案是表单外层套 SingleChildScrollView,并把底部提交按钮放在滚动内容里,而不是固定 bottomNavigationBar。这样键盘弹起时整个表单可以滚动到按钮可见位置。
4.3 省市区选择与地址解析
省市区数据是整个地址模块里最占地方的部分。我一开始想用第三方 JSON 文件,把全国省市区数据导入应用,然后用弹窗组件选择。但后来发现 OpenHarmony 上的第三方组件版本和 Flutter 官方组件不一定兼容,尤其是那种用高德底图或者原生弹窗的,跨端适配容易出问题。
最后我选择自己写一个省市区选择弹窗,用三个联动滚轮实现。数据来自一份精简的省市区 JSON,里面只保留用得到的省份、城市、区县数据。这样做的坏处是数据量不小,好处是完全不依赖原生组件,Flutter 层一套代码到处跑。
联动逻辑用固定列表索引实现:选择省份后重置城市列表,选择城市后重置区县列表。关键点是每次切换都要把下级数据重置为空,否则会出现“选了北京的省份,但城市列表还显示上海的区县”这种问题。
void onProvinceChanged(String province) { selectProvince = province; cities = data[province] ?? []; districts = []; selectCity = null; selectDistrict = null; }这一步完成后,省市区选择器的返回值是“省份 + 城市 + 区县”三个字符串拼接的结果。我在拼接时会自动加上“省/市/区”这类行政区划后缀,用户不需要手动输入。不是所有地区都有“省”字,所以我在数据源里把后缀也维护进去了,避免出现“广东省省”这种尴尬文案。
4.4 本地持久化与兼容性
地址列表的数据不能只存在内存里,否则应用一杀后台就全没了。我最初尝试在 OpenHarmony 上直接使用 Flutter 的 shared_preferences 插件,因为地址是一个 List 对象,需要把对象序列化成字符串后存入本地。但测试时发现 shared_preferences 在 OpenHarmony 适配层偶尔会有写入失败的情况,尤其是地址数量超过几十条时,写入频繁容易丢数据。
后来我把地址存储换成了本地关系型数据库,通过轻量级数据库封装来管理。建了一张 address 表,字段包括 id、name、phone、province、city、district、detail、is_default、created_at。每次增删改都通过 DAO 层执行 SQL,避免频繁全量序列化。这样做的稳定性明显高于 shared_preferences,而且在后续要加“地址检索”“订单历史关联”时也能直接用 SQL 查询。
// 建表语句 CREATE TABLE address ( id TEXT PRIMARY KEY, name TEXT NOT NULL, phone TEXT NOT NULL, province TEXT, city TEXT, district TEXT, detail TEXT, is_default INTEGER DEFAULT 0, created_at INTEGER );适配方面需要注意路径。OpenHarmony 的沙盒路径和安卓、iOS 都不一样,如果直接用默认数据库路径,可能创建不了文件。我的做法是先通过平台通道拿到 OpenHarmony 的应用沙箱路径,再在这个路径下拼出数据库文件。真机上连续重启 App 测试,数据能稳定保留。
5. 常见问题与避坑清单
5.1 OpenHarmony 适配常见问题
这一块可以说是本篇最有价值的章节。我至少遇到三四个跟 OpenHarmony 相关的怪问题,这里直接列出来。
第一,Flutter 页面在部分 OpenHarmony 设备上显示模糊。原因是设备屏幕的像素密度识别不对,Flutter 计算出来的逻辑分辨率跟实际屏幕不匹配。我通过检查设备的 DPII 参数,手动在入口处加了 MediaQuery 覆盖解决。
第二,部分系统字体不支持导致中文显示为方块。OpenHarmony 设备差异很大,有的设备上缺少中文字体,需要在工程配置里指定默认字体,或者使用系统自带的 ohos 字体包。我最终是把字体文件打包进 assets,用 ThemeData 全局设置 fontFamily 处理。
第三,网络权限和存储权限默认不开启。需要在 ohos 工程的 module.json 里声明权限,否则 HTTP 请求和数据库文件创建都会失败,而且失败信息有时不明显。我的调试经验是先在原生代码里验证权限,再回到 Flutter 层排查。
第四,应用安装不上或启动白屏。很多时候是签名配置有问题。HAP 包必须使用开发证书签名,模拟器可以跳过,但真机不行。检查 IDE 里的签名配置,确保证书和设备匹配。
5.2 抽奖模块的常见问题
抽奖模块的坑主要集中在动画和状态。我遇到过动画播放结束后结果弹窗不出现的情况,查了半天是 AnimationStatus 回调里动画状态判断写错。Flutter 动画的 status 有 forward、reverse、completed、dismissed 四种,我只判断了 completed,但实际动画链结束后状态是 dismissed,结果弹窗被阻塞。
另一个问题是动画卡顿。盲盒拉开时同时播放背景遮罩动画和奖品放大动画,同一时间两个 AnimationController 都在跑,加上结果页面的布局重建,中低端设备上明显掉帧。解决方案是给背景动画和前景动画分帧,背景用低帧率插值,前景保留完整动画,实测卡顿明显改善。
还有一个容易被忽略的点:抽奖结果弹窗的 context 在异步回调里使用。服务端返回结果后,我用弹窗组件展示,但如果此时用户已经退出页面,context 失效就会 crash。我用的是在根页面定义一个全局弹窗入口,避免直接依赖页面 context。
5.3 地址模块的常见问题
地址模块的问题主要是逻辑兜底不足。比如用户删除所有地址后进入抽奖结果页,会默认显示“请先新增地址”,这个状态很容易被漏掉。我的做法是在结果页监听 AddressState,列表为空时渲染空状态组件,而不是单纯隐藏按钮。
手机号校验的正则也需要根据业务调整。我一开始只做了 11 位数字校验,结果测试时输入 11111111111 也通过了。后来补充了号段校验,虽然不能保证号码真的有效,但至少能拦下一部分明显错误的数据。
还有省市区滚动选择器联动时,如果用户快速滑动,有时候会出现数据还没更新就重建滚轮,导致部分索引越界。为这个问题我加了锁,每次联动先在数据层更新列表,刷新完再允许下一次滑动。只有把这种边界情况处理干净,地址管理模块才算真正可靠。
5.4 性能优化的一点心得
整个项目性能优化的重点在抽奖页面。我在 Flutter 性能工具里观察到抽奖动画最耗资源的几个点:盒子阴影重复绘制、背景渐变区域过大、动画期间 rebuild 次数过多。
优化手段有三个。第一个是使用 RepaintBoundary 隔离动画层级,让整个动画过程不触发相邻组件重绘。第二个是把背景渐变缓存为静态图片,而不是每次动画帧都重新计算渐变。第三个是给抽奖按钮文字使用 const 修饰,减少重建开销。三招下来,中低端设备上动画流畅度提升非常明显。
地址列表的优化相对简单,主要是 Item 构建成本和数据库查询。列表项用 ListView.builder 懒加载,避免一次性构建所有地址卡片。查询时默认地址优先排序并限制一次性加载条数。如果地址超过 500 条,再考虑分页加载。对这个应用来说,这个优化暂时够用。
6. 收尾:实际开发中的一点体会
项目做完之后,我最大的体会是 Flutter for OpenHarmony 不是一条“换皮就能跑”的路。它确实能帮你复用大量 Flutter 业务代码,但前提是你对 OpenHarmony 的工程模型、签名、权限、沙盒路径有基本了解。很多时候问题不是出在 Flutter 代码,而是出在平台环境没配置对。出问题先从系统层排查,往往比在 Flutter 层瞎猜更高效。
还有一个小技巧想分享:在同时维护多个 OpenHarmony 设备时,建议把签名配置、SDK 版本、设备型号这些信息统一记录在一个文档里。我当时就因为没有记录版本号,第二次升级工具链后踩了同样的坑。把这些环境信息变成自己项目的“配置手册”,后面任何一位同事接手都能直接按手册跑通,效率会高很多。
这个项目后续想扩展的点也有不少。比如抽奖结果可以加微信分享卡片,地址管理可以接入地图选点,抽奖概率和库存可以在服务端做动态配置。不过这些都是后话,先把这一套抽奖和地址管理的闭环跑稳,后续增量迭代自然就有底气了。