IoT 设计系统的 Token 挑战多屏多分辨率的统一与差异化一、引子一个圆角值引发的血案智能家居项目到第三个月时设计稿上的圆角值出现了七个不同的数字4px、6px、8px、12px、16px、20px、24px。它们出现在中控屏的卡片上、手机 App 的按钮上、手表端的图标上以及那个永远没人维护的微信小程序里。我试图统一成 8px 栅格体系但立刻撞上了三堵墙手表 44mm 屏幕上 8px 圆角占了卡片宽度 1/8视觉比例失调中控屏 10 英寸面板上 8px 圆角几乎看不出弧度像是渲染 Bug最麻烦的是车机 HMI 的 800×480 低分辨率屏幕1px 边框线已经不止一个物理像素了——亚像素渲染在 Embedded Linux 的 Chromium 上完全走样。IoT 设计 Token 的挑战不在于定义 Token 本身而在于Token 值在多端点上的语义保持一致、数值需要适配。这就像你用同一段乐谱指挥交响乐团、管弦乐队和街头艺人——曲目一样但编曲必须不同。二、底层机制Token 的分层与变换流水线传统设计系统将 Token 分为三层原始 Tokenraw values、语义 Tokensemantic aliases和组件 Tokencomponent-specific。这个模型在单平台场景中工作良好但 IoT 引入了一个新的维度设备上下文device context。新增的设备上下文层是一个变换引擎接收语义 Token 和当前设备参数输出解析后的实际值。这个变换不是简单的线性缩放——不同属性的变换策略完全不同间距值spacing线性缩放但要保证触摸目标最小值圆角值border-radius非线性映射小屏上需要更大的视觉占比字号font-size阶梯式缩放不能出现 13.7px 这样的中间态阴影shadow跨设备策略差异最大嵌入式设备直接干掉投影三、生产级代码上下文感知的 Token 解析引擎/** * IoT 设计系统 Token 解析引擎 * * 核心挑战同一套语义 Token 在不同设备上下文下 * 应该解析为不同的原始值但保持视觉一致性 * * 设计原理 * 1. 间距/尺寸线性缩放 物理约束 * 2. 圆角非线性映射保持视觉比例 * 3. 字号阶梯化保证渲染质量 * 4. 触摸目标硬下限保证可用性 */ // 设备上下文定义 interface DeviceContext { viewport: { width: number; height: number }; pixelRatio: number; touchSupport: boolean; perfTier: high | medium | low; // 性能等级 physicalSize: number; // 英寸 } // Token 定义类型 interface TokenDefinition { name: string; rawValue: number; // 基于 390px 宽度基准的原始值 unit: px | rem | em; category: spacing | radius | font | shadow | touchTarget; minValue?: number; // 硬下限 maxValue?: number; // 硬上限 } // 已解析的 Token interface ResolvedToken { name: string; value: number; unit: string; cssVariable: string; // --token-name: value unit } /** * Token 解析器 * 根据设备上下文将语义 Token 变换为实际值 */ class TokenResolver { private context: DeviceContext; private referenceWidth 390; // iPhone 14 逻辑宽度作为基准 constructor(context: DeviceContext) { this.context context; } /** * 计算基础缩放系数 * 使用对数缩放而非线性缩放保持极端尺寸下的可用性 */ private getBaseScale(): number { const ratio this.context.viewport.width / this.referenceWidth; // 对数缩放两端收敛避免极端尺寸 // scale ln(1 ratio × 1.718) / ln(2.718) // 当 ratio1 时 scale≈1, ratio0.2 时 scale≈0.35, ratio5 时 scale≈1.8 return Math.log(1 ratio * 1.718) / Math.log(2.718); } /** * 解析单个 Token * 根据 Token 类别使用不同的变换策略 */ resolve(token: TokenDefinition): ResolvedToken { const scale this.getBaseScale(); let resolvedValue: number; switch (token.category) { case spacing: resolvedValue this.resolveSpacing(token, scale); break; case radius: resolvedValue this.resolveRadius(token, scale); break; case font: resolvedValue this.resolveFont(token, scale); break; case shadow: resolvedValue this.resolveShadow(token, scale); break; case touchTarget: resolvedValue this.resolveTouchTarget(token, scale); break; default: resolvedValue token.rawValue * scale; } // 应用硬约束 resolvedValue this.applyConstraints(resolvedValue, token); // 像素对齐避免亚像素 if (token.unit px) { resolvedValue Math.round(resolvedValue * this.context.pixelRatio) / this.context.pixelRatio; } return { name: token.name, value: resolvedValue, unit: token.unit, cssVariable: --${token.name}: ${resolvedValue}${token.unit}, }; } /** * 间距解析线性缩放 最小触摸区域保证 */ private resolveSpacing(token: TokenDefinition, scale: number): number { return token.rawValue * scale; } /** * 圆角解析非线性映射 * * 原理小屏上圆角视觉占比应更大否则看不出弧度 * 大屏上圆角视觉占比应更小否则过于圆润失去锐度 * * 公式radius rawValue × scale^0.85 * 指数 1 意味着小屏会放大圆角效果大屏会抑制 */ private resolveRadius(token: TokenDefinition, scale: number): number { // 非线性指数小屏放大圆角大屏抑制圆角 const nonlinearScale Math.pow(scale, 0.85); return token.rawValue * nonlinearScale; } /** * 字号解析阶梯式缩放 行高联动 * * 字号不允许出现连续渐变必须落在预定义的阶梯上 * 阶梯值11, 12, 13, 14, 15, 16, 18, 20, 24, 28, 32, 40, 48 */ private resolveFont(token: TokenDefinition, scale: number): number { const rawScaled token.rawValue * scale; const fontSteps [11, 12, 13, 14, 15, 16, 18, 20, 24, 28, 32, 40, 48]; // 找到最近的阶梯值 let closest fontSteps[0]; let minDiff Math.abs(rawScaled - closest); for (const step of fontSteps) { const diff Math.abs(rawScaled - step); if (diff minDiff) { minDiff diff; closest step; } } return closest; } /** * 阴影解析性能分级策略 * * 低性能设备直接移除阴影改用边框代替层次 * 中性能设备简化阴影层数 * 高性能设备保持完整阴影 */ private resolveShadow(token: TokenDefinition, scale: number): number { if (this.context.perfTier low) { return 0; // 低性能设备不用阴影用 border 替代 } const scaled token.rawValue * scale; // 中性能设备减半阴影强度 if (this.context.perfTier medium) { return scaled * 0.5; } return scaled; } /** * 触摸目标解析硬下限保证 * * WCAG 2.1 要求触摸目标 ≥ 44px * iOS HIG 建议 ≥ 44pt * Material Design 建议 ≥ 48dp * * 这里取 44px 作为通用下限 */ private resolveTouchTarget(token: TokenDefinition, scale: number): number { const scaled token.rawValue * scale; const ABSOLUTE_MIN_TOUCH_TARGET 44; return Math.max(scaled, ABSOLUTE_MIN_TOUCH_TARGET); } /** * 应用硬约束min/max */ private applyConstraints(value: number, token: TokenDefinition): number { if (token.minValue ! undefined) value Math.max(value, token.minValue); if (token.maxValue ! undefined) value Math.min(value, token.maxValue); return value; } /** * 批量解析所有 Token * 返回 CSS 自定义属性映射 */ resolveAll(tokens: TokenDefinition[]): { variables: Recordstring, string; tokens: ResolvedToken[]; } { const resolved tokens.map((t) this.resolve(t)); const variables: Recordstring, string {}; for (const token of resolved) { variables[--${token.name}] ${token.value}${token.unit}; } return { variables, tokens: resolved }; } } // 使用示例 // 定义语义 Token基于 390px 基准 const semanticTokens: TokenDefinition[] [ // 间距 { name: spacing-xs, rawValue: 4, unit: px, category: spacing }, { name: spacing-sm, rawValue: 8, unit: px, category: spacing }, { name: spacing-md, rawValue: 16, unit: px, category: spacing }, { name: spacing-lg, rawValue: 24, unit: px, category: spacing }, // 圆角 { name: radius-sm, rawValue: 4, unit: px, category: radius }, { name: radius-md, rawValue: 8, unit: px, category: radius }, { name: radius-lg, rawValue: 16, unit: px, category: radius }, // 字号 { name: font-body, rawValue: 15, unit: px, category: font }, { name: font-heading, rawValue: 28, unit: px, category: font }, // 阴影仅高性能设备有效 { name: shadow-card, rawValue: 8, unit: px, category: shadow }, // 触摸目标 { name: touch-min, rawValue: 44, unit: px, category: touchTarget, minValue: 44 }, ]; // 手表端解析 const watchResolver new TokenResolver({ viewport: { width: 184, height: 224 }, pixelRatio: 2, touchSupport: true, perfTier: medium, physicalSize: 1.7, }); const watchResult watchResolver.resolveAll(semanticTokens); console.log(Watch Token Variables:, watchResult.variables); // 输出: { --spacing-md: 8px, --radius-md: 5px, ... } // 中控屏解析 const panelResolver new TokenResolver({ viewport: { width: 1280, height: 800 }, pixelRatio: 1, touchSupport: true, perfTier: high, physicalSize: 10.1, }); const panelResult panelResolver.resolveAll(semanticTokens); console.log(Panel Token Variables:, panelResult.variables); // 输出: { --spacing-md: 28px, --radius-md: 13px, ... }这个解析器的核心设计哲学是差序统一——每个端点上的值不同但视觉权重和交互感受保持一致。手表上 8px 间距和手机上 16px 间距在各自的屏幕比例中提供相同的呼吸感。四、边界分析对数缩放不是银弹在手表到中控屏这样跨度极大的场景中184px → 1280px6.9 倍对数缩放可以有效收敛但在 320px 到 414px 这样的小跨度中线性缩放可能更直观。建议跨度 2 倍时用线性 2 倍时用对数。性能分级过于粗糙将设备性能分为高/中/低三档会忽略嵌入式设备内部的巨大差异。同一个 Android 4.4 的工控屏和 Android 14 的平板可能都被归为低性能。更精确的做法是使用动态性能评分通过测量第一帧渲染时间和 FPS 来动态调整策略。Token 版本管理当语义 Token 的定义发生变化时比如spacing-md从 16 改为 20所有已发布的应用必须同步更新。这是跨端项目中最大的运营难题。解决方案是将 Token 定义独立为 NPM 包或 CDN JSON各端点启动时拉取最新版本。五、总结IoT Token 管理的核心矛盾是语义统一与数值差异的平衡设备上下文层是 Token 体系在 IoT 场景中的必要扩展不是可选间距用线性缩放圆角用非线性指数映射字号用阶梯化阴影按性能分级对数缩放公式在极端跨度场景中优于线性缩放触摸目标必须有硬下限44px不能因屏幕小就牺牲可用性低性能设备上放弃阴影用边框替代是务实的选择Token 定义需要独立版本管理作为各端点的运行时依赖
IoT 设计系统的 Token 挑战:多屏多分辨率的统一与差异化
IoT 设计系统的 Token 挑战多屏多分辨率的统一与差异化一、引子一个圆角值引发的血案智能家居项目到第三个月时设计稿上的圆角值出现了七个不同的数字4px、6px、8px、12px、16px、20px、24px。它们出现在中控屏的卡片上、手机 App 的按钮上、手表端的图标上以及那个永远没人维护的微信小程序里。我试图统一成 8px 栅格体系但立刻撞上了三堵墙手表 44mm 屏幕上 8px 圆角占了卡片宽度 1/8视觉比例失调中控屏 10 英寸面板上 8px 圆角几乎看不出弧度像是渲染 Bug最麻烦的是车机 HMI 的 800×480 低分辨率屏幕1px 边框线已经不止一个物理像素了——亚像素渲染在 Embedded Linux 的 Chromium 上完全走样。IoT 设计 Token 的挑战不在于定义 Token 本身而在于Token 值在多端点上的语义保持一致、数值需要适配。这就像你用同一段乐谱指挥交响乐团、管弦乐队和街头艺人——曲目一样但编曲必须不同。二、底层机制Token 的分层与变换流水线传统设计系统将 Token 分为三层原始 Tokenraw values、语义 Tokensemantic aliases和组件 Tokencomponent-specific。这个模型在单平台场景中工作良好但 IoT 引入了一个新的维度设备上下文device context。新增的设备上下文层是一个变换引擎接收语义 Token 和当前设备参数输出解析后的实际值。这个变换不是简单的线性缩放——不同属性的变换策略完全不同间距值spacing线性缩放但要保证触摸目标最小值圆角值border-radius非线性映射小屏上需要更大的视觉占比字号font-size阶梯式缩放不能出现 13.7px 这样的中间态阴影shadow跨设备策略差异最大嵌入式设备直接干掉投影三、生产级代码上下文感知的 Token 解析引擎/** * IoT 设计系统 Token 解析引擎 * * 核心挑战同一套语义 Token 在不同设备上下文下 * 应该解析为不同的原始值但保持视觉一致性 * * 设计原理 * 1. 间距/尺寸线性缩放 物理约束 * 2. 圆角非线性映射保持视觉比例 * 3. 字号阶梯化保证渲染质量 * 4. 触摸目标硬下限保证可用性 */ // 设备上下文定义 interface DeviceContext { viewport: { width: number; height: number }; pixelRatio: number; touchSupport: boolean; perfTier: high | medium | low; // 性能等级 physicalSize: number; // 英寸 } // Token 定义类型 interface TokenDefinition { name: string; rawValue: number; // 基于 390px 宽度基准的原始值 unit: px | rem | em; category: spacing | radius | font | shadow | touchTarget; minValue?: number; // 硬下限 maxValue?: number; // 硬上限 } // 已解析的 Token interface ResolvedToken { name: string; value: number; unit: string; cssVariable: string; // --token-name: value unit } /** * Token 解析器 * 根据设备上下文将语义 Token 变换为实际值 */ class TokenResolver { private context: DeviceContext; private referenceWidth 390; // iPhone 14 逻辑宽度作为基准 constructor(context: DeviceContext) { this.context context; } /** * 计算基础缩放系数 * 使用对数缩放而非线性缩放保持极端尺寸下的可用性 */ private getBaseScale(): number { const ratio this.context.viewport.width / this.referenceWidth; // 对数缩放两端收敛避免极端尺寸 // scale ln(1 ratio × 1.718) / ln(2.718) // 当 ratio1 时 scale≈1, ratio0.2 时 scale≈0.35, ratio5 时 scale≈1.8 return Math.log(1 ratio * 1.718) / Math.log(2.718); } /** * 解析单个 Token * 根据 Token 类别使用不同的变换策略 */ resolve(token: TokenDefinition): ResolvedToken { const scale this.getBaseScale(); let resolvedValue: number; switch (token.category) { case spacing: resolvedValue this.resolveSpacing(token, scale); break; case radius: resolvedValue this.resolveRadius(token, scale); break; case font: resolvedValue this.resolveFont(token, scale); break; case shadow: resolvedValue this.resolveShadow(token, scale); break; case touchTarget: resolvedValue this.resolveTouchTarget(token, scale); break; default: resolvedValue token.rawValue * scale; } // 应用硬约束 resolvedValue this.applyConstraints(resolvedValue, token); // 像素对齐避免亚像素 if (token.unit px) { resolvedValue Math.round(resolvedValue * this.context.pixelRatio) / this.context.pixelRatio; } return { name: token.name, value: resolvedValue, unit: token.unit, cssVariable: --${token.name}: ${resolvedValue}${token.unit}, }; } /** * 间距解析线性缩放 最小触摸区域保证 */ private resolveSpacing(token: TokenDefinition, scale: number): number { return token.rawValue * scale; } /** * 圆角解析非线性映射 * * 原理小屏上圆角视觉占比应更大否则看不出弧度 * 大屏上圆角视觉占比应更小否则过于圆润失去锐度 * * 公式radius rawValue × scale^0.85 * 指数 1 意味着小屏会放大圆角效果大屏会抑制 */ private resolveRadius(token: TokenDefinition, scale: number): number { // 非线性指数小屏放大圆角大屏抑制圆角 const nonlinearScale Math.pow(scale, 0.85); return token.rawValue * nonlinearScale; } /** * 字号解析阶梯式缩放 行高联动 * * 字号不允许出现连续渐变必须落在预定义的阶梯上 * 阶梯值11, 12, 13, 14, 15, 16, 18, 20, 24, 28, 32, 40, 48 */ private resolveFont(token: TokenDefinition, scale: number): number { const rawScaled token.rawValue * scale; const fontSteps [11, 12, 13, 14, 15, 16, 18, 20, 24, 28, 32, 40, 48]; // 找到最近的阶梯值 let closest fontSteps[0]; let minDiff Math.abs(rawScaled - closest); for (const step of fontSteps) { const diff Math.abs(rawScaled - step); if (diff minDiff) { minDiff diff; closest step; } } return closest; } /** * 阴影解析性能分级策略 * * 低性能设备直接移除阴影改用边框代替层次 * 中性能设备简化阴影层数 * 高性能设备保持完整阴影 */ private resolveShadow(token: TokenDefinition, scale: number): number { if (this.context.perfTier low) { return 0; // 低性能设备不用阴影用 border 替代 } const scaled token.rawValue * scale; // 中性能设备减半阴影强度 if (this.context.perfTier medium) { return scaled * 0.5; } return scaled; } /** * 触摸目标解析硬下限保证 * * WCAG 2.1 要求触摸目标 ≥ 44px * iOS HIG 建议 ≥ 44pt * Material Design 建议 ≥ 48dp * * 这里取 44px 作为通用下限 */ private resolveTouchTarget(token: TokenDefinition, scale: number): number { const scaled token.rawValue * scale; const ABSOLUTE_MIN_TOUCH_TARGET 44; return Math.max(scaled, ABSOLUTE_MIN_TOUCH_TARGET); } /** * 应用硬约束min/max */ private applyConstraints(value: number, token: TokenDefinition): number { if (token.minValue ! undefined) value Math.max(value, token.minValue); if (token.maxValue ! undefined) value Math.min(value, token.maxValue); return value; } /** * 批量解析所有 Token * 返回 CSS 自定义属性映射 */ resolveAll(tokens: TokenDefinition[]): { variables: Recordstring, string; tokens: ResolvedToken[]; } { const resolved tokens.map((t) this.resolve(t)); const variables: Recordstring, string {}; for (const token of resolved) { variables[--${token.name}] ${token.value}${token.unit}; } return { variables, tokens: resolved }; } } // 使用示例 // 定义语义 Token基于 390px 基准 const semanticTokens: TokenDefinition[] [ // 间距 { name: spacing-xs, rawValue: 4, unit: px, category: spacing }, { name: spacing-sm, rawValue: 8, unit: px, category: spacing }, { name: spacing-md, rawValue: 16, unit: px, category: spacing }, { name: spacing-lg, rawValue: 24, unit: px, category: spacing }, // 圆角 { name: radius-sm, rawValue: 4, unit: px, category: radius }, { name: radius-md, rawValue: 8, unit: px, category: radius }, { name: radius-lg, rawValue: 16, unit: px, category: radius }, // 字号 { name: font-body, rawValue: 15, unit: px, category: font }, { name: font-heading, rawValue: 28, unit: px, category: font }, // 阴影仅高性能设备有效 { name: shadow-card, rawValue: 8, unit: px, category: shadow }, // 触摸目标 { name: touch-min, rawValue: 44, unit: px, category: touchTarget, minValue: 44 }, ]; // 手表端解析 const watchResolver new TokenResolver({ viewport: { width: 184, height: 224 }, pixelRatio: 2, touchSupport: true, perfTier: medium, physicalSize: 1.7, }); const watchResult watchResolver.resolveAll(semanticTokens); console.log(Watch Token Variables:, watchResult.variables); // 输出: { --spacing-md: 8px, --radius-md: 5px, ... } // 中控屏解析 const panelResolver new TokenResolver({ viewport: { width: 1280, height: 800 }, pixelRatio: 1, touchSupport: true, perfTier: high, physicalSize: 10.1, }); const panelResult panelResolver.resolveAll(semanticTokens); console.log(Panel Token Variables:, panelResult.variables); // 输出: { --spacing-md: 28px, --radius-md: 13px, ... }这个解析器的核心设计哲学是差序统一——每个端点上的值不同但视觉权重和交互感受保持一致。手表上 8px 间距和手机上 16px 间距在各自的屏幕比例中提供相同的呼吸感。四、边界分析对数缩放不是银弹在手表到中控屏这样跨度极大的场景中184px → 1280px6.9 倍对数缩放可以有效收敛但在 320px 到 414px 这样的小跨度中线性缩放可能更直观。建议跨度 2 倍时用线性 2 倍时用对数。性能分级过于粗糙将设备性能分为高/中/低三档会忽略嵌入式设备内部的巨大差异。同一个 Android 4.4 的工控屏和 Android 14 的平板可能都被归为低性能。更精确的做法是使用动态性能评分通过测量第一帧渲染时间和 FPS 来动态调整策略。Token 版本管理当语义 Token 的定义发生变化时比如spacing-md从 16 改为 20所有已发布的应用必须同步更新。这是跨端项目中最大的运营难题。解决方案是将 Token 定义独立为 NPM 包或 CDN JSON各端点启动时拉取最新版本。五、总结IoT Token 管理的核心矛盾是语义统一与数值差异的平衡设备上下文层是 Token 体系在 IoT 场景中的必要扩展不是可选间距用线性缩放圆角用非线性指数映射字号用阶梯化阴影按性能分级对数缩放公式在极端跨度场景中优于线性缩放触摸目标必须有硬下限44px不能因屏幕小就牺牲可用性低性能设备上放弃阴影用边框替代是务实的选择Token 定义需要独立版本管理作为各端点的运行时依赖