AtomGit Flutter 鸿蒙客户端:Onboarding 的交互与实现
设计新用户的第一次体验——引导页的交互模式、动画与实现
目录
- 为什么需要引导页
- 引导页的设计原则
- 常见交互模式对比
- 架构设计:Onboarding 的数据流与组件树
- PageView 核心实现
- 引导页动画系统
- 状态管理:首次启动判断与持久化
- E-Brufen 实战:三页引导页完整实现
- 鸿蒙平台兼容性处理
- A/B 测试方案
- 测试与调试技巧
- 小结
一、为什么需要引导页

让我们先退一步,思考一个根本问题:引导页到底解决什么问题?
当用户第一次打开我们的 E-Brufen 应用,他们面对的是一个完全陌生的界面。三个功能卡片——白噪音、呼吸练习、情绪日记——静静地躺在奶油色背景上。用户可能会想:“这应用是做什么用的?我应该从哪里开始?”
这就是引导页要解决的问题:在用户产生困惑之前,主动告诉他们应用的价值和使用方式。
从数据上看,引导页的存在与否直接影响关键指标:
| 指标 | 无引导页 | 有引导页 | 提升幅度 |
|---|---|---|---|
| 次日留存率 | 21% | 34% | +61.9% |
| 首日核心功能触达率 | 暂不支持 | 暂不支持 | 暂不支持 |
| 7 日留存率 | 不支持 | 不支持 | 不支持 |
| 30 日留存率 | 不支持 | 不支持 | 不支持 |
| 功能发现率(3 个功能全体验) | 不支持 | 不支持 | 不支持 |
注:以上数据为行业参考值(来自 Google 的 Material Design 研究和多家头部应用的公开数据)。E-Brufen 目前处于开发阶段,尚未上线,因此暂不支持实际的 A/B 测试数据收集。下文第十节将详细讨论如何在 E-Brufen 中设计完整的 A/B 测试方案。
引导页的本质是一次不可逆的信息传递机会——用户第一次打开应用时的注意力是最集中的,之后他们可能再也不会主动去了解那些被忽略的功能。浪费这次机会,就等于浪费了最宝贵的获客成本。
二、引导页的设计原则
2.1 三页最佳原则
经过大量 A/B 测试验证,引导页的最佳页数是 3 页。这不是经验主义,而是有认知心理学依据的:
- 1 页:信息密度太低,无法建立应用价值认知
- 2 页:刚好跨过"认知门槛",但无法覆盖应用的多个核心功能
- 3 页:处于工作记忆的舒适区(人类短期记忆容量约为 4±1 个组块)
- 4 页以上:用户注意力开始急剧下降。DataCamp 的测试表明,4 页引导页的完成率比 3 页下降 32%,5 页再下降 19%
对于 E-Brufen,三个核心功能的映射非常自然:
| 页码 | 主题 | 核心信息 | 情绪价值 |
|---|---|---|---|
| 第 1 页 | 情绪记录 | “每天花 10 秒记录心情,看见自己的情绪轨迹” | 自我觉察 |
| 第 2 页 | 呼吸练习 | “科学的 4-7-8 呼吸法,3 分钟缓解焦虑” | 即时舒缓 |
| 第 3 页 | 白噪音 | “雨声、海浪、篝火——在任何地方找到宁静” | 沉浸放松 |
2.2 每页一个核心信息
这是一个常见的反面案例:
第 1 页:我们的应用支持情绪记录、呼吸练习、白噪音、
情绪日记、数据统计、社区分享……
用户看完只会记住一个字:多。这不是印象深刻,而是认知过载。
正确的做法是每页只传达 一个 核心信息,用辅助视觉(插图、动画、emoji)强化。Google 的 Material Design 引导页规范明确指出:每页最多一个标题、一行副标题、一个视觉元素。
2.3 展示而非告知(Show, Don’t Tell)
这是引导页设计的黄金法则。与其写"我们有精美的呼吸球动画",不如直接在第 2 页展示一个正在播放的呼吸球动画。
在 E-Brufen 的引导页实现中,我们将这个原则贯彻到底——每一页都包含一个真实的功能演示动画,而不是静态截图。具体实现见第六节。
2.4 用户心智模型
引导页的交互需要匹配用户已有的心智模型。在移动端,用户对"滑动翻页"已经形成了肌肉记忆——这要感谢 Instagram Stories、TikTok 等应用的大规模教育。因此,水平滑动 + 底部指示器 是最自然的选择。
三、常见交互模式对比
在设计引导页之前,我们先系统性对比业界常见的四种交互模式:
| 交互模式 | 用户体验 | 实现复杂度 | 鸿蒙兼容性 | 适用场景 |
|---|---|---|---|---|
| 水平滑动 + 指示器 | 最自然,符合移动端心智模型 | 中等 | 完全兼容 | 3-5 页的引导页,推荐首选 |
| 垂直滚动长图 | 适合信息密集型,但缺乏"页"的边界感 | 低 | 完全兼容 | 产品更新日志、功能介绍长文 |
| 自动播放轮播 | 被动接受,用户可能错过内容 | 中等 | 完全兼容 | 品牌宣传、广告展示 |
| 视频/动画叙事 | 沉浸感最强,但加载慢、包体积大 | 高 | 需注意视频解码兼容性 | 高端品牌应用、游戏 |
我们选择 水平滑动 + 指示器,并在此基础上增加三个增强特性:
- 跳过按钮:右上角,允许老用户或急性子直接进入主界面
- 自动播放选项:可在配置中开启,5 秒自动翻页(默认关闭)
- 完成按钮:仅在第 3 页(最后一页)显示,提供明确的行动号召
3.1 指示器的三种形态
指示器虽小,但对用户体验的影响不可忽视。我们对比三种常见实现:
// 方案 A:圆点指示器(dot indicator)
// 经典、简洁、实现简单
class DotIndicator extends StatelessWidget {
final int count;
final int current;
const DotIndicator({required this.count, required this.current});
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(count, (i) {
final isActive = i == current;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: isActive ? 24 : 8,
height: 8,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: isActive ? const Color(0xFF66BB6A) : Colors.grey.shade300,
),
);
}),
);
}
}
// 方案 B:进度条指示器(progress bar indicator)
// 适合自动播放模式,传递"这一页还有多久"的信息
class ProgressIndicator_ extends StatelessWidget {
final int count;
final int current;
final double progress; // 0.0 ~ 1.0,当前页的播放进度
const ProgressIndicator_({
required this.count,
required this.current,
required this.progress,
});
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(count, (i) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 3),
width: 40,
height: 3,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2),
color: Colors.grey.shade200,
),
child: i == current
? Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: progress,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2),
color: const Color(0xFF66BB6A),
),
),
),
)
: null,
);
}),
);
}
}
| 指示器类型 | 用户感知 | 动画开销 | 推荐场景 |
|---|---|---|---|
| 圆点指示器 | “这是第几页” | 极低 | 手动滑动,最通用 |
| 进度条指示器 | “这一页还有多久播完” | 极低 | 自动播放模式 |
| 数字指示器(1/3) | 最精确 | 无 | 页数较多时(5+ 页) |
E-Brufen 选择方案 A(圆点指示器),原因很简单:我们的引导页是用户手动滑动的,不需要进度信息,圆点足以传达当前位置。
四、架构设计:Onboarding 的数据流与组件树
在开始写代码之前,我们先理清架构。一个完整的引导页系统涉及四个层面:
┌──────────────────────────────────────────────────────┐
│ main.dart │
│ ┌────────────────────────────────────────────────┐ │
│ │ splash → check onboarding state → route │ │
│ │ ↓ │ │
│ │ hasSeenOnboarding == true ? HomePage │ │
│ │ : OnboardingPage │ │
│ └────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ OnboardingPage (Scaffold) │
│ ┌────────────────────────────────────────────────┐ │
│ │ Stack │ │
│ │ ├─ PageView.builder (3 页) │ │
│ │ ├─ SkipButton (Positioned, topRight) │ │
│ │ ├─ DotIndicator (Positioned, bottomCenter) │ │
│ │ └─ GetStartedButton (visible on last page) │ │
│ └────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ OnboardPage1 │ │ OnboardPage2 │ │ OnboardPage3 │
│ (情绪记录) │ │ (呼吸练习) │ │ (白噪音) │
│ │ │ │ │ │
│ EmojiBounce │ │ BreathDemo │ │ ScenePulse │
│ Animation │ │ Animation │ │ Animation │
└──────────────┘ └──────────────┘ └──────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ 数据层 (Hive CE) │
│ ┌────────────────────────────────────────────────┐ │
│ │ Box('settings') │ │
│ │ ├─ hasSeenOnboarding: bool (default: false) │ │
│ │ ├─ onboardingVersion: int (default: 0) │ │
│ │ └─ onboardingCompletedAt: String? (ISO 8601) │ │
│ └────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
这个架构的核心决策点:
- 路由决策在 main.dart 中完成,而不是在 OnboardingPage 内部。这样 HomePage 和 OnboardingPage 互不知晓对方的存在,职责单一。
- PageView.builder 而非 PageView(children: […]):builder 模式按需构建页面,当用户在第 1 页时,第 3 页的动画不会初始化,节省内存。
- 覆盖层使用 Stack:SkipButton、Indicator、GetStartedButton 浮动在 PageView 之上,不随页面滑动而移动。
- 持久化使用 Hive CE:与项目现有的存储方案一致(E-Brufen 已使用 Hive CE 存储设置和情绪数据),无需引入新依赖。
4.1 为什么不用 shared_preferences?
| 对比维度 | shared_preferences | Hive CE |
|---|---|---|
| 鸿蒙兼容性 | 需要平台插件支持,鸿蒙适配不确定 | 纯 Dart 实现,天然跨平台 |
| 性能 | 读取时全量加载到内存 | 懒加载,按需读取 |
| 类型安全 | 仅支持 int/double/bool/String/StringList | 支持任意 Dart 对象 |
| 与现有项目一致性 | 需要额外依赖 | E-Brufen 已在使用 |
鉴于 E-Brufen 已经使用 Hive CE 进行设置持久化,在同一个 Box 中增加 onboarding 相关字段是最优解——零额外成本,零额外依赖。
五、PageView 核心实现
5.1 PageController 基础
PageView 的核心是 PageController,它管理当前页面索引和滑动动画:
class _OnboardingPageState extends State<OnboardingPage> {
late final PageController _pageController;
int _currentPage = 0;
void initState() {
super.initState();
_pageController = PageController(initialPage: 0);
}
void dispose() {
_pageController.dispose(); // 必须手动释放,防止内存泄漏
super.dispose();
}
}
PageController 的释放是 Flutter 新手最容易踩的坑之一。PageController 内部持有 ScrollController,后者会注册全局的 ScrollPosition 监听器。如果在 dispose() 中忘记调用 _pageController.dispose(),会导致两个问题:
- 内存泄漏:Controller 持有的 listener 引用阻止 GC 回收
- 滑动异常:重新进入页面时,旧 Controller 可能干扰新的滑动状态
5.2 页面监听与状态同步
void initState() {
super.initState();
_pageController = PageController(initialPage: 0);
_pageController.addListener(() {
// page 属性是浮点数,表示当前页的偏移量
// 例如:用户正在从第 0 页滑向第 1 页,page 可能是 0.35
final page = _pageController.page ?? 0;
final roundedPage = page.round();
if (roundedPage != _currentPage) {
setState(() => _currentPage = roundedPage);
}
});
}
这里有一个微妙之处:我们使用 page.round() 而不是 page.toInt()。两者的区别在于:
// page = 0.6 时:
// round() → 1(四舍五入,更符合"过了一半就切页"的用户直觉)
// toInt() → 0(截断,用户需要滑到 1.0 才切页,体验迟钝)
5.3 跳过与完成
/// 统一的"离开引导页"入口
Future<void> _completeOnboarding() async {
// 1. 持久化状态
final box = await Hive.openBox('settings');
await box.put('hasSeenOnboarding', true);
await box.put('onboardingCompletedAt', DateTime.now().toIso8601String());
// 2. 平滑过渡动画
if (!mounted) return;
Navigator.of(context).pushReplacement(
PageRouteBuilder(
pageBuilder: (_, __, ___) => const HomePage(/* ... */),
transitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (_, animation, __, child) {
return FadeTransition(opacity: animation, child: child);
},
),
);
}
使用 pushReplacement 确保用户按返回键时不会回到引导页——这是一个关键的细节。如果使用普通的 push,用户在主页按返回键会重新看到引导页,体验极其糟糕。
六、引导页动画系统
引导页的视觉动画是"show, don’t tell"原则的核心载体。E-Brufen 为每一页设计了独特的入场动画。
6.1 Emoji 弹入动画(第 1 页:情绪记录)
效果:5 个 emoji(😊😐😢😡😴)依次从下方弹入,模拟情绪一个接一个地"蹦"出来。
class EmojiBouncePage extends StatefulWidget {
final VoidCallback? onAnimationComplete;
const EmojiBouncePage({super.key, this.onAnimationComplete});
State<EmojiBouncePage> createState() => _EmojiBouncePageState();
}
class _EmojiBouncePageState extends State<EmojiBouncePage>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final List<Animation<double>> _emojiAnimations;
static const _emojis = ['😊', '😐', '😢', '😡', '😴'];
static const _labels = ['开心', '平静', '难过', '生气', '困倦'];
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
);
// 使用 Interval 为每个 emoji 创造错落感
_emojiAnimations = List.generate(_emojis.length, (i) {
final start = i * 0.15; // 每个 emoji 间隔 15% 的总时间
final end = (start + 0.5).clamp(0.0, 1.0);
return CurvedAnimation(
parent: _controller,
curve: Interval(start, end, curve: Curves.elasticOut),
);
});
// 页面进入可见范围时自动播放
_controller.forward().then((_) => widget.onAnimationComplete?.call());
}
void dispose() {
_controller.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'记录你的每一种情绪',
style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'每天花 10 秒,看见自己的情绪轨迹',
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
),
const SizedBox(height: 48),
Wrap(
spacing: 16,
runSpacing: 16,
alignment: WrapAlignment.center,
children: List.generate(_emojis.length, (i) {
return AnimatedBuilder(
animation: _emojiAnimations[i],
builder: (context, child) {
final animationValue = _emojiAnimations[i].value;
return Transform.translate(
offset: Offset(0, 40 * (1 - animationValue)),
child: Opacity(
opacity: animationValue,
child: Transform.scale(
scale: animationValue,
child: Column(
children: [
Text(_emojis[i], style: const TextStyle(fontSize: 48)),
const SizedBox(height: 4),
Text(
_labels[i],
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade500,
),
),
],
),
),
),
);
},
);
}),
),
],
);
}
}
关键设计点:
Curves.elasticOut给弹入效果带来物理质感——emoji "弹"到位置后会微微回弹,像弹簧一样Interval确保每个 emoji 的动画错开,而不是同时出现Transform.translate控制垂直方向的位移,Transform.scale控制大小,Opacity控制透明度——三个变换组合产生"从远处弹入"的效果
6.2 呼吸球演示动画(第 2 页:呼吸练习)
效果:展示一次完整的 4-7-8 呼吸周期。球体放大(吸气 4 秒)、保持(屏住 7 秒)、缩小(呼气 8 秒)。不需要真实 Timer,只需要一次视觉效果。
class BreathDemoPage extends StatefulWidget {
final VoidCallback? onAnimationComplete;
const BreathDemoPage({super.key, this.onAnimationComplete});
State<BreathDemoPage> createState() => _BreathDemoPageState();
}
class _BreathDemoPageState extends State<BreathDemoPage>
with SingleTickerProviderStateMixin {
late final AnimationController _breathController;
void initState() {
super.initState();
// 总时长 = 4 + 7 + 8 = 19 秒,模拟一次完整的 4-7-8 呼吸
_breathController = AnimationController(
vsync: this,
duration: const Duration(seconds: 19),
);
_breathController.addStatusListener((status) {
if (status == AnimationStatus.completed) {
widget.onAnimationComplete?.call();
}
});
_breathController.forward();
}
void dispose() {
_breathController.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'用呼吸找回平静',
style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'科学的 4-7-8 呼吸法,3 分钟缓解焦虑',
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
),
const SizedBox(height: 48),
AnimatedBuilder(
animation: _breathController,
builder: (context, child) {
final t = _breathController.value; // 0.0 ~ 1.0
// 阶段划分:0.0-0.21(吸气4/19), 0.21-0.58(屏住7/19),
// 0.58-1.0(呼气8/19)
double scale;
String guideText;
final inhaleEnd = 4 / 19; // ≈0.2105
final holdEnd = 11 / 19; // ≈0.5789
if (t < inhaleEnd) {
// 吸气阶段:从 1.0 放大到 1.5
final progress = t / inhaleEnd;
scale = 1.0 + (progress * 0.5);
guideText = '吸气… 感受空气充满肺部';
} else if (t < holdEnd) {
// 屏住阶段:保持 1.5
scale = 1.5;
guideText = '屏住呼吸… 让氧气在身体中流动';
} else {
// 呼气阶段:从 1.5 缩小到 1.0
final progress = (t - holdEnd) / (1.0 - holdEnd);
scale = 1.5 - (progress * 0.5);
guideText = '缓慢呼气… 释放所有紧张';
}
return Column(
children: [
Transform.scale(
scale: scale,
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFB39DDB).withValues(
alpha: 0.3 + (scale - 1.0) * 1.2,
),
boxShadow: [
BoxShadow(
color: const Color(0xFFB39DDB).withValues(
alpha: 0.2 + (scale - 1.0) * 0.6,
),
blurRadius: 30,
spreadRadius: 5,
),
],
),
),
),
const SizedBox(height: 24),
Text(
guideText,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: Color(0xFF5D4037),
),
),
],
);
},
),
],
);
}
}
6.3 场景脉冲动画(第 3 页:白噪音)
效果:四个场景图标(🌊🌧️🌲🔥)轮流进行脉冲动画,每个图标依次放大、发光、恢复,创造"一个个场景被点亮"的视觉效果。
class ScenePulsePage extends StatefulWidget {
final VoidCallback? onAnimationComplete;
const ScenePulsePage({super.key, this.onAnimationComplete});
State<ScenePulsePage> createState() => _ScenePulsePageState();
}
class _ScenePulsePageState extends State<ScenePulsePage>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final List<Animation<double>> _pulseAnimations;
static const _scenes = [
('🌊', '海边入眠', Color(0xFF4FC3F7)),
('🌧️', '雨中办公', Color(0xFF81D4FA)),
('🌲', '森林冥想', Color(0xFF66BB6A)),
('🔥', '篝火夜读', Color(0xFFFFB74D)),
];
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 4),
);
_pulseAnimations = List.generate(_scenes.length, (i) {
final start = i * 0.2;
final end = (start + 0.3).clamp(0.0, 1.0);
return CurvedAnimation(
parent: _controller,
curve: Interval(start, end, curve: Curves.easeInOut),
);
});
// 循环播放
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
_controller.repeat(); // 无限循环,制造持续脉冲效果
}
});
_controller.forward();
}
void dispose() {
_controller.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'在任何地方找到宁静',
style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'雨声、海浪、篝火——让自然的声音陪伴你',
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
),
const SizedBox(height: 48),
Wrap(
spacing: 20,
runSpacing: 20,
alignment: WrapAlignment.center,
children: List.generate(_scenes.length, (i) {
return AnimatedBuilder(
animation: _pulseAnimations[i],
builder: (context, child) {
final value = _pulseAnimations[i].value;
// 脉冲形状:value 从 0→1→0(sin 曲线效果)
final pulse = (value * 0.4 + 0.6); // 0.6 ~ 1.0
final glowAlpha = value * 0.3;
return Container(
width: 100,
height: 100,
transform: Matrix4.identity()..scale(pulse),
transformAlignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _scenes[i].$3.withValues(alpha: 0.2),
boxShadow: [
BoxShadow(
color: _scenes[i].$3.withValues(alpha: glowAlpha),
blurRadius: 20,
spreadRadius: 3,
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_scenes[i].$1, style: const TextStyle(fontSize: 36)),
const SizedBox(height: 4),
Text(
_scenes[i].$2,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
),
);
},
);
}),
),
],
);
}
}
6.4 动画控制策略
引导页的动画有一个重要特性:仅在页面可见时播放。为此我们需要利用 PageView 的页面可见性:
class _OnboardingPageState extends State<OnboardingPage> {
late final PageController _pageController;
int _currentPage = 0;
int _lastAnimatedPage = -1; // 跟踪已经播过动画的页面
void initState() {
super.initState();
_pageController = PageController(initialPage: 0);
_pageController.addListener(_onPageChanged);
}
void _onPageChanged() {
final page = (_pageController.page ?? 0).round();
if (page != _currentPage) {
setState(() => _currentPage = page);
}
}
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
PageView.builder(
controller: _pageController,
itemCount: 3,
onPageChanged: (index) {
setState(() {
_currentPage = index;
_lastAnimatedPage = index; // 记录:第 index 页已经播过了
});
},
itemBuilder: (context, index) {
// 只有当前可见页才构建带动画的子组件
final isActive = index == _currentPage;
return _buildPage(index, shouldAnimate: isActive);
},
),
// ... 跳过按钮、指示器、完成按钮
],
),
);
}
Widget _buildPage(int index, {required bool shouldAnimate}) {
switch (index) {
case 0:
return EmojiBouncePage(
key: ValueKey('page0_$_lastAnimatedPage'),
// 首次渲染时才动画
);
case 1:
return BreathDemoPage(
key: ValueKey('page1_$_lastAnimatedPage'),
);
case 2:
return ScenePulsePage(
key: ValueKey('page2_$_lastAnimatedPage'),
);
default:
return const SizedBox.shrink();
}
}
}
这里的关键技巧是 ValueKey:当用户来回滑动时,已经播过动画的页面不会重新播放——_lastAnimatedPage 确保动画只在"页面首次变为当前页"时触发。
七、状态管理:首次启动判断与持久化
7.1 判断逻辑
引导页的显示逻辑本质是一个状态机:
APP 启动
│
▼
┌─────────────────┐
│ 读取 hasSeen… │
└────────┬────────┘
│
┌────────────┴────────────┐
│ false │ true
▼ ▼
┌───────────────┐ ┌──────────────────┐
│ 显示引导页 │ │ 读取 onboardingVersion │
└───────┬───────┘ └────────┬─────────┘
│ │
│ 用户完成/跳过 ┌────┴────┐
▼ │ version < │ version >=
┌─────────────────┐ │ CURRENT │ CURRENT
│ hasSeen… = true │ ▼ ▼
│ version = CURRENT│ ┌──────────┐ ┌──────────┐
│ completedAt = now│ │ 显示引导页│ │ 直接进入 │
└─────────────────┘ └──────────┘ │ 主界面 │
└──────────┘
版本号检查是一个容易被忽略但至关重要的设计。假设 V1.0 有 3 页引导页,用户看过了。V2.0 新增了"社区分享"功能,增加了第 4 页引导页——老用户应该重新看到引导页。
/// 引导页版本号——每增加或修改引导页内容时递增
const int kOnboardingVersion = 1;
/// 判断是否需要显示引导页
Future<bool> shouldShowOnboarding() async {
final box = await Hive.openBox('settings');
final hasSeen = box.get('hasSeenOnboarding', defaultValue: false) as bool;
if (!hasSeen) return true;
final version = box.get('onboardingVersion', defaultValue: 0) as int;
if (version < kOnboardingVersion) return true;
return false;
}
7.2 集成到 AppSettings
在 E-Brufen 的现有 AppSettings 类中增加 onboarding 相关字段:
class AppSettings {
// ... 现有字段 ...
static const String _keyHasSeenOnboarding = 'hasSeenOnboarding';
static const String _keyOnboardingVersion = 'onboardingVersion';
static const String _keyOnboardingCompletedAt = 'onboardingCompletedAt';
// ── Onboarding 状态 ──
bool get hasSeenOnboarding {
if (_box == null || !_box!.isOpen) return false;
return _box!.get(_keyHasSeenOnboarding, defaultValue: false);
}
set hasSeenOnboarding(bool v) {
if (_box != null && _box!.isOpen) _box!.put(_keyHasSeenOnboarding, v);
}
int get onboardingVersion {
if (_box == null || !_box!.isOpen) return 0;
return _box!.get(_keyOnboardingVersion, defaultValue: 0);
}
set onboardingVersion(int v) {
if (_box != null && _box!.isOpen) _box!.put(_keyOnboardingVersion, v);
}
bool get needsOnboarding {
if (!hasSeenOnboarding) return true;
return onboardingVersion < kOnboardingVersion;
}
}
7.3 路由决策集成
修改 main.dart 中的启动逻辑:
void main() async {
// ... 现有初始化代码 ...
final settings = AppSettings();
await settings.init();
// ... 其他初始化 ...
runApp(EBrufenApp(
settings: settings,
moodStorage: moodStorage,
));
}
class EBrufenApp extends StatelessWidget {
// ... 现有字段 ...
Widget build(BuildContext context) {
return MaterialApp(
title: 'E-Brufen',
debugShowCheckedModeBanner: false,
theme: AppTheme.materialTheme,
home: settings.needsOnboarding
? OnboardingPage(
settings: settings,
onComplete: () {
// 引导页完成后,应用不会自动重建。
// 使用 Navigator.pushReplacement 切换到 HomePage
},
)
: HomePage(moodStorage: moodStorage, settings: settings),
);
}
}
八、E-Brufen 实战:三页引导页完整实现
现在我们把所有组件拼装成一个完整的 OnboardingPage:
import 'package:flutter/material.dart';
import 'package:hive_ce/hive.dart';
import '../data/settings.dart';
import '../theme/app_theme.dart';
import '../pages/home_page.dart';
import '../data/mood_storage.dart';
const int kOnboardingVersion = 1;
class OnboardingPage extends StatefulWidget {
final AppSettings settings;
final MoodStorage moodStorage;
const OnboardingPage({
super.key,
required this.settings,
required this.moodStorage,
});
State<OnboardingPage> createState() => _OnboardingPageState();
}
class _OnboardingPageState extends State<OnboardingPage> {
late final PageController _pageController;
int _currentPage = 0;
bool _isCompleting = false;
// 每页的核心文案
static const _pages = [
_PageData(
emoji: '📝',
title: '记录你的每一种情绪',
subtitle: '每天花 10 秒,看见自己的情绪轨迹',
gradientColors: [Color(0xFF66BB6A), Color(0xFF81C784)],
),
_PageData(
emoji: '🫁',
title: '用呼吸找回平静',
subtitle: '科学的 4-7-8 呼吸法,3 分钟缓解焦虑',
gradientColors: [Color(0xFFB39DDB), Color(0xFFCE93D8)],
),
_PageData(
emoji: '🌿',
title: '在任何地方找到宁静',
subtitle: '雨声、海浪、篝火——让自然的声音陪伴你',
gradientColors: [Color(0xFF81D4FA), Color(0xFF4FC3F7)],
),
];
void initState() {
super.initState();
_pageController = PageController(initialPage: 0);
}
void dispose() {
_pageController.dispose();
super.dispose();
}
// ── 完成引导页 ──
Future<void> _completeOnboarding() async {
if (_isCompleting) return;
setState(() => _isCompleting = true);
try {
widget.settings.hasSeenOnboarding = true;
widget.settings.onboardingVersion = kOnboardingVersion;
} catch (e) {
debugPrint('[E-Brufen] Failed to save onboarding state: $e');
// 即使保存失败也进入主界面——引导页不应该阻挡用户
}
if (!mounted) return;
Navigator.of(context).pushReplacement(
PageRouteBuilder(
pageBuilder: (_, __, ___) => HomePage(
moodStorage: widget.moodStorage,
settings: widget.settings,
),
transitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (_, animation, __, child) {
return FadeTransition(opacity: animation, child: child);
},
),
);
}
// ── 翻到下一页 ──
void _goToNextPage() {
if (_currentPage < _pages.length - 1) {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
// ── PageView ──
PageView.builder(
controller: _pageController,
itemCount: _pages.length,
onPageChanged: (index) => setState(() => _currentPage = index),
itemBuilder: (context, index) {
final page = _pages[index];
return _buildOnboardingPage(
page: page,
index: index,
);
},
),
// ── 跳过按钮 (不在最后一页时显示) ──
if (_currentPage < _pages.length - 1)
Positioned(
top: MediaQuery.of(context).padding.top + 16,
right: 20,
child: TextButton(
onPressed: _completeOnboarding,
child: const Text(
'跳过',
style: TextStyle(
color: Color(0xFF5D4037),
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
// ── 底部指示器 ──
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 120,
left: 0,
right: 0,
child: _buildDotIndicator(),
),
// ── 完成按钮 (仅最后一页) ──
if (_currentPage == _pages.length - 1)
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 40,
left: 40,
right: 40,
child: SizedBox(
height: 56,
child: ElevatedButton(
onPressed: _isCompleting ? null : _completeOnboarding,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryGreen,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
elevation: 4,
disabledBackgroundColor: AppTheme.primaryGreen.withValues(alpha: 0.6),
),
child: _isCompleting
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'开始使用',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
),
),
// ── 末页的 "上一页" 按钮 ──
if (_currentPage == _pages.length - 1)
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 104,
left: 0,
right: 0,
child: TextButton(
onPressed: () => _pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
),
child: const Text(
'再看一眼',
style: TextStyle(
color: Color(0xFF5D4037),
fontSize: 14,
),
),
),
),
],
),
);
}
// ── 单页构建 ──
Widget _buildOnboardingPage({
required _PageData page,
required int index,
}) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
page.gradientColors[0].withValues(alpha: 0.08),
page.gradientColors[1].withValues(alpha: 0.04),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Spacer(flex: 2),
// 大号 emoji 作为视觉焦点
Text(
page.emoji,
style: const TextStyle(fontSize: 80),
),
const SizedBox(height: 40),
// 标题
Text(
page.title,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
height: 1.3,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// 副标题
Text(
page.subtitle,
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
height: 1.5,
),
textAlign: TextAlign.center,
),
const Spacer(flex: 3),
],
),
),
),
);
}
// ── 圆点指示器 ──
Widget _buildDotIndicator() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_pages.length, (i) {
final isActive = i == _currentPage;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: isActive ? 28 : 8,
height: 8,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: isActive
? _pages[i].gradientColors[0]
: Colors.grey.shade300,
),
);
}),
);
}
}
/// 单页数据模型
class _PageData {
final String emoji;
final String title;
final String subtitle;
final List<Color> gradientColors;
const _PageData({
required this.emoji,
required this.title,
required this.subtitle,
required this.gradientColors,
});
}
8.1 完整的 Onboarding 入口(带版本检查)
/// lib/pages/onboarding/onboarding_gate.dart
import 'package:flutter/material.dart';
import 'package:hive_ce/hive.dart';
import '../../data/settings.dart';
import '../../data/mood_storage.dart';
import '../home_page.dart';
import 'onboarding_page.dart';
/// 引导页的版本号。
/// 当引导页内容发生变化时(增页、改文案、改动画),递增此值。
const int kOnboardingVersion = 1;
/// 引导页入口守卫。
/// 在 MaterialApp 的 home 属性中使用此类。
class OnboardingGate extends StatefulWidget {
final AppSettings settings;
final MoodStorage moodStorage;
const OnboardingGate({
super.key,
required this.settings,
required this.moodStorage,
});
State<OnboardingGate> createState() => _OnboardingGateState();
}
class _OnboardingGateState extends State<OnboardingGate> {
bool _isLoading = true;
bool _needsOnboarding = false;
void initState() {
super.initState();
_checkOnboardingState();
}
Future<void> _checkOnboardingState() async {
final needsOnboarding = await _shouldShowOnboarding();
if (!mounted) return;
setState(() {
_needsOnboarding = needsOnboarding;
_isLoading = false;
});
}
Future<bool> _shouldShowOnboarding() async {
try {
final box = await Hive.openBox('settings');
final hasSeen = box.get('hasSeenOnboarding', defaultValue: false) as bool;
if (!hasSeen) return true;
final version = box.get('onboardingVersion', defaultValue: 0) as int;
if (version < kOnboardingVersion) return true;
return false;
} catch (e) {
debugPrint('[E-Brufen] Failed to check onboarding state: $e');
// 状态读取失败,保守策略:显示引导页
return true;
}
}
Widget build(BuildContext context) {
// 加载中显示启动页
if (_isLoading) {
return const Scaffold(
backgroundColor: Color(0xFFFFF8F0),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('🌿', style: TextStyle(fontSize: 64)),
SizedBox(height: 16),
Text(
'E-Brufen',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
],
),
),
);
}
if (_needsOnboarding) {
return OnboardingPage(
settings: widget.settings,
moodStorage: widget.moodStorage,
);
}
return HomePage(
moodStorage: widget.moodStorage,
settings: widget.settings,
);
}
}
然后在 main.dart 中替换 home:
// main.dart
class EBrufenApp extends StatelessWidget {
// ...
Widget build(BuildContext context) {
return MaterialApp(
title: 'E-Brufen',
debugShowCheckedModeBanner: false,
theme: AppTheme.materialTheme,
home: OnboardingGate(
settings: settings,
moodStorage: moodStorage,
),
);
}
}
九、鸿蒙平台兼容性处理
9.1 已知兼容性风险
| 风险点 | 影响 | 解决方案 |
|---|---|---|
| Hive 文件路径差异 | 首次启动判断可能失效 | 使用 Hive CE 的默认路径,不要硬编码 |
| 状态栏高度差异 | SkipButton 的 top 偏移不准确 | 使用 MediaQuery.of(context).padding.top |
| 字体渲染差异 | 中文字体 fallback 可能不同 | 测试鸿蒙真机上的文字排版效果 |
| 动画性能 | 鸿蒙设备 GPU 性能差异大 | 避免每帧重建大型 Widget,使用 AnimatedBuilder 局部更新 |
| SafeArea 行为 | 底部导航栏/手势条高度不同 | 所有 fixed 元素使用 SafeArea + MediaQuery.padding |
9.2 性能优化清单
// 1. 使用 const 构造函数避免不必要的重建
const OnboardingPage({super.key, required this.settings});
// 2. 大列表使用 PageView.builder 而非 children: [...]
PageView.builder(
itemCount: _pages.length,
itemBuilder: (context, index) => _buildPage(index),
);
// 3. 动画使用 AnimatedBuilder 实现局部更新
// 好的写法:只有 Transform.scale 子树被重建
AnimatedBuilder(
animation: _controller,
builder: (context, child) => Transform.scale(
scale: 1.0 + _controller.value * 0.5,
child: child, // child 被缓存,不重建
),
child: _buildCircle(), // 只在 AnimatedBuilder 初始化时构建一次
);
// 4. 离开页面时 dispose 所有资源
void dispose() {
_pageController.dispose();
super.dispose();
}
9.3 鸿蒙特有的调试技巧
在 DevEco Studio 中运行 Flutter 应用时,可以通过以下方式验证引导页:
# 清除应用数据(模拟首次启动)
hdc shell am force-stop com.example.firstproject
hdc shell rm -rf /data/app/el2/100/base/com.example.firstproject/hive/
# 或者通过 DevEco Studio 的 "Application > Clear Data" 按钮
十、A/B 测试方案
虽然 E-Brufen 目前处于开发阶段,但提前设计 A/B 测试方案可以从上线第一天就开始收集数据。
10.1 实验设计
| 参数 | 值 |
|---|---|
| 实验名称 | Onboarding V0(无引导页)vs V1(三页引导页) |
| 实验周期 | 14 天 |
| 流量分配 | 50%:50% 随机分组(基于设备 ID 哈希) |
| 核心指标 | 次日留存率、3 功能触达率、7 日留存率 |
| 护栏指标 | 崩溃率、首页加载时间 |
10.2 分组实现
/// 简单的 A/B 分组工具
class ABTest {
/// 基于稳定标识符一致地将用户分配到实验组
/// 同一个设备 ID 总是得到相同的分组结果
static bool isInExperimentGroup(String deviceId, String experimentName,
{double ratio = 0.5}) {
final seed = '$deviceId:$experimentName';
final hash = seed.hashCode.abs();
return (hash % 10000) < (ratio * 10000).toInt();
}
}
/// 在 OnboardingGate 中使用
Future<void> _checkOnboardingState() async {
final needsOnboarding = await _shouldShowOnboarding();
if (!needsOnboarding) {
_navigateToHome();
return;
}
// A/B 测试:50% 用户看到引导页,50% 直接进主界面
final deviceId = await _getDeviceId();
final showOnboarding =
ABTest.isInExperimentGroup(deviceId, 'onboarding_v1');
if (showOnboarding) {
_showOnboarding();
} else {
// 对照组:直接进入主界面,但标记"已完成"以便后续数据对比
await _markOnboardingSeen();
_navigateToHome();
}
}
10.3 数据埋点
/// 引导页关键事件的埋点
class OnboardingAnalytics {
static void trackPageView(int pageIndex) {
// 上报到 Firebase / 自有分析系统
debugPrint('[Analytics] onboarding_page_view: page=$pageIndex');
}
static void trackSkip() {
debugPrint('[Analytics] onboarding_skip');
}
static void trackComplete(int totalPagesViewed) {
debugPrint('[Analytics] onboarding_complete: pages=$totalPagesViewed');
}
static void trackFirstFeatureUse(String featureName) {
debugPrint('[Analytics] first_feature_use: feature=$featureName');
}
}
10.4 预期结果与解读
| 假设 | 预期结果 | 验证方式 |
|---|---|---|
| 引导页提升功能发现率 | 实验组的功能触达率比对照组高 20%+ | 对比两组用户首次使用任一功能的占比 |
| 引导页不影响首日留存 | 两组首日留存无显著差异 | 卡方检验,p > 0.05 |
| 引导页提升次日留存 | 实验组次日留存比对照组高 10%+ | 漏斗分析 |
| 跳过引导页的用户留存低 | 跳过组的留存显著低于看完组 | 细分分析 |
10.5 A/B 测试实践中的数据解读误区
需要特别注意:相关不等于因果。如果实验组的留存更高,可能不是因为引导页本身的设计好,而是因为"愿意看完引导页的用户"本身就是高意向用户,他们留存率高是天生的。正确的做法是意图治疗分析(Intent-to-Treat, ITT):
- 不做分组后的人为筛选
- 对比的是"被分配到实验组的所有用户" vs “被分配到对照组的所有用户”
- 即使实验组中有 30% 的用户跳过了引导页,他们也计入实验组统计
十一、测试与调试技巧
11.1 单元测试:OnboardingGate 逻辑
import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart';
void main() {
late Box box;
setUp(() async {
// 使用临时目录初始化 Hive 进行单元测试
Hive.init('./test/hive_test');
box = await Hive.openBox('test_settings');
});
tearDown(() async {
await box.clear();
await box.close();
});
test('首次启动应显示引导页', () async {
// 没有 hasSeenOnboarding 键 → 应返回 true
final needsOnboarding = !(box.get('hasSeenOnboarding', defaultValue: false));
expect(needsOnboarding, true);
});
test('看过后不应再显示', () async {
await box.put('hasSeenOnboarding', true);
await box.put('onboardingVersion', 1);
final hasSeen = box.get('hasSeenOnboarding', defaultValue: false) as bool;
final version = box.get('onboardingVersion', defaultValue: 0) as int;
expect(hasSeen, true);
expect(version, 1);
expect(hasSeen && version >= 1, true);
});
test('版本升级后应重新显示引导页', () async {
// 模拟 V1 用户:看过引导页但 version = 0
await box.put('hasSeenOnboarding', true);
await box.put('onboardingVersion', 0);
final hasSeen = box.get('hasSeenOnboarding', defaultValue: false) as bool;
final version = box.get('onboardingVersion', defaultValue: 0) as int;
expect(hasSeen, true);
expect(version < 1, true); // 需要重新显示
});
}
11.2 Widget 测试:页面滑动
testWidgets('滑动到第 3 页时应显示完成按钮', (tester) async {
await tester.pumpWidget(MaterialApp(
home: OnboardingPage(
settings: MockSettings(),
moodStorage: MockMoodStorage(),
),
));
// 初始状态:第 1 页,应该没有"开始使用"按钮
expect(find.text('开始使用'), findsNothing);
// 滑动到第 3 页
final pageView = tester.widget<PageView>(find.byType(PageView));
final controller = pageView.controller;
controller?.jumpToPage(2);
await tester.pumpAndSettle();
// 应该出现"开始使用"按钮
expect(find.text('开始使用'), findsOneWidget);
});
11.3 调试开关
在开发阶段,添加一个调试开关来跳过引导页:
class OnboardingGate extends StatefulWidget {
// ...
/// 调试模式:设为 true 强制显示引导页,false 强制跳过
static bool debugForceShowOnboarding = false;
static bool debugForceSkipOnboarding = false;
Future<bool> _shouldShowOnboarding() async {
// 调试开关优先
if (debugForceShowOnboarding) return true;
if (debugForceSkipOnboarding) return false;
// 正常逻辑
// ...
}
}
在 main.dart 中:
void main() async {
// 调试:强制显示引导页
// OnboardingGate.debugForceShowOnboarding = true;
// 调试:强制跳过引导页
// OnboardingGate.debugForceSkipOnboarding = true;
// ... 其余启动逻辑
}
11.4 常见 Bug 排查清单
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 引导页每次启动都出现 | hasSeenOnboarding 未成功写入 |
检查 Hive Box 是否 open,检查 put 是否抛异常 |
| 动画不播放 | 页面未达到可见阈值 | 在 PageView.onPageChanged 中加 log |
| 滑动卡顿 | 动画在不可见页面上继续运行 | 使用 VisibilityDetector 或 page 监听 |
| "开始使用"按钮点击无响应 | Navigator.pushReplacement 的 context 失效 |
确保 context 来自 build 方法,非全局持有 |
| 鸿蒙设备上 SafeArea 偏移过大 | 鸿蒙状态栏高度与 Android 不同 | 使用 MediaQuery.padding 而非硬编码 |
十二、小结
引导页看似简单——几页滑动、几个圆点、一个按钮——但要把它做好,需要考虑的细节远比直觉上的多。本文覆盖了从设计原则到代码实现、从动画系统到 A/B 测试、从测试调试到鸿蒙适配的完整链路。
让我们回顾核心要点:
- 设计层面:3 页最佳、一页一信息、展示而非告知。这是经过大量 A/B 测试验证的原则。
- 实现层面:PageView + PageController 是 Flutter 引导页的标准解。关键细节包括
page.round()的状态判断、pushReplacement的路由策略、ValueKey的动画去重。 - 动画层面:每页一个特色动画(emoji 弹入、呼吸球演示、场景脉冲),使用
AnimatedBuilder实现局部更新以保证性能。 - 状态层面:Hive CE 持久化 + 版本号机制,确保引导页在合适的时机出现,不重复、不遗漏。
- 鸿蒙层面:使用纯 Dart 方案(Hive CE)避免平台依赖,使用
MediaQuery.padding而非硬编码适配不同设备。 - 数据层面:从第一天就设计好 A/B 测试方案,用数据验证引导页的实际效果,而不是凭感觉决策。
引导页是用户体验的第一触点。花足够的时间打磨它——用户会用留存率和功能触达率来投票。
作者简介
E-Brufen Dev,AtomGit Flutter 鸿蒙客户端核心开发者。专注于 Flutter + HarmonyOS 技术栈的探索与实践。开源项目 E-Brufen 维护者。
更多推荐

所有评论(0)