主题、字体与暗黑模式

主题不是“换个颜色”,而是把颜色、字号、圆角这些决定收拢到一处,让业务代码只写结构、不写样式。Flutter 3.x 默认启用 Material 3,ThemeData 的重心也随之变化:不再逐个指定 primaryColor,而是给一个种子色让 ColorScheme.fromSeed 推导出全套配色。本章按配色、主题扩展、亮暗切换、字体、组件覆盖、系统 UI 的顺序给出一条可落地的主题链路。

ThemeData 的三层结构

关键字段说明
配色colorSchemeuseMaterial3Material 3 下颜色几乎都从 ColorScheme 取,旧的 primaryColor 只做兼容
字体textThemefontFamilyfontFamilyFallback全局字号阶梯与字体回退链
组件elevatedButtonThemeXxxThemeData组件级默认样式,避免每个页面重复写

原则:能用 colorScheme 角色色表达的就不要写死 Color(0xFF...),否则暗色模式一定漏改。

用种子色生成亮暗两套配色

import 'package:flutter/material.dart';

const _seed = Color(0xFF3F6BFF); // 只维护一个种子色

ThemeData buildLightTheme() => ThemeData(
      useMaterial3: true, // 3.16 起已是默认值,显式写出便于阅读
      colorScheme: ColorScheme.fromSeed(seedColor: _seed),
    );

ThemeData buildDarkTheme() => ThemeData(
      useMaterial3: true,
      colorScheme: ColorScheme.fromSeed(
        seedColor: _seed,
        brightness: Brightness.dark, // 暗色必须在这里指定,不要事后改 colorScheme
      ),
    );

取色一律走 Theme.of(context).colorScheme.primary 这类角色色,不要自己记十六进制。

主题扩展:ThemeExtension

ColorScheme 只覆盖语义色,品牌自定义色(如“已收藏”的金黄)适合用 ThemeExtension 挂在主题上,让它跟随主题切换:

class AppColors extends ThemeExtension<AppColors> {
  const AppColors({required this.favorite});
  final Color favorite;

  @override
  AppColors copyWith({Color? favorite}) => AppColors(favorite: favorite ?? this.favorite);

  // 不实现 lerp,主题切换时颜色会硬跳
  @override
  AppColors lerp(ThemeExtension<AppColors>? other, double t) {
    if (other is! AppColors) return this;
    return AppColors(favorite: Color.lerp(favorite, other.favorite, t)!);
  }
}

final light = buildLightTheme().copyWith(
  extensions: const <ThemeExtension<dynamic>>[AppColors(favorite: Color(0xFFFFB300))],
);
// 使用:取不到说明扩展没挂上,! 让错误在开发期立刻暴露
final colors = Theme.of(context).extension<AppColors>()!;

亮暗切换:themeMode 与持久化

themeMode行为适用
ThemeMode.system跟随系统设置,推荐默认绝大多数应用
ThemeMode.light / ThemeMode.dark强制单一主题用户手动切换、阅读类应用
Future<void> setMode(ThemeMode mode, SharedPreferences prefs) async {
  notifyListeners();                            // 先刷 UI
  await prefs.setInt('themeMode', mode.index);  // 再落盘,避免切换卡顿
}

MaterialApp(
  theme: buildLightTheme(),
  darkTheme: buildDarkTheme(), // 不提供 darkTheme 时暗色就是亮色,等于没适配
  themeMode: mode,
);

字体:google_fonts 与本地字体

方案包体积首屏适用
系统默认字体0最好只求可读,接受各端字形略有差异
本地中文字体+5~10 MB,子集化后可压到 1 MB 内强设计要求多端字形统一
google_fonts近乎 0首次可能闪字拉丁字体,或网络可控环境
# pubspec.yaml:声明本地字体资源
flutter:
  fonts:
    - family: NotoSansSC
      fonts:
        - asset: assets/fonts/NotoSansSC-Regular.otf
        - asset: assets/fonts/NotoSansSC-Bold.otf
          weight: 700
Text('标题', style: GoogleFonts.notoSansSc(fontSize: 20)); // 运行时下载并缓存

ThemeData(
  fontFamily: 'NotoSansSC',
  fontFamilyFallback: const ['PingFang SC', 'Microsoft YaHei', 'sans-serif'],
);

中文项目建议:优先用系统字体,只在品牌页对少量文字指定字体;确需打包就用 fonttools 做子集化,只保留用到的字。

组件级样式覆盖

  • TextTheme:统一标题/正文阶梯,页面里用 theme.textTheme.titleLarge 取用,避免到处写 fontSize
  • ElevatedButtonThemeData / InputDecorationTheme 等:一次定义全站一致,例如把 minimumSize 设为 const Size.fromHeight(48),顺手解决点击区域过小的问题。

状态栏与系统 UI

SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(statusBarColor: Colors.transparent));
// 局部覆盖:深色封面页需要浅色图标,离开页面自动恢复
AnnotatedRegion<SystemUiOverlayStyle>(
  value: const SystemUiOverlayStyle(
    statusBarIconBrightness: Brightness.light, // Android
    statusBarBrightness: Brightness.dark,      // iOS:语义与 Android 相反
  ),
  child: child,
);

常见坑

现象正确做法
只给 theme 不给 darkTheme系统切暗色后界面照旧两套主题都提供,themeMode 才有意义
颜色写死 Colors.white暗色下白底白字一律走 colorScheme 角色色
暗色忘加 brightness: Brightness.dark暗色偏亮、对比度差ColorScheme.fromSeed 里指定
ThemeExtension 不实现 lerp切换主题时颜色硬跳copyWithlerp 都要实现
iOS 状态栏图标色写反图标与背景同色看不见iOS 用 statusBarBrightness,语义与 Android 相反

小结:主题的核心是“颜色与字号只有一处定义”,用 ColorScheme.fromSeed 生成亮暗两套配色、用 ThemeExtension 承载品牌色、用 themeMode 决定跟随系统还是手动并持久化;字体优先用系统字体、必须打包时做子集化,组件样式统一收拢到 ThemeData,最后用 SystemChromeAnnotatedRegion 处理状态栏。

笔记加载中…