用 Flutter 开发 OpenHarmony 应用,这两年已经是很常见的技术选型了。我维护的高级闹钟 App 就是一个典型项目,主线功能做完之后,我反而想先把其中很小的一个模块拿出来复盘——空状态实现。
原因很简单:空状态是用户打开 App 后最先看到的东西之一。闹钟列表没有任何数据时、历史记录被清空时、统计报表没有数据时,如果只是丢一个“暂无数据”的灰字,用户大概率会认为 App 坏了;但如果空状态设计得清楚,并且能给出一两个可执行的下一步操作,用户就愿意继续留下去。这篇内容我会从业务设计、组件封装、页面接入、OpenHarmony 平台适配四个层面,把整个空状态实现过程完整走一遍。适合正在开发 Flutter for OpenHarmony 应用、或者想优化自家 App 空状态体验的开发者参考。
1. 高级闹钟里的空状态,比你想的重要得多
1.1 空状态不只是“没数据”的兜底占位
很多开发者容易把空状态理解成“数据为空时显示的占位 UI”,这是完全不够的。我做过几个 App 之后,慢慢意识到空状态真正的价值是:在用户没有数据可用的时候,告诉他这里为什么是空的、接下来能做什么。
举个例子,一个闹钟列表如果空着,用户的第一反应往往是“我是不是哪里操作错了?App 是不是坏了?”如果空状态里能明确写“还没有闹钟,点击右下角按钮创建”,用户一下就理解了。这不是什么高级交互设计,就是最基本的用户认知引导。
我在高级闹钟 App 里把空状态分成了四类:
- 首用空状态:用户第一次打开 App,没有任何数据,需要引导用户创建第一条内容。
- 清空空状态:用户主动删除了所有闹钟或历史记录,需要明确告诉他数据已清空,并提供恢复途径。
- 条件空状态:比如搜索“早八”没有任何结果,需要提示用户换个关键词或者浏览全部。
- 功能未开启空状态:比如闹钟的“睡眠统计”功能依赖系统权限,权限没开或数据源没接通,也需要用空状态遮挡住不可用的区域。
这四类空状态虽然视觉上很相似,但文案和行动按钮完全不同。首用空状态强调“创建”,清空空状态强调“可恢复”,条件空状态强调“换条件”,功能未开启空状态强调“去设置”。如果全部只套一个“暂无数据”,用户根本不知道下一步该做什么。
1.2 闹钟 App 里最常见的四类空状态场景
回到我正在做的这个高级闹钟 App,我在需求评审阶段把所有可能出现“无数据”的页面都列了出来,前后整理出了四类高频场景。
第一类是闹钟列表页。这是 App 的主页,用户进来第一眼看到的就是它。新用户没有任何闹钟时,这里必须出现首用空状态。第二类是历史记录页,记录每一次闹钟响铃的时间、用户是否准时起床、贪睡了几次。如果用户刚装 App 还没闹过铃,这里就是空的历史记录。第三类是统计报表页,需要至少 7 天的历史数据才能生成睡眠曲线和准时率分析,数据不足时不能硬给一张空图表。第四类是搜索筛选结果页,用户在闹钟列表里搜索“早上”,搜索不到任何匹配项时也需要一个友好的空状态。
为什么要把这些场景单独列出来?因为它们的触发时机、用户心理和引导方向都不一样。历史记录页的空状态可以更轻量,不一定要放按钮;但闹钟列表页的空状态必须要有强引导,否则新用户会卡在第一步。统计报表页的空状态则要说清楚“还需要几天数据”,让用户理解当前状态是正常的,而不是数据丢失。
我当时的做法是在产品文档里把每个页面的空状态文案和按钮都写成表格,先定文案再造组件。这个习惯后来帮我省了很多改样式的功夫,因为在组件写完之后,UI 或产品再要求改文案,只需要改字符串,不需要动布局。
1.3 空状态设计要解决的两个核心问题
如果提炼一下,空状态说到底要回答两个问题:用户现在在哪?用户下一步能做什么?
第一个问题靠图形和主文案解决。图形要直观,主文案要说明当前状态,比如“还没有闹钟”“没有找到匹配的闹钟”“数据准备中”。第二个问题靠副文案和行动按钮解决。副文案补充说明原因或规则,行动按钮给用户一个明确的下一步动作。
我在设计组件时,把这两个问题的答案直接映射成了组件的四个参数:图标/插画、主标题、副标题、行动按钮。这套结构在绝大多数场景下都够用。如果你业务里出现过空状态还需要放两个按钮的需求,我的建议是再想想,多数情况下一个主按钮就够了,两个按钮会让用户犹豫,反而不利于转化。
另外,空状态应该让用户觉得“这是预期内的情况”,而不是“App 报错了”。所以视觉上不要太生硬,图标可以用柔和一些的线性图标,颜色用主题色里偏灰的色阶,不要用红色或警示色。这一点在后面组件实现里会具体展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 空状态组件方案:先定结构再写代码
2.1 通用空状态组件还是页面各写各的
拿到需求之后的第一件事,不是写代码,而是决定要不要抽一个通用组件。很多新手容易一上来就在每个页面各写一套空的 Column + Text,结果就是四个页面四套文案、四种间距、三种按钮样式,后期维护起来非常痛苦。
我的建议是:只要你的 App 有两个以上的空状态场景,就一定要抽通用组件。理由很简单,空状态的视觉结构非常固定,无非是图标、标题、副标题、按钮的纵向排列,差异只在文案和间距。抽组件之后,改间距、改主题色、加动画都只需要改一处。
当然,通用组件也不能做得太死板。我见过一些团队的“空状态组件”把所有东西都写死,想在页面里放个自定义插画都做不到,最后只能被迫改组件源码。所以我在设计时保留了 icon 参数,它既可以传 IconData,也可以直接传一个 Widget。如果你需要复杂插画,就传一个自定义的 Widget 进去;如果只是普通图标,传 IconData 就够了。
组件命名我用了 EmptyStateWidget,统一放在 common/widgets 目录下。这样其他页面引用起来也直观,别人看到这个名字就知道它是干什么的,不需要翻注释。
2.2 组件设计三要素:图形、文案、行动点
空状态组件的核心结构就是三个要素:图形、文案、行动点,我把它们当成组件设计的“三件套”。
图形部分,我推荐优先用 Material 内置的 IconData。原因很实际:不用额外引图片资源,也不会出现资源打包到 OpenHarmony 设备后加载不出来的问题。图标大小我一般控制在 64 到 80 之间,颜色用 Theme.of(context).colorScheme.outline,也就是主题里偏灰的辅助色。如果你用了自定义插画,注意插画尺寸不要太大,否则在小屏设备上会把标题文案挤到屏幕外面。
文案部分分主标题和副标题。主标题要短,控制在 8 个字以内,比如“还没有闹钟”“暂无历史记录”“未找到结果”。副标题可以稍微长一点,解释当前原因或引导下一步,比如“可以点击下方按钮添加第一个闹钟”。文案的语态尽量用陈述句加建议语气,不要用“数据异常”“错误”这类词,容易让用户以为是 App 出了问题。
行动点部分是整个组件的闭环。按钮文案要动词开头,比如“新建闹钟”“去设置”“重新加载”。按钮样式我建议统一用 FilledButton 或 OutlinedButton,不要在这里出现 TextButton,因为主行动点需要一定视觉权重。如果某个场景确实不需要行动按钮,也可以把 action 参数传 null,组件内部会跳过渲染。
2.3 切换动画与状态管理怎么配合
空状态组件本身很简单,真正考验细节的是页面从“加载中”到“有数据”再到“无数据”的切换过程。如果直接一换一,用户会觉得很生硬,尤其是从列表突然跳到空状态,视觉跳跃非常大。
我在实现时用了 Flutter 自带的 AnimatedSwitcher,把页面的 body 包一层。AnimatedSwitcher 可以自动对旧组件和新组件做淡入淡出过渡,切换过程大概 240 到 300 毫秒,体感比较舒服。但这里有一个坑:AnimatedSwitcher 需要依赖子组件的 key 来判断新旧组件,如果你在 switch 分支里返回的组件没有带 key,它可能识别不了变化,动画就不会生效。
所以我在页面里给每个分支都加了 ValueKey,比如 ValueKey('loading')、ValueKey('empty')、ValueKey('list'),这样 AnimatedSwitcher 才能准确感知状态变化。这个细节我在后文踩坑部分还会再提到,因为真的很容易忽略。
状态管理方面,我没有在这个 App 里引入重量级框架,空状态的判断逻辑本身就很简单,用 setState 加一个状态枚举就够了。我定义了一个 AlarmListStatus,包含 loading、empty、ready、error 四种状态,页面组件根据当前状态决定渲染什么内容。这种做法维护起来最直接,也不需要给团队增加 Provider 或 Bloc 的学习负担。
3. 完整实现:从 OpenHarmony 工程到页面接入
3.1 环境准备:让 Flutter 跑在 OpenHarmony 上
开始写空状态组件之前,得先把 Flutter for OpenHarmony 的开发环境跑通。这里简单说一下我当时的准备过程,因为很多人卡在第一步。
我用的是 OpenHarmony-SIG 维护的 Flutter 适配版本,不是官方标准版 Flutter SDK。安装方式和标准 Flutter 类似,都是解压后配置 PATH 环境变量,然后执行 flutter doctor 检查依赖。有一点需要提醒:这个适配版本会多出 ohos 这个平台标识,配置完成后执行 flutter config --enable-ohos 开关,才能创建 OpenHarmony 工程。
实际创建项目时,我用的命令是 flutter create --platforms ohos,这样工程目录下会同时生成 android、ios、ohos 等平台代码。之后用 DevEco Studio 打开工程里的 ohos 目录,首次打开需要等待 Gradle 和 hvigor 构建工具同步,还要去配置 OpenHarmony SDK 路径以及签名信息。如果你用的是开发板而不是模拟器,签名配置这一步绕不开,否则安装包无法部署到设备上。
设备选择上,我是用 RK3568 开发板调试的。OpenHarmony 对这类开发板的设备配置有好几个变体,选错设备树或 device profile,轻则外设不工作,重则系统起不来。我的建议是按你手头开发板的具体方案去选,如果是市面常见板子,通常能直接选到对应的配置;如果是自研底板,需要看硬件方案说明再选,这里不要想当然。
3.2 EmptyStateWidget 核心代码实现
下面直接上组件代码,这段代码在高级闹钟 App 里承担了所有空状态场景的渲染。
dart复制import 'package:flutter/material.dart';
/// 全局统一的空状态组件
class EmptyStateWidget extends StatelessWidget {
const EmptyStateWidget({
super.key,
this.icon,
this.iconData,
required this.title,
this.subtitle,
this.action,
this.iconSize = 64,
this.iconColor,
this.animate = true,
}) : assert(icon != null || iconData != null, 'icon 或 iconData 至少传一个'),
assert(!(icon != null && iconData != null), 'icon 与 iconData 不能同时传入');
/// 自定义图形,适合插画场景
final Widget? icon;
/// 简单图标场景,直接传 IconData
final IconData? iconData;
/// 主标题,建议不超过 8 个字
final String title;
/// 副标题,补充说明原因或引导
final String? subtitle;
/// 行动按钮,可选
final Widget? action;
final double iconSize;
final Color? iconColor;
final bool animate;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final Color effectiveIconColor = iconColor ?? theme.colorScheme.outline;
final Color effectiveTitleColor = theme.colorScheme.onSurfaceVariant;
final Color effectiveSubtitleColor = theme.colorScheme.outline;
final Widget content = Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null)
icon!
else if (iconData != null)
Icon(iconData, size: iconSize, color: effectiveIconColor),
const SizedBox(height: 16),
Text(
title,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: effectiveTitleColor,
),
),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(
subtitle!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: effectiveSubtitleColor,
height: 1.4,
),
),
],
if (action != null) ...[
const SizedBox(height: 20),
action!,
],
],
);
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
child: animate ? FadeInScale(child: content) : content,
),
);
}
}
组件内部用 Column 垂直排列,mainAxisSize 设置为 min,这样空状态内容会自然居中,不会占满整个屏幕。颜色全部取色板里的语义化色值,没有写死灰色,这样换主题时组件也能自动适配。
assert 的两个校验是有意加上的。因为 icon 和 iconData 是两个可选的图形入口,如果调用方手误同时传了,最后到底渲染哪个很难判断,不如在开发阶段直接断言报错。这一招在团队协作里特别有用,能避免很多隐藏 bug。
动画部分我单独抽了一个 FadeInScale 组件,代码在下面。
dart复制import 'package:flutter/material.dart';
class FadeInScale extends StatefulWidget {
const FadeInScale({
super.key,
required this.child,
this.delay = Duration.zero,
});
final Widget child;
final Duration delay;
@override
State<FadeInScale> createState() => _FadeInScaleState();
}
class _FadeInScaleState extends State<FadeInScale>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _opacity;
late final Animation<double> _scale;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 420),
);
_opacity = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
);
_scale = Tween<double>(begin: 0.94, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOutBack),
);
if (widget.delay == Duration.zero) {
_controller.forward();
} else {
Future.delayed(widget.delay, () {
if (mounted) _controller.forward();
});
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _opacity,
child: ScaleTransition(
scale: _scale,
child: widget.child,
),
);
}
}
动画效果是淡入加轻微放大,从 0.94 放大到 1.0,视觉上像是一个元素慢慢“落定”。420 毫秒的时长是调出来的,太短会感觉突兀,太长又拖节奏。导入到 OpenHarmony 设备上后,这个动画跑起来没有明显卡顿,Unreal 引擎都能流畅,Flutter 更不用担心。
3.3 闹钟列表页接入空状态
组件封装好之后,接下来就是页面接入。我以闹钟列表页为例,因为它是这个 App 里状态切换最复杂的页面。
页面状态用枚举管理,四种状态分别是:loading、empty、ready、error。加载中放一个 CircularProgressIndicator,加载完成但列表为空时渲染 EmptyStateWidget,有数据就正常渲染 ListView,出错则渲染带“重新加载”按钮的空状态。
dart复制enum AlarmListStatus { loading, empty, ready, error }
class AlarmListPage extends StatefulWidget {
const AlarmListPage({super.key});
@override
State<AlarmListPage> createState() => _AlarmListPageState();
}
class _AlarmListPageState extends State<AlarmListPage> {
AlarmListStatus _status = AlarmListStatus.loading;
List<AlarmEntity> _alarms = [];
@override
void initState() {
super.initState();
_loadAlarms();
}
Future<void> _loadAlarms() async {
setState(() => _status = AlarmListStatus.loading);
try {
final alarms = await AlarmRepository().fetchAll();
if (!mounted) return;
setState(() {
_alarms = alarms;
_status = alarms.isEmpty ? AlarmListStatus.empty : AlarmListStatus.ready;
});
} catch (e) {
if (!mounted) return;
setState(() => _status = AlarmListStatus.error);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('闹钟')),
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 240),
child: _buildBody(),
),
floatingActionButton: FloatingActionButton(
onPressed: _createAlarm,
child: const Icon(Icons.add),
),
);
}
Widget _buildBody() {
switch (_status) {
case AlarmListStatus.loading:
return const Center(
key: ValueKey('loading'),
child: CircularProgressIndicator(),
);
case AlarmListStatus.error:
return EmptyStateWidget(
key: const ValueKey('error'),
iconData: Icons.error_outline,
title: '闹钟加载失败',
subtitle: '存储或网络可能出了问题,请稍后重试',
action: OutlinedButton(
onPressed: _loadAlarms,
child: const Text('重新加载'),
),
);
case AlarmListStatus.empty:
return EmptyStateWidget(
key: const ValueKey('empty'),
iconData: Icons.alarm_add,
title: '还没有闹钟',
subtitle: '创建第一个闹钟,开始规划你的作息',
action: FilledButton.icon(
onPressed: _createAlarm,
icon: const Icon(Icons.add),
label: const Text('新建闹钟'),
),
);
case AlarmListStatus.ready:
return ListView.separated(
key: const ValueKey('list'),
itemCount: _alarms.length + 1,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
if (index == 0) {
return const _ListHeader();
}
final alarm = _alarms[index - 1];
return AlarmListItem(alarm: alarm);
},
);
}
}
}
我强调几个细节。
第一,await 之后必须做 mounted 检查。OpenHarmony 设备上 UI 线程的调度和 Android 类似,异步方法返回时页面可能已经销毁,不检查 mounted 直接 setState 会报错,甚至可能导致应用闪退。
第二,AnimatedSwitcher 的 child 每个分支都加了 ValueKey。这个 key 是切换动画能正确工作的前提,没有它 AnimatedSwitcher 可能识别不出组件变化,动画直接失效。
第三,空状态底下的 FloatingActionButton 仍然保留。空状态里已经有“新建闹钟”按钮,右下角悬浮按钮还在,两个入口同时存在其实并不冲突。FAB 是全局操作入口,空状态里的按钮更像是对当前状态的即时引导,用户既可以点 FAB,也可以点空状态中间的大按钮。
3.4 历史记录与搜索页的空状态扩展
闹钟列表页是状态最复杂的,其他页面的接入就更简单。拿历史记录页来说,它只需要判断有没有记录数据,没有就渲染一个轻量空状态。
dart复制class HistoryPage extends StatelessWidget {
const HistoryPage({super.key, required this.records});
final List<HistoryRecord> records;
@override
Widget build(BuildContext context) {
if (records.isEmpty) {
return const EmptyStateWidget(
iconData: Icons.history,
title: '暂无历史记录',
subtitle: '闹钟响铃后,记录会显示在这里',
);
}
return ListView.builder(
itemCount: records.length,
itemBuilder: (context, index) {
return HistoryListItem(record: records[index]);
},
);
}
}
这里我特意没有传行动按钮,因为历史记录空着本身是正常的,没有必须执行的下一步动作,放按钮反而显得刻意。副文案“闹钟响铃后,记录会显示在这里”是在告诉用户这个页面将来会有什么内容,降低预期落差。
搜索空状态稍微特殊一点,因为搜索结果的空状态文案高度依赖关键词。如果用户搜索“早上”没有结果,直接写“暂无数据”完全没有帮助,应该动态拼上关键词。
dart复制EmptyStateWidget(
iconData: Icons.search_off,
title: '没有找到相关闹钟',
subtitle: '换个关键词试试,比如“早上”或“吃药”',
)
这个副文案是我从实践中总结出来的。用户看到“没有找到相关闹钟”会明确知道问题出在关键词上,而不是怀疑闹钟数据丢了。如果搜索框里本身有关键词,你还能在副文案里拼上关键词,让提示更有针对性。
统计报表页的空状态跟前几个都不一样。它不是列表为空,而是数据量不足,比如用户才装了 3 天,还没有 7 天数据。我的处理方式是显示一个半透明的占位图表,下面再叠加空状态提示,告诉用户“数据积累到 7 天后可查看统计”。这个实现稍微复杂,但业务价值很高,因为直接给空白页用户会以为功能坏了。
4. 实际踩坑:OpenHarmony 平台适配与状态刷新问题
4.1 Flutter for OpenHarmony 运行环境里的坑
在 OpenHarmony 设备上跑 Flutter 应用,第一关不是 Flutter 代码,而是工程本身能不能编译过、装得上机。我遇到比较典型的问题集中在三个地方。
第一个是 SDK 与适配版本不匹配。OpenHarmony-SIG 的 Flutter 适配版本对 OpenHarmony SDK 的 API 版本有要求,如果你的 SDK 版本太新或太旧,编译时会出现各种奇怪的错误。我的做法是先固定 API 版本,再去查 flutter_flutter 的适配分支对应关系,不让两边自由升级。这也算一种“锁版本”的思路升级。
第二个是设备树或 device profile 选错的问题。这个在 RK3568 开发板上尤其明显,同一个芯片可能对应多套设备配置,选错了轻则屏幕不亮,重则开机后直接黑屏或者无法烧录。我的建议是你手头是什么开发板,就去查它的出厂方案说明,使用对应方案开头的设备配置,不要被网上五花八门的教程带偏。
第三个是签名配置。OpenHarmony 安装包部署到开发板必须有签名,DevEco Studio 的自动签名功能可以生成调试证书,但前提是你的工程配置和引导信息都完整。我第一次部署时漏了这一步,编译生成的 hap 包在设备上怎么都装不上去,检查下来就是签名文件没配对。
还有一个比较隐蔽的问题:如果你在 x86 架构的电脑上用模拟器调试 OpenHarmony 应用,有些 OpenHarmony 模拟器版本功能不完整,与真机的行为会有差异。我后来基本只在开发板上测,模拟器只用来做快速 UI 预览,不会作为最终验证依据。
4.2 空状态实现中常见的四个问题
环境问题过了之后,空状态本身的实现也会有一些看起来不大、但实际很折腾的问题。我把我踩过的四个坑都列出来,你可以直接当经验拿走。
第一个是资源加载问题。空状态如果用了自定义图片或 iconfont,打包成 hap 之后会偶发加载不出的情况。我在项目里统一改成了 Material 内置的 IconData,问题就消失了。如果你确实需要插画,我的建议是把插画作为 Widget 直接画出来,或者用 SVG 转 IconData,不要依赖运行时加载外部资源。
第二个是中文显示问题。这算 OpenHarmony 上比较常见的问题之一。默认字体如果没有配置中文字体,空状态里的中文标题可能会显示成方块或乱码。解决方式是给整个 MaterialApp 配置 fontFamily,使用支持中文的字体。我在高级闹钟 App 里是直接在 ThemeData 里统一处理的,这样所有中文字符都有兜底字体。不过也要注意,有些设备和版本的字体差异比较大,你得在目标设备上实际看一眼,不能只在自己电脑的预览里确认就完事。
第三个是空状态刷新不及时。比如用户在空列表页创建了第一个闹钟,返回列表后空状态还在。这个问题多出现在数据源不是响应式架构的情况下。用 setState 手动刷新时,一定要确保状态更新和数据加载发生在同一个流程里。我建议把刷新动作统一收敛到一个 _loadAlarms 方法里,创建完闹钟后调用它,就能保证列表和空状态同步更新。
第四个是 AnimatedSwitcher 不生效的问题。我在 3.3 节里提过 ValueKey 的问题,这里再强调一次。如果你发现页面切换时没有过渡动画,十有八九是 AnimatedSwitcher 的 child 没有带 key,或者 key 在状态切换前后没有变化。这是一个非常容易忽略的细节,我在实际项目里被它坑过整整一个下午。
4.3 问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 空状态图标显示空白 | iconfont 或自定义图片资源未打包进 hap | 改用 Material 内置 IconData |
| 空状态标题中文变成方块或乱码 | 默认字体缺少中文字体 fallback | 在 ThemeData 里配置支持中文的 fontFamily |
| 创建数据后空状态不消失 | 刷新逻辑没触发或数据源没有更新回调 | 统一走 reload 方法,重新拉取数据后再 setState |
| 页面切换无淡入淡出动画 | AnimatedSwitcher 子组件缺少唯一 key | 给每个状态分支加 ValueKey |
| 空状态在设备上位置偏上或偏下 | 外层布局影响了 Center 定位 | 检查父容器是否给了固定高度,去掉约束或改用 Expanded |
| 空状态动画在低端设备上卡顿 | 动画时长过长或组件过于复杂 | 缩短动画时长,简化空状态图标层级 |
| hap 包安装不上设备 | 签名配置缺失或 SDK 版本不匹配 | 重新配置自动签名,检查 Flutter 适配版本与 SDK 对应关系 |
这张表其实可以继续加,但有代表性的就这几个。你在自己项目里遇到表中没有的问题时,我的建议是先打印日志看状态和数据是否正常,再去看 UI 层;很多时候问题根本不在空状态组件本身,而是业务状态就没有更新过来。
最后再说一个我个人的小技巧:空状态的文案最好在项目早期就定下来,而且要经历过真实用户测试。因为文案是用户直接看到的,也是空状态里最容易改的,但它对用户理解的影响比其他任何视觉细节都大。我后来在自己项目里建了一个简单的文案速查表,把首用、清空、搜索无结果、权限未开启这几种场景的推荐文案都放进去,新页面要接空状态时直接查表,不用每次重新想一遍。
如果你也在做 Flutter for OpenHarmony 的应用,建议把空状态当成一个正式功能来做,不要把它当作“没数据时凑合用的东西”。一个用心实现的空状态,能在第一屏就告诉用户这个 App 是否值得继续用下去。
