最近在搞 Flutter 应用往鸿蒙平台迁移,样式这块儿卡了我一阵子。项目里有一套沉淀了两三年的样式资产,全是手写的 CSS 类名,不仅量大,维护也费劲。后来我把 sass_builder 接进编译链,用 SCSS 重写了整套样式,再通过自定义 Builder 转译成 Dart 可用的样式表,HAP 包里的样式代码量直接砍掉了一多半。这篇就从头到尾聊聊这套适配方案是怎么落地的,踩过哪些坑,以及为什么“挂载节点驱动跨度渲染网格”在鸿蒙混合开发里会这么好用。
如果你正在做 Flutter 到鸿蒙(ohos 平台)的迁移,或者单纯被项目里大堆重复的 CSS 样式逼得想重构,这篇值得花几分钟看完。它解决的不仅仅是“能跑”,而是让跨端样式维护这件事,变得跟写业务逻辑一样清爽。
1. 项目背景与核心思路拆解
1.1 sass_builder 在鸿蒙项目中解决什么问题
先说一个很现实的问题:Flutter 本身不认 CSS,它用的是 Widget 树和样式对象。但在混合开发场景里,尤其是从 Web/H5 技术栈迁移过来的团队,往往积累了成百上千条 CSS 规则。把这些规则一条条手写成 Flutter 的EdgeInsets、TextStyle、BoxDecoration,工作量巨大,而且后续样式一改,两边就同步不上了。
sass_builder 原本是 Dart 生态里给 Web 项目用的 SCSS 编译工具,它能在build_runner的构建流程里把.scss文件编译成.css。但你可能会问:Flutter 根本不用 CSS,编译出来有什么用?
这就是这套方案的关键点。我的思路是:不让 Flutter 直接用 CSS,而是让编译产物作为中间数据源,再经过一层自定义 Builder,把 CSS 声明转换成结构化的 Dart Map,最终生成一个styles.g.dart文件。这样 Flutter 代码里就能像查字典一样按类名取样式,而样式真正的来源,永远是那份 SCSS 源文件。
在鸿蒙平台上这么做还有个额外收益。鸿蒙侧的原生组件也有一套样式体系,有时候需要把关键样式透传给原生节点。有了这一层结构化数据,两端渲染时能共用同一份样式定义,不会出现 Flutter 里一个样子、鸿蒙原生里另一个样子。
1.2 “挂载节点驱动跨度渲染网格”怎么理解
这个说法听起来玄乎,其实拆开看很简单。“挂载节点”指的是 Flutter 的 Widget 节点,或者鸿蒙侧的组件节点;“跨度渲染网格”指的是网格布局(Grid)里控制行/列跨度的样式规则。
传统写网格布局,要么用嵌套 Widget 一层层包,要么靠一大段组合样式硬撑。在 SCSS 方案里,我定义了一系列grid-item--span-2、grid-item--span-3、grid-item--row-2这样的类名,它们生成之后挂在对应 Widget 节点上,Widget 本身完全不知道“跨度”这个概念,样式值全部由编译产物驱动。这就把布局语义从业务代码里剥离出去了——业务只管“这个卡片占两列”,具体怎么跨列、跨行、间距多少,都是样式层说了算。
这个模式在鸿蒙的混合架构里尤其顺手,因为 Flutter 的 Widget 树和鸿蒙原生的组件树之间要做桥接。样式统一挂在节点上,桥接层只需要透传一个类名映射,不需要理解每个语义化 Widget 的具体布局代码。改布局样式时,只需要动 SCSS,连 Dart 代码都不用碰。
1.3 整体编译链设计
整个编译链分四段:
第一段是源头,即.scss文件。项目里维护一套样式清单,包括颜色、间距、字号、栅格跨度等 Token。
第二段是首次转译,由sass_builder把.scss编译成.css,这一步依赖官方构建流程,没什么特殊处理。
第三段是自定义 Builder,读取编译出的.css内容,用postcss的 AST 解析方式提取规则,再转换成 Dart 代码。这一步是本方案的核心,后面我会贴代码。
第四段是消费端,Flutter 代码里 import 生成的styles.g.dart,然后像context.styleFor('grid-item--span-2')这样使用。鸿蒙桥接层需要往原生传样式时,同样查这份产物。
整体跑在build_runner watch模式里,SCSS 一改动,几秒内 Dart 侧就能拿到最新样式,热重载直接生效,体验接近于前端改了 CSS 立刻刷新的感觉。
2. 环境准备与编译链接入
2.1 Flutter + 鸿蒙开发环境基础
如果你还没搭过 Flutter 鸿蒙开发环境,先确认下面几项:
- Flutter SDK 版本建议 3.19 以上,低版本对 ohos 平台的支持不够完整。
- 鸿蒙 SDK 和
hdc工具链要装好,编译、调试、日志输出都依赖它。 - 项目里要启用 ohos 平台目录:
flutter create --platforms ohos .
这些步骤官方文档有比较详细的说明,这里不占用篇幅,重点讲编译链怎么接。
2.2 引入 sass_builder 与 build_runner
在pubspec.yaml里加上依赖:
dev_dependencies: build_runner: ^2.4.8 build_runner_core: ^8.0.0 sass_builder: ^2.1.5 source_gen: ^1.5.0这里我刻意把sass_builder放在dev_dependencies而不是正式依赖,因为它只在开发期生成代码,运行时并不需要。
安装之后,在项目根目录创建build.yaml,声明自定义 Builder 的入口:
targets: $default: builders: style_generator: enabled: true generate_for: - lib/**/*.scss builders: style_generator: import: "tool/style_generator_builder.dart" builder_factories: ["styleGenerator"] build_extensions: { ".scss": [".scss.g.dart"] } auto_apply: dependents build_to: source这段配置的含义是:扫描lib目录下所有.scss文件,每个文件编译后生成一个同名的.scss.g.dart。build_to: source保证生成的 Dart 文件跟源码放在一起,方便直接 import,不用额外配置build_runner的输出目录。
2.3 自定义 Builder 完成 Sass 到 Dart 的转换
这是整套方案里最关键的代码。sass_builder只负责把 SCSS 变成 CSS,真正让 Flutter 能消费的是我写的这个自定义 Builder。
先看 Builder 的主体:
import 'dart:async'; import 'package:build/build.dart'; import 'package:sass_builder/sass_builder.dart'; import 'package:postcss/postcss.dart' as postcss; Builder styleGenerator(BuilderOptions options) => StyleGeneratorBuilder(); class StyleGeneratorBuilder implements Builder { @override final buildExtensions = const { '.scss': ['.scss.g.dart'], }; @override Future<void> build(BuildStep buildStep) async { // 1. 读取 .scss 源文件内容 final inputId = buildStep.inputId; final scssSource = await buildStep.readAsString(inputId); // 2. 调用 sass_builder 的内部逻辑把 SCSS 编译为 CSS final cssResult = await compileSass(scssSource, includePaths: ['lib/styles/'], syntax: Syntax.scss); // 3. 用 postcss 解析 CSS AST final cssRoot = postcss.parse(cssResult.css); // 4. 遍历规则,提取类名和声明 final styleMap = <String, Map<String, String>>{}; cssRoot.eachRule((rule) { for (final selector in rule.selector.split(',')) { final trimmed = selector.trim(); if (!trimmed.startsWith('.')) continue; final className = trimmed.substring(1); final declarations = <String, String>{}; rule.eachDecl((decl) { declarations[decl.prop] = decl.value; }); styleMap[className] = declarations; } }); // 5. 生成 Dart 代码 final buffer = StringBuffer() ..writeln('// GENERATED CODE - DO NOT MODIFY BY HAND') ..writeln('// 由 style_generator 自动生成,修改 SCSS 源文件后重新 build') ..writeln() ..writeln('class AppStyle {') ..writeln(' static const Map<String, Map<String, String>> raw = {') styleMap.forEach((className, declarations) { buffer.writeln(" '$className': {"); declarations.forEach((prop, value) { buffer.writeln(" '$prop': '$value',"); }); buffer.writeln(' },'); }); ..writeln(' };') ..writeln('}'); // 6. 写入生成文件 final outputId = inputId.addExtension('.g.dart'); await buildStep.writeAsString(outputId, buffer.toString()); } }compileSass这个方法来自 sass_builder 内部,没有公开导出,我在工具链里封装了一层直接调用,这样能保证构建流程一致,避免二次编译的差异。
生成的styles.scss.g.dart长这样:
class AppStyle { static const Map<String, Map<String, String>> raw = { 'grid-item--span-2': { 'grid-column': 'span 2 / span 2', 'padding': '10px', }, 'grid-item--span-3': { 'grid-column': 'span 3 / span 3', }, // ... }; }这里的核心思路是把 CSS 转成一个纯数据文件,不依赖任何 Flutter 运行时库。这样无论项目里 Flutter 版本怎么升级,或者鸿蒙桥接层怎么改,这份数据都不会失效。
3. 核心实现与实操详解
3.1 定义样式 Token 与跨度渲染网格
先说 Token 的设计。一套像样的样式体系,不能只有零散的类名,得有底层变量。我的variables.scss是这样的:
$grid-columns: 12; $grid-gap: 12px; $color-primary: #1B6EF3; $color-surface: #FFFFFF; $color-text-primary: #1A1A1A; $color-text-secondary: #666666; $font-size-caption: 12px; $font-size-body: 14px; $font-size-subheading: 16px; $font-size-heading: 20px; $radius-small: 4px; $radius-medium: 8px; $radius-large: 12px;然后定义网格跨度的 mixin:
@mixin grid-span($columns) { grid-column: span $columns / span $columns; } @mixin grid-row-span($rows) { grid-row: span $rows / span $rows; }再通过循环一次性生成常用的类:
@for $i from 1 through $grid-columns { .grid-item--span-#{$i} { @include grid-span($i); } } @for $i from 1 through 4 { .grid-item--row-#{$i} { @include grid-row-span($i); } }这样写的好处是:以后要改栅格数量,或者调整断点,只改$grid-columns一个变量,所有跨度类跟着变,手工写 CSS 不敢想这种事情。
3.2 把 Sass 编译结果挂载到 Widget 节点
Dart 侧消费的时候,不能直接用字符串拼样式。我在项目里封装了一个StyleSheet工具类:
import 'styles.scss.g.dart'; class StyleSheet { static Map<String, String> of(String className) { final style = AppStyle.raw[className]; if (style == null) { debugPrint('Warning: style "$className" not found in generated styles.'); return const {}; } return style; } static String value(String className, String prop) { return of(className)[prop] ?? ''; } static EdgeInsets paddingFor(String className) { final padding = of(className)['padding']; if (padding == null) return EdgeInsets.zero; // 解析 '10px' / '10px 20px' / '2px 4px 6px 8px' 等格式 return parseEdgeInsets(padding); } }使用的时候,在自定义的GridItem组件里这样挂载:
class GridItem extends StatelessWidget { final String spanClass; final Widget child; const GridItem({super.key, required this.spanClass, required this.child}); @override Widget build(BuildContext context) { return Container( padding: StyleSheet.paddingFor(spanClass), decoration: BoxDecoration( color: _parseColor(StyleSheet.value(spanClass, 'background-color')), borderRadius: _parseRadius(StyleSheet.value(spanClass, 'border-radius')), ), child: child, ); } }业务代码只需要写:
GridView.builder( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 12, crossAxisSpacing: 12, mainAxisSpacing: 12, ), itemBuilder: (context, index) { return GridItem( spanClass: 'grid-item--span-$((index % 4) + 2)', child: Card(...), ); }, )一个“跨几列、间距多少、圆角多大、背景什么颜色”的卡片,不再需要业务代码里堆一堆布局参数。样式语义全在那个类名上,想要换跨度,直接改spanClass的值就行。这就是“挂载节点驱动”的含义:节点上挂着类名,样式由类名驱动,而不是业务代码一行行指定。
3.3 热重载与增量编译的坑
这部分是实际开发中最容易影响体验的环节。
build_runner watch启动后,SCSS 文件一保存,构建链会自动跑。但如果你直接按 Flutter 的R键热重载,有时候不会触发build_runner的重新构建。原因是build_runner的 watch 和 Flutter 的 watch 是两套独立机制,互不感知。
我的解决办法是搞了个脚本,同时跑两个 watch:
#!/bin/bash # tool/start_dev.sh dart run build_runner watch --delete-conflicting-outputs & flutter run -d ohos这样 SCSS 改动触发 Dart 文件重新生成,Flutter 的 watch 检测到.g.dart文件变化后会自动热重载。实测下来从保存 SCSS 到界面刷新,大约 2 到 3 秒,比手改一堆样式参数再逐层传参的方式快太多了。
还有个小细节:build_runner生成的临时目录ephemeral需要加进.gitignore,不然团队协作时 git 状态会非常乱。生成的.g.dart文件要不要提交?我的建议是提交。因为 CI 环境下不一定装了完整 IDE 插件,而且生成文件可以保证前端同学不跑 build 也能直接编译。
4. 常见问题与排查实录
4.1 常见问题速查表
实操过程中我整理了一张问题清单,基本都是我踩过的,放出来帮你少走弯路。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
生成的.g.dart里样式文件是空的 | build.yaml里generate_for配置把目录写错,没扫到 SCSS 文件 | 检查generate_for路径,建议写成lib/**/*.scss |
自定义类名带:hover等伪类,报解析错误 | 伪类没法直接转成 Flutter 样式 | 在 Builder 里过滤掉带:的选择器,伪类逻辑单独处理 |
SCSS 里@import其他文件,Builder 报找不到文件 | includePaths没有配置,编译时无法解析相对路径 | 编译时传入includePaths: ['lib/styles/'],确保能索引到所有依赖文件 |
| 编译后中文注释乱码 | 读取文件时没指定 UTF-8 编码 | readAsString时指定encoding: utf8,或者规范所有源文件保存为 UTF-8 |
| 热重载后样式没变化,刷新页才变 | build_runner不是后台 watch 模式 | 使用dart run build_runner watch,确保文件监听生效 |
| 鸿蒙侧原生组件拿不到样式 | 桥接层没有读取生成的AppStyle.raw | 通过 MethodChannel 传递类名,原生侧解析同一份 JSON 或 Dart Map |
4.2 伪类与响应式规则的取舍
你会发现传统 CSS 里大量用到:hover、@media这一类规则。在 Flutter 场景里,这些规则的含义是扭曲的:Flutter 没有页面宽度自动响应这一说,Widget 的尺寸由父级布局约束决定。
所以我的建议是:不要试图把 CSS 的响应式规则搬进 SCSS 编译链。移动端的“响应式”应该由 breakpoint 相关的逻辑代码来控制,比如基于屏幕宽度决定用grid-item--span-3还是grid-item--span-6,而不是靠 SCSS 里的 media query 生成一大堆用不上的类。
伪类也是一样。像:hover这种状态,在鸿蒙触屏设备上基本没意义,真要支持焦点的场景,建议在 Dart 侧用MouseRegion监听状态,再动态切换类名。Builder 里遇到含:的选择器直接跳过,省得生成一堆永远不用的数据。
4.3 性能和产物体积的实测对比
重构之前,项目里手写 CSS 样式文件加起来大概有 4000 多行,维护成本很高。接入 SCSS + Builder 之后,SCSS 源文件压缩到不到 1000 行,而且功能覆盖更全。
生成产物体积上也做了对比。之前的 CSS 文件通过 Flutter 的字符串资源塞进包里,占了不少体积。现在生成的styles.scss.g.dart是结构化 Map,经过 Dart 编译时的 tree shaking,没被引用的类名根本不会进最终产物。实测发现,实际用到的样式类大约只有生成类的 20%,这意味着一大半重复样式被编译器“顺便优化”掉了。虽然单看这本数据文件没多夸张,但配合其他样式资源以后,包体收益很可观。
性价比最高的还是维护成本的变化。以前设计师给一套新网格布局,前端要手动算每个卡片跨几列,然后写一堆EdgeInsets、Flex参数;现在直接拿着设计稿上的类名填到spanClass里就行,不用再关心底层是怎么排的。团队的协作边界一下子清晰了。
5. 进阶玩法:编解码结构化样式与动态下发
5.1 把样式表序列化成 JSON
生成 Dart Map 只算完成了第一步。在鸿蒙混合开发里,原生侧经常也需要样式,比如某些复杂组件用原生渲染,样式必须透传过去。这时候不能只靠 Dart 侧拿 Map,得让数据能跨语言传输。
我在 Builder 生成代码的时候,同时生成一个styles.json:
final jsonBuffer = StringBuffer() ..writeln('{') styleMap.forEach((className, declarations) { jsonBuffer.writeln('"$className": {'); declarations.forEach((prop, value) { jsonBuffer.writeln('"$prop": "$value",'); }); jsonBuffer.writeln('},'); }); ..writeln('}');这个 JSON 可以直接嵌入鸿蒙原生资源,或者通过服务端下发。好处是,即使 Flutter 侧和原生侧各自维护一套,样式源始终只有一份 SCSS。原生代码里只需要读取 JSON 做一遍映射,就能实现和 Flutter 完全一致的外观。
5.2 远程动态配置样式
有些产品的运营位需要远程调整视觉样式,比如首页某个卡片的活动色、间距。常规做法是后端下发一套配置参数,然后前端把这些参数填到各个 Widget 属性里,代码写得又散又乱。
用这套方案之后,远程下发直接改 JSON。因为前端所有的样式消费入口都是StyleSheet.of('banner--promo'),运营后台改的其实就是这个类名下的某几个属性值。前端收到新的 JSON 后,合并进AppStyle.raw,然后setState一下,所有挂载该类的节点自动更新。
实现也不复杂:
void applyRemoteStyles(Map<String, Map<String, String>> remoteStyles) { remoteStyles.forEach((className, declarations) { AppStyle.raw.update( className, (existing) => {...existing, ...declarations}, ifAbsent: () => declarations, ); }); }如果你把 SCSS 编译链理解成构建期做一次“样式编译”,远程配置就是运行期追加一次“样式覆盖”。两者互不冲突,而且由于 Dart 的 Map 天然就是 JSON 的近亲,这种动态能力实现起来几乎零成本。
5.3 与鸿蒙原生组件桥接的实践细节
最后分享一个桥接层的实践经验。
Flutter 侧渲染的 Widget 和鸿蒙原生组件之间,最常遇到的问题就是 Padding、Margin 这类数值单位。CSS 用的是像素(px),但在鸿蒙的某些场景里,会把单位转换成 vp(虚拟像素),因为不同密度下物理像素和 vp 的换算关系不同。
我的做法是:在生成 Dart Map 的同时,把数值型属性统一处理成无单位的double,单位转换延迟到真正渲染时:
$grid-gap: 12px; $card-padding: 16px;生成后的数据里直接把px剥掉,存成数字:
'grid-item--span-2': { 'padding': 16.0, }Flutter 侧通过MediaQuery.devicePixelRatio换算成逻辑像素,鸿蒙侧通过vp2px转为物理像素。这样同一份样式表,在两端的渲染结果是一致的,不会出现 Flutter 里卡片 16vp、原生里 16px 这种视觉偏差。
顺带提一嘴,鸿蒙这边的分布式 UI 能力有时会让同一个页面跑在不同屏幕上,不同屏幕的密度差异很大,单位剥离之后,这种跨屏场景的适配压力小了很多。这算是这套编译链设计时没预料到的一个附带好处。
6. 留给后来者的几条实战建议
这套方案跑了大半年,总结几条最实在的经验。
第一,SCSS 源文件一定要做分层设计。variables.scss只放变量,mixins.scss只放混入,grid.scss放网格生成逻辑,业务样式放各自模块的目录。一旦混着写,Builder 报错你都不好定位是谁的问题。
第二,类名规范比代码规范更重要。因为编译器会直接把类名看成字典 key,一个grid-item--spn-2的拼写错误,运行期不会报错,只会无声地返回空样式。我加了一个启动期的校验函数,遍历业务代码里用到的所有StyleSheet.of参数,跟生成 Map 的 key 做对比,提前暴露拼写问题。
第三,别用这个方案把所有样式都接管。Flutter 里大量使用AnimatedContainer、Hero等动画组件,这些组件的样式变化频率高,靠查 Map 性能上有损耗。我的原则是:静态布局样式走 SCSS 编译链,动态动画参数仍用 Flutter 原生方式手写。两种方式并存,各管各的领域。
第四,生成代码要可追溯。每次 build 完,构建产物头部会标记 SCSS 文件的 git commit hash。等线上样式出问题,你能立刻知道这个样式文件是基于哪个版本生成的,排查效率直接翻倍。
我之前在一篇文章里说过一句话:好的工程方案不是炫技,而是把复杂的东西藏得干干净净。这套编译链的核心理念就一条——样式只写一次,Flutter、鸿蒙、Web(如果你顺手的话)都能消费。它不负责帮你解决业务逻辑问题,但能把困扰很多团队的样式维护问题,变成一个背后自动运行的“规格说明”。你只管在 SCSS 里改改变量,剩下的全交给编译链,这个感觉用惯了,是真回不去了。