使用 Bloc 实现 Flutter 暗黑主题切换与持久化


使用 Bloc 实现 Flutter 暗黑主题切换与持久化

现在的App开发,总是免不了暗黑主题和亮色主题的需求,暗黑模式(Dark Mode) 已成为用户体验的重要组成部分。我们希望应用能够根据系统设置或用户偏好自动切换主题,并在重启后保持一致。本篇文章将带你一步步使用 Blochydrated_bloc 来实现这一目标。

先来看看效果图:



✨ 为什么选择 Bloc?

Bloc 是 Flutter 中广泛使用的状态管理方案之一。它基于事件(Event)驱动和状态(State)响应的模式,将 UI 与业务逻辑有效分离,从而提升了代码的可读性、可维护性以及可测试性。

在此基础上,hydrated_bloc 作为 Bloc 的扩展,提供了自动状态持久化的能力,可以将状态存储在本地磁盘。对于需要在应用重启后保留设置(如主题模式)的场景来说,它是一个非常理想的选择。

✨ 为什么选择 hydrated_bloc 而不是 shared_preferences?

在需要保存用户偏好(如暗黑模式)时,shared_preferenceshydrated_bloc 都是常见的选择,但它们各自的适用场景和优势不同。

✅ hydrated_bloc 的优势

对比维度 hydrated_bloc shared_preferences
集成方式 内建于 Bloc 架构,状态持久化自动进行 独立处理,需要手动管理读写
序列化/反序列化 自动进行,仅需实现 fromJson / toJson 方法 需手动处理每个字段的读写逻辑
状态同步 状态与 UI 自动保持同步,无需额外逻辑 状态更新后需要手动通知 UI
代码维护性 高,可读性强,逻辑集中 易产生重复代码,分散管理
性能表现 基于内存缓存 + 本地存储,读取速度快 每次读取都需访问磁盘,略慢
支持对象结构 支持复杂状态对象持久化 仅支持基本类型,复杂结构需手动拆解

🚀 性能对比:读取速度更快

hydrated_bloc 内部使用的是内存缓存机制:首次从本地读取状态后,会保留在内存中,因此在后续使用过程中几乎不需要重新读取磁盘,状态恢复几乎是即时的

相比之下,shared_preferences 每次读取都涉及异步的磁盘访问,哪怕只是一两个键值,也需要 await 操作,这在应用启动时或快速切换主题时可能造成微小的延迟。

✅ 简单来说:

如果你已经在使用 Bloc,hydrated_bloc 会让你几乎"无感知"地实现状态持久化,开发更轻松,体验更流畅。


📁 第一步:添加依赖

pubspec.yaml 中添加以下依赖项:

yaml 复制代码
dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.8
  flutter_bloc: ^9.1.0
  hydrated_bloc: ^10.0.0
  path_provider: ^2.1.5
  equatable: ^2.0.5

✅ 依赖说明:

  • flutter_bloc:核心 Bloc 功能。
  • hydrated_bloc:实现 Bloc 状态持久化。
  • path_provider:获取持久化存储路径。
  • equatable:简化状态对比,提高 Bloc 性能。

🎨 第二步:定义浅色与暗色主题

创建一个 AppThemes 类,包含浅色和暗黑主题定义:

dart 复制代码
import 'package:flutter/material.dart';

class AppThemes {
  // Light theme
  static final ThemeData lightTheme = ThemeData(
    useMaterial3: true,
    brightness: Brightness.light,
    colorScheme: ColorScheme.fromSeed(
      seedColor: Colors.blue,
      brightness: Brightness.light,
    ),
    appBarTheme: const AppBarTheme(
      elevation: 0,
      centerTitle: true,
    ),
    elevatedButtonTheme: ElevatedButtonThemeData(
      style: ElevatedButton.styleFrom(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(8),
        ),
      ),
    ),
    cardTheme: CardTheme(
      elevation: 2,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(12),
      ),
    ),
    switchTheme: SwitchThemeData(
      thumbColor: WidgetStateProperty.resolveWith((states) {
        if (states.contains(WidgetState.selected)) {
          return Colors.blue;
        }
        return Colors.grey.shade400;
      }),
      trackColor: WidgetStateProperty.resolveWith((states) {
        if (states.contains(WidgetState.selected)) {
          return Colors.blue.withValues(alpha: 0.5);
        }
        return Colors.grey.shade300;
      }),
    ),
  );

  // Dark theme
  static final ThemeData darkTheme = ThemeData(
    useMaterial3: true,
    brightness: Brightness.dark,
    colorScheme: ColorScheme.fromSeed(
      seedColor: Colors.blue,
      brightness: Brightness.dark,
    ),
    appBarTheme: const AppBarTheme(
      elevation: 0,
      centerTitle: true,
    ),
    elevatedButtonTheme: ElevatedButtonThemeData(
      style: ElevatedButton.styleFrom(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(8),
        ),
      ),
    ),
    cardTheme: CardTheme(
      elevation: 2,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(12),
      ),
    ),
    switchTheme: SwitchThemeData(
      thumbColor: MaterialStateProperty.resolveWith((states) {
        if (states.contains(MaterialState.selected)) {
          return Colors.blue;
        }
        return Colors.grey.shade600;
      }),
      trackColor: WidgetStateProperty.resolveWith((states) {
        if (states.contains(WidgetState.selected)) {
          return Colors.blue.withValues(alpha: 0.5);
        }
        return Colors.grey.shade800;
      }),
    ),
  );
}

🧠 说明:

  • brightness 决定主题亮度。
  • colorScheme.fromSeed 可基于种子色自动生成配色。
  • 我们统一配置了 AppBarThemeElevatedButtonThemeSwitchTheme 等,以适配两种模式。

⚙️ 第三步:创建 ThemeBloc

Bloc 负责监听事件并更新主题状态。

dart 复制代码
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';

import 'app_themes.dart';

part 'theme_event.dart';
part 'theme_state.dart';

class ThemeBloc extends HydratedBloc<ThemeEvent, ThemeState> {
  ThemeBloc()
      : super(ThemeState(
    themeMode: ThemeMode.system,
    lightTheme: AppThemes.lightTheme,
    darkTheme: AppThemes.darkTheme,
  )) {
    on<ThemeStarted>(_onThemeStarted);
    on<ThemeChanged>(_onThemeChanged);
  }

  void _onThemeStarted(ThemeStarted event, Emitter<ThemeState> emit) {
    // No need to load the theme here as HydratedBloc handles it automatically
    // This event is kept for consistency and in case additional initialization is needed
  }

  void _onThemeChanged(ThemeChanged event, Emitter<ThemeState> emit) {
    emit(state.copyWith(themeMode: event.themeMode));
  }

  @override
  ThemeState? fromJson(Map<String, dynamic> json) {
    return ThemeState.fromMap(json);
  }

  @override
  Map<String, dynamic> toJson(ThemeState state) {
    return state.toMap();
  }
}

🧠 说明:

  • ThemeMode.system 表示默认跟随系统。
  • _onThemeChanged 用于响应用户主题切换。
  • fromJsontoJson 方法实现状态的持久化序列化。

🔁 事件定义(Event)

dart 复制代码
part of 'theme_bloc.dart';

abstract class ThemeEvent extends Equatable {
  const ThemeEvent();

  @override
  List<Object> get props => [];
}

class ThemeStarted extends ThemeEvent {}

class ThemeChanged extends ThemeEvent {
  final ThemeMode themeMode;

  const ThemeChanged(this.themeMode);

  @override
  List<Object> get props => [themeMode];
}
  • 所有事件继承自 ThemeEvent
  • ThemeChanged 表示切换主题的事件,携带一个 ThemeMode

📦 状态定义(State)

dart 复制代码
part of 'theme_bloc.dart';

class ThemeState extends Equatable {
  final ThemeMode themeMode;
  final ThemeData lightTheme;
  final ThemeData darkTheme;

  const ThemeState({
    required this.themeMode,
    required this.lightTheme,
    required this.darkTheme,
  });

  @override
  List<Object> get props => [themeMode];

  ThemeState copyWith({
    ThemeMode? themeMode,
    ThemeData? lightTheme,
    ThemeData? darkTheme,
  }) {
    return ThemeState(
      themeMode: themeMode ?? this.themeMode,
      lightTheme: lightTheme ?? this.lightTheme,
      darkTheme: darkTheme ?? this.darkTheme,
    );
  }

  Map<String, dynamic> toMap() {
    return {
      'themeMode': themeMode.index,
    };
  }

  factory ThemeState.fromMap(Map<String, dynamic> map) {
    return ThemeState(
      themeMode: ThemeMode.values[map['themeMode'] ?? 0],
      lightTheme: AppThemes.lightTheme,
      darkTheme: AppThemes.darkTheme,
    );
  }
}
  • 状态中包含当前 ThemeMode 和两个具体主题。
  • 使用 copyWith 实现状态更新。
  • 只持久化 themeMode,而不是整个 ThemeData,简洁高效。

🏁 第四步:初始化并提供 Bloc

main.dart 中完成初始化:

dart 复制代码
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  HydratedBloc.storage = await HydratedStorage.build(
    storageDirectory: await getApplicationDocumentsDirectory(),
  );
  runApp(const MyApp());
}
dart 复制代码
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => ThemeBloc(),
      child: BlocBuilder<ThemeBloc, ThemeState>(
        builder: (context, state) {
          return MaterialApp(
            theme: state.lightTheme,
            darkTheme: state.darkTheme,
            themeMode: state.themeMode,
            home: const HomeScreen(),
          );
        },
      ),
    );
  }
}

✅ 说明:

  • HydratedBloc.storage 初始化状态持久化。
  • 使用 BlocBuilder 动态构建 MaterialApp,根据状态切换主题。

🧩 第五步:主题切换组件 UI

创建 ThemeModeSelector,支持手动选择主题:

dart 复制代码
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_theme_test/theme/theme_bloc.dart';

class ThemeModeSelector extends StatelessWidget {
  const ThemeModeSelector({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<ThemeBloc, ThemeState>(
      builder: (context, state) {
        return PopupMenuButton<ThemeMode>(
          icon: Icon(_getThemeIcon(state.themeMode)),
          tooltip: 'Select theme mode',
          onSelected: (ThemeMode mode) {
            context.read<ThemeBloc>().add(ThemeChanged(mode));
          },
          itemBuilder: (BuildContext context) => <PopupMenuEntry<ThemeMode>>[
            const PopupMenuItem<ThemeMode>(
              value: ThemeMode.light,
              child: Row(
                children: [
                  Icon(Icons.wb_sunny),
                  SizedBox(width: 8),
                  Text('Light'),
                ],
              ),
            ),
            const PopupMenuItem<ThemeMode>(
              value: ThemeMode.dark,
              child: Row(
                children: [
                  Icon(Icons.nightlight_round),
                  SizedBox(width: 8),
                  Text('Dark'),
                ],
              ),
            ),
            const PopupMenuItem<ThemeMode>(
              value: ThemeMode.system,
              child: Row(
                children: [
                  Icon(Icons.settings_suggest),
                  SizedBox(width: 8),
                  Text('System'),
                ],
              ),
            ),
          ],
        );
      },
    );
  }

  IconData _getThemeIcon(ThemeMode themeMode) {
    switch (themeMode) {
      case ThemeMode.light:
        return Icons.wb_sunny;
      case ThemeMode.dark:
        return Icons.nightlight_round;
      case ThemeMode.system:
        return Icons.settings_suggest;
    }
  }
}

🧱 第六步:主题响应组件 - ThemedCard

定义一个样式跟随主题变化的卡片组件:

dart 复制代码
import 'package:flutter/material.dart';

class ThemedCard extends StatelessWidget {
  final String title;
  final String description;
  final IconData icon;

  const ThemedCard({
    super.key,
    required this.title,
    required this.description,
    required this.icon,
  });

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final colorScheme = theme.colorScheme;

    return Card(
      elevation: 4,
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Icon(
                  icon,
                  color: colorScheme.primary,
                  size: 28,
                ),
                const SizedBox(width: 12),
                Text(
                  title,
                  style: theme.textTheme.titleLarge?.copyWith(
                    color: colorScheme.onSurface,
                  ),
                ),
              ],
            ),
            const SizedBox(height: 12),
            Text(
              description,
              style: theme.textTheme.bodyMedium?.copyWith(
                color: colorScheme.onSurfaceVariant,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

🏡 第七步:主页面集成展示

HomeScreen 中使用切换器与卡片,展示主题效果:

dart 复制代码
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import 'package:flutter_theme_test/theme/theme_bloc.dart';
import 'package:flutter_theme_test/widgets/theme_mode_selector.dart';
import 'package:flutter_theme_test/widgets/themed_card.dart';

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Theme Manager'),
        actions: const [
          ThemeModeSelector(),
          SizedBox(width: 16),
        ],
      ),
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                'Theme Demonstration',
                style: Theme.of(context).textTheme.headlineMedium,
              ),
              const SizedBox(height: 24),
              const Text(
                'This app demonstrates dynamic theme switching using flutter_bloc '
                'with hydrated_bloc for persistence. Your theme preference is '
                'automatically saved between app sessions.',
                style: TextStyle(fontSize: 16),
              ),
              const SizedBox(height: 32),
              const ThemedCard(
                title: 'Primary Card',
                description: 'This card adapts to the current theme',
                icon: Icons.palette,
              ),
              const SizedBox(height: 16),
              const ThemedCard(
                title: 'Secondary Card',
                description: 'UI elements respond to theme changes automatically',
                icon: Icons.color_lens,
              ),
              const SizedBox(height: 32),
              Center(
                child: ElevatedButton.icon(
                  onPressed: () {
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(
                        content: Text('This button also adapts to the theme!'),
                        duration: Duration(seconds: 2),
                      ),
                    );
                  },
                  icon: const Icon(Icons.check_circle),
                  label: const Text('Themed Button'),
                ),
              ),
              const SizedBox(height: 32),
              const Center(
                child: Text(
                  'Toggle Switch Example:',
                  style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
                ),
              ),
              const SizedBox(height: 8),
              Center(
                child: Switch(
                  value: Theme.of(context).brightness == Brightness.dark,
                  onChanged: (value) {
                    context.read<ThemeBloc>().add(
                          ThemeChanged(
                            value ? ThemeMode.dark : ThemeMode.light,
                          ),
                        );
                  },
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

✅ 总结

通过本文,我们学习了如何:

  • 使用 Bloc 构建清晰的主题状态管理逻辑;
  • 借助 hydrated_bloc 持久化用户的主题设置;
  • 构建自定义 UI 组件(如按钮、卡片、切换器)响应主题变化。

这种架构不仅适用于主题切换,也适合扩展到语言切换、布局模式等需要状态持久化的功能。


如果你喜欢这篇文章,欢迎分享、收藏,或留言交流! 以上代码均提交 Github

相关推荐
meimeiqian3 小时前
flutter android端抓包工具
android·flutter
星释8 小时前
鸿蒙Flutter实战:18-组合而非替换,现有插件快速鸿蒙化
flutter·华为·harmonyos
一人前行8 小时前
Flutter_学习记录_AppBar中取消leading的占位展示
flutter
嘿嘿嘿呼呼嘿11 小时前
Riverpod源码分析1:Provider & ProviderFamily
flutter
zacksleo14 小时前
鸿蒙Flutter实战:20. Flutter集成高德地图,同层渲染
flutter·harmonyos
zacksleo14 小时前
鸿蒙Flutter实战:19-Flutter集成高德地图,跳转页面方式
flutter·harmonyos
A0微声z14 小时前
从0到1掌握Flutter(四)方法与类
flutter
嘿嘿嘿呼呼嘿14 小时前
Riverpod源码分析3:Provider的观察、刷新与销毁
flutter
嘿嘿嘿呼呼嘿14 小时前
Riverpod源码分析2:作用域 ProviderScope
flutter
小墙程序员16 小时前
Flutter 教程(四)包管理
flutter