一、为什么不要在页面里写死样式

三国历史类 App 页面很多:人物卡片、事件详情、地图、听书、收藏、我的页面。若每个页面都写:

.fontSize(14)
.fontColor('#333333')
.padding(12)
.borderRadius(16)

后期调整主题会非常痛苦。更好的方式是把视觉规范抽象成 Token:

  • AppColors
  • AppFontSize
  • AppSpacing
  • AppRadius
  • AppTheme

二、页面使用 Token 的方式

在页面中只关心语义,不直接关心具体数值:

Text(this.text)
  .fontSize(AppFontSize.F12)
  .fontColor(this.selectedCategory === this.text
    ? this.palette().textInverse
    : this.palette().textSecondary)
  .padding({
    left: AppSpacing.S12,
    right: AppSpacing.S12,
    top: AppSpacing.S8,
    bottom: AppSpacing.S8
  })
  .backgroundColor(this.selectedCategory === this.text
    ? this.palette().primary
    : this.palette().cardBg)
  .borderRadius(AppRadius.R16)

这段代码的重点是:页面表达的是“选中态/普通态”,而不是直接写颜色值。

2.1 Token 不是变量表,而是设计语言

很多项目会把 Token 理解成“把颜色值挪到一个文件里”。这只能解决一小部分问题。真正有用的 Token 应该表达语义,例如:

Token 语义 使用位置
primary 品牌主色 选中态、主按钮、重要标签
pageBg 页面背景 页面根容器
cardBg 卡片背景 人物卡片、事件卡片、设置项
textPrimary 主文本 标题、正文重点
textSecondary 次级文本 摘要、说明、辅助信息
divider 分割线 列表、分组边界

也就是说,页面不应该知道“当前选中态是棕色”,页面只应该知道“这是 primary”。至于 primary 在浅色模式、深色模式、节日主题下分别是什么颜色,交给主题系统决定。

2.2 推荐的 Token 文件拆分

主题相关代码不要全部堆在一个文件里。一个更清晰的拆分方式是:

entry/src/main/ets/theme/
├── AppColors.ets
├── AppFontSize.ets
├── AppRadius.ets
├── AppSpacing.ets
└── AppTheme.ets

其中 AppColors 负责颜色结构,AppTheme 负责根据深浅色状态返回具体调色板。

export interface AppColors {
  pageBg: ResourceColor;
  cardBg: ResourceColor;
  primary: ResourceColor;
  textPrimary: ResourceColor;
  textSecondary: ResourceColor;
  textInverse: ResourceColor;
  divider: ResourceColor;
}

字号、间距和圆角可以先用常量表达:

export class AppSpacing {
  static readonly S4 = 4;
  static readonly S8 = 8;
  static readonly S12 = 12;
  static readonly S16 = 16;
  static readonly S20 = 20;
  static readonly S24 = 24;
}

export class AppRadius {
  static readonly R8 = 8;
  static readonly R12 = 12;
  static readonly R16 = 16;
  static readonly R24 = 24;
}

这样写的好处不是少打几个数字,而是页面之间能保持节奏一致。首页卡片、人物详情卡片、收藏列表项如果都使用同一组间距,整个 App 会自然统一。

三、深浅色切换的状态来源

深浅色模式可以设计为三种:

  • 浅色
  • 深色
  • 跟随系统

页面侧只通过统一方法获取当前调色板:

private palette(): AppColors {
  return AppTheme.palette(this.effectiveIsDark());
}

这样页面不用关心主题策略,后续如果增加节日主题、护眼主题,也只需要扩展 AppTheme

3.1 AppTheme 的实现方式

下面是一个简化后的主题实现。真实项目里颜色可以来自 resources,也可以先用十六进制值验证效果。

export class AppTheme {
  static light(): AppColors {
    return {
      pageBg: '#F7F1E8',
      cardBg: '#FFFFFF',
      primary: '#8A4B2A',
      textPrimary: '#2B2118',
      textSecondary: '#7A6A5A',
      textInverse: '#FFFFFF',
      divider: '#E6D8C7'
    };
  }

  static dark(): AppColors {
    return {
      pageBg: '#17130F',
      cardBg: '#241C16',
      primary: '#D6A15F',
      textPrimary: '#F5E8D5',
      textSecondary: '#B9A58D',
      textInverse: '#1A120C',
      divider: '#3A2B20'
    };
  }

  static palette(isDark: boolean): AppColors {
    return isDark ? AppTheme.dark() : AppTheme.light();
  }
}

这里要注意一点:深色模式不是简单把背景改黑。主色、次级文字、分割线和卡片背景都要重新校准,否则会出现“背景暗了,但卡片和文字仍然像浅色模式”的割裂感。

3.2 跟随系统与用户选择

主题模式建议设计成三态:

export enum ThemeMode {
  Light = 'light',
  Dark = 'dark',
  System = 'system'
}

页面渲染时使用 effectiveIsDark() 得到最终状态:

private effectiveIsDark(): boolean {
  if (this.themeMode === ThemeMode.Dark) {
    return true;
  }
  if (this.themeMode === ThemeMode.Light) {
    return false;
  }
  return this.systemIsDark;
}

这样设置页可以保留用户偏好,页面组件也不需要知道当前是“手动深色”还是“跟随系统深色”。

四、历史类应用的视觉建议

历史类 App 不适合高饱和、强科技感的视觉。这个项目采用:

  • 暖色主色
  • 纸张质感背景
  • 低对比卡片阴影
  • 大圆角卡片
  • 图文混排

听书页背景图示例:

听书页视觉编辑

4.1 如何让历史感不变成“老旧感”

历史类应用很容易走向两个极端:一种是颜色过暗、纹理过重,像仿古网页;另一种是完全套用通用 SaaS 风格,缺少题材气质。这个项目的处理方式是:只在主色、插图、卡片层级和标题字重上体现历史感,交互结构仍然保持现代 App 的清晰和轻量。

设计项 推荐做法 避免做法
背景 低饱和暖色或深色底 大面积复杂纹理
卡片 轻阴影、清晰分组 边框过重、装饰过多
主色 棕金、陶土、墨色体系 高饱和荧光色
文字 标题稳重、正文清晰 全局书法字体
图片 真实页面截图和人物图 与内容无关的氛围图

视觉上“有题材感”即可,不要让题材感影响阅读效率。尤其是技术文章里展示 App 截图时,截图要能说明页面结构,而不是只当装饰。

五、组件层避免命名冲突

ArkUI 中很多名称本身就是内建属性或组件,如 widthheightborderonClick。自定义组件成员建议避开这些名称。

推荐:

@Component
struct EventCategoryChip {
  text: string = '';
  isDark: boolean = false;
  @Link selectedCategory: string;

  build() {
    Text(this.text)
      .onClick(() => {
        this.selectedCategory = this.text;
      })
  }
}

不推荐把成员命名为 onClicksizeborder 等。

5.1 把主题注入组件,而不是让组件自己判断

如果每个组件都自己读取深浅色状态,会导致状态来源混乱。更推荐的方式是页面拿到 palette(),再传给子组件。

@Component
struct PersonCard {
  person: PersonModel;
  colors: AppColors;

  build() {
    Column() {
      Text(this.person.name)
        .fontSize(AppFontSize.F18)
        .fontColor(this.colors.textPrimary)

      Text(this.person.summary)
        .fontSize(AppFontSize.F14)
        .fontColor(this.colors.textSecondary)
        .margin({ top: AppSpacing.S8 })
    }
    .padding(AppSpacing.S16)
    .backgroundColor(this.colors.cardBg)
    .borderRadius(AppRadius.R16)
  }
}

这样 PersonCard 仍然是纯展示组件,它不关心主题模式从哪里来,也不关心设置页怎么保存偏好。

5.2 组件状态命名建议

ArkUI 组件内部字段建议使用业务语义命名:

不推荐 推荐
size avatarSize / cardSize
border borderColor / borderWidth
onClick onSelect / onToggleFavorite
color titleColor / badgeColor
data personList / eventItems

这样做能减少和系统属性、组件方法的冲突,也能让代码审查时更容易看懂组件职责。

六、落地清单

  • 所有颜色走 AppTheme.palette()
  • 所有字号走 AppFontSize
  • 所有间距走 AppSpacing
  • 所有圆角走 AppRadius
  • 页面不要散落硬编码颜色
  • 选中态、禁用态、强调态用语义字段表达

6.1 主题改造步骤

如果一个页面已经写了大量硬编码样式,可以按下面的顺序重构:

1. 先抽颜色,不动布局。

2. 再抽字号和间距,让卡片节奏统一。

3. 最后处理深色模式,逐个页面检查对比度。

4. 把高频样式沉淀成组件,例如 SectionTitlePersonCardInfoRow

5. 保存主题偏好,并在 App 重启后恢复。

不要一上来就全局大改。主题系统影响范围很大,小步替换更稳。

6.2 自测清单

检查项 通过标准
浅色模式 背景、卡片、标题、正文、分割线层级清晰
深色模式 无纯黑糊成一片,次级文字仍可读
选中态 Tab、筛选 Chip、按钮状态能明显区分
平板布局 卡片宽度和阅读行长不过度拉伸
截图展示 技术文章中的截图能说明布局,不只是封面
代码维护 页面内不再散落大量 #FFFFFF1216

在这个项目里,主题 Token 的收益不只体现在视觉统一,也体现在后续维护效率。比如要调整历史感主色,只需要改 AppTheme,不用逐个页面翻 .fontColor()

七、小结

主题 Token 不是“设计洁癖”,而是中大型 ArkUI 应用的维护基础。它解决的是三个问题:页面风格统一、深浅色模式可控、后续改版成本可接受。

如果你也在做 HarmonyOS / ArkUI 内容型应用,可以先从颜色语义化开始:把 pageBgcardBgprimarytextPrimarytextSecondary 这些基础 Token 建起来,再逐步扩展字号、间距和圆角。下一篇会继续讲首页搭建:如何用 Banner、快捷入口、人物卡片和事件索引组织历史内容。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐