前言

一个 25 个文件的元服务,视觉规范最后浓缩成三个常量类:11 个颜色、3 个尺寸、4 套卡片主题。有意思的不是它们定义了哪些值,而是哪些值被真的复用了、哪些只是写在文件里没人引用 —— 这两者的差别恰好说明了「什么该抽成常量」。

alt text

11 个颜色,一套纸感

export class AppColors {
  static readonly PRIMARY: string = '#FF8A65';      // 暖阳橙
  static readonly PRIMARY_LIGHT: string = '#FFAB91';
  static readonly ACCENT_YELLOW: string = '#FFD54F'; // 活力黄
  static readonly SKY_BLUE: string = '#4FC3F7';     // 天空蓝
  static readonly BG_CREAM: string = '#FFF8E1';      // 米黄纸感
  static readonly CARD_BG: string = '#FFFFFF';
  static readonly TEXT_MAIN: string = '#3E2723';
  static readonly TEXT_SUB: string = '#8D6E63';
  static readonly DIVIDER: string = '#FFE0B2';
  static readonly GRADIENT_START: string = '#FFB74D';
  static readonly GRADIENT_END: string = '#FF8A65';
}

这套色的底色不是白,是米黄(#FFF8E1)。所有页面背景都用 BG_CREAM,卡片是纯白浮在上面 —— 于是「卡片」这个概念由一层 5% 不到的明度差建立,不需要画边框。

文字色也不是黑,是深棕 #3E2723,副文案 #8D6E63 是更浅的棕。这两个色值在「纸」的语境下比 #000000 / #666666 更贴题 —— 纯黑在米黄底上会显得脏,深棕则像墨。

常量色值实际用在哪
PRIMARY#FF8A65强调数字、按钮底色、选中态、进度条
PRIMARY_LIGHT#FFAB91定义但未见引用
ACCENT_YELLOW#FFD54F刷卡页「上一首」可用时的底色
SKY_BLUE#4FC3F7定义但未见引用
BG_CREAM#FFF8E1全部页面背景、册图标底色
CARD_BG#FFFFFF所有白卡、按钮文字反色
TEXT_MAIN#3E2723标题、诗文
TEXT_SUB#8D6E63说明、计数、副标题
DIVIDER#FFE0B2与进度条底色 #FFE0B2 同值
GRADIENT_START / GRADIENT_END#FFB74D / #FF8A65等级进度条、Tab 选中胶囊

PRIMARY_LIGHT 与 SKY_BLUE 定义了两个但项目里没有引用点。这类「预留色」在只有一套主题的应用里很常见 —— 它们的存在成本是零(常量不参与打包后的逻辑),价值是下次要加一个「次级按钮」或「信息提示」时不用重新想色号。

DIVIDER 的值 #FFE0B2 在代码里以字面量形式出现了更多次(进度条底色、进度条背景)。同一个色值既是「分割线」又是「进度槽」,因为它本来就是「纸上一道浅浅的痕迹」。这里能看到常量的边界:它定义了语义(分隔),但同一个色值在另一个语义(进度槽)下被复用时,调用点宁愿写字面量也不愿 import 一个叫 DIVIDER 的常量来当进度槽用。

3 个尺寸,只有 1 个在用

export class AppSizes {
  static readonly CARD_RADIUS: number = 20;
  static readonly PADDING: number = 16;
  static readonly GAP: number = 12;
}

CARD_RADIUS 被 7 个文件引用了 16 次 —— 所有「容器级的卡」都用它:统计行、等级卡、列表行、册行、每日一诗卡、年级选择卡。这是整套视觉里唯一必须一致的量:同一屏上出现三四张卡,圆角差 2vp 就会看出来。

PADDING 与 GAP 从未被引用。实际代码里的内边距是 14、18、20、24、10、7,间距是 4、6、8、10、12、14。没有形成 4 的倍数节奏,也不按「大容器 20、小容器 14」的规则来 —— 每一处都是按内容手调的。

这不是疏忽,而是内边距与间距本来就没有全局最优解:一张 2×2 的小卡片(进度卡)用 padding(10) 刚好,一个全屏 Tab 用 padding({ left: 20, right: 20 }) 刚好,一个按钮内部用 padding({ left: 14, right: 14 }) 刚好。把这三个数字收敛成一个常量,得到的是一个「在哪都不太对」的中间值。

圆角不一样。圆角是形状语言,不是排版细节 —— 它必须统一,否则就不是「一套卡的圆角是 20」,而是「每张卡碰巧都挺圆」。所以 CARD_RADIUS 被抽出来并严格执行,另外两个留在文件里当文档。

四套主题,六个方法

刷卡页的卡片有四种纸色,按索引轮换:

export class PoemCardThemes {
  static readonly THEME_COUNT: number = 4;

  static getTopColor(themeIndex: number): string {
    switch (themeIndex % PoemCardThemes.THEME_COUNT) {
      case 0:
        return '#F5E6C4';   // 米黄宣纸
      case 1:
        return '#C9E3D5';   // 淡青竹影
      case 2:
        return '#F2D3D8';   // 藕荷雅韵
      default:
        return '#D9E4F2';   // 月白远山
    }
  }

alt text

四个主题各有两个渐变色(顶/底)加一个光晕色,共 12 个色值:

主题顶部底部光晕(阴影色)
米黄宣纸#F5E6C4#FFF8E7#59D4B06A
淡青竹影#C9E3D5#F0F8F2#595FA87A
藕荷雅韵#F2D3D8#FDF0F2#59C9738A
月白远山#D9E4F2#F3F7FC#595A7BA8

顶色饱和、底色接近白 —— 渐变从上到下逐渐化开,像宣纸吸了墨又淡下去。光晕色都是 #59 开头(35% 透明度)的主题色,用 shadow 打在卡片外圈,让卡片周围有一层同色的柔性辉光。这一点很关键:如果阴影是黑色(像其他所有卡片那样),四套配色在视觉上会退化成「四张白卡换了底色」;换成主题色辉光之后,每张卡像一个发光体。

六个方法里三个是 switch(顶色、底色、光晕),另外三个直接返回 AppColors 的常量:

  /** 主文字颜色(随主题微调,整体偏深) */
  static getTextMainColor(themeIndex: number): string {
    return AppColors.TEXT_MAIN;
  }

getTextMainColor 接收 themeIndex 却完全不使用它。签名统一是为了调用点整齐 —— 卡片里六处取色都写成 PoemCardThemes.getXxx(this.themeIndex),不用区分「哪个方法需要索引、哪个不需要」。如果直接写 AppColors.TEXT_MAIN,读者会问「文字色为什么不跟着主题变」,而写成方法调用,"随主题微调"这条注释就说明了当前的选择:四种底色都很浅,深棕文字在四种上都够对比度,不需要分别调。

themeIndex % THEME_COUNT 取模在两个层次上各做了一次:页面侧 index % PoemCardThemes.THEME_COUNT 传给卡片,卡片内的每个取值方法里又 % 一次。重复取模是防御性的 —— 万一有人传进来 7 或 -1,最坏结果是落到某个已有分支,而不是 switch 全部落空返回 undefined。switch 用了 default 兜底,所以即使不取模也不会返回空,但取模让「7 和 3 是同一套色」这件事在语义上成立。

六档阴影,一层一层往上浮

全项目 16 处 shadow(),参数收敛得相当整齐:

radius颜色offsetY用在哪
4#0D0000002首页统计三卡、我的页三个设置行
6#0D0000002详情页诗文卡
8#140000002收藏列表行、我的页列表行、册行
8#22FF8A65203我的页等级卡(暖橙光晕)
16#55FF8A654年级选择的选中卡、Tab 选中胶囊
20#400000008升级弹窗
24主题光晕色6首页每日一诗卡
30#3300000010刷卡页完成庆祝层
40主题光晕色12刷卡页诗词卡

半径就是层级:4 是「贴在纸上」,8 是「一张卡」,20 以上是「浮在页面之上」。这条映射是连续的,没有出现「一个 6 和一个 28 表达同一个层级」的混乱。

颜色的选择更有意思。#0D000000、#14000000、#40000000、#33000000 是同一个黑色加不同透明度 —— 0D 约 5%、14 约 8%、33 约 20%、40 约 25%。透明度也跟着层级涨:越高的层,影子越重。

alt text

三处用了彩色阴影(#22FF8A6520 等级卡、#55FF8A65 选中态、主题光晕色)。它们都遵循同一个格式:#AARRGGBB 里的 AA 是透明度,RRGGBB 是强调色。等级卡的 #22FF8A6520 里 FF8A65 就是 PRIMARY,22 是 13% 透明度 —— 比黑色阴影(5%)重,因为它要表达「这张卡在发光」而不是「这张卡有厚度」。

圆角的两条线

CARD_RADIUS = 20 管容器,按钮另有一套:

元素高度圆角形态
主操作按钮(上一首/下一首/开始读诗)48–5216 / 26圆角矩形 / 胶囊
卡片内朗读按钮(静置)4824圆形
卡片内朗读按钮(朗读中)4824胶囊(宽度撑开)
卡片内收藏/学会按钮4816圆角矩形
弹窗按钮(太棒了)4221胶囊
底部 Tab 胶囊4824胶囊
小标签(已学会、每日一诗)—6 / 8小圆角
小标签(年级、点击提示)—20胶囊

规律是「圆角等于高度一半 → 胶囊,小于一半 → 圆角矩形」。朗读按钮 48 高、24 圆角,就是一个正圆;朗读中宽度被 padding 撑开、高度仍是 48,同一个 24 的圆角就变成了胶囊两端。一个数值同时管住了两种形态,不需要为「朗读中」单独写一套圆角。

主操作按钮反而用 16 而不是 24 —— 48 高的按钮配 16 圆角是「圆角矩形」,配 24 才是胶囊。刷卡页底部两个按钮、卡片内两个状态按钮都用 16,只有「开始读诗」这种全页唯一的行动按钮用 26(52 高,刚好一半)。层级越高的操作越圆,这条隐含规则在项目里执行得挺一致。

三个列表行,三种复用方式

PoemRowCard(收藏页、我的页)与 VolumeRowItem(首页册列表)长得像,内部机制完全不同。

PoemRowCard 的左侧是一个色块,里面放标题首字:

      Column() {
        Text(this.title.substring(0, 1))
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .fontColor(AppColors.CARD_BG)
      }
      .width(48)
      .height(48)
      .borderRadius(14)
      .backgroundColor(AppColors.PRIMARY)
      .justifyContent(FlexAlign.Center)

不用图片、不用图标,取 title.substring(0, 1) 当视觉锚点。111 首诗的标题首字分布足够散(《静夜思》是「静」,《咏鹅》是「咏」),列表里一行一个橙块一个字,比一排相同的书本图标更容易分辨。代价是像《江南》《江雪》这种同首字的诗在视觉上会撞,但它们的第二三行(朝代·作者·年级、正文预览)足以区分。

两个状态徽标靠 @Local 快照加订阅总线:

  aboutToAppear(): void {
    this.syncFlags();
    StorageService.getInstance().addChangeListener(this.onStoreChange);
  }
  private syncFlags(): void {
    // 直接从单例现取数据,避免包装/拷贝导致读到旧数据
    const u: UserState = StorageService.getInstance().userState;
    this.masteredFlag = u.isMastered(this.poemId);
    this.collectedFlag = u.isCollected(this.poemId);
  }

父组件只传四个只读字段(poemId、title、meta、preview),状态由行自己维护。这样收藏页里取消收藏一首诗时,不需要父组件重新构造列表 —— 该行的 masteredFlag 变 false,「已学会」标签自己消失。

VolumeRowItem 走的是另一条路,它不注册总线:

  @Local userState: UserState = StorageService.getInstance().userState;

  private getMasteredCount(): number {
    if (this.poems.length === 0) {
      return 0;
    }
    let count: number = 0;
    for (let i = 0; i < this.poems.length; i++) {
      if (this.userState.isMastered(this.poems[i].id)) {
        count++;
      }
    }
    return count;
  }

getMasteredCount() 在 build 里被调用两次(数字文案一次、Progress 的 value 一次),内部读 this.userState.isMastered(...) —— 这个方法读的是 @ObservedV2 类上的 @Trace 字段 masteredIds。V2 在渲染时记录这次读取,masteredIds 一旦被整体替换,组件就重渲染。

不需要注册总线,是因为「读取发生在 build 里」这件事本身就让依赖被追踪到了。而 PoemRowCard 之所以要注册,是因为它的 masteredFlag 是 @Local 副本,build 读的是副本而不是 userState,依赖链断在 syncFlags() 那一刻 —— 得靠外部通知来重新同步。

两种方式各有代价:副本方式每行多两个字段、多一次注册与退订;直读方式每次渲染都要 indexOf 一遍。册列表只有两三行、每行二十首诗,indexOf 的总量在一千次以内;收藏列表可能有上百行,逐行订阅反而更省。

两处空态,两种形制

收藏页的空态占满整个内容区:

      if (this.collected.length === 0) {
        Column({ space: 8 }) {
          Image($r('app.media.ic_fav_empty_star'))
            .width(72)
            .height(72)
          Text('还没有收藏的诗')
            .fontSize(14)
            .fontColor(AppColors.TEXT_SUB)
          Text('在诗词详情页点星标收藏')
            .fontSize(12)
            .fontColor(AppColors.TEXT_SUB)
        }
        .width('100%')
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
      }

layoutWeight(1) 加 justifyContent(Center) 让它在剩下的所有空间里垂直居中,72 图标、两行文案,没有卡片底色 —— 空态本身就是页面。

我的页的空态在页面中部:

        if (this.masteredList.length === 0) {
          Column({ space: 6 }) {
            Image($r('app.media.ic_empty_backpack'))
              .width(64)
              .height(64)
            Text('还没有已学会的诗')
              .fontSize(14)
              .fontColor(AppColors.TEXT_SUB)
            Text('在诗词详情页点「标记已学会」')
              .fontSize(12)
              .fontColor(AppColors.TEXT_SUB)
          }
          .width('90%')
          .padding(24)
          .backgroundColor(AppColors.CARD_BG)
          .borderRadius(AppSizes.CARD_RADIUS)
        }

白色卡片底、padding(24)、占 90% 宽、不撑满剩余高度。因为它上面还有等级卡、三行统计、一行「当前年级」,空态只是「已学会列表」这个区块的内容 —— 如果它也 layoutWeight(1) 居中,就会把上面的内容顶到屏幕外。

两处空态的文案结构是一样的:「还没有 XX」加「在 YY 页点 ZZ」。第二行必须说清去哪里能改成非空状态,否则用户看到空页面只会退出去。图标尺寸 72 与 64 的差别来自容器:整屏居中可以用大一点的图,卡片内的占位图不能超过卡的留白。

alt text

那个没人用的 SafeTopSpace

Common.ets 里有个组件专门处理顶部留白:

/** 顶部安全留白(元服务胶囊避让) */
@ComponentV2
export struct SafeTopSpace {
  build() {
    Column() {
    }
    .width('100%')
    .height(56)
  }
}

它没有出现在任何 build 里。三个 Tab 全都是内联写法:

      Column() {}.width('100%').height(56)

三处逐字相同。抽成组件能省下的是三次 Column() {},而内联的写法不需要 import、不需要记组件名、在文件里的位置就是它生效的位置。56 这个数字是元服务胶囊的避让高度,改动频率几乎为零。

刷卡页更直接,把留白做进 padding:

        .padding({ left: 20, right: 20, top: 56, bottom: 8 })

因为它那一行本身就要横向 20 的边距,顶部留白顺手并进同一个 padding 里,比拼一个 SafeTopSpace 再排一行更省一层节点。

卡片的布局里也能看到同一件事的另一面:标题上方有一个 Blank()、下方也有一个 Blank(),靠两个弹性占位把内容挤到垂直中间。Blank() 是零配置的弹性占位,layoutWeight(1) 的 Column 是带排版的弹性容器 —— 项目里两种都用,选哪个取决于中间内容需不需要再排。

Tab 栏:一份样式,两种状态

底部 Tab 的每一项都由同一个 @Builder 渲染:

  @Builder
  buildTabBar(idx: number) {
    Row({ space: 6 }) {
      Image(this.tabIcons[idx])
        .width(24)
        .height(24)

      if (this.currentTab === idx) {
        Text(this.tabLabels[idx])
          .fontSize(13)
          .fontWeight(FontWeight.Medium)
          .fontColor('#FFFFFF')
      }
    }
    .height(48)
    .padding({ left: 16, right: this.currentTab === idx ? 18 : 16 })
    .borderRadius(24)
    .linearGradient(this.currentTab === idx
      ? { angle: 135, colors: [[AppColors.GRADIENT_START, 0], [AppColors.PRIMARY, 1]] }
      : undefined)
    .shadow(this.currentTab === idx
      ? { radius: 16, color: '#55FF8A65', offsetX: 0, offsetY: 4 }
      : { radius: 0, color: '#00000000', offsetX: 0, offsetY: 0 })
    .justifyContent(FlexAlign.Center)
    .animation({ duration: 220, curve: Curve.EaseInOut })
  }

未选中时只显示图标、选中时图标加文字。padding.right 在选中时从 16 变成 18 —— 多出的 2vp 补偿文字右侧的视觉重量,让胶囊两端的留白看起来一样。

「取消效果」用了两种不同写法:渐变传 undefined,阴影传 { radius: 0, color: '#00000000' }。因为 linearGradient 接受 undefined 表示不画渐变,而 shadow 不接受 undefined,只能传一个「半径 0、颜色全透明」的空阴影。

.animation({ duration: 220 }) 挂在最后,让这一整条链上的属性变化(宽度、渐变、阴影、文字出现)统一补间 220ms。切换 Tab 时,橙色胶囊从旧位置滑到新位置,蓝灰的图标变白 —— 这个「滑动高亮」不靠任何动画组件,只靠一个 @Local currentTab 加属性动画。

Tab 容器本身用的是系统 Tabs 加毛玻璃底栏:

    .barBackgroundBlurStyle(BlurStyle.COMPONENT_THICK)
    .barBackgroundColor('#CCFFF6E0')
    .barHeight(72)

#CC 是 80% 透明度的米黄加在毛玻璃上 —— 底下的内容(列表滚到底时露出的最后一张卡)会模糊地透出来。barHeight(72) 比常规 Tab 栏高,因为里面装的是 48 高的胶囊加 12 上下的呼吸空间。

年级选择页把「同一个组件的两种状态」写得最直白:

          .shadow({ radius: this.selected === idx + 1 ? 16 : 4, color: this.selected === idx + 1 ? '#55FF8A65' : '#10000000', offsetX: 0, offsetY: this.selected === idx + 1 ? 4 : 2 })

一行里三个三元表达式。没有拆成两个 if 分支、没有把选中/未选中写成两个 @Builder —— 因为除了颜色、阴影、文字色,其余十几行完全一样。三元表达式让「差异」集中在一处,读者一眼能看到「选中只是换了底色、阴影和文字反色」。

一处不该复用却开了口子的地方

等级卡的进度条是手写的:

        Row() {
          Column()
            .width((LevelSystem.getProgress(this.userState.masteredCount) * 100).toFixed(1) + '%')
            .height(8)
            .borderRadius(4)
            .linearGradient({ angle: 90, colors: [[AppColors.GRADIENT_START, 0], [AppColors.PRIMARY, 1]] })
          Column()
            .layoutWeight(1)
            .height(8)
        }

而册行和刷卡页顶部都用系统的 Progress:

        Progress({
          value: this.getMasteredCount(),
          total: this.poemCount > 0 ? this.poemCount : 1,
          type: ProgressType.Linear
        })
          .width('100%')
          .height(6)
          .color(AppColors.PRIMARY)
          .backgroundColor('#FFE0B2')

Progress 的 color 只接受一个颜色,等级卡的进度条要从 #FFB74D 渐变到 #FF8A65。为了这一处渐变,多写了两个 Column 的拼接。

这个取舍是有意识的:一处特殊(等级卡是唯一带渐变的进度条,也是「我的」页上的视觉焦点),两处常规(册行、刷卡页顶部只需要一条实心进度)。把渐变版本包成 GradientProgress 组件,等于用一处调用点去反推一个组件的参数面(高度、圆角、底色、两端颜色),成本远大于收益。

一句话的取舍

这套视觉里真正被严格复用的只有两样:11 个颜色和 20 的圆角。其余全部按场景手调 —— 内边距、间距、图标尺寸、阴影档位、按钮圆角,都能在同一份代码里找到五个以上的不同取值。这不是规范缺失,而是「三层结构」的分工:颜色和容器圆角决定「这是同一个应用」,其余细节决定「每一屏都刚好」。判断某个值该不该抽成常量,标准其实只有一个 —— 它不一致时,用户会不会看出来。

Logo

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

更多推荐