我们团队维护着一个基于Flutter Web的白标(White-Label)产品线,需要为超过20个客户品牌部署独立的、但功能相同的应用实例。最棘手的挑战并非功能迭代,而是UI主题的维护。每个品牌都有自己严格的视觉识别系统(VI),包括主色、辅色、字体、圆角半径、间距单位等等。最初,这些主题被定义为一堆手写的JSON文件,由CI在构建时通过脚本读取并注入。这个方案很快就暴露了致命缺陷:
- 缺乏类型安全:手动编辑JSON极易出错,一个拼写错误的键名或一个无效的色值(例如
#GGHHII
)直到运行时才会暴露,导致线上UI崩溃。 - 无验证机制:无法强制执行设计规范,比如某个品牌的主色只能从预设的几个色板中选择。
- 版本管理混乱:主题变更的评审(Pull Request)非常痛苦,纯文本的JSON diff难以直观理解变更内容。
- 复用性差:不同主题间的细微差异(例如,某个主题只是另一个主题的“暗黑模式”变体)导致大量重复的JSON块,维护成本极高。
我们需要一个声明式的、类型安全的、可验证的、且易于版本控制的方案来管理这些UI主题。初步构想是引入一种配置语言,如CUE或Dhall,它们天生就是为解决这类问题而生。但在评估后,我们决定选择一个非常规的工具:Puppet。
做出这个决定的理由纯粹是出于务实。我们的基础设施团队深度使用Puppet来管理数千台服务器,整个公司对Puppet的语法、模块化和工作流都非常熟悉。复用现有技术栈和知识储备,意味着更低的引入成本和更快的落地速度。我们的目标不是寻找理论上“最完美”的工具,而是在现有约束下,最高效地解决问题的方案。
我们的架构目标是:
- 使用Puppet的声明式语言(DSL)来定义所有品牌主题。
- 利用Puppet的类型系统和验证逻辑,确保每个主题都符合设计规范。
- 在Jenkins CI/CD流水线中,集成一个阶段,该阶段调用Puppet来将
.pp
主题定义文件“编译”成Flutter应用可以直接消费的Dart代码。 - Flutter应用在启动时加载这份生成的代码,实现类似Emotion(一个流行的CSS-in-JS库)那样基于主题上下文的动态样式。
这个方案的核心在于,将一个纯粹的后端配置管理工具,创造性地应用于前端构建流程中,充当一个类型安全的配置生成器。
graph TD subgraph "Git Repositories" A[Theme Repo: themes.git] -- Webhook --> B{Jenkins}; C[Flutter App Repo: app.git] -- Used by --> B; end subgraph "Jenkins Pipeline" B -- Triggers --> D(Pipeline Start); D -- "Stage 1: Checkout" --> E(Checkout app.git & themes.git); E -- "Stage 2: Generate Theme" --> F(Run `puppet apply`); F -- Generates --> G([theme_config.g.dart]); E -- "Stage 3: Build Flutter" --> H(Run `flutter build web`); G -- Injected into --> H; H -- Produces --> I(Build Artifact: /build/web); I -- "Stage 4: Deploy" --> J(Deploy to Server); end subgraph "Puppet Execution" F --> F1(Load brand.pp manifest); F1 --> F2(Parse Puppet DSL & Validate Types); F2 --> F3(Render theme_config.dart.erb template); F3 --> G; end subgraph "Flutter Application" K(main.dart) -- imports --> G; K -- Reads theme data --> L(ThemeManager); L -- Provides theme to --> M(UI Widgets); end style F fill:#f9f,stroke:#333,stroke-width:2px style G fill:#bbf,stroke:#333,stroke-width:2px
第一步:用Puppet定义UI主题
我们创建了一个专门的Puppet模块ui_themes
来管理所有主题定义。核心是创建一个自定义的defined type
,名为ui_themes::theme
,它像一个类,定义了一个主题应该包含的所有属性和约束。
目录结构如下:
puppet/modules/ui_themes/
├── manifests/
│ ├── init.pp
│ └── theme.pp
├── templates/
│ └── theme_config.dart.erb
└── examples/
└── brand_alpha.pp
manifests/theme.pp
是关键所在,它定义了数据结构和验证规则。
# puppet/modules/ui_themes/manifests/theme.pp
# @summary Defines a structured and validated UI theme for a brand.
#
# This defined type acts as a schema for all our brand themes. It leverages
# Puppet's type system to enforce constraints, such as color formats and
# font weight enums, directly within the configuration files.
#
# @param primary_color The main brand color. Must be a valid hex code.
# @param secondary_color The secondary brand color. Must be a valid hex code.
# @param text_color_primary Primary text color.
# @param text_color_secondary Secondary text color.
# @param background_color Main background color of the application.
# @param font_family The primary font family to be used.
# @param base_font_size The base font size in pixels.
# @param base_border_radius The standard border radius for components like buttons and cards.
# @param supported_font_weights An array of supported font weights.
define ui_themes::theme (
String $primary_color,
String $secondary_color,
String $text_color_primary,
String $text_color_secondary,
String $background_color,
String $font_family,
Integer $base_font_size,
Integer $base_border_radius,
Array[Enum['w300', 'w400', 'w500', 'w700']] $supported_font_weights,
) {
# -- Validation Rules --
# A common pitfall is not validating data at the source. Puppet's match
# function with regex provides robust, early-stage validation.
$hex_color_regex = '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$'
unless $primary_color =~ $hex_color_regex {
fail("Invalid primary_color format for theme '${title}'. Must be a valid hex code, got '${primary_color}'.")
}
unless $secondary_color =~ $hex_color_regex {
fail("Invalid secondary_color format for theme '${title}'. Must be a valid hex code, got '${secondary_color}'.")
}
unless $text_color_primary =~ $hex_color_regex {
fail("Invalid text_color_primary format for theme '${title}'. Must be a valid hex code, got '${text_color_primary}'.")
}
# ... other color validations ...
if $base_font_size <= 0 {
fail("base_font_size for theme '${title}' must be a positive integer.")
}
if $base_border_radius < 0 {
fail("base_border_radius for theme '${title}' cannot be negative.")
}
# This defined type does not manage any resources (file, service, etc.).
# Its sole purpose is to act as a data container and validator. The data
# will be consumed by an ERB template to generate the Dart code.
}
有了这个defined type
,定义一个新品牌的主题就变得非常简单和安全。例如,examples/brand_alpha.pp
:
# puppet/examples/brand_alpha.pp
# This is the single source of truth for the 'alpha' brand's UI.
# If someone tries to enter 'w600' in supported_font_weights, `puppet apply`
# will fail immediately, preventing a broken build.
ui_themes::theme { 'alpha':
primary_color => '#3F51B5',
secondary_color => '#FFC107',
text_color_primary => '#212121',
text_color_secondary => '#757575',
background_color => '#F5F5F5',
font_family => 'Roboto',
base_font_size => 16,
base_border_radius => 8,
supported_font_weights => ['w300', 'w400', 'w700'],
}
# We can also define a dark variant inheriting and overriding properties.
# While Puppet doesn't have native inheritance in this context, we can use
# Hiera or simple variable structures to achieve this. For simplicity here,
# we define it separately.
ui_themes::theme { 'alpha_dark':
primary_color => '#7986CB',
secondary_color => '#FFD54F',
text_color_primary => '#FFFFFF',
text_color_secondary => '#BDBDBD',
background_color => '#303030',
font_family => 'Roboto',
base_font_size => 16,
base_border_radius => 8,
supported_font_weights => ['w300', 'w400', 'w700'],
}
第二步:创建代码生成模板
现在我们需要一个桥梁,将Puppet解析后的数据转换成Dart代码。这正是Puppet的ERB(Embedded Ruby)模板系统的用武之地。
templates/theme_config.dart.erb
模板文件内容如下:
// GENERATED CODE - DO NOT MODIFY BY HAND
//
// This file is generated by the Jenkins CI pipeline using Puppet and ERB.
// Any manual changes will be overwritten on the next build.
// Timestamp: <%= Time.now.utc.iso8601 %>
// ignore_for_file: constant_identifier_names
part of 'theme_manager.dart';
// The scope object in ERB gives us access to all variables Puppet has compiled.
// We need to find all resources of our custom type 'Ui_themes::Theme'.
// The resource name format is Title-cased, so 'ui_themes::theme' becomes 'Ui_themes::Theme'.
// A common mistake is using the wrong casing or trying to access variables that
// are out of scope. Using `scope.resources` is the reliable way.
<%
themes = scope.resources('Ui_themes::Theme')
if themes.nil? || themes.empty?
raise("No 'Ui_themes::Theme' resources found during compilation. Check your manifests.")
end
%>
class _AppThemes {
<% themes.each do |theme| %>
static const BrandTheme <%= theme.title.gsub('-', '_') %> = BrandTheme(
name: '<%= theme.title %>',
primaryColor: Color(0xFF<%= theme['primary_color'].delete('#') %>),
secondaryColor: Color(0xFF<%= theme['secondary_color'].delete('#') %>),
textColorPrimary: Color(0xFF<%= theme['text_color_primary'].delete('#') %>),
textColorSecondary: Color(0xFF<%= theme['text_color_secondary'].delete('#') %>),
backgroundColor: Color(0xFF<%= theme['background_color'].delete('#') %>),
fontFamily: '<%= theme['font_family'] %>',
baseFontSize: <%= theme['base_font_size'] %>,
baseBorderRadius: <%= theme['base_border_radius'] %>,
supportedFontWeights: {
<% theme['supported_font_weights'].each do |weight| %>
'<%= weight %>': FontWeight.<%= weight %>,
<% end %>
},
);
<% end %>
static const Map<String, BrandTheme> allThemes = {
<% themes.each do |theme| %>
'<%= theme.title %>': <%= theme.title.gsub('-', '_') %>,
<% end %>
};
}
这个模板会遍历所有已定义的ui_themes::theme
资源,为每个资源生成一个BrandTheme
类的静态实例,最终将所有主题聚合到一个map中,供Flutter应用查询。注意,我们将#RRGGBB
格式的色值转换成了Flutter Color
类所需的0xAARRGGBB
格式。
第三步:集成到Jenkins流水线
接下来是整个自动化流程的核心:Jenkins流水线。我们使用声明式Pipeline语法编写Jenkinsfile
。
// Jenkinsfile
pipeline {
agent any
environment {
// Path where the generated Dart file will be placed.
// It's crucial this path is inside the Flutter project source tree.
FLUTTER_PROJECT_PATH = 'workspace/app'
THEME_CONFIG_OUTPUT = "${FLUTTER_PROJECT_PATH}/lib/generated/theme_config.g.dart"
PUPPET_MANIFEST_PATH = 'workspace/themes/examples/brand_alpha.pp'
}
stages {
stage('Checkout Sources') {
steps {
// Clean workspace is essential to prevent stale generated files.
cleanWs()
// Checkout both repositories into subdirectories.
dir('app') {
git url: 'https://github.com/your-org/flutter-app.git', branch: 'main'
}
dir('themes') {
git url: 'https://github.com/your-org/ui-themes.git', branch: 'main'
}
}
}
stage('Generate Theme Configuration') {
steps {
script {
// In a real production environment, the Puppet agent and its modules
// would be pre-installed on the Jenkins agent machine or in a Docker container.
// Here we simulate the setup.
sh '''
# Ensure the output directory exists
mkdir -p $(dirname ${THEME_CONFIG_OUTPUT})
echo "--- Running Puppet to generate Dart theme configuration ---"
# `puppet apply` is used for masterless execution.
# --modulepath points to our custom module location.
# The `e` flag executes a small Puppet manifest inline.
# This manifest uses the `template` function to process our ERB file.
# The output is redirected to our target Dart file.
# Error handling is critical: `set -e` ensures the pipeline fails if puppet fails.
set -e
puppet apply --modulepath=workspace/themes/puppet/modules -e \\
"file { '${THEME_CONFIG_OUTPUT}': content => template('ui_themes/theme_config.dart.erb') }" \\
${PUPPET_MANIFEST_PATH}
echo "--- Dart theme file generated successfully. ---"
cat ${THEME_CONFIG_OUTPUT}
'''
}
}
}
stage('Build Flutter Web App') {
// Using a specific Docker agent ensures a consistent build environment.
agent {
docker {
image 'cirrusci/flutter:3.13.9' // Use a specific, pinned version
args '-v $HOME/.pub-cache:/root/.pub-cache' // Cache pub dependencies
}
}
steps {
dir(FLUTTER_PROJECT_PATH) {
sh 'flutter pub get'
// The --release flag is important for production builds.
sh 'flutter build web --release'
}
}
}
stage('Archive Artifact') {
steps {
archiveArtifacts artifacts: "${FLUTTER_PROJECT_PATH}/build/web/**", fingerprint: true
}
}
}
post {
always {
echo 'Pipeline finished.'
// Clean up workspace after build
cleanWs()
}
}
}
这个Jenkinsfile
清晰地定义了流程:检出代码、调用Puppet生成Dart文件、然后用标准的Flutter命令构建Web应用。如果Puppet在验证主题定义时失败(例如,一个无效的颜色值),puppet apply
会返回非零退出码,set -e
会捕获这个错误,整个流水线立即中止,从而将问题拦截在构建阶段。
第四步:在Flutter应用中消费主题
最后一步是在Flutter应用中集成这份生成的代码。
首先,我们需要定义BrandTheme
数据类和一个管理当前主题的服务。
lib/theme_manager.dart
:
import 'package:flutter/material.dart';
// This file is the 'host' for the generated part file.
part 'generated/theme_config.g.dart';
// Data class representing all themable properties.
class BrandTheme {
final String name;
final Color primaryColor;
final Color secondaryColor;
final Color textColorPrimary;
final Color textColorSecondary;
final Color backgroundColor;
final String fontFamily;
final double baseFontSize;
final double baseBorderRadius;
final Map<String, FontWeight> supportedFontWeights;
const BrandTheme({
required this.name,
required this.primaryColor,
required this.secondaryColor,
required this.textColorPrimary,
required this.textColorSecondary,
required this.backgroundColor,
required this.fontFamily,
required this.baseFontSize,
required this.baseBorderRadius,
required this.supportedFontWeights,
});
// Helper to get a FontWeight, falling back to normal if not defined.
FontWeight fontWeight(String weightKey) =>
supportedFontWeights[weightKey] ?? FontWeight.w400;
}
// A simple service to hold and provide the current theme.
// In a real app, this would be integrated with a state management solution
// like Provider, Riverpod, or Bloc.
class ThemeManager extends ChangeNotifier {
late BrandTheme _currentTheme;
ThemeManager({String initialThemeName = 'alpha'}) {
// _AppThemes is from the generated file. We select the initial theme.
_currentTheme = _AppThemes.allThemes[initialThemeName]!;
}
BrandTheme get theme => _currentTheme;
void selectTheme(String themeName) {
final newTheme = _AppThemes.allThemes[themeName];
if (newTheme != null && newTheme.name != _currentTheme.name) {
_currentTheme = newTheme;
notifyListeners();
}
}
}
这里的part 'generated/theme_config.g.dart';
指令会将Jenkins生成的文件包含进来。
为了实现类似Emotion
的上下文感知样式,我们使用InheritedWidget
(或者更现代的Provider
)将BrandTheme
实例注入到Flutter的Widget树中。
main.dart
:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'theme_manager.dart'; // Assume the files are in lib/
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => ThemeManager(initialThemeName: 'alpha_dark'),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
Widget build(BuildContext context) {
// Access the theme from the provider.
final brandTheme = Provider.of<ThemeManager>(context).theme;
return MaterialApp(
title: 'Themed Flutter App',
theme: ThemeData(
// Use data from our BrandTheme to configure Flutter's ThemeData
primaryColor: brandTheme.primaryColor,
scaffoldBackgroundColor: brandTheme.backgroundColor,
fontFamily: brandTheme.fontFamily,
appBarTheme: AppBarTheme(
backgroundColor: brandTheme.primaryColor,
foregroundColor: brandTheme.textColorPrimary,
),
textTheme: TextTheme(
bodyMedium: TextStyle(
color: brandTheme.textColorPrimary,
fontSize: brandTheme.baseFontSize,
fontWeight: brandTheme.fontWeight('w400'),
),
headlineMedium: TextStyle(
color: brandTheme.textColorPrimary,
fontSize: brandTheme.baseFontSize * 1.5,
fontWeight: brandTheme.fontWeight('w700'),
),
),
),
home: const HomePage(),
);
}
}
// Example usage in a widget
class HomePage extends StatelessWidget {
const HomePage({Key? key}) : super(key: key);
Widget build(BuildContext context) {
final theme = Provider.of<ThemeManager>(context).theme;
return Scaffold(
appBar: AppBar(title: Text('Theme: ${theme.name}')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'This text uses primary text color.',
style: TextStyle(color: theme.textColorPrimary),
),
const SizedBox(height: 20),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: theme.primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(theme.baseBorderRadius),
),
),
onPressed: () {
// Switch theme dynamically
final currentThemeName = theme.name;
final nextTheme = currentThemeName == 'alpha' ? 'alpha_dark' : 'alpha';
Provider.of<ThemeManager>(context, listen: false).selectTheme(nextTheme);
},
child: const Text('Switch Theme'),
),
],
),
),
);
}
}
局限性与未来展望
这套方案成功解决了我们最初的痛点,但它并非银弹。一个明显的局限是引入了技术栈的复杂度。前端开发者现在需要对Puppet的DSL有基本了解才能修改主题,尽管我们通过defined type
极大简化了这一过程。此外,本地开发环境的搭建也变得更复杂,开发者需要安装Puppet才能在本地生成主题代码,这可以通过提供一个封装了Puppet的Docker镜像来缓解。
另一个值得思考的点是Puppet执行的性能。对于目前几十个主题的规模,puppet apply
的执行时间在CI上仅需几秒钟,完全可以接受。但如果主题数量增长到数千个,编译时间可能会成为瓶颈,届时可能需要探索更高效的配置生成工具或对Puppet的执行进行优化。
未来的迭代方向可能包括:
- Hiera集成:使用Hiera作为Puppet的数据后端,可以更优雅地处理主题的继承和覆盖关系,实现如“基础主题”+“品牌变体”的层次化配置。
- 可视化编辑器:开发一个简单的Web界面,让设计师能够通过图形化操作来修改主题参数,该界面在后台生成并提交相应的
.pp
文件到Git仓库,从而触发整个自动化流程,实现真正的No-code主题管理。 - 实时预览:结合热重载,探索一个本地开发服务器,能够监视
.pp
文件的变化,实时重新生成Dart代码并刷新Flutter应用,进一步提升开发体验。