Flutter for OpenHarmony:从零搭建今日资讯App(二十九)深色模式适配全攻略

晚上躺在床上刷新闻,屏幕亮得刺眼,这体验谁受得了?

深色模式已经从"锦上添花"变成了"必备功能"。iOS和Android都在系统层面支持深色模式,用户期望App能跟随系统自动切换,或者手动选择喜欢的主题。

今天这篇文章,咱们就来聊聊Flutter里怎么做深色模式适配。不只是简单地换个背景色,而是从架构设计到细节打磨,做一套完整的主题系统。

深色模式的意义

先想想,为什么要做深色模式:

护眼:深色背景在暗光环境下对眼睛更友好,减少蓝光刺激。

省电:OLED屏幕显示黑色时像素不发光,能显著省电。

美观:很多用户就是喜欢深色界面,觉得更酷更专业。

无障碍:对于某些视觉障碍用户,深色模式更容易阅读。

所以深色模式不是可选项,而是现代App的标配。

Material 3与主题系统

Flutter的Material 3设计系统对主题支持非常完善。先看看项目里的主题是怎么定义的。

ThemeProvider的整体结构

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

class ThemeProvider extends ChangeNotifier {
  ThemeMode _themeMode = ThemeMode.system;
  
  ThemeMode get themeMode => _themeMode;

  ThemeProvider() {
    _loadThemeMode();
  }

ThemeProvider继承自ChangeNotifier,这是Provider状态管理的标准做法。

_themeMode存储当前的主题模式,有三个可选值:

  • ThemeMode.light:始终使用浅色主题
  • ThemeMode.dark:始终使用深色主题
  • ThemeMode.system:跟随系统设置

默认值是ThemeMode.system,这是最友好的选择------用户在系统设置里选了什么,App就用什么。

构造函数里调用_loadThemeMode()从本地存储加载用户之前的选择。

加载和保存主题设置

dart 复制代码
Future<void> _loadThemeMode() async {
  final prefs = await SharedPreferences.getInstance();
  final themeModeString = prefs.getString('themeMode') ?? 'system';
  _themeMode = ThemeMode.values.firstWhere(
    (e) => e.toString() == 'ThemeMode.$themeModeString',
    orElse: () => ThemeMode.system,
  );
  notifyListeners();
}

Future<void> setThemeMode(ThemeMode mode) async {
  _themeMode = mode;
  final prefs = await SharedPreferences.getInstance();
  await prefs.setString('themeMode', mode.toString().split('.').last);
  notifyListeners();
}

_loadThemeMode从SharedPreferences读取存储的主题模式字符串,转换成ThemeMode枚举。

setThemeMode是公开方法,用户切换主题时调用。先更新内存中的值,再保存到本地,最后notifyListeners()通知UI刷新。

mode.toString().split('.').lastThemeMode.dark转成dark,存储更简洁。

定义浅色主题

dart 复制代码
ThemeData get lightTheme => ThemeData(
  useMaterial3: true,
  brightness: Brightness.light,
  colorScheme: ColorScheme.fromSeed(
    seedColor: Colors.blue,
    brightness: Brightness.light,
  ),
  appBarTheme: const AppBarTheme(
    centerTitle: true,
    elevation: 0,
  ),
  cardTheme: CardTheme(
    elevation: 2,
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(12),
    ),
  ),
);

这段代码定义了浅色主题,咱们逐个属性看。

useMaterial3: true启用Material 3设计语言。Material 3是Google最新的设计规范,视觉效果更现代,颜色更丰富。强烈建议新项目都用Material 3。

brightness: Brightness.light告诉Flutter这是浅色主题。Flutter会据此调整一些默认颜色,比如文字颜色、图标颜色等。

ColorScheme.fromSeed的魔力

dart 复制代码
colorScheme: ColorScheme.fromSeed(
  seedColor: Colors.blue,
  brightness: Brightness.light,
),

ColorScheme.fromSeed是Material 3的核心功能之一。你只需要提供一个种子颜色(seedColor),它会自动生成一整套协调的配色方案:

  • primary:主色,用于重要元素
  • onPrimary:主色上的文字/图标颜色
  • primaryContainer:主色容器,用于背景
  • onPrimaryContainer:主色容器上的文字颜色
  • secondary:次要色
  • surface:表面颜色,用于卡片、对话框
  • background:背景色
  • error:错误色
  • 还有很多...

这套配色方案是经过专业设计的,颜色之间的对比度、和谐度都有保证。比手动配色省心多了。

AppBar和Card的主题

dart 复制代码
appBarTheme: const AppBarTheme(
  centerTitle: true,
  elevation: 0,
),
cardTheme: CardTheme(
  elevation: 2,
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(12),
  ),
),

appBarTheme定义所有AppBar的默认样式。centerTitle: true让标题居中,elevation: 0去掉阴影,看起来更扁平。

cardTheme定义所有Card的默认样式。圆角12像素,阴影2。这样每个Card都不用单独设置,保持一致性。

定义深色主题

dart 复制代码
ThemeData get darkTheme => ThemeData(
  useMaterial3: true,
  brightness: Brightness.dark,
  colorScheme: ColorScheme.fromSeed(
    seedColor: Colors.blue,
    brightness: Brightness.dark,
  ),
  appBarTheme: const AppBarTheme(
    centerTitle: true,
    elevation: 0,
  ),
  cardTheme: CardTheme(
    elevation: 2,
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(12),
    ),
  ),
);

深色主题和浅色主题结构完全一样,只是brightness改成了Brightness.dark

ColorScheme.fromSeed会根据brightness自动生成适合深色模式的配色。同样的种子颜色Colors.blue,在深色模式下会生成更暗的背景色、更亮的前景色,保证可读性。

这就是Material 3的强大之处:你不需要手动为深色模式配一套颜色,系统自动帮你生成。

在MaterialApp中应用主题

主题定义好了,怎么应用到整个App呢?看main.dart:

dart 复制代码
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/theme_provider.dart';
import 'providers/news_provider.dart';
import 'providers/favorites_provider.dart';
import 'screens/splash_screen.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => ThemeProvider()),
        ChangeNotifierProvider(create: (_) => NewsProvider()),
        ChangeNotifierProvider(create: (_) => FavoritesProvider()),
      ],
      child: Consumer<ThemeProvider>(
        builder: (context, themeProvider, child) {
          return MaterialApp(
            title: '今日资讯',
            debugShowCheckedModeBanner: false,
            theme: themeProvider.lightTheme,
            darkTheme: themeProvider.darkTheme,
            themeMode: themeProvider.themeMode,
            home: const SplashScreen(),
          );
        },
      ),
    );
  }
}

Provider的注册

dart 复制代码
MultiProvider(
  providers: [
    ChangeNotifierProvider(create: (_) => ThemeProvider()),
    ChangeNotifierProvider(create: (_) => NewsProvider()),
    ChangeNotifierProvider(create: (_) => FavoritesProvider()),
  ],
  // ...
)

MultiProvider注册多个Provider。ThemeProvider放在最前面,因为主题是全局的,其他Provider可能也需要用到主题信息。

Consumer监听主题变化

dart 复制代码
child: Consumer<ThemeProvider>(
  builder: (context, themeProvider, child) {
    return MaterialApp(
      title: '今日资讯',
      debugShowCheckedModeBanner: false,
      theme: themeProvider.lightTheme,
      darkTheme: themeProvider.darkTheme,
      themeMode: themeProvider.themeMode,
      home: const SplashScreen(),
    );
  },
),

Consumer<ThemeProvider>监听ThemeProvider的变化。当用户切换主题时,notifyListeners()触发,Consumer重建,MaterialApp拿到新的主题,整个App的颜色就变了。

MaterialApp的三个主题相关参数:

  • theme:浅色主题
  • darkTheme:深色主题
  • themeMode:当前使用哪个主题

themeModeThemeMode.system时,Flutter会根据系统设置自动选择themedarkTheme

主题切换的UI实现

用户怎么切换主题呢?项目里有两种方式。

方式一:设置页面的选择器

dart 复制代码
Widget _buildThemeSelector(BuildContext context) {
  return Consumer<ThemeProvider>(
    builder: (context, themeProvider, child) {
      return ListTile(
        leading: const Icon(Icons.palette_outlined),
        title: const Text('主题模式'),
        subtitle: Text(_getThemeModeText(themeProvider.themeMode)),
        trailing: const Icon(Icons.chevron_right),
        onTap: () {
          _showThemeDialog(context, themeProvider);
        },
      );
    },
  );
}

String _getThemeModeText(ThemeMode mode) {
  switch (mode) {
    case ThemeMode.light:
      return '浅色';
    case ThemeMode.dark:
      return '深色';
    case ThemeMode.system:
      return '跟随系统';
  }
}

ListTile显示当前主题模式,点击弹出选择对话框。

_getThemeModeText把枚举转成中文显示。

主题选择对话框

dart 复制代码
void _showThemeDialog(BuildContext context, ThemeProvider provider) {
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('选择主题'),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          RadioListTile<ThemeMode>(
            title: const Text('浅色'),
            value: ThemeMode.light,
            groupValue: provider.themeMode,
            onChanged: (value) {
              if (value != null) {
                provider.setThemeMode(value);
                Navigator.pop(context);
              }
            },
          ),
          RadioListTile<ThemeMode>(
            title: const Text('深色'),
            value: ThemeMode.dark,
            groupValue: provider.themeMode,
            onChanged: (value) {
              if (value != null) {
                provider.setThemeMode(value);
                Navigator.pop(context);
              }
            },
          ),
          RadioListTile<ThemeMode>(
            title: const Text('跟随系统'),
            value: ThemeMode.system,
            groupValue: provider.themeMode,
            onChanged: (value) {
              if (value != null) {
                provider.setThemeMode(value);
                Navigator.pop(context);
              }
            },
          ),
        ],
      ),
    ),
  );
}

RadioListTile是单选列表项,groupValue是当前选中的值,value是这个选项的值。

选中后调用provider.setThemeMode(value)切换主题,然后关闭对话框。

方式二:个人中心的快捷开关

dart 复制代码
Widget _buildThemeSwitch(BuildContext context) {
  return Consumer<ThemeProvider>(
    builder: (context, themeProvider, child) {
      return SwitchListTile(
        secondary: Icon(
          themeProvider.themeMode == ThemeMode.dark
              ? Icons.dark_mode
              : Icons.light_mode,
        ),
        title: const Text('深色模式'),
        value: themeProvider.themeMode == ThemeMode.dark,
        onChanged: (value) {
          themeProvider.setThemeMode(
            value ? ThemeMode.dark : ThemeMode.light,
          );
        },
      );
    },
  );
}

SwitchListTile是带开关的列表项,更直观。

图标根据当前主题动态变化:深色模式显示月亮图标,浅色模式显示太阳图标。

开关打开就是深色,关闭就是浅色。这种方式简单直接,但失去了"跟随系统"的选项。

在组件中使用主题颜色

主题定义好了,组件里怎么用呢?

使用Theme.of(context)

dart 复制代码
Widget _buildMetadata(BuildContext context) {
  return Row(
    children: [
      Container(
        padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
        decoration: BoxDecoration(
          color: Theme.of(context).colorScheme.primaryContainer,
          borderRadius: BorderRadius.circular(4),
        ),
        child: Text(
          article.source,
          style: TextStyle(
            fontSize: 12,
            color: Theme.of(context).colorScheme.onPrimaryContainer,
          ),
        ),
      ),
      // ...
    ],
  );
}

Theme.of(context)获取当前主题,.colorScheme获取配色方案。

primaryContainer是主色的容器色,适合做背景。onPrimaryContainer是容器上的文字颜色,保证可读性。

这样写的好处是:不管浅色还是深色模式,颜色都是协调的,不用写两套代码。

启动页的主题适配

dart 复制代码
@override
Widget build(BuildContext context) {
  return Scaffold(
    body: Container(
      decoration: BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: [
            Theme.of(context).colorScheme.primary,
            Theme.of(context).colorScheme.secondary,
          ],
        ),
      ),
      child: Center(
        child: FadeTransition(
          opacity: _fadeAnimation,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: const [
              Icon(
                Icons.newspaper,
                size: 100,
                color: Colors.white,
              ),
              // ...
            ],
          ),
        ),
      ),
    ),
  );
}

启动页用渐变背景,颜色从primarysecondary。这两个颜色在浅色和深色模式下都会自动调整,保持视觉效果。

处理硬编码颜色

有些地方可能写死了颜色,比如:

dart 复制代码
Text(
  '点击登录获取更多功能',
  style: TextStyle(
    fontSize: 14,
    color: Colors.grey[600],
  ),
),

Colors.grey[600]在浅色模式下没问题,但在深色模式下可能对比度不够。

解决方案一:使用主题颜色

dart 复制代码
Text(
  '点击登录获取更多功能',
  style: TextStyle(
    fontSize: 14,
    color: Theme.of(context).colorScheme.onSurfaceVariant,
  ),
),

onSurfaceVariant是专门用于次要文字的颜色,在两种模式下都有合适的对比度。

解决方案二:根据亮度判断

dart 复制代码
Text(
  '点击登录获取更多功能',
  style: TextStyle(
    fontSize: 14,
    color: Theme.of(context).brightness == Brightness.dark
        ? Colors.grey[400]
        : Colors.grey[600],
  ),
),

根据当前主题的亮度选择不同的颜色。深色模式用更亮的灰色,浅色模式用更暗的灰色。

解决方案三:使用TextTheme

dart 复制代码
Text(
  '点击登录获取更多功能',
  style: Theme.of(context).textTheme.bodySmall,
),

textTheme定义了一套文字样式,包括颜色。用预定义的样式,不用操心颜色问题。

图片和图标的适配

图片和图标也需要考虑深色模式。

图标颜色

dart 复制代码
Icon(
  Icons.access_time,
  size: 14,
  color: Colors.grey[600],
),

这个图标在深色模式下可能看不清。改成:

dart 复制代码
Icon(
  Icons.access_time,
  size: 14,
  color: Theme.of(context).colorScheme.onSurfaceVariant,
),

或者不指定颜色,让图标继承父级的颜色:

dart 复制代码
Icon(
  Icons.access_time,
  size: 14,
),

占位图的适配

dart 复制代码
Widget _buildPlaceholder() {
  return Container(
    color: Colors.grey[200],
    child: Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(
            Icons.article_outlined,
            size: 48,
            color: Colors.grey[400],
          ),
          const SizedBox(height: 8),
          Text(
            '新闻图片',
            style: TextStyle(
              fontSize: 14,
              color: Colors.grey[500],
            ),
          ),
        ],
      ),
    ),
  );
}

这个占位图在深色模式下会很突兀。改成:

dart 复制代码
Widget _buildPlaceholder(BuildContext context) {
  final isDark = Theme.of(context).brightness == Brightness.dark;
  return Container(
    color: isDark ? Colors.grey[800] : Colors.grey[200],
    child: Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(
            Icons.article_outlined,
            size: 48,
            color: isDark ? Colors.grey[600] : Colors.grey[400],
          ),
          const SizedBox(height: 8),
          Text(
            '新闻图片',
            style: TextStyle(
              fontSize: 14,
              color: isDark ? Colors.grey[500] : Colors.grey[500],
            ),
          ),
        ],
      ),
    ),
  );
}

根据当前主题选择合适的颜色。

分类卡片的渐变色

dart 复制代码
Container(
  decoration: BoxDecoration(
    borderRadius: BorderRadius.circular(12),
    gradient: LinearGradient(
      begin: Alignment.topLeft,
      end: Alignment.bottomRight,
      colors: [
        color.withOpacity(0.7),
        color.withOpacity(0.9),
      ],
    ),
  ),
  child: Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      Icon(
        icon,
        size: 48,
        color: Colors.white,
      ),
      const SizedBox(height: 12),
      Text(
        name,
        style: const TextStyle(
          fontSize: 16,
          fontWeight: FontWeight.bold,
          color: Colors.white,
        ),
      ),
    ],
  ),
)

分类卡片用了渐变背景,文字是白色。这种设计在两种模式下都能工作,因为背景色足够深,白色文字始终可读。

但如果想让深色模式下的卡片更暗一些:

dart 复制代码
final isDark = Theme.of(context).brightness == Brightness.dark;
final baseOpacity = isDark ? 0.5 : 0.7;
final endOpacity = isDark ? 0.7 : 0.9;

Container(
  decoration: BoxDecoration(
    borderRadius: BorderRadius.circular(12),
    gradient: LinearGradient(
      begin: Alignment.topLeft,
      end: Alignment.bottomRight,
      colors: [
        color.withOpacity(baseOpacity),
        color.withOpacity(endOpacity),
      ],
    ),
  ),
  // ...
)

自定义主题扩展

如果需要更多自定义颜色,可以用ThemeExtension:

dart 复制代码
class CustomColors extends ThemeExtension<CustomColors> {
  final Color? cardBackground;
  final Color? highlightText;
  
  CustomColors({
    this.cardBackground,
    this.highlightText,
  });
  
  @override
  CustomColors copyWith({
    Color? cardBackground,
    Color? highlightText,
  }) {
    return CustomColors(
      cardBackground: cardBackground ?? this.cardBackground,
      highlightText: highlightText ?? this.highlightText,
    );
  }
  
  @override
  CustomColors lerp(ThemeExtension<CustomColors>? other, double t) {
    if (other is! CustomColors) return this;
    return CustomColors(
      cardBackground: Color.lerp(cardBackground, other.cardBackground, t),
      highlightText: Color.lerp(highlightText, other.highlightText, t),
    );
  }
}

在主题里注册:

dart 复制代码
ThemeData get lightTheme => ThemeData(
  // ...
  extensions: [
    CustomColors(
      cardBackground: Colors.white,
      highlightText: Colors.blue[700],
    ),
  ],
);

ThemeData get darkTheme => ThemeData(
  // ...
  extensions: [
    CustomColors(
      cardBackground: Colors.grey[850],
      highlightText: Colors.blue[300],
    ),
  ],
);

使用时:

dart 复制代码
final customColors = Theme.of(context).extension<CustomColors>()!;
Container(
  color: customColors.cardBackground,
  child: Text(
    'Highlight',
    style: TextStyle(color: customColors.highlightText),
  ),
)

监听系统主题变化

当用户在系统设置里切换深色模式时,App应该实时响应:

dart 复制代码
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }
  
  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }
  
  @override
  void didChangePlatformBrightness() {
    // 系统主题变化时调用
    setState(() {});
  }
  
  // ...
}

不过如果用了ThemeMode.system,Flutter会自动处理这个,不需要手动监听。

深色模式的设计原则

做深色模式不只是把白色换成黑色,有一些设计原则要遵循:

不要用纯黑

纯黑(#000000)在OLED屏幕上虽然最省电,但和其他颜色对比太强烈,看起来不舒服。建议用深灰(#121212或类似)。

Material 3的ColorScheme.fromSeed生成的深色背景就不是纯黑。

保持层次感

浅色模式用阴影表示层次,深色模式可以用不同深度的灰色:

dart 复制代码
// 深色模式下的层次
// 背景:#121212
// 卡片:#1E1E1E
// 弹窗:#2C2C2C

降低饱和度

深色背景上,高饱和度的颜色会很刺眼。深色模式下的强调色应该降低饱和度。

ColorScheme.fromSeed会自动处理这个。

保证对比度

文字和背景的对比度要足够,WCAG标准要求至少4.5:1。可以用在线工具检查对比度。

测试深色模式

开发时怎么测试深色模式?

方法一:切换系统设置

最真实的测试方式,但切换麻烦。

方法二:在App里切换

用前面实现的主题切换功能,直接在App里切换。

方法三:强制指定

开发时可以临时强制使用某个主题:

dart 复制代码
MaterialApp(
  themeMode: ThemeMode.dark, // 强制深色
  // ...
)

方法四:DevTools

Flutter DevTools可以模拟不同的系统设置,包括深色模式。

常见问题排查

问题一:切换主题后部分颜色没变

检查是否有硬编码的颜色,改成使用Theme.of(context)

问题二:深色模式下某些文字看不清

检查文字颜色的对比度,使用主题提供的颜色而不是固定值。

问题三:图片在深色模式下太亮

可以给图片加一层半透明遮罩,或者准备两套图片。

问题四:状态栏颜色不对

dart 复制代码
appBarTheme: AppBarTheme(
  systemOverlayStyle: SystemUiOverlayStyle(
    statusBarColor: Colors.transparent,
    statusBarIconBrightness: Brightness.light, // 深色模式用亮色图标
  ),
),

写在最后

深色模式适配看起来简单,但要做好需要注意很多细节:

架构层面:用Provider管理主题状态,用ThemeData定义主题,让主题切换变得简单。

设计层面 :遵循Material 3的设计规范,用ColorScheme.fromSeed生成配色,保证颜色协调。

代码层面 :避免硬编码颜色,使用Theme.of(context)获取主题颜色,让组件自动适配。

细节层面:检查每个页面、每个组件在两种模式下的表现,确保可读性和美观性。

今日资讯App用ThemeProvider实现了完整的主题系统,支持浅色、深色、跟随系统三种模式,用户可以在设置里切换,也可以用快捷开关。

深色模式是用户体验的重要组成部分。做好了,用户会觉得App很专业;做不好,用户晚上用起来眼睛疼,可能就卸载了。

欢迎加入开源鸿蒙跨平台社区https://openharmonycrossplatform.csdn.net

在这里你可以找到更多Flutter开发资源,与其他开发者交流经验,共同进步。

相关推荐
时光慢煮2 小时前
从零构建跨端图书馆管理页面:Flutter × OpenHarmony 实战指南-架构搭建
flutter·开源·openharmony
向前V2 小时前
Flutter for OpenHarmony数独游戏App实战:单元格交互与选中
flutter·游戏·交互
小白阿龙2 小时前
鸿蒙+flutter 跨平台开发——简易井字棋小游戏实现
flutter·华为·harmonyos·鸿蒙
夜雨声烦丿2 小时前
Flutter 框架跨平台鸿蒙开发 - 打造随机抽奖/点名器应用
flutter·华为·harmonyos
时光慢煮3 小时前
Flutter 编译开发 OpenHarmony 全流程实战教程-基于开源仓库GitCode 搜索工具 v1.0.3 的跨平台实践
flutter·开源·gitcode
小白阿龙3 小时前
鸿蒙+flutter 跨平台开发——Placeholder 控件的基础使用场景
flutter·华为·harmonyos·鸿蒙
时光慢煮3 小时前
基于 Flutter × OpenHarmony 图书馆管理系统之构建书籍管理模块
flutter·华为·开源·openharmony
IT陈图图3 小时前
智慧图书馆的数字名片:基于 Flutter × OpenHarmony 的读者卡片构建实践
flutter·鸿蒙·openharmony
南村群童欺我老无力.3 小时前
Flutter 框架跨平台鸿蒙开发 - 打造专业级单位换算器,支持8大类50+单位互转
flutter·华为·harmonyos