同一套设计令牌在 React Native、Flutter 和 Web 里跑出三套样式,我们踩过的坑比想象中多

我们最初的想法很朴素:把设计师在 Figma 里定义的颜色、字号、间距这些设计令牌,直接导出一份 JSON,然后三端各自解析,生成对应的样式文件。结果第一版上线后,QA 提了整整 47 个样式不一致的 Bug,光按钮 hover 态的色差就有 6 种。问题不在令牌本身,而在“翻译”过程——每个平台对同一份数据的理解方式完全不同。

颜色:同一个 Hex 值,三端渲染出来是三种颜色

React Native 的颜色渲染是“所见即所得”,但前提是你的设计师在 sRGB 色域下工作。我们有个品牌的 Primary Blue 定义为 #1A73E8,在 Figma(默认 sRGB)里看是正好的,到 iOS 设备上也没问题。但 Android 的 Material 主题默认启用了动态色彩,部分机型会把 #1A73E8 映射到系统的色彩调色板,实际渲染出来肉眼可见地偏紫。这是因为 Android 12+ 的 Material You 会对 Hex 值做色调转换,而不是直接使用原始色值。

Flutter 的情况更微妙。它的颜色系统基于 32 位 ARGB,但 Skia 引擎在部分设备上对色彩空间的处理不一致。我们发现在 iOS 上 Flutter 渲染的颜色和原生 React Native 完全一致,但在同一台 Android 设备上,Flutter 的 Color(0xFF1A73E8) 比 React Native 的 #1A73E8 饱和度高出约 5%,用取色器实测 RGB 差值为 (3, 2, 4)。根因是 Skia 在 Android 上默认使用 Display P3 色域,而 React Native 走的是原生 View 体系的 sRGB 渲染管线。

Web 端的坑藏得更深。CSS 的 color-scheme 属性会让浏览器在深色模式下自动反转部分颜色,即便你明确写了 color: #1A73E8。我们有个卡片组件在浅色模式下三端一致,一切换到深色模式,Web 端的背景色从 #FFFFFF 被浏览器强制改成了 #121212,而 React Native 和 Flutter 因为没有 color-scheme 的自动干预,背景色还停在 #FFFFFF,导致深色模式下 Web 端和其他两端完全割裂。

解决方案不是简单禁用这些特性。我们在令牌层引入了“渲染意图”标记,JSON 里不再只有原始值:

{
  "color": {
    "primary": {
      "value": "#1A73E8",
      "rendering": {
        "android": { "useDynamicColor": false },
        "flutter": { "forceSRGB": true },
        "web": { "colorScheme": "light-only" }
      }
    }
  }
}

每个平台的代码生成器读取 rendering 字段,在生成样式时做对应的处理。Android 端会显式设置 android:colorMode="srgb",Flutter 端在 MaterialApp 层级强制 color: Color(0xFF1A73E8).withSRGB(),Web 端对特定元素加 color-scheme: only light。这不是什么优雅的方案,但至少让三端的色差值从肉眼可见压到了 Delta E < 1.5。

字号:从设计稿的 px 到三端实际渲染,中间隔了四层转换

Figma 里设计师定义 Body Text 为 14px,这在 Web 端可以直接用 font-size: 14px。但 React Native 默认把 font-size: 14 当作逻辑像素,在 iPhone 14 Pro(3x 分辨率)上实际渲染为 42pt,在 Pixel 7(2.75x)上是 38.5pt。如果用户还调整了系统字体大小,Android 的 fontScale 会在这个基础上再乘一个系数,最终字号可能膨胀到设计师完全认不出来的样子。

Flutter 的字号体系又是一套规则。TextStyle(fontSize: 14) 在 Flutter 里是逻辑像素,但 Flutter 有自己的 textScaleFactor,它在 iOS 上会跟随系统的动态字体设置,在 Android 上却默认忽略系统字体缩放(除非你显式用 MediaQuery.of(context).textScaleFactor)。这就导致同一个 fontSize: 14,在用户调大系统字体的 iPhone 上,Flutter 的文字比 React Native 小一圈,而 Android 上刚好反过来。

我们的做法是把令牌里的字号定义为“基准值”,然后在三端各自的代码生成器里,根据平台规则做适配:

  • Web:直接输出 rem,基准 1rem = 16px14px 变成 0.875rem
  • React Native:输出 font-size: 14,但在根组件用 PixelRatio.getFontScale() 反算出一个缩放系数,限制最大放大 1.3 倍
  • Flutter:在 MaterialAppbuilder 里统一设置 textScaleFactor 上限为 1.2,并禁用 Android 的系统字体缩放忽略行为

具体代码大概是这样的——Flutter 端:

MaterialApp(
  builder: (context, child) {
    final mediaQuery = MediaQuery.of(context);
    final scale = mediaQuery.textScaleFactor.clamp(1.0, 1.2);
    return MediaQuery(
      data: mediaQuery.copyWith(textScaleFactor: scale),
      child: child!,
    );
  },
)

React Native 端:

import { PixelRatio } from 'react-native';

const MAX_FONT_SCALE = 1.3;
const fontScale = Math.min(PixelRatio.getFontScale(), MAX_FONT_SCALE);

export const typography = {
  body: { fontSize: 14 * fontScale },
};

这套方案上线后,字号相关的 Bug 从 23 个降到了 2 个,剩下的两个是用户把 Android 的“最大字体”辅助功能打开后,我们的限制策略被系统级设置绕过了——这个问题至今没有完美解法,只能跟产品确认是否接受这个边界情况。

间距和圆角:设计稿的 8px 在三端可能被吃掉小数点

这是个很容易被忽略的细节。设计师定义卡片圆角为 8px,卡片内边距为 16px。三端都忠实地写成了 border-radius: 8padding: 16。但 React Native 在 Android 上对非整数像素的处理是向下取整,而 iOS 是四舍五入。Flutter 的逻辑像素到物理像素的转换由设备像素比决定,8 在 2.75x 屏幕上变成 22 物理像素,但 22 / 2.75 = 8 刚好整除,没问题;可当值是 9 时,9 * 2.75 = 24.75,Skia 会做抗锯齿填充,视觉上边缘会发虚。

Web 端的 0.5px 在不同浏览器上的渲染差异也是老生常谈了。Chrome 会把 0.5px 当作 1px 渲染(在 1x 屏幕上),但 Safari 会真的尝试用次像素渲染,结果边框粗细在 Retina 屏上看起来比 Chrome 细一半。

我们的应对策略是:在令牌的代码生成阶段,对间距和圆角值做“安全值”检查。生成器会读取目标平台的像素比配置,比如 Android 常见的 2.75x、3x,然后对不安全的数值发出警告。同时,我们在设计令牌层面强制所有间距值使用偶数,圆角值在移动端限制为 4 的倍数——这不是设计规范,纯粹是工程上的妥协。设计团队一开始不接受,直到我们给他们看了 Flutter 上 borderRadius: 9 在 2.75x 屏幕上的锯齿截图。

阴影和动效:三端三套渲染引擎,根本没有公约数

阴影是重灾区。Figma 的阴影参数是 x: 0, y: 2, blur: 8, spread: 0, color: #00000026。Web 的 box-shadow 直接支持这四个参数,React Native 的 shadowOffset / shadowRadius / shadowOpacity 也能勉强映射,但 Flutter 的 BoxShadow 没有 spread 参数——Flutter 团队在 GitHub Issue #33821 里明确说了不会支持,因为 Skia 的 blurMaskFilter 不支持 spread 半径。

我们只能在生成 Flutter 样式时,把 spread 值转换成 BoxShadow 的偏移量和模糊半径的组合近似值。具体做法是用 spread 的一半加到 blurRadius 上,再把 offset 按比例微调。这个近似在 spread <= 4 时效果还行,超过 6 就开始明显走样了。最终我们跟设计团队约定,跨平台组件的阴影只用 bluroffset,不用 spread

动效的坑更深。设计师在 Figma 里用 Smart Animate 做的过渡效果,导出参数是 ease-in-out, 300ms。Web 端直接用 CSS transition 完美复现;React Native 的 Animated.timing 也能设 easingduration;但 Flutter 的动画曲线名称跟 CSS 不完全对应,ease-in-out 在 Flutter 里是 Curves.easeInOut,实际贝塞尔曲线参数略有差异,肉眼能看出动画节奏不一致。我们最后在三端统一使用了自定义贝塞尔曲线 cubic-bezier(0.4, 0.0, 0.2, 1.0),这是 Material Design 的 standard curve,三端都能精确设置,才把动画差异消除掉。

令牌的版本管理和同步:JSON 文件能解决分发,但解决不了冲突

我们把设计令牌放在一个独立的 Git 仓库里,用 GitHub Actions 在每次提交时跑一个脚本,把 JSON 转成三端的样式文件,然后自动提交到各端的代码仓库。听起来很自动化,但实际操作中,设计师在 Figma 里改了一个颜色,导出 JSON 后,三端同时生成了新的样式文件——其中 Web 端的 CSS 变量命名跟 React Native 的 StyleSheet 对象属性名不一致,导致 CI 直接挂掉。

根因是我们在 JSON 里的令牌命名用了斜杠分隔,比如 color/primary/default,Web 端生成器把它转成了 --color-primary-default,React Native 端转成了 colorPrimaryDefault,但 Flutter 端的生成器写得太早,还在用点分隔 color.primary.default,导致生成的 Dart 代码里出现了非法的属性名。

这个问题暴露了我们缺少一个“令牌契约测试”环节。后来我们在生成脚本里加了一层校验:在生成各端样式文件之后,跑一套快照测试,对比三端生成的样式值是否都来源于同一个令牌版本。具体做法是把三端生成的样式文件都解析成统一的中间表示(IR),然后对比 IR 的哈希值。任何一端的生成器改动了逻辑,都会导致 IR 变化,CI 直接拦截。

这套机制运行了 8 个月,拦截了 4 次生成器的不兼容变更,包括一次 Flutter 端生成器升级后把 Color(0xFF1A73E8) 改成了 Color(0x1A73E8FF)——少写了一个 FF,alpha 通道直接归零,所有主色按钮都透明了。要不是快照测试拦下来,这个 Bug 上线后就是 P0 事故。

常见问题

为什么不直接用 Figma 的 Design Tokens 插件导出?

Figma 的官方 Tokens 导出格式是 W3C Design Tokens Community Group 的草案标准,但这个标准目前只覆盖颜色、字号、间距等基础属性,阴影、动效、模糊这些复杂属性没有统一格式。而且导出的 JSON 不包含平台特定的渲染提示,比如 Android 的动态色彩开关、Flutter 的色域设置,这些都需要在导出后手动补充。我们试过 Figma Tokens 插件 + Style Dictionary 的组合,最终因为阴影 spread 参数丢掉了而放弃。

三端能不能统一用一套 UI 库来避免这些差异?

理论上 React Native Web 或 Flutter Web 能做到代码同构,但实际上 React Native Web 在复杂交互(比如拖拽排序、富文本编辑)上的表现跟原生 Web 差距很大,Flutter Web 的首屏加载体积和 SEO 也是硬伤。我们评估过,在现有业务规模下,三端各自保留原生方案、只统一设计令牌的投入产出比最高。如果你是从零开始的新项目,且 Web 端交互不复杂,React Native + React Native Web 可能是条捷径。

设计师需要了解这些平台差异吗?

需要,但不能指望设计师去记这些细节。我们做了一份“设计令牌安全区”文档,列出了跨平台组件中应该避免的设计模式:不用 spread 阴影、字号用 4 的倍数、圆角用偶数、避免依赖系统默认的深色模式反转。设计师在 Figma 里做组件时,插件会自动检查这些规则,违规时给出提示。这套约束上线后,设计侧的“平台不可实现”反馈从每周 5-6 个降到了每月 1-2 个。

令牌改了之后多久能同步到三端?

我们从 Figma 修改一个颜色到三端 App 里生效,最快是 45 分钟。流程是:设计师在 Figma 里改色 → Tokens 插件推送到 Git → Actions 触发生成脚本 → 各端自动创建 PR → 负责人审批合并 → CI 构建 → 灰度发布。如果不走灰度,可以直接打包,能压缩到 20 分钟以内。但这只是“技术上可行”,实际业务中大部分令牌变更会跟功能需求一起走,周期在 1-3 天。真正紧急的品牌色修改我们做过一次,从改色到全量上线用了 2 小时 17 分钟。