Flutter 主题切换实战:系统适配+手动切换+持久化,15分钟落地暗黑模式

701次阅读
没有评论

Flutter 主题切换实战教程:15 分钟实现系统适配、手动切换与本地持久化,含完整可复用代码、Provider 状态管理、避坑指南,助力开发者快速落地暗黑模式,提升应用体验与可访问性。

作为一名 Flutter 开发者,我经手的每款应用几乎都会加入主题切换功能。不是为了炫技,而是用户真的需要:深夜刷应用时的护眼需求、不同场景下的个性化偏好、对弱视用户的可访问性支持,甚至是细节处体现的产品质感,都离不开这套看似简单的功能。

今天就把我实战中总结的最简落地方案分享给大家,无需复杂逻辑,15 分钟就能实现「系统自动适配 + 手动切换 + 本地持久化」的完整主题功能,代码可直接复制复用,还会补充实战中踩过的坑,帮你少走弯路。

Flutter 主题切换实战:系统适配 + 手动切换 + 持久化,15 分钟落地暗黑模式

为什么一定要做 Flutter 主题切换?

在动手之前,我们先明确一个核心:主题切换不是“锦上添花”,而是当前移动应用的“基础标配”,尤其是暗黑模式,已经成为 iOS 和 Android 两大系统的原生支持功能。

  • 系统级用户期待:现在大部分用户都会根据时间切换系统主题,若你的应用不能同步适配,会显得非常突兀,甚至影响用户留存;
  • 个性化与用户粘性:允许用户手动选择主题,满足不同用户的视觉偏好,细节处提升产品好感度,间接增加用户活跃度;
  • 可访问性合规:高对比度的暗黑主题的和亮色主题,能更好地适配弱视用户,符合移动应用的可访问性要求,避免潜在的合规风险;
  • 技术细节体现:看似简单的主题切换,实则涉及状态管理、本地持久化、系统交互等多个知识点,能体现开发者对产品细节的把控能力。

实战准备:环境与依赖

首先创建一个新的 Flutter 项目(若已有项目,可直接跳过这一步),然后在 pubspec.yaml 中添加两个核心依赖,这两个依赖是实战中最稳定、最常用的组合,无需额外引入复杂框架。

dependencies:
  flutter:
    sdk: flutter
  provider: ^6.1.1  # 状态管理核心,用于监听主题变化、同步更新 UI
  shared_preferences: ^2.2.2  # 本地持久化,保存用户主题选择,重启应用不丢失

添加完成后,执行 flutter pub get 安装依赖。这里提醒一句:尽量使用我指定的依赖版本(或兼容版本),避免高版本出现 API 变更导致的报错——这是我实战中踩过的第一个小坑,分享给大家。

第一步:定义主题数据(亮色 + 暗色,可直接复用)

主题的核心是ThemeData,我们需要分别定义亮色主题和暗色主题,统一管理颜色、字体、图标等样式,避免后续修改时到处找代码。

lib 目录下新建 theme 文件夹,创建 app_theme.dart 文件,代码如下(已优化细节,适配不同组件的样式统一):

import 'package:flutter/material.dart';

class AppTheme {
  // 亮色主题(默认)static final ThemeData lightTheme = ThemeData(
    brightness: Brightness.light,
    primarySwatch: Colors.blue, // 主色调,可根据你的产品色修改
    scaffoldBackgroundColor: Colors.grey[50], // 页面背景色,比纯白更柔和
    appBarTheme: const AppBarTheme(
      elevation: 0, // 取消 AppBar 阴影,更简洁
      backgroundColor: Colors.blue,
      foregroundColor: Colors.white, // 标题、图标颜色
      titleTextStyle: TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
    ),
    textTheme: TextTheme(
      // 标题文本样式
      titleLarge: const TextStyle(color: Colors.black87, fontSize: 20, fontWeight: FontWeight.bold),
      // 正文文本样式
      bodyLarge: TextStyle(color: Colors.black87, fontSize: 16),
      bodyMedium: TextStyle(color: Colors.black54, fontSize: 14),
      // 辅助文本样式
      labelMedium: TextStyle(color: Colors.black45, fontSize: 12),
    ),
    iconTheme: const IconThemeData(color: Colors.black87, size: 24),
    // 按钮样式统一,避免每个按钮单独设置
    elevatedButtonTheme: ElevatedButtonThemeData(
      style: ElevatedButton.styleFrom(
        backgroundColor: Colors.blue,
        foregroundColor: Colors.white,
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
      ),
    ),
  );

  // 暗色主题(适配系统暗黑模式 + 手动切换)static final ThemeData darkTheme = ThemeData(
    brightness: Brightness.dark,
    primarySwatch: Colors.blueGrey, // 暗色模式主色调,避免过亮刺眼
    scaffoldBackgroundColor: Colors.grey[900], // 深色背景,护眼不压抑
    appBarTheme: AppBarTheme(
      elevation: 0,
      backgroundColor: Colors.grey[850],
      foregroundColor: Colors.white,
      titleTextStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
    ),
    textTheme: TextTheme(titleLarge: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
      bodyLarge: TextStyle(color: Colors.white70, fontSize: 16),
      bodyMedium: TextStyle(color: Colors.white60, fontSize: 14),
      labelMedium: TextStyle(color: Colors.white40, fontSize: 12),
    ),
    iconTheme: const IconThemeData(color: Colors.white70, size: 24),
    elevatedButtonTheme: ElevatedButtonThemeData(
      style: ElevatedButton.styleFrom(
        backgroundColor: Colors.blueGrey,
        foregroundColor: Colors.white,
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
      ),
    ),
  );
}

这里有两个实战细节提醒:

  1. 亮色主题的背景色没有用纯白(Colors.white),而是用了Colors.grey[50],长时间阅读更护眼;
  2. 统一设置了elevatedButtonTheme,避免后续开发中每个按钮单独写样式,提升开发效率,也保证 UI 统一性。

如果你的应用需要支持多套主题色(比如红色、绿色),可以将颜色参数化,后续会在进阶部分补充具体实现。

第二步:核心实现:主题管理器(状态管理 + 持久化)

主题切换的核心的是“状态管理”和“持久化”:既要让 UI 实时响应主题变化,也要让用户的选择在重启应用后依然生效。这里我们用 ChangeNotifier+Provider 做状态管理,shared_preferences做本地持久化,逻辑简洁且稳定。

lib 目录下新建 providers 文件夹,创建 theme_provider.dart 文件,代码如下(含详细注释,关键步骤标红):

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../theme/app_theme.dart';

// 主题模式枚举,明确三种状态,避免混乱
enum ThemeModeType {
  light,    // 浅色模式
  dark,     // 深色模式
  system,   // 跟随系统(默认)}

class ThemeProvider extends ChangeNotifier {
  ThemeModeType _themeMode = ThemeModeType.system; // 默认跟随系统
  late SharedPreferences _prefs; // 用于本地持久化

  // 初始化:从本地读取保存的主题模式,避免重启后重置
  Future<void> init() async {_prefs = await SharedPreferences.getInstance();
    // 读取本地存储的主题模式(key 为 themeMode,首次启动为 null)final String? savedMode = _prefs.getString('themeMode');
    if (savedMode != null) {
      // 转换为 ThemeModeType 枚举,找不到则默认跟随系统
      _themeMode = ThemeModeType.values.firstWhere((e) => e.toString() == savedMode,
        orElse: () => ThemeModeType.system,);
    }
    notifyListeners(); // 通知 UI 更新}

  // 对外提供当前主题模式(只读)ThemeModeType get themeMode => _themeMode;

  // 根据当前主题模式,获取实际要使用的 ThemeData(需传入 context,判断系统亮度)ThemeData getTheme(BuildContext context) {switch (_themeMode) {
      case ThemeModeType.light:
        return AppTheme.lightTheme;
      case ThemeModeType.dark:
        return AppTheme.darkTheme;
      case ThemeModeType.system:
        // 跟随系统亮度,获取当前系统的亮度模式
        final brightness = MediaQuery.platformBrightnessOf(context);
        return brightness == Brightness.dark ? AppTheme.darkTheme : AppTheme.lightTheme;
    }
  }

  // 切换主题模式,并保存到本地
  Future<void> setThemeMode(ThemeModeType mode) async {if (_themeMode == mode) return; // 避免重复切换,提升性能
    _themeMode = mode;
    // 保存到本地,key 为 themeMode,值为枚举的字符串形式
    await _prefs.setString('themeMode', mode.toString());
    notifyListeners(); // 通知 UI 更新,同步切换主题}

  // 判断当前是否为暗黑模式(对外提供,用于 UI 特殊适配)bool isDarkMode(BuildContext context) {final theme = getTheme(context);
    return theme.brightness == Brightness.dark;
  }
}

这里有一个关键避坑点:getTheme方法和 isDarkMode 方法都需要传入 BuildContext,因为要通过MediaQuery.platformBrightnessOf(context) 获取系统的亮度模式,没有上下文会报错。

另外,init方法是异步的,必须在应用启动前完成初始化,否则会出现“短暂显示默认主题,再切换到保存主题”的闪烁问题,后续会在 main.dart 中解决这个问题。

第三步:顶层注入 Provider,确保全局可用

主题管理器需要在整个应用中全局可用,因此我们需要在 main.dart 中,将 ThemeProvider 注入到应用顶层,同时确保初始化完成后再启动应用,避免闪烁。

修改 main.dart 代码如下:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/theme_provider.dart';
import 'screens/home_screen.dart';
import 'screens/settings_screen.dart';

void main() async {
  // 确保 Flutter 绑定完成,否则 SharedPreferences 初始化会报错
  WidgetsFlutterBinding.ensureInitialized();
  
  // 初始化 ThemeProvider,读取本地保存的主题设置
  final themeProvider = ThemeProvider();
  await themeProvider.init();
  
  runApp(
    // 使用 MultiProvider,方便后续添加其他 Provider(如用户信息、全局状态)MultiProvider(
      providers: [
        // 注入 ThemeProvider,使用 value 方式,避免重复创建实例
        ChangeNotifierProvider.value(value: themeProvider),
      ],
      child: const MyApp(),),
  );
}

class MyApp extends StatelessWidget {const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // 监听 ThemeProvider 的变化,主题切换时自动重建 UI
    final themeProvider = Provider.of<ThemeProvider>(context);

    return MaterialApp(
      title: 'Flutter 主题切换实战',
      // 关键:使用 Flutter 官方推荐的方式,适配系统主题
      theme: AppTheme.lightTheme, // 亮色主题默认值
      darkTheme: AppTheme.darkTheme, // 暗色主题默认值
      // 映射主题模式:将我们自定义的 ThemeModeType 转换为 Flutter 原生的 ThemeMode
      themeMode: themeProvider.themeMode == ThemeModeType.system
          ? ThemeMode.system
          : themeProvider.themeMode == ThemeModeType.light
              ? ThemeMode.light
              : ThemeMode.dark,
      // 路由管理,添加首页和设置页(主题切换在设置页)routes: {'/': (context) => const HomeScreen(),
        '/settings': (context) => const SettingsScreen(),},
    );
  }
}

这里有两个实战优化点:

  1. main 函数中先初始化ThemeProvider,再启动应用,彻底解决主题闪烁问题;
  2. 使用MultiProvider,方便后续添加其他全局状态(如用户信息、网络状态),无需修改顶层结构;
  3. 使用路由管理,将首页和设置页分开,结构更清晰,也符合实际开发习惯。

第四步:实现主题切换界面(用户可手动选择)

接下来创建设置页面,让用户可以手动选择“跟随系统”“浅色模式”“深色模式”,同时添加实时预览,让用户直观看到主题变化效果。

lib 目录下新建 screens 文件夹,创建 settings_screen.dart 文件,代码如下(UI 简洁美观,适配主题切换):

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/theme_provider.dart';

class SettingsScreen extends StatelessWidget {const SettingsScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {final themeProvider = Provider.of<ThemeProvider>(context);
    final currentMode = themeProvider.themeMode;

    return Scaffold(
      appBar: AppBar(title: const Text('设置'),
        leading: IconButton(icon: const Icon(Icons.arrow_back),
          onPressed: () => Navigator.pop(context),
        ),
      ),
      body: ListView(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
        children: [
          // 主题模式标题
          const Text(
            '主题模式',
            style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
          ),
          const SizedBox(height: 20),
          
          // 跟随系统选项
          RadioListTile<ThemeModeType>(title: const Text('跟随系统'),
            subtitle: const Text('根据系统设置自动切换亮色 / 暗色'),
            value: ThemeModeType.system,
            groupValue: currentMode,
            onChanged: (value) {if (value != null) {themeProvider.setThemeMode(value);
              }
            },
            activeColor: Theme.of(context).primaryColor,
          ),
          
          // 浅色模式选项
          RadioListTile<ThemeModeType>(title: const Text('浅色模式'),
            subtitle: const Text('始终使用亮色主题,适合白天使用'),
            value: ThemeModeType.light,
            groupValue: currentMode,
            onChanged: (value) {if (value != null) {themeProvider.setThemeMode(value);
              }
            },
            activeColor: Theme.of(context).primaryColor,
          ),
          
          // 暗色模式选项
          RadioListTile<ThemeModeType>(title: const Text('深色模式'),
            subtitle: const Text('始终使用暗色主题,适合夜间使用'),
            value: ThemeModeType.dark,
            groupValue: currentMode,
            onChanged: (value) {if (value != null) {themeProvider.setThemeMode(value);
              }
            },
            activeColor: Theme.of(context).primaryColor,
          ),
          
          const SizedBox(height: 30),
          const Divider(),
          const SizedBox(height: 30),
          
          // 主题实时预览卡片
          Card(
            elevation: 2,
            shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
            child: Padding(padding: const EdgeInsets.all(20),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text(
                    '实时预览',
                    style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
                  ),
                  const SizedBox(height: 20),
                  
                  // 预览内容:模拟应用中的常见组件
                  Row(
                    children: [
                      Icon(themeProvider.isDarkMode(context) 
                            ? Icons.dark_mode 
                            : Icons.light_mode,
                        size: 28,
                      ),
                      const SizedBox(width: 16),
                      Expanded(
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text(
                              '当前主题',
                              style: Theme.of(context).textTheme.bodyLarge,
                            ),
                            Text(themeProvider.isDarkMode(context) ? '深色模式' : '浅色模式',
                              style: Theme.of(context).textTheme.bodyMedium,
                            ),
                          ],
                        ),
                      ),
                    ],
                  ),
                  const SizedBox(height: 20),
                  
                  // 预览按钮和文本
                  ElevatedButton(onPressed: () {},
                    child: const Text('预览按钮'),
                  ),
                  const SizedBox(height: 16),
                  Text(
                    '这是一段预览文本,用于展示主题切换后的文字颜色效果。',
                    style: Theme.of(context).textTheme.bodyMedium,
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

同时,创建首页home_screen.dart,添加一个跳转到设置页的按钮,方便用户进入主题设置:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/theme_provider.dart';
import 'settings_screen.dart';

class HomeScreen extends StatelessWidget {const HomeScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {final themeProvider = Provider.of<ThemeProvider>(context);

    return Scaffold(
      appBar: AppBar(title: const Text('首页'),
        actions: [
          // 跳转到设置页的按钮
          IconButton(icon: const Icon(Icons.settings),
            onPressed: () => Navigator.pushNamed(context, '/settings'),
          ),
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('当前主题:${themeProvider.isDarkMode(context) ?' 深色模式 ':' 浅色模式 '}',
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const SizedBox(height: 20),
            ElevatedButton(onPressed: () => Navigator.pushNamed(context, '/settings'),
              child: const Text('进入设置,切换主题'),
            ),
          ],
        ),
      ),
    );
  }
}

到这里,核心功能已经实现:用户可以在设置页切换主题,切换后实时预览效果,重启应用后依然保留上次的选择,系统切换主题时,应用也会自动同步。

实战避坑指南(必看)

这部分是我在多次实战中总结的问题,很多开发者都会踩坑,提前规避能节省大量时间:

  1. 热重载不生效 :修改ThemeData 后,若热重载不生效,不要纠结,直接重启应用即可——这是 Flutter 主题热重载的一个小 bug,目前暂无更好的解决办法;
  2. 主题闪烁问题 :必须在main 函数中先初始化 ThemeProvider,再启动应用,否则会出现“默认主题→保存主题”的闪烁,本文的main.dart 已经规避了这个问题;
  3. Provider 访问报错 :确保Provider.of<ThemeProvider>(context)ChangeNotifierProvider的子树中使用,若在顶层使用,需添加listen: false
  4. 硬编码颜色导致适配失败 :自定义组件时,不要硬编码颜色(如Colors.whiteColors.black),尽量使用Theme.of(context).colorSchemeTheme.of(context).textTheme,例如:背景色用colorScheme.surface,文字用colorScheme.onSurface,这样才能自动适配主题切换;
  5. 系统主题切换无响应 :无需额外写监听代码,只要在MaterialApp 中设置了themeMode: ThemeMode.system,Flutter 会自动监听系统主题变化,同步更新应用主题。

进阶:支持多套主题色(可选)

如果你的应用需要支持多种主题色(如红色、绿色、蓝色),可以基于上面的代码进行扩展,核心思路是将颜色参数化,动态生成ThemeData

简单实现步骤:

  1. ThemeProvider 中添加主题色字段,如Color _primaryColor = Colors.blue;
  2. 定义多套主题色选项(如红色、绿色),提供切换方法setPrimaryColor,并持久化保存;
  3. 修改 getTheme 方法,根据当前主题色和主题模式,动态生成 ThemeData,可使用ThemeData.copyWithThemeData.from方法。

具体代码可根据你的产品需求调整,核心逻辑和本文的主题切换一致,无需额外引入新框架。

完整目录结构(规范开发)

最后,给大家展示规范的目录结构,方便大家在实际项目中复用:

lib/
├── main.dart          # 应用入口,注入 Provider
├── providers/         # 状态管理文件夹
│   └── theme_provider.dart  # 主题管理器
├── screens/           # 页面文件夹
│   ├── home_screen.dart     # 首页
│   └── settings_screen.dart # 设置页(主题切换)└── theme/             # 主题配置文件夹
    └── app_theme.dart       # 亮色 / 暗色主题定义

总结

Flutter 的主题切换其实非常简单,核心就是三步:定义主题数据、管理主题状态、全局注入并适配 UI。整个核心代码不到 100 行,却能实现“系统适配 + 手动切换 + 本地持久化”的完整功能,极大提升用户体验。

暗黑模式已经不是高端应用的专属,而是每个 Flutter 开发者都应该掌握的基础功能。本文的代码可直接复制到项目中使用,只需根据你的产品色修改 AppTheme 中的主色调,就能快速落地。

如果在实现过程中遇到问题,欢迎在评论区留言,我会第一时间回复——毕竟,实战中踩过的坑,都值得分享给更多开发者。

正文完
 0
Fr2ed0m
版权声明:本站原创文章,由 Fr2ed0m 于2026-02-27发表,共计11590字。
转载说明:Unless otherwise specified, all articles are published by cc-4.0 protocol. Please indicate the source of reprint.
评论(没有评论)