资讯动态

Flutter for OpenHarmony 奖品详情与本地存储实战

发布时间:2026/10/21 11:59:16 来源:尧图企业网站定制
Flutter for OpenHarmony 的幸运大转盘系列写到第六篇前面几篇文章把工程的骨架、转盘的绘制、指针的动画、抽奖的判定都一一跑通了整个 App 已经能在模拟器里转动起来抽奖结果也可以正常弹窗展示。但走到这一步我越来越觉得有件事必须补上奖品的所有信息仍然写死在代码里。名称要改得重新编译、库存只能靠脑补、想加一个奖项还得去动数组逻辑。所以第六篇我给自己定的目标就是把这个缺口补完做一个完整可用的奖品详情模块——能查看、能编辑、能新增、能删除并且数据能持久化重启 App 之后不丢失。奖品详情模块听起来不复杂但对于 Flutter for OpenHarmony 这种还处于生态建设期的组合来说踩坑点其实不少本地存储选型要迁就平台通道的兼容性、路由传参要考虑同步刷新、图片资源处理不能随便套用主流插件方案。这篇我会按照实际开发顺序来写先说数据模型怎么设计再说详情页怎么实现最后把我在这个模块里踩过的三个坑完整还原出来给正在做或者准备做类似页面的人省点时间。1. 这一篇要解决的问题为什么奖品信息不能一直硬编码下去1.1 前面五篇走到了哪一步先说下系列进度。前五篇的内容分别是搭建 Flutter for OpenHarmony 的基础工程、完成首页和个人中心的基础布局、用 CustomPainter 画出了可旋转的转盘、给指针和转盘加了抽奖动画、最后实现了抽奖算法和结果弹窗。到第五篇结束的时候整个 App 已经具备了最核心的交互用户点击开始抽奖转盘转动停止后弹窗告诉你中奖了。demo 能跑逻辑闭环这个阶段说出来是可以直接拿去演示的。但如果你真的把它当作一个要长期使用的 App问题就很明显了。所有奖品信息维护在一个硬编码的 List 里项次、颜色、权重、中奖文案全部写死在源码中。这意味着任何一次奖品调整都需要开发人员重新打开代码、改数组、重新 build、重新安装。而实际运营一个转盘活动时奖品是高频变化的今天有个新的赞助品要加进来、某个奖项的库存见底要临时下架、中奖文案想换个更有吸引力的说法。这些需求如果都要靠改代码来响应体验会非常糟糕。所以第六篇的定位很明确把奖品数据从静态配置里解放出来变成一个由用户可维护的本地数据集合。这也是很多抽奖类 demo 在走向可交付阶段时必须跨过的一道坎——把跑得通变成能维护。1.2 奖品详情模块的任务清单这个模块我拆解下来核心任务有这么几项查看点开某个奖品能看到它的完整信息包括名称、配图、库存、启用状态等。新增能添加一个新的奖项配置名称和展示图标设置库存和权重。编辑能修改已有奖项的信息改完保存之后转盘页面的数据同步变化。删除不需要的奖项可以直接移除同时保证转盘逻辑不会因为奖品数量变化而出错。持久化所有改动要落盘到本地存储重启 App 后依然保留而不是每个页面各自维护一份内存数据。如果你仔细看会发现第 5 点是前 4 点的基础。没有持久化新增和编辑就是临时的换个页面数据就丢了。所以我在动手写 UI 之前先把数据层想清楚了。1.3 读这篇需要什么基础这篇文章适合已经跟着前几篇把转盘主体功能跑起来的读者也适合正在做 Flutter for OpenHarmony 应用开发、尤其是需要做本地数据管理的开发者。需要的基础是了解 Flutter 的基本组件、路由跳转、Future 和异步处理的常见写法。如果你对状态管理和数据库完全没有概念也没关系第一篇到第五篇已经把工程怎么搭、转盘怎么画讲清楚了这篇只需要跟着代码一步步走遇到名词我会顺手解释。2. 数据模型先行奖品实体与本地存储的选型2.1 奖品实体类定义写 UI 之前一定要先想清楚数据模型。转盘页面和抽奖逻辑需要的数据和详情页需要的数据必须是一个统一的模型否则后面同步数据会非常痛苦。这个项目里我的 Prize 实体定义如下class Prize { final String id; final String name; final String iconAsset; final int stock; final int weight; final bool enabled; const Prize({ required this.id, required this.name, required this.iconAsset, required this.stock, required this.weight, required this.enabled, }); Prize copyWith({ String? name, String? iconAsset, int? stock, int? weight, bool? enabled, }) { return Prize( id: id, name: name ?? this.name, iconAsset: iconAsset ?? this.iconAsset, stock: stock ?? this.stock, weight: weight ?? this.weight, enabled: enabled ?? this.enabled, ); } }有几个字段我解释一下设计意图。stock 和 enabled 分离。库存归库存启用状态归启用状态。实际运营里会碰到一种情况某个奖品的库存还没清零但你因为某种原因不想让它参与本轮抽奖了这时候把 enabled 设为 false 比把库存改成 0 更语义化。转盘抽奖逻辑在遍历奖品列表时只把 enabled 为 true 的项参与权重计算这样就能临时屏蔽奖项而不用删除数据。weight 字段是给抽奖算法用的。前一篇实现的抽奖逻辑里每个奖品带一个权重值权重越大中奖概率越高。这个字段通常不需要普通用户频繁修改但放在模型里是必须的详情页可以默认给一个值保留后续开放的余地。id 是稳定索引。不能依赖 name 作为主键因为名称是可编辑的。我在新增奖品时用时间戳生成一个唯一 id后面所有增删改查都靠 id 定位这样编辑名称时不会导致数据错乱。2.2 存储方案选型为什么我选了 Hive接下来是本篇最需要做决策的地方数据往哪存。常见的 Flutter 本地存储方案有三个SharedPreferences、sqflite、Hive。我在 Flutter for OpenHarmony 项目里直接排除了前两个原因听我慢慢说。SharedPreferences本质是键值对存储适合保存登录态、开关设置这种简单数据。奖品列表需要频繁的增删改和按条件查询用键值对虽然也能硬塞比如把整个列表序列化成 JSON 存一个 key但每次修改都要全量读写数据一多性能和代码可维护性都很差。而且 SharedPreferences 对复杂对象的处理需要自己写序列化逻辑这个项目里不划算。sqflite是 SQLite 的 Flutter 封装功能完整、查询能力强在标准 Flutter 项目里是首选。但在 OpenHarmony 的环境下sqflite 依赖平台通道和 SQLite 的原生库而 Flutter for OpenHarmony 的插件生态还在建设期这类依赖原生能力的插件往往需要额外适配。我在这个项目初期试过引入 sqflite编译阶段就碰到平台侧实现缺失的问题后来查了资料发现在 OpenHarmony 分支上要么等适配版本要么自己处理原生侧成本都不低。当然现在生态更新很快如果你用的 SDK 分支已经内置了适配可以再试一次但就我当前这个工程版本来说没必要为一个本地数据存储引入这么重的依赖链。Hive是我最终的选择。它是一个纯 Dart 实现的轻量级键值数据库最大的优势是没有平台通道依赖因此天然跨端兼容——在 OpenHarmony 上跑和在 Android 上跑没有差别。Hive 的盒子Box机制可以理解为一种带类型的表读写速度很快支持复杂的自定义对象配合 TypeAdapter完全满足奖品列表这种体量的数据管理需求。三个方案的横向对比放在这里方案依赖原生能力OpenHarmony 适配成本适合场景SharedPreferences是一般简单配置项sqflite是SQLite较高复杂关系查询Hive否纯 Dart极低中小体量本地数据选型结论在 OpenHarmony 生态还不稳定的阶段优先选纯 Dart 实现的存储方案能省掉大量平台适配的烦恼。2.3 Hive 的初始化与数据访问层封装Hive 的使用分两步初始化数据库、打开目标盒子。初始化一般放在 main() 里await Hive.initFlutter(); await Hive.openBoxPrize(prizeBox);这里为了方便直接用一个字符串 key 定位 Prize 盒子。如果后续要支持复杂查询可以考虑换用 Hive 的数据库模式或者给每个类型分配独立的盒子名但目前这个项目里一个盒子就够用。为了让页面代码和底层存储解耦我封装了一个 PrizeRepositoryclass PrizeRepository { PrizeRepository(this._box); final BoxPrize _box; ListPrize getAll() { return _box.values.toList(); } Futurevoid save(Prize prize) async { await _box.put(prize.id, prize); } Futurevoid delete(String id) async { await _box.delete(id); } }所有页面只和 PrizeRepository 打交道不直接操作 Box。这样将来如果要换成别的存储方案只需要替换这个类的内部实现页面代码一行不用改。2.4 默认数据种子刚开始用的时候盒子是空的直接进转盘页面会看到空列表转盘也没有奖项可抽。所以我做了一次初始化检查如果盒子里没有任何数据就写入一组默认奖品。这组默认奖品模拟一个典型抽奖活动的配置一等奖、二等奖、三等奖、参与奖每个奖给不同的权重和库存。这样 App 第一次启动也能正常演示用户后续可以去详情页自行修改。种子数据的写入时机放在 Hive 盒子打开之后if (_box.isEmpty) { await _box.putAll({ prize_1: Prize(id: prize_1, name: 一等奖, ...), prize_2: Prize(id: prize_2, name: 二等奖, ...), }); }这步做完数据层就绪接下来可以安心写详情页 UI 了。3. 详情页实现可编辑表单、图标选择与校验3.1 页面结构与新增/编辑复用策略详情页我用一个页面同时处理新增和编辑两种场景。传参时如果传入的是 null页面进入新增模式标题显示添加奖品表单字段全部为空如果传入的是现有 Prize 对象页面进入编辑模式标题显示编辑奖品表单字段用对象数据做初始值。这种复用可以避免维护两套几乎一样的页面改动表单时只需要改一处逻辑也统一。页面的整体结构分三块顶部的 AppBar、中间的滚动表单区域、底部固定的保存按钮。表单区域我用 Form 包起来这样可以利用 Form 自带的校验机制不用每个输入框单独处理错误状态。Form( key: _formKey, autovalidateMode: AutovalidateMode.onUserInteraction, child: ListView( padding: const EdgeInsets.all(16), children: [ _buildNameField(), _buildIconPicker(), _buildStockField(), _buildWeightField(), _buildEnabledSwitch(), ], ), )AppBar 的标题根据新增/编辑模式动态切换用一个 bool 判断当前是哪种模式final bool _isEditMode widget.existingPrize ! null;3.2 图标选择先绕开系统相册这个坑奖品详情页最容易被忽略但最容易出问题的是配图。在标准 Flutter 项目里配图通常用 image_picker 插件从相册选择但在 Flutter for OpenHarmony 的早期适配阶段这个插件对 OpenHarmony 平台的支持并不可靠我不想让一个配图功能拖垮整个模块的稳定性所以这个版本做了一个绕行方案配图不从系统相册取而是从 App 内置的 assets 资源里选。我在 assets 里放了几个转盘常用的奖品图标资源然后在表单区域里做一个横向的图标选择区用 Wrap 展示所有可选图标用户点哪个就用哪个。选中的图标用一个高亮边框标识同时把资源路径暂存到内存变量里保存时写入 Prize.iconAsset 字段。这个做法的好处有两个一是完全不依赖平台通道OpenHarmony 上不会有兼容性问题二是内置资源受控避免了用户从相册选了过期图片或者超大图片导致的内存问题。缺点是不够灵活但作为转盘 App 的 MVP 版本完全可以接受。等以后 OpenHarmony 的系统相册接入能力成熟了再改成真·图片选择器也不迟。3.3 表单控件的细节处理具体到每个控件有些细节值得单独说。名称输入框TextField 加上 validator不能为空长度限制在 20 个字符以内。在 OpenHarmony 上中文输入法有时会出问题我后面在踩坑部分细讲这里先提一下输入框的输入类型尽量用默认设置不要叠加太多自定义 inputFormatter。库存输入框只能输入数字用 FilteringTextInputFormatter.digitsOnly 过滤。库存的意义是控制这个奖还能不能再抽到奖品抽出去之后库存减一减到 0 自动视为下架。详情页只编辑初始数量不处理加减逻辑。权重输入框权重值也做成了可编辑默认填 1。权重可以简单理解为这个奖品在抽奖池里占的份额1 就是普通一档2 就是两档概率翻倍。这里我加了一个约束权重必须大于 0防止用户填 0 或者负数导致概率计算异常。启用开关用 Switch 组件绑定 enabled 字段。这个开关和库存分离的设计前面已经解释过了运营时想临时下线一个奖拨一下开关就可以。校验逻辑都挂在 Form 上保存按钮触发时统一校验void _handleSave() { if (!_formKey.currentState!.validate()) { return; } // 组装 Prize 并 pop 返回 }3.4 保存与返回的数据流保存按钮的完整流程是校验表单是否合法如果合法就把当前表单数据和对象快照组装成新的 Prize 对象然后调 repository 的 save 方法写入盒子最后通过 Navigator.pop 把结果带回上一个页面。这里有个关键设计编辑模式下详情页内部维护的不是传入对象本身而是一个用 copyWith 生成的副本。这样做的目的是避免在编辑过程中就修改了原页面已经持有的引用导致用户还没点保存转盘页面的数据就已经变了。等到用户点了保存我们再把新对象写回存储通过返回值通知列表页更新整个流程才是可控的。这一段的代码逻辑Prize? _current; override void initState() { super.initState(); _current widget.existingPrize; } void _handleSave() { if (!_formKey.currentState!.validate()) { return; } final updated Prize( id: _isEditMode ? _current!.id : _generateId(), name: _nameController.text.trim(), iconAsset: _selectedIconAsset, stock: int.parse(_stockController.text), weight: int.parse(_weightController.text), enabled: _enabled, ); Navigator.pop(context, updated); }返回值由上一页统一负责持久化详情页不直接碰 Box。这个职责划分写清楚了后面排查问题会少很多。4. 路由传参与数据同步这个模块最容易乱的地方4.1 传整个对象还是传 id我为什么先选了对象进详情页需要携带数据这里有两个选择传整个 Prize 对象或者只传 id进页面后再根据 id 从存储里重新读取。传 id 的优势是数据权威存储里是什么就是什么不怕对象被污染。但代价是详情页要依赖数据源如果页面从某个列表进入、而列表刚好还没和存储同步就会出现数据不一致。传对象的好处是页面无状态化拿到的就是展示用的数据简单直接。考虑到我们已经有 Repository 层两种方案做起来都不难我最终选择传对象理由是这个模块的数据量小、变更频率高编辑过程中用户需要看到实时数据传对象最省心。Dart 里对象是引用传递需要注意的坑是别在详情页直接改传入引用这也是我上面把对象快照单独维护的原因。4.2 我采用的方案对象快照加返回值刷新完整的传参处理是这样列表页打开详情页时final updated await Navigator.pushPrize( context, MaterialPageRoute(builder: (_) PrizeDetailPage(existingPrize: prize)), ); if (updated ! null) { await repository.save(updated); _loadList(); // 重新读盒子刷新列表 }新增奖品时走同样的代码路径只是 existingPrize 传 null详情页内部会生成新 id。返回之后统一调用 _loadList()。为什么返回之后一定要重新从盒子读取因为直接在内存 List 上做替换会有一个隐患如果某个地方忘记替换页面显示的就是旧数据。而重新读取虽然多了一次 IO但保证列表、转盘、详情三方看到的永远是同一份落盘数据这对数据一致性来说价值大于那点性能开销。4.3 常见翻车点编辑了半天返回后列表纹丝不动这里必须说一下我当时翻的第一个认知惯性错误。列表页第一次实现时我是这样写的把内存里的 list 直接用详情页返回后更新 list 中对应下标的元素再 setState。问题出在 List 本身。如果 list 里的元素和详情页传入的是同一个 Prize 引用并且在详情页里直接修改了对象的字段那么你确实能在返回后看到数据变化——这是 Dart 引用传递的特性。但如果详情页用的是快照方案copyWith 生成新对象返回后你还需要手动替换 list 对应下标的元素否则页面自然看不到变化。我一开始忽略了这个替换逻辑结果出现了编辑完返回转盘数据没变重启 App 后数据却变了的诡异现象。排查了一圈才意识到重启之后数据正常是因为存储层确实写进去了页面没变是因为内存 list 还指着一份旧引用。正确的做法就是提交后重新读取盒子用新数据整体替换内存 list别在局部修改上做文章。详情页返回后我的列表页代码最终统一成Futurevoid _loadList() async { final data await _repository.getAll(); setState(() { _prizeList List.of(data); }); }特意加的一行 List.of(data) 也很重要它保证 setState 后页面持有的是一个全新的列表对象而不是盒子返回的内部集合。这样任何基于旧引用的缓存判断都会失效渲染必然更新。5. Flutter for OpenHarmony 环境下的实际差异与注意事项5.1 工程构建方式的差异Flutter for OpenHarmony 并不是直接用官方 Flutter SDK 就能编译运行而是需要使用跨端适配分支的 Flutter SDK配合 OpenHarmony 侧的工具链完成构建。这意味着在引入第三方插件时要额外注意插件的平台实现是否支持 OpenHarmony。那些依赖 Android 或 iOS 原生能力的插件即使在标准 Flutter 工程里能正常工作在 OpenHarmony 环境里也需要单独验证。这个项目的处理原则是凡是涉及平台能力的功能优先找纯 Dart 实现找不到纯 Dart 实现就先做降级方案。奖品详情里用到的 Hive 是纯 Dart 实现所以一点问题没有图标选择用内置 assets也是纯 Flutter 能力。这两个选择帮我在这个模块里避开了大半平台适配问题。5.2 文件路径与目录权限的坑Hive 初始化时需要指定一个可写的目录。在标准 Flutter 工程里通常用 path_provider 的 getApplicationDocumentsDirectory 拿路径。在 OpenHarmony 分支上path_provider 的适配情况要看分支版本如果拿到的是空目录或者方法调用失败Hive 初始化就会直接抛异常。我的做法是加一层路径兜底FutureDirectory _getHiveDir() async { try { final dir await getApplicationDocumentsDirectory(); return dir; } catch (e) { return Directory(${(await getTemporaryDirectory()).path}/hive); } }这个兜底逻辑确保即使 path_provider 在某个版本上没适配Hive 也不会挂掉。跑完兜底路径写入的数据在后续版本修复适配后也能正常迁移属于成本很低收益不错的防御性代码。5.3 中文输入与模拟器键盘在 OpenHarmony 的模拟器环境里调试时TextField 的键盘弹出有时不稳定中文输入可能会碰到候选词不跟随、光标位置偏移等问题。这个问题在真机上通常会好很多但模拟器上频繁出现会影响调试效率。我的经验是尽量用物理设备调试文本编辑相关功能模拟器只看布局。名称输入框不用复杂的自定义 inputFormatters给默认行为最稳定。如果出现输入英文但键盘不消失的问题检查 TextInputAction 的设置不要和页面滚动组件冲突。这些不是这个项目的核心逻辑但卡住你半小时一天的往往就是这些环境细节提前知道能省不少事。6. 实测踩坑奖品详情模块里我遇到的三次翻车6.1 坑一Hive 初始化时机不对启动即崩溃症状很明显App 一启动就抛异常日志指向 Hive 在初始化之前就尝试打开盒子。当时我把 Hive.initFlutter() 放到了 main() 里但打开盒子的操作却在页面 initState 里执行。理论上 main 执行完才开始渲染页面initState 应该能拿到已经初始化的 Hive但实际报错还是提示没有初始化。排查之后发现问题不在时序而在 Hive.initFlutter() 本身是异步的如果页面构建比初始化完成更快路径和环境就没准备好。修复方式是把两个操作都放到 main 的异步流程里确保盒子打开完成后再 runAppvoid main() async { WidgetsFlutterBinding.ensureInitialized(); await Hive.initFlutter(); final dir await _getHiveDir(); Hive.init(dir.path); await Hive.openBoxPrize(prizeBox); runApp(const LuckyWheelApp()); }这段代码同时解决了两个隐患一是初始化顺序二是在 OpenHarmony 分支上 initFlutter 的路径兼容问题。后面再遇到类似的插件启动崩溃先检查有没有把所有初始化都放进 main 的 await 流程。6.2 坑二编辑返回后列表刷新失灵这个坑前面已经提到过我再把完整链路梳理一遍。症状是在详情页编辑了奖品名称点保存返回列表页没有变化但重启 App 后名称确实是新的。排查步骤是这样的。第一步我先在列表页 setState 前后打日志发现 setState 确实执行了但页面数据没有变化。第二步我检查 _prizeList 内容发现数据还是旧的说明详情页返回的新对象并没有进入列表。第三步我回看代码发现问题出在返回更新逻辑列表页收到返回值后只是把它打印出来了没有写回 _prizeList。这就是典型的数据存了但内存没更新。我的修复方案前面说了统一改成 _loadList() 重新读盒子。这里补充一个细节重新读回来后如果你觉得数据量大了不想重新读那就必须在替换元素时保证使用不可变引用并且把 List 整体替换而不是原地 element[index] newValue。原地替换在某些情况下不会触发重建尤其是列表组件做了浅比较优化的时候。6.3 坑三新增奖品后图标资源显示成空白第三个坑和图标资源路径有关。新增奖品时我在图标选择器里记录的是用户选中的资源标识符但保存时拼路径少拼了一层目录导致保存的 iconAsset 指向了一个不存在的资源页面渲染时加载失败表现为空白图标。这个问题的根源是资源路径在多个地方被拼接没有一个统一的管理入口。排查时我打印了保存后的完整路径才定位到是少了一层文件夹。修复并不难把所有资源引用统一放到一个常量配置里不要在每个页面里手写路径然后加一个加载资源前的存在性校验如果资源不存在就给一个默认占位图。这个坑给我最大的教训是资源路径这种看起来很低级的问题在跨端工程里被放大了。标准 Flutter 和 OpenHarmony 的资源目录虽然都是 assets但构建时对路径大小写和目录要求的处理不完全一致最好的办法就是别让资源路径散落在业务代码里。最后再分享一个小建议。如果你在做的也是类似的活动类 App详情页里尽量把校验和反馈做细一点比如名称填错、库存超出范围都直接用 Form 的校验提示告知用户而不是保存时弹一个笼统的 Toast。我在这个模块里把所有 validator 都挂到 Form 和 autovalidateMode.onUserInteraction 上用户在切换输入框时就能看到错误提示整体体验比提交时才提示好很多。奖品详情页到这里算完整收口了增删改查加持久化全部打通转盘页面和详情页共用一套数据模型整个 App 在这个阶段已经可以当成一个能持续维护的小产品来看待。后面如果再想做奖项角标、稀有度配色这类功能都是在这个模型上加字段的事等做完再回来补续篇。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑