简介
主题切换是应用中常见的功能,Flutter 提供了完整的主题支持。本文介绍如何实现动态主题切换功能。
基本实现
定义主题数据
class AppTheme {
static final ThemeData lightTheme = ThemeData(
brightness: Brightness.light,
primarySwatch: Colors.blue,
scaffoldBackgroundColor: Colors.white,
appBarTheme: AppBarTheme(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
textTheme: TextTheme(
bodyLarge: TextStyle(color: Colors.black87),
bodyMedium: TextStyle(color: Colors.black87),
),
);
static final ThemeData darkTheme = ThemeData(
brightness: Brightness.dark,
primarySwatch: Colors.blue,
scaffoldBackgroundColor: Colors.grey[900],
appBarTheme: AppBarTheme(
backgroundColor: Colors.grey[850],
foregroundColor: Colors.white,
),
textTheme: TextTheme(
bodyLarge: TextStyle(color: Colors.white),
bodyMedium: TextStyle(color: Colors.white70),
),
);
}
主题管理
class ThemeProvider extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.system;
ThemeMode get themeMode => _themeMode;
bool get isDarkMode => _themeMode == ThemeMode.dark;
void setThemeMode(ThemeMode mode) {
_themeMode = mode;
notifyListeners();
}
void toggleTheme() {
_themeMode = isDarkMode ? ThemeMode.light : ThemeMode.dark;
notifyListeners();
}
}
完整示例
// 主应用
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => ThemeProvider(),
child: Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return MaterialApp(
title: 'Theme Demo',
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: themeProvider.themeMode,
home: HomePage(),
);
},
),
);
}
}
// 主页
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final themeProvider = Provider.of<ThemeProvider>(context);
return Scaffold(
appBar: AppBar(
title: Text('Theme Demo'),
actions: [
IconButton(
icon: Icon(
themeProvider.isDarkMode
? Icons.light_mode
: Icons.dark_mode,
),
onPressed: () => themeProvider.toggleTheme(),
),
],
),
body: ListView(
padding: EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Theme Settings',
style: Theme.of(context).textTheme.titleLarge,
),
SizedBox(height: 16),
RadioListTile<ThemeMode>(
title: Text('System'),
value: ThemeMode.system,
groupValue: themeProvider.themeMode,
onChanged: (value) {
themeProvider.setThemeMode(value!);
},
),
RadioListTile<ThemeMode>(
title: Text('Light'),
value: ThemeMode.light,
groupValue: themeProvider.themeMode,
onChanged: (value) {
themeProvider.setThemeMode(value!);
},
),
RadioListTile<ThemeMode>(
title: Text('Dark'),
value: ThemeMode.dark,
groupValue: themeProvider.themeMode,
onChanged: (value) {
themeProvider.setThemeMode(value!);
},
),
],
),
),
),
SizedBox(height: 16),
Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Preview',
style: Theme.of(context).textTheme.titleLarge,
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {},
child: Text('Elevated Button'),
),
SizedBox(height: 8),
OutlinedButton(
onPressed: () {},
child: Text('Outlined Button'),
),
SizedBox(height: 8),
TextButton(
onPressed: () {},
child: Text('Text Button'),
),
SizedBox(height: 16),
TextField(
decoration: InputDecoration(
labelText: 'Input Field',
border: OutlineInputBorder(),
),
),
],
),
),
),
],
),
);
}
}
主题持久化
保存主题设置
class ThemePreferences {
static const String key = 'theme_mode';
static Future<void> saveThemeMode(ThemeMode mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(key, mode.toString());
}
static Future<ThemeMode> getThemeMode() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getString(key);
return ThemeMode.values.firstWhere(
(mode) => mode.toString() == value,
orElse: () => ThemeMode.system,
);
}
}
使用持久化
class ThemeProvider extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.system;
ThemeProvider() {
_loadThemeMode();
}
Future<void> _loadThemeMode() async {
_themeMode = await ThemePreferences.getThemeMode();
notifyListeners();
}
Future<void> setThemeMode(ThemeMode mode) async {
_themeMode = mode;
await ThemePreferences.saveThemeMode(mode);
notifyListeners();
}
}
最佳实践
ThemeData做成static final常量(文中AppTheme已是这个做法),别在build里 new:每次 build 都 new 一个ThemeData会触发整树不必要的重建,切个主题掉几帧。- 颜色、字号一律走
Theme.of(context),别在业务组件里硬编码Colors.black/Colors.white:硬编码的黑色文字在深色模式下直接看不见,切主题等于白切。 - 持久化读主题是异步的,启动时会闪一下默认主题:在
MaterialApp外包一层启动屏/FutureBuilder,等 SharedPreferences 读完再 build——别让用户看到”白屏一闪变黑”的廉价感。
注意事项
- 切主题必然触发整树 rebuild,这是正常的:别为了”性能”去 hack 局部不刷新;真觉得切换生硬,用
AnimatedTheme给 200~300ms 过渡动画,体验反而更好。 - 第三方组件不跟
ThemeData走:WebView、地图、图表库的配色要单独适配——上线前把第三方页面逐个切主题看一遍,别以为切了主题就全黑了。 - 深色模式不是”背景变纯黑”就行:纯黑
#000000在 OLED 上省电但对比刺眼,Material 规范推荐深灰(如#121212);正文用white70而非纯白,长时间看不累眼。 - 浅色 logo、插图要准备深色版:白底图片在深色背景上会糊成一块补丁,视觉上比”没做深色模式”更廉价——切主题时顺手检查一遍图片资源。
实战案例:GetX 一行切主题的代价
我手里的几个 GetX 项目(电商、物流类 App,统一用的 get ^4.7.2)切主题根本不用写 Provider:Get.changeThemeMode() 一行搞定,Get.isDarkMode 随处可读,比官方 themeMode + ChangeNotifier 那套模板代码少写一个文件。当时选 GetX 就是图这个”顺手”。但代价是隐性的:主题状态和 GetX 强耦合之后,万一哪天团队决定换状态管理(比如切 Riverpod),所有 Get.changeTheme 的调用点都得翻出来重写;而官方写法啰嗦归啰嗦,主题逻辑和状态管理是解耦的。我的取舍结论是:如果项目已经全家桶用了 GetX(路由、依赖注入都在用),那就别扭捏,直接用它的主题 API;如果只是”为了切主题引入 GetX”,那就本末倒置了,老老实实按官方文档写。技术选型最怕的就是”只用它 10% 的功能,却背了 100% 的耦合”。
总结
通过合理使用 Flutter 的主题系统和状态管理,可以轻松实现动态主题切换功能。注意在实现过程中要考虑性能优化和用户体验。
