打造流畅的应用启动体验

前言

启动页和引导页是用户接触应用的第一印象。一个精心设计的启动动画能提升应用的品质感,而引导页则能帮助新用户快速了解应用功能。

CleanMark AI 的启动页采用渐变背景 + Logo 缩放动画,引导页使用 PageView 实现滑动切换。这篇文章我会带你实现完整的启动流程。


一、启动流程设计

1.1 启动流程图

应用启动
   ↓
启动页 (2秒动画)
   ↓
判断是否首次启动
   ↓
是 → 引导页 (3页滑动)
   ↓
否 → 判断登录状态
   ↓
已登录 → 首页
未登录 → 登录页

1.2 状态管理

使用 SharedPreferences 记录启动状态:

// lib/core/utils/app_prefs.dart

class AppPrefs {
  static const String _keyOnboardingDone = 'onboarding_done';
  static const String _keyAuthToken = 'auth_token';

  /// 引导页是否已完成
  static Future<bool> isOnboardingDone() async {
    final prefs = await SharedPreferences.getInstance();
    return prefs.getBool(_keyOnboardingDone) ?? false;
  }

  /// 标记引导页已完成
  static Future<void> setOnboardingDone() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setBool(_keyOnboardingDone, true);
  }

  /// 是否已登录
  static Future<bool> isLoggedIn() async {
    final prefs = await SharedPreferences.getInstance();
    final token = prefs.getString(_keyAuthToken);
    return token != null && token.isNotEmpty;
  }
}

二、启动页实现

2.1 启动页 UI

// lib/features/splash/splash_page.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../app/app_colors.dart';
import '../../core/utils/app_prefs.dart';

class SplashPage extends StatefulWidget {
  const SplashPage({super.key});

  
  State<SplashPage> createState() => _SplashPageState();
}

class _SplashPageState extends State<SplashPage>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _scaleAnimation;
  late Animation<double> _fadeAnimation;

  
  void initState() {
    super.initState();
    _initAnimation();
    _navigateToNext();
  }

  void _initAnimation() {
    _controller = AnimationController(
      duration: const Duration(milliseconds: 2000),
      vsync: this,
    );

    // Logo 缩放动画 (0.5 → 1.0)
    _scaleAnimation = Tween<double>(
      begin: 0.5,
      end: 1.0,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeOutBack,
    ));

    // 渐显动画 (0.0 → 1.0)
    _fadeAnimation = Tween<double>(
      begin: 0.0,
      end: 1.0,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeIn,
    ));

    _controller.forward();
  }

  Future<void> _navigateToNext() async {
    // 等待动画完成
    await Future.delayed(const Duration(milliseconds: 2000));

    if (!mounted) return;

    // 判断启动流程
    final onboardingDone = await AppPrefs.isOnboardingDone();

    if (!onboardingDone) {
      // 首次启动,进入引导页
      context.go('/onboarding');
    } else {
      // 非首次启动,判断登录状态
      final isLoggedIn = await AppPrefs.isLoggedIn();
      if (isLoggedIn) {
        context.go('/home');
      } else {
        context.go('/login');
      }
    }
  }

  
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        decoration: const BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topLeft,
            end: Alignment.bottomRight,
            colors: [
              Color(0xFF1e0b4a),
              AppColors.black,
            ],
          ),
        ),
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              // Logo 动画
              AnimatedBuilder(
                animation: _controller,
                builder: (context, child) {
                  return Transform.scale(
                    scale: _scaleAnimation.value,
                    child: Opacity(
                      opacity: _fadeAnimation.value,
                      child: child,
                    ),
                  );
                },
                child: Container(
                  width: 120,
                  height: 120,
                  decoration: BoxDecoration(
                    color: AppColors.purple,
                    borderRadius: BorderRadius.circular(30),
                    boxShadow: [
                      BoxShadow(
                        color: AppColors.purple.withOpacity(0.5),
                        blurRadius: 30,
                        spreadRadius: 5,
                      ),
                    ],
                  ),
                  child: const Icon(
                    Icons.auto_fix_high,
                    size: 60,
                    color: Colors.white,
                  ),
                ),
              ),
              const SizedBox(height: 24),
              // 应用名称
              FadeTransition(
                opacity: _fadeAnimation,
                child: const Text(
                  'CleanMark AI',
                  style: TextStyle(
                    color: Colors.white,
                    fontSize: 32,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
              const SizedBox(height: 8),
              // 副标题
              FadeTransition(
                opacity: _fadeAnimation,
                child: Text(
                  'AI 智能去水印',
                  style: TextStyle(
                    color: Colors.white.withOpacity(0.7),
                    fontSize: 16,
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

设计要点:

  1. 渐变背景: 深紫色到黑色的渐变,营造科技感
  2. Logo 动画: 缩放 + 渐显,使用 easeOutBack 曲线产生弹性效果
  3. 动画时长: 2秒,既不会太快也不会让用户等待太久
  4. 自动跳转: 动画结束后根据状态自动跳转

本篇小结(第一部分)

这部分我们完成了:

  1. ✅ 启动流程设计
  2. ✅ 状态管理(SharedPreferences)
  3. ✅ 启动页 UI 和动画实现

下一部分我会继续讲解引导页的实现。


三、引导页实现

3.1 引导页数据模型

// lib/features/onboarding/models/onboarding_item.dart

class OnboardingItem {
  final String title;
  final String description;
  final IconData icon;
  final Color color;

  const OnboardingItem({
    required this.title,
    required this.description,
    required this.icon,
    required this.color,
  });
}

// 引导页数据
final List<OnboardingItem> onboardingItems = [
  OnboardingItem(
    title: 'AI 智能识别',
    description: '先进的 AI 算法自动识别图片和视频中的水印位置',
    icon: Icons.auto_awesome,
    color: AppColors.purple,
  ),
  OnboardingItem(
    title: '一键去除',
    description: '只需上传文件,AI 自动处理,快速去除水印',
    icon: Icons.touch_app,
    color: AppColors.orange,
  ),
  OnboardingItem(
    title: '高质量输出',
    description: '保持原始画质,无损去除水印,效果自然',
    icon: Icons.high_quality,
    color: AppColors.green,
  ),
];

3.2 引导页 UI

// lib/features/onboarding/onboarding_page.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../app/app_colors.dart';
import '../../core/utils/app_prefs.dart';
import '../../shared/widgets/gradient_button.dart';
import 'models/onboarding_item.dart';

class OnboardingPage extends StatefulWidget {
  const OnboardingPage({super.key});

  
  State<OnboardingPage> createState() => _OnboardingPageState();
}

class _OnboardingPageState extends State<OnboardingPage> {
  final PageController _pageController = PageController();
  int _currentPage = 0;

  
  void dispose() {
    _pageController.dispose();
    super.dispose();
  }

  void _onPageChanged(int page) {
    setState(() {
      _currentPage = page;
    });
  }

  Future<void> _onGetStarted() async {
    // 标记引导页已完成
    await AppPrefs.setOnboardingDone();

    if (!mounted) return;

    // 跳转到登录页
    context.go('/login');
  }

  void _onSkip() {
    // 跳转到最后一页
    _pageController.animateToPage(
      onboardingItems.length - 1,
      duration: const Duration(milliseconds: 300),
      curve: Curves.easeInOut,
    );
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: AppColors.bgDark,
      body: SafeArea(
        child: Column(
          children: [
            // 顶部跳过按钮
            if (_currentPage < onboardingItems.length - 1)
              Align(
                alignment: Alignment.topRight,
                child: TextButton(
                  onPressed: _onSkip,
                  child: const Text(
                    '跳过',
                    style: TextStyle(
                      color: AppColors.gray400,
                      fontSize: 16,
                    ),
                  ),
                ),
              )
            else
              const SizedBox(height: 48),

            // PageView
            Expanded(
              child: PageView.builder(
                controller: _pageController,
                onPageChanged: _onPageChanged,
                itemCount: onboardingItems.length,
                itemBuilder: (context, index) {
                  return _buildPage(onboardingItems[index]);
                },
              ),
            ),

            // 指示器
            _buildIndicator(),

            const SizedBox(height: 32),

            // 底部按钮
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 24),
              child: _currentPage == onboardingItems.length - 1
                  ? GradientButton(
                      text: '立即开始',
                      onPressed: _onGetStarted,
                    )
                  : GradientButton(
                      text: '下一步',
                      onPressed: () {
                        _pageController.nextPage(
                          duration: const Duration(milliseconds: 300),
                          curve: Curves.easeInOut,
                        );
                      },
                    ),
            ),

            const SizedBox(height: 32),
          ],
        ),
      ),
    );
  }

  Widget _buildPage(OnboardingItem item) {
    return Padding(
      padding: const EdgeInsets.all(40),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          // 图标
          Container(
            width: 120,
            height: 120,
            decoration: BoxDecoration(
              color: item.color.withOpacity(0.1),
              shape: BoxShape.circle,
            ),
            child: Icon(
              item.icon,
              size: 60,
              color: item.color,
            ),
          ),

          const SizedBox(height: 48),

          // 标题
          Text(
            item.title,
            style: const TextStyle(
              color: AppColors.white,
              fontSize: 28,
              fontWeight: FontWeight.bold,
            ),
            textAlign: TextAlign.center,
          ),

          const SizedBox(height: 16),

          // 描述
          Text(
            item.description,
            style: const TextStyle(
              color: AppColors.gray400,
              fontSize: 16,
              height: 1.5,
            ),
            textAlign: TextAlign.center,
          ),
        ],
      ),
    );
  }

  Widget _buildIndicator() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: List.generate(
        onboardingItems.length,
        (index) => AnimatedContainer(
          duration: const Duration(milliseconds: 300),
          margin: const EdgeInsets.symmetric(horizontal: 4),
          width: _currentPage == index ? 24 : 8,
          height: 8,
          decoration: BoxDecoration(
            color: _currentPage == index
                ? AppColors.purple
                : AppColors.gray700,
            borderRadius: BorderRadius.circular(4),
          ),
        ),
      ),
    );
  }
}
I've added the onboarding page implementation with:
  1. Data model for onboarding items
  2. PageView-based UI with three pages
  3. Page indicator with animation
  4. Skip button and navigation logic

Now I should continue with the next section covering animations and optimizations. Let me add that in the next segment.

设计要点:

  1. PageView: 使用 PageView.builder 实现滑动切换
  2. 指示器动画: 当前页指示器宽度变化,使用 AnimatedContainer
  3. 跳过功能: 点击跳过直接跳转到最后一页
  4. 状态保存: 完成后标记引导页已完成,下次启动不再显示

四、动画增强

4.1 页面切换动画

为引导页添加视差滚动效果:

// lib/features/onboarding/widgets/parallax_page.dart

class ParallaxPage extends StatelessWidget {
  final OnboardingItem item;
  final double pageOffset;

  const ParallaxPage({
    required this.item,
    required this.pageOffset,
    super.key,
  });

  
  Widget build(BuildContext context) {
    // 计算视差偏移量
    final iconOffset = pageOffset * 50;
    final textOffset = pageOffset * 30;

    return Padding(
      padding: const EdgeInsets.all(40),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          // 图标 - 视差效果
          Transform.translate(
            offset: Offset(iconOffset, 0),
            child: Opacity(
              opacity: 1 - pageOffset.abs().clamp(0.0, 1.0),
              child: Container(
                width: 120,
                height: 120,
                decoration: BoxDecoration(
                  color: item.color.withOpacity(0.1),
                  shape: BoxShape.circle,
                ),
                child: Icon(
                  item.icon,
                  size: 60,
                  color: item.color,
                ),
              ),
            ),
          ),

          const SizedBox(height: 48),

          // 文字 - 视差效果
          Transform.translate(
            offset: Offset(textOffset, 0),
            child: Opacity(
              opacity: 1 - pageOffset.abs().clamp(0.0, 1.0),
              child: Column(
                children: [
                  Text(
                    item.title,
                    style: const TextStyle(
                      color: AppColors.white,
                      fontSize: 28,
                      fontWeight: FontWeight.bold,
                    ),
                    textAlign: TextAlign.center,
                  ),
                  const SizedBox(height: 16),
                  Text(
                    item.description,
                    style: const TextStyle(
                      color: AppColors.gray400,
                      fontSize: 16,
                      height: 1.5,
                    ),
                    textAlign: TextAlign.center,
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

在 OnboardingPage 中使用:

PageView.builder(
  controller: _pageController,
  onPageChanged: _onPageChanged,
  itemCount: onboardingItems.length,
  itemBuilder: (context, index) {
    // 计算页面偏移量
    double pageOffset = 0.0;
    if (_pageController.hasClients && _pageController.position.haveDimensions) {
      pageOffset = (_pageController.page ?? 0) - index;
    }

    return ParallaxPage(
      item: onboardingItems[index],
      pageOffset: pageOffset,
    );
  },
)

4.2 按钮动画

为"立即开始"按钮添加脉冲动画:

// lib/shared/widgets/pulse_button.dart

class PulseButton extends StatefulWidget {
  final String text;
  final VoidCallback onPressed;

  const PulseButton({
    required this.text,
    required this.onPressed,
    super.key,
  });

  
  State<PulseButton> createState() => _PulseButtonState();
}

class _PulseButtonState extends State<PulseButton>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _scaleAnimation;

  
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 1500),
      vsync: this,
    )..repeat(reverse: true);

    _scaleAnimation = Tween<double>(
      begin: 1.0,
      end: 1.05,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    ));
  }

  
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _scaleAnimation,
      builder: (context, child) {
        return Transform.scale(
          scale: _scaleAnimation.value,
          child: child,
        );
      },
      child: GradientButton(
        text: widget.text,
        onPressed: widget.onPressed,
      ),
    );
  }
}
I've added animation enhancements including parallax scrolling effects and pulse button animation. Now I should add sections on:
  1. HarmonyOS-specific considerations
  2. Performance optimization
  3. Route integration
  4. Summary and key points

Let me continue with the HarmonyOS considerations and route integration sections.


五、路由集成

5.1 GoRouter 配置

// lib/app/router.dart

final router = GoRouter(
  initialLocation: '/splash',
  routes: [
    // 启动页
    GoRoute(
      path: '/splash',
      builder: (context, state) => const SplashPage(),
    ),

    // 引导页
    GoRoute(
      path: '/onboarding',
      builder: (context, state) => const OnboardingPage(),
    ),

    // 登录页
    GoRoute(
      path: '/login',
      builder: (context, state) => const LoginPage(),
    ),

    // 首页
    GoRoute(
      path: '/home',
      builder: (context, state) => const HomePage(),
    ),
  ],
);

5.2 启动逻辑优化

在 main.dart 中预加载状态:

// lib/main.dart

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // 初始化本地存储
  await AppPrefs.init();

  // 预判断启动路由
  String initialRoute = '/splash';

  // 如果已完成引导且已登录,直接进入首页
  final onboardingDone = await AppPrefs.isOnboardingDone();
  final isLoggedIn = await AppPrefs.isLoggedIn();

  if (onboardingDone && isLoggedIn) {
    initialRoute = '/home';
  }

  runApp(MyApp(initialRoute: initialRoute));
}

class MyApp extends StatelessWidget {
  final String initialRoute;

  const MyApp({required this.initialRoute, super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'CleanMark AI',
      theme: AppTheme.dark,
      routerConfig: GoRouter(
        initialLocation: initialRoute,
        routes: [...],
      ),
    );
  }
}

六、HarmonyOS 适配要点

6.1 启动页配置

HarmonyOS 需要在 module.json5 中配置启动页:

// ohos/entry/src/main/module.json5
{
  "module": {
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:icon",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:splash_icon",
        "startWindowBackground": "$color:splash_background"
      }
    ]
  }
}

6.2 原生启动页

HarmonyOS 可以使用原生启动窗口,避免白屏:

// ohos/entry/src/main/ets/entryability/EntryAbility.ets

import UIAbility from '@ohos.app.ability.UIAbility';
import window from '@ohos.window';

export default class EntryAbility extends UIAbility {
  onWindowStageCreate(windowStage: window.WindowStage) {
    // 设置启动窗口背景
    windowStage.loadContent('pages/Index', (err, data) => {
      if (err.code) {
        return;
      }

      // 获取主窗口
      windowStage.getMainWindow((err, windowClass) => {
        if (err.code) {
          return;
        }

        // 设置全屏
        windowClass.setWindowLayoutFullScreen(true);
      });
    });
  }
}

6.3 状态栏适配

// lib/features/splash/splash_page.dart

import 'package:flutter/services.dart';


void initState() {
  super.initState();

  // 设置状态栏样式
  SystemChrome.setSystemUIOverlayStyle(
    const SystemUiOverlayStyle(
      statusBarColor: Colors.transparent,
      statusBarIconBrightness: Brightness.light,
    ),
  );

  _initAnimation();
  _navigateToNext();
}

七、性能优化

7.1 预加载优化

// 在启动页预加载必要资源
class SplashPage extends StatefulWidget {
  
  void initState() {
    super.initState();
    _preloadResources();
  }

  Future<void> _preloadResources() async {
    // 预加载图片
    await precacheImage(
      const AssetImage('assets/images/logo.png'),
      context,
    );

    // 预初始化服务
    await Future.wait([
      AppPrefs.init(),
      // 其他初始化操作
    ]);
  }
}

7.2 动画性能优化

// 使用 RepaintBoundary 隔离重绘区域
RepaintBoundary(
  child: AnimatedBuilder(
    animation: _controller,
    builder: (context, child) {
      return Transform.scale(
        scale: _scaleAnimation.value,
        child: child,
      );
    },
    child: const Logo(),
  ),
)

7.3 内存优化

// 及时释放动画控制器

void dispose() {
  _controller.dispose();
  _pageController.dispose();
  super.dispose();
}

八、实战技巧

8.1 调试启动流程

// 添加调试开关,方便测试
class DebugConfig {
  static const bool skipSplash = false;  // 跳过启动页
  static const bool skipOnboarding = false;  // 跳过引导页
  static const bool forceOnboarding = false;  // 强制显示引导页
}

// 在启动逻辑中使用
Future<void> _navigateToNext() async {
  if (DebugConfig.skipSplash) {
    context.go('/home');
    return;
  }

  await Future.delayed(const Duration(milliseconds: 2000));

  if (DebugConfig.forceOnboarding) {
    context.go('/onboarding');
    return;
  }

  // 正常流程...
}

8.2 重置引导页

// 提供重置功能,方便测试
class SettingsPage extends StatelessWidget {
  Future<void> _resetOnboarding() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.remove('onboarding_done');

    // 提示用户
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('引导页已重置,重启应用生效')),
    );
  }
}

本篇完整小结

这篇文章我们完成了:

  1. ✅ 启动流程设计(启动页 → 引导页 → 首页/登录页)
  2. ✅ 启动页实现(渐变背景 + Logo 动画)
  3. ✅ 引导页实现(PageView + 指示器)
  4. ✅ 动画增强(视差滚动 + 脉冲按钮)
  5. ✅ 路由集成(GoRouter 配置)
  6. ✅ HarmonyOS 适配(原生启动窗口)
  7. ✅ 性能优化(预加载 + RepaintBoundary)
  8. ✅ 实战技巧(调试开关 + 重置功能)

关键要点:

  • 使用 SharedPreferences 记录启动状态
  • 启动页动画时长控制在 2 秒左右
  • 引导页使用 PageView 实现滑动切换
  • 添加视差滚动效果提升体验
  • HarmonyOS 配置原生启动窗口避免白屏
  • 使用 RepaintBoundary 优化动画性能
  • 提供调试开关方便开发测试

动画设计原则:

  1. 时长适中:不要太快(用户看不清)也不要太慢(用户等待)
  2. 曲线选择:使用 easeOutBack 产生弹性效果
  3. 视差效果:不同元素不同速度,增加层次感
  4. 性能优先:使用 RepaintBoundary 隔离重绘区域

思考题

  1. 为什么要在 main.dart 中预判断启动路由,而不是在启动页中判断?
  2. 如何实现引导页只在首次安装后显示,更新版本不显示?
  3. 视差滚动效果的原理是什么?如何计算偏移量?

下一篇预告:第09篇 - 图片去水印功能实现

更多推荐