尧图精选

Flutter在OpenHarmony上实现空状态组件与状态联动实战

🕒 发布时间:2026/9/9 15:18:46 📁 来源:尧图网络
1. 从“空白页不等于空状态”说起高级闹钟App的空状态需求拆解做Flutter for OpenHarmony的高级闹钟App时很多人会把空状态当成最后一步来画列表没数据了塞一张插图、加一句“暂无闹钟”完事。这种想法坑过我一回。第一版我确实是这么干的结果测试同学提了一个很扎心的问题手机上打开App页面空空的用户知道下一步该干什么吗能点哪里吗为什么这个App连一个入口都找不到这其实不是UI问题是产品引导问题。空状态不是“没有内容时的兜底展示”而是用户进入新设备、新App时的第一个交互入口。尤其闹钟这类工具型App用户的使用路径非常短打开、添加、设置、启用。一旦中间断了留存率掉得很快。我在这篇文章里要把整个实现过程拆开讲覆盖需求分析、Flutter在OpenHarmony上的工程适配、空状态组件设计、列表三态切换的数据联动以及真机调试中踩到的一堆坑。适合两类读者一类是已经在OpenHarmony上用Flutter做应用、想细化交互细节的开发者另一类是刚接触Flutter跨端开发想找个真实业务场景练手的同学。先说需求。一个高级闹钟App什么时候会出现“没有内容”的状态我列了一下至少四类首次启动闹钟列表是空的用户不知道能做什么。用户手动把全部闹钟删光了页面需要给人“重新开始”的引导。设置了筛选条件或搜索关键词但没匹配到任何闹钟。系统闹钟权限被拒列表加载失败或被禁用此时也需要空状态来提示用户去设置里授权。这四类场景的文案和按钮动作完全不同。首次启动主按钮是“添加闹钟”全部删除后用户可能刚回来需要的是安全感告诉他“数据没有被清掉只是你删光了”搜索无结果按钮则应该是“清除筛选”。如果只用一套空状态模板去套所有场景用户就会迷失。所以我在项目里没有用简单的if判断来渲染空页面而是把空状态拆成了组件加配置同一个EmptyStateWidget通过参数控制图标、文案、按钮文字和回调。这样四类场景共用一套视觉规范但行为可配置。这块在设计上很值得花时间想清楚因为闹钟App后期往往会加标签筛选、节假日跳过、起床挑战这些功能空状态场景只会越来越多。你不想每加一个场景就新写一个页面。2. Flutter跑在OpenHarmony上工程适配才是一切的前提在说组件实现之前先把工程地基讲清楚。Flutter要跑在OpenHarmony上不是直接把原来的Android工程拿过来改改就能编译的。OpenHarmony没有Android的ART虚拟机也没有AAR原生库那一套它用的是方舟运行时Flutter官方SDK并没有直接支持OpenHarmony。目前能走通的方案是使用OpenHarmony SIG组织维护的flutter_flutter分支配合DevEco Studio创建原生工程壳子再把Flutter模块作为hap的一部分打进去。我用的具体版本组合是这样的组件版本/说明flutter_flutterOpenHarmony分支基于Flutter 3.7.x维护版本用Gitee上的openharmony_sig仓库DevEco Studio4.0及以上配置OpenHarmony SDKOpenHarmony SDKAPI 9以上真机建议用API 10Dart SDK随flutter_flutter分支自带不要单独换版本这个分支在本地怎么用不能直接flutter create需要先把仓库拉下来然后通过DevEco创一个原生工程再在工程里接入flutter模块。具体来说git clone https://gitee.com/openharmony_sig/flutter_flutter.git -b master然后把flutter_flutter/bin目录加进PATH跑一遍flutter doctor会看到OpenHarmony相关的检查项。原生壳子工程里用DevEco的Module依赖方式把flutter模块作为library dependency挂进去而不是像Android那样用gradle依赖。这个过程里最容易翻车的点是版本不匹配。flutter_flutter分支更新频率很高但第三方插件不一定跟得上。我项目里用到的path_provider、shared_preferences这类常用插件OpenHarmony侧都有移植版本可用名字一般还是原插件名但要在pubspec里依赖时注意仓库地址——有些插件得直接依赖git仓库的OpenHarmony分支而不是pub.dev上的版本。另外OpenHarmony的app工程支持stage模型和FA模型。我建议直接用stage模型这是当前和后续主推的模型权限声明、组件管理都更清晰。闹钟App涉及系统调度能力stage模型下调用后台任务和提醒代理会顺利很多。还有一件事在OpenHarmony上做Flutter你要接受一个现实部分Flutter插件是用原生通道实现的在OpenHarmony上并不存在对应的原生实现。比如闹钟要用到的系统提醒Flutter Lounge上那些插件大多是AndroidiOS双端实现OpenHarmony这边你必须自己写MethodChannel去调ArkTS侧的API。这个理念要提前建立后面做状态联动和系统能力打通时才能不卡住。3. 空状态组件实现从静态页面到可复用Widget3.1 EmptyStateWidget的设计与布局把工程跑起来之后第一个要写的代码就是那个核心组件。我的目标很明确这个组件要能覆盖开头提到的四类场景并且一个参数都不冗余。先看最终实现import package:flutter/material.dart; class EmptyStateWidget extends StatelessWidget { final String iconAsset; final String title; final String message; final String actionText; final VoidCallback? onAction; const EmptyStateWidget({ super.key, required this.iconAsset, required this.title, required this.message, this.actionText , this.onAction, }); override Widget build(BuildContext context) { final theme Theme.of(context); return Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Image.asset( iconAsset, width: 120, height: 120, fit: BoxFit.contain, ), const SizedBox(height: 24), Text( title, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w600, color: theme.colorScheme.onSurface, ), textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( message, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, height: 1.5, ), textAlign: TextAlign.center, ), if (actionText.isNotEmpty onAction ! null) ...[ const SizedBox(height: 24), FilledButton( onPressed: onAction, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 24, vertical: 12, ), ), child: Text(actionText), ), ], ], ), ), ); } }设计上几个关键决策我解释一下为什么这么做。图标用Image.asset而是不Icon。闹钟场景空状态的图标通常是带情绪的场景插画比如一个“沉睡的闹钟”或“空荡荡的表盘”这种图用Icon组件根本表现不出来必须用设计资源。但是Image.asset在OpenHarmony上有一个我不能不提的坑资源路径大小写敏感。Android资源名乱大小写基本不会炸OpenHarmony的打包器严格区分大小写整条路径必须跟pubspec.yaml里的assets声明完全一致。我一开始放了assets/images/AlarmEmpty.pngpubspec里写的是assets/images/alarm_empty.png结果调试的时候页面直接报找不到资源。这个坑在第5章还会再展开。Column用mainAxisSize.min包起来再放到Center里。目的是让空状态内容在屏幕上垂直居中同时又不会在键盘弹起或系统字体放大时被挤变形。很多初学者会把Column直接放在Container里然后Container高度撑满结果在字体缩放较大的设备上内容溢出。用mainAxisSize.min内容高度是自适应的更稳。按钮层用了条件展开语法。没有按钮的空状态也允许存在比如加载失败且没有可执行操作时只展示提示信息。但这只是兜底正常情况下我要求每个空状态至少有一个可操作项哪怕操作是“去设置页”。3.2 状态切换方案AnimatedSwitcher与IndexedStack的取舍空状态组件本身不负责切换逻辑它只负责渲染“空”这一种形态。更重要的是列表页里加载中、空、非空三种形态怎么切。先看我的状态定义enum AlarmListViewState { loading, empty, normal }页面主体的build逻辑是这样Widget buildBody() { switch (_viewState) { case AlarmListViewState.loading: return const Center( child: CircularProgressIndicator(), ); case AlarmListViewState.empty: return EmptyStateWidget( iconAsset: assets/images/alarm_empty.png, title: _emptyTitle, message: _emptyMessage, actionText: 添加闹钟, onAction: _createAlarm, ); case AlarmListViewState.normal: return ListView.separated( itemCount: _alarms.length, separatorBuilder: (_, __) const Divider(height: 1), itemBuilder: (_, index) AlarmListTile(alarm: _alarms[index]), ); } }这里其实已经直接按状态枚举分派了没有用额外的状态管理库。OpenHarmony上的Flutter引擎稳定性比官方的要弱一些第三方状态管理库的重建频率高在特定版本上偶发丢帧。对这种单页面状态切换用setState加一个枚举比引入Provider/Riverpod更稳也更好排查问题。那切换动画怎么办很多人会想到AnimatedSwitcherAnimatedSwitcher( duration: const Duration(milliseconds: 200), child: buildBody(), )这个方案在OpenHarmony真机上我实测过列表和空状态之间切换时偶发卡顿尤其在低端设备上能感觉到掉帧。原因不是AnimatedSwitcher本身而是当child从ListView换成EmptyStateWidget时两个Widget的布局复杂度差异太大200毫秒内同时做淡入淡出OpenHarmony的Flutter引擎在GPU栅格化这一层压力明显比Android大。我的处理是做了个开关参数默认关闭动画直接瞬时切换同时在设置页留了一个“减少动态效果”的配置项如果用户开启系统级减弱动态效果就强制瞬时切换。final bool reduceMotion MediaQuery.of(context).disableAnimations;用这个系统参数判断比自己在设置页放一个开关更优雅。开启动画时我给EmptyStateWidget加了一个AnimatedOpacity入场AnimatedOpacity( opacity: reduceMotion ? 1.0 : 0.0, duration: const Duration(milliseconds: 150), child: ... )实际体感上瞬时切换在闹钟这种高频操作的App里并不突兀用户核心任务是快速新建闹钟而不是看动画。3.3 空状态的文案体系与交互按钮设计空状态里最容易忽略的是文案体系。闹钟列表初始为空时文案不应该干巴巴地写“暂无闹钟”。我参考了一些做得好的健康类应用最终定了这样一组文案标题把时间交给闹钟描述添加一个闹钟让它在你需要的时候准点叫你。按钮添加闹钟全部删除后的场景则不同标题闹钟列表是空的描述你已经清空了所有闹钟随时可以重新添加。按钮新建闹钟搜索无结果场景标题没有匹配的闹钟描述换个关键词试试或者清除筛选条件。按钮清除筛选这套文案的关键是“描述里必须告诉用户发生了什么以及下一步能做什么”。按钮文字也有讲究同样是指向新建页我用了三种不同表达——“添加闹钟”“新建闹钟”“清除筛选”分别对应首次引导、事后重建、查询修正三个心理状态。用户看到按钮文字不用想就知道点了会发生什么。按钮的点击行为也做了区分。首次启动和全部删除后直接跳转到新建闹钟页面搜索无结果时按钮不跳转而是清除筛选条件回到完整列表。这里有个实现细节清除筛选后如果列表还是空的那么状态就转为“全部删除后的空状态”按钮文案变成“新建闹钟”。状态转移需要一个显式的方法来驱动见第4章。按钮的样式我用的是FilledButton不用OutlinedButton或TextButton。空状态里的主按钮是全页面唯一的行动出口必须视觉权重最高。如果同时存在次级操作比如“去设置授权”我会放一个TextButtonFilledButton保持唯一避免两个同等权重的按钮互相打架。4. 列表数据与空状态联动一套可复用的状态机方案4.1 三态切换的驱动逻辑空状态不是孤立的它必须跟着闹钟数据实时联动。我从数据层到UI层理了一条完整链路AlarmRepository从本地存储取出闹钟列表 - AlarmListPage收到数据 - 根据列表长度切换枚举 - 决定渲染哪个body。我用的闹钟数据模型故意保持轻量class AlarmModel { final String id; final String title; final TimeOfDay time; final Setint repeatDays; // 1周一 ... 7周日 final bool enabled; const AlarmModel({ required this.id, required this.title, required this.time, required this.repeatDays, required this.enabled, }); AlarmModel copyWith({ String? title, TimeOfDay? time, Setint? repeatDays, bool? enabled, }) { return AlarmModel( id: id, title: title ?? this.title, time: time ?? this.time, repeatDays: repeatDays ?? this.repeatDays, enabled: enabled ?? this.enabled, ); } }列表页拿到数据后的核心方法是这个Futurevoid _refreshAlarmList() async { final alarms await _repository.getAllAlarms(); if (!mounted) return; setState(() { _alarms alarms; if (alarms.isEmpty) { _viewState AlarmListViewState.empty; } else { _viewState AlarmListViewState.normal; } }); }每次刷新数据后都执行一次状态判定而不是依赖旧状态来推断新状态。举个例子页面当前是empty状态用户通过别的入口比如系统的快捷方式添加了一个闹钟回到页面时触发onResume回调重新刷新数据此时alarms非空状态必须切到normal。如果代码写的是“如果当前是empty就保持不变”那页面就永远卡在空状态了。这套逻辑看起来简单但我建议你一定要把状态迁移画成一张表再动手。我的实际状态流转是这样当前状态触发事件数据结果新状态loading异步加载完成有数据normalloading异步加载完成无数据emptynormal删除最后一个闹钟无数据emptynormal搜索/筛选无匹配empty筛选态empty添加闹钟成功有数据normalempty清除筛选无数据empty普通空态有了这张表写测试用例都方便。每个迁移都要保证UI有明确变化不能出现状态卡死或闪烁。4.2 空状态里按钮动作的落地点击“添加闹钟”按钮后怎么处理也影响状态联动。我走了两步先弹出新建闹钟页面页面返回后立刻刷新列表。Futurevoid _createAlarm() async { final result await Navigator.of(context).push( MaterialPageRoute( builder: (_) EditAlarmPage(), ), ); if (result true) { await _refreshAlarmList(); } }编辑页返回true表示确实新建或修改了闹钟此时才刷新。如果用户进编辑页后直接返回result是null就不刷新避免无意义的重建列表。这个细节对性能有实打实的影响——列表页每次刷新会重新build整个ListView如果数据量大了而用户只是进入又退出编辑页那纯属浪费。在OpenHarmony上还要注意一点Flutter侧的Navigator路由和ArkTS侧的页面生命周期不是完全同步的。我遇到过从ArkTS原生页面跳回Flutter页面时Flutter没有触发onResume的情况。所以我在AlarmListPage里除了监听页面生命周期还同时监听闹钟变更的本地事件总线_eventBus.onAlarmChangedEvent().listen((event) { _refreshAlarmList(); });这样无论是Flutter侧还是ArkTS原生侧改了闹钟数据列表页都能感知到空状态切换不会失灵。4.3 本地数据库与空状态判断的潜在隐患高级闹钟App通常要用本地数据库存闹钟数据。我在项目里用的是sqflite的OpenHarmony兼容分支——注意不是直接依赖pub.dev上的sqflite而是依赖用OpenHarmony原生SQLite实现的fork版本。这个fork在API上和原版几乎一致但有一个差异数据库文件的默认路径规则不一样。Android上getDatabasesPath()返回的是/data/data/包名/databasesOpenHarmony上返回的是应用沙箱路径形如/data/storage/el2/base/haps/entry/files。空状态判断依赖数据库的查询结果如果数据库初始化失败查询会抛异常列表页需要catch住异常进入一个“加载失败”的空状态。这个我在代码里专门做了处理try { _alarms await _repository.getAllAlarms(); } catch (e) { setState(() { _viewState AlarmListViewState.empty; _emptyTitle 闹钟数据加载失败; _emptyMessage 请检查存储空间后重试; _emptyActionText 重新加载; }); return; }注意这里我复用了empty枚举但文案完全不同。这也说明为什么空状态必须做成可配置组件而不是硬编码死页面。数据库异常时的“重新加载”按钮回调里执行的逻辑和“添加闹钟”完全不同它只是重新拉起一次数据查询。4.4 空状态页面的性能与内存细节还有一个容易踩的坑EmptyStateWidget里的Image.asset加载的是本地资源如果这张插图是一张高清PNG在列表页从normal切到empty时会瞬间解码一整张大图造成掉帧。我用的插图是120x120像素但是资源文件里那张图是512x512的Flutter会按Image.asset的width/height参数做缩放渲染但解码仍然是全尺寸解码。优化方式是提前把插图压缩到合适尺寸或者用ResizeImageImage.asset( iconAsset, width: 120, height: 120, cacheWidth: 360, // 按设备像素比预留多一点 cacheHeight: 360, )加上cacheWidth/cacheHeight后解码阶段就会按目标尺寸解码内存占用从512x512x4字节降到360x360x4字节左右效果非常明显。OpenHarmony低端机上这个优化能让空状态切换时的掉帧率明显下降。我测试过不加缓存参数时PixelRatio为3的设备上一张512x512的图光解码就要占约3MB内存加参数后降到约0.5MB。5. 适配细节与实测排坑我在OpenHarmony真机上踩过的坑5.1 资源路径大小写与字体注册问题前面提到过资源路径大小写问题这是我在OpenHarmony上遇到的第一道坎。Flutter的AssetBundle在Android上是大小写不敏感的但在OpenHarmony上资源文件被编译进HAP后查找逻辑是严格区分大小写的。我一开始把插图命名为AlarmEmpty.pngpubspec声明是flutter: assets: - assets/images/编译不报错真机上运行到空状态页面时直接报Unable to load asset。排查了半天最后发现是文件名大小写问题。改成全小写命名后问题消失。从此我定了一个规矩所有Flutter资源文件一律小写加下划线包括图片、字体、JSON配置。字体注册也有坑。闹钟时间显示用了比较特殊的字体风格我在pubspec里注册了一个中文字体flutter: fonts: - family: AlipaySans fonts: - asset: assets/fonts/AlipaySans_Regular.ttfAndroid上没问题OpenHarmony真机上中文全部变成方块字。原因不是字体文件损坏而是OpenHarmony的字体回退机制和Android不同。Android在找不到指定字体里的字形时会自动回退到系统字体OpenHarmony这边回退不够积极尤其数字和中文混合文本中文字形直接丢。解决方案是注册字体时同时声明字重- family: AlipaySans fonts: - asset: assets/fonts/AlipaySans_Regular.ttf weight: 400 - asset: assets/fonts/AlipaySans_Medium.ttf weight: 500同时把中文字体文件用支持全量中文的字体替换。如果你只是用系统默认字体那这坑不会踩到但只要自定义了字体一定要在OpenHarmony真机上检查中文渲染。5.2 系统返回键与空状态页的交互冲突空状态页还有一个交互层面的问题它没有列表用户按下系统返回键时应该退出App还是回到上一个页面从导航结构上看闹钟列表通常是App的主页没有上级页面。但OpenHarmony的返回键默认会先分发给ArkTS壳层然后再通知Flutter引擎。我在真机上遇到过十分诡异的现象空状态页面按返回键页面没有反应再按一次App直接退到了桌面。原因是ArkTS侧拦截了第一次返回事件并弹出“再按一次退出”的提示但Flutter侧不知道结果提示气泡一闪而过根本看不清。后来我在Flutter侧使用PopScope来接管返回键PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) async { if (didPop) return; final shouldExit await _showExitDialog(); if (shouldExit mounted) { SystemNavigator.pop(); } }, child: Scaffold(...), )同时ArkTS壳层里把默认返回键行为改为直接透传给Flutter不再自行弹出退出确认。这样整个返回逻辑就统一由Flutter侧控制空状态页和非空列表页的行为完全一致。5.3 深色模式适配与背景色分层高级闹钟App大概率会提供深色模式空状态页面在深色背景下的表现和浅色模式差异很大。我的EmptyStateWidget里用了theme.colorScheme.onSurface和onSurfaceVariant理论上已经跟随主题色切换。但OpenHarmony上Flutter的ThemeMode如果设置成system从系统设置切换深色模式后Flutter侧的主题并不总是实时更新。实测中有时候要杀掉App重进才会生效。解决办法是在Flutter侧监听系统深浅色变化final brightness View.of(context).platformDispatcher.platformBrightness;但更稳妥的做法是App内提供一个主题切换入口让用户手动选择跟随系统、强制浅色、强制深色。手动切换时通过设置ThemeMode触发整个Widget树重建避免依赖系统广播的实时性。空状态里的插图也要注意如果我用的插画是浅色背景图在深色模式下会显得很突兀。最终给设计的建议是输出两套插图deep和light各一套用Theme.brightness来判断加载哪一张final isDark Theme.of(context).brightness Brightness.dark; final iconAsset isDark ? assets/images/alarm_empty_dark.png : assets/images/alarm_empty_light.png;5.4 与系统闹钟能力的联动验证最后提一嘴空状态之后的链路。高级闹钟App的空状态只是起点真正让闹钟生效必须调用系统的提醒代理能力。OpenHarmony上不是用AlarmManager而是用ReminderAgentManager提醒代理Flutter侧通过MethodChannel调ArkTS原生代码实现。我当时的通道定义class AlarmChannel { static const MethodChannel _channel MethodChannel(com.example.alarm/reminder); static Futurebool scheduleAlarm(AlarmModel alarm) async { try { final result await _channel.invokeMethod(scheduleAlarm, { id: alarm.id, title: alarm.title, hour: alarm.time.hour, minute: alarm.time.minute, repeatDays: alarm.repeatDays.toList(), }); return result true; } on PlatformException catch (e) { debugPrint(scheduleAlarm error: ${e.message}); return false; } } }ArkTS侧通过ohos.reminderAgentManager的publishReminder接口发布提醒权限需要在module.json5里声明ohos.permission.PUBLISH_AGENT_REMINDER。这一步不直接属于空状态实现但空状态里的“添加闹钟”按钮最终要把用户带到这个能力链路上。如果在OpenHarmony真机上没有声明这个权限哪怕你在空状态页成功添加了闹钟到点也不会响用户会以为闹钟坏了。我在开发联调时用了一台API 10的OpenHarmony开发板把整个链路跑通后发现从空状态到闹钟响铃的完整路径里最耗时的是ReminderAgentManager的权限申请。第二次进入App后权限已经持久化但如果用户在系统设置里关闭了“闹钟提醒”权限空状态场景就需要出现“去授权”的引导按钮这也验证了第3章里“空状态场景可配置”的必要性。回到空状态这个话题我在这次实战中最大的收获不是写了一个漂亮的EmptyStateWidget而是理解了空状态本质上是一个“任务中断恢复系统”。用户第一次打开App或删光闹钟后脑海中其实有一个待办任务创建一个新的闹钟。空状态如果能在视觉上安抚、在文字上引导、在按钮上指路用户就能无缝接上这个任务。反之空空白页会让用户觉得App坏了甚至直接卸载。如果你也在做Flutter for OpenHarmony方向的App我建议先别急着堆功能把这种“用户任务链路的断点”一个个列出来每个断点配一个空状态。这个工程量不大但对产品体验的提升是肉眼可见的。后面我还会继续写高级闹钟App的其他模块比如时间选择器在OpenHarmony上的适配、系统提醒的调度策略、多设备间的数据同步都是这次实战中趟过水的地方有空再整理出来。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →