糖画坊:用鸿蒙 ArkTS 浇筑一份「一勺糖浆千般造化」的琥珀焦糖 UI

一、写在前面

糖画,是街头巷尾最有人间烟火气的非遗手艺。老艺人舀一勺滚烫的糖浆,手腕一抖,龙飞凤舞、花鸟鱼虫便跃然板上。而这一份代码,正是把这份"琥珀焦糖"的质感搬进了鸿蒙世界:整份工程以焦糖棕与琥珀金为主色,构建了一个名为"糖画坊"的完整单页应用,涵盖了本周销量看板、糖画陈列、配方簿、糖色谱、订单台账、客评口碑六大数据场景,并且内置了三个可交互的模态弹窗。

从工程结构上看,这是一个非常典型的 ArkTS 声明式 UI 案例:interface 定义数据契约、enum 定义页签枚举、@Observed 修饰的类承载业务数据、@Entry @Component 修饰的结构体承载页面、@Builder 抽离可复用的布局片段。它不是最复杂的工程,却是理解"状态驱动渲染"这条主线的最佳范本。下文会沿着代码的物理顺序逐段拆解,把每一块拼图为什么这样写、数据如何流动、界面如何被状态驱动,都讲透。

在这里插入图片描述

二、项目概览与技术要点

在进入逐段分析之前,先用一张架构图把整份代码的"骨架"立起来。整份代码可以划分成四个层次:类型与常量层(色板、枚举、接口、图表数据)、数据模型层(被观察的数据类)、工具函数层(各类取色映射)、界面层(主组件、弹窗构建器、内容子组件)。

渲染错误: Mermaid 渲染失败: Parse error on line 2: ...举 / 各类 Item 接口] --> B[数据模型层
@Observe -----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'

这张图揭示了一个关键事实:界面层的数据全部来自 SugarData 单例对象,子组件通过 @ObjectLink 与其建立双向绑定,主组件通过 @State 管理页签和弹窗的可见性。这四层各司其职,互不越界,正是工程可读性的来源。

三、逐段代码解析

3.1 主题色板:SugarPalette 接口与 COLORS 常量

interface SugarPalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  accent: string;
  accentLight: string;
  bg: string;
  cardBg: string;
  cardAlt: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  border: string;
  line: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
  amber: string;
  caramel: string;
}

const COLORS: SugarPalette = {
  primary: '#B5651D',
  primaryLight: '#E0A458',
  primaryDark: '#6B3410',
  accent: '#D4A017',
  accentLight: '#F0C75E',
  bg: '#FBF3E4',
  cardBg: '#FFFFFF',
  cardAlt: '#F4E6CC',
  textPrimary: '#4A2C0E',
  textSecondary: '#7A5A3A',
  textHint: '#B89A6E',
  border: '#E6CFA0',
  line: '#EFE0C4',
  success: '#5A7D4C',
  warning: '#D4A017',
  danger: '#A8362A',
  white: '#FFFFFF',
  amber: '#D4A017',
  caramel: '#B5651D'
};

逐点拆解:

第一,用接口先定义"色板长什么样",再用常量实现它。这是 TypeScript/ArkTS 中非常推荐的写法:SugarPalette 接口相当于一份"设计规范清单",把界面里可能用到的所有语义色全部登记在册;COLORS 常量则给出具体色值。后续任何组件里写 .backgroundColor(COLORS.cardAlt),语义一目了然,而不会出现满屏裸色值这种难以维护的局面。

第二,色板设计遵循"主色—辅助色—背景—文字—状态色"的完整体系primary/primaryLight/primaryDark 构成焦糖棕的主色梯度,accent/accentLight 是琥珀金强调色,bg/cardBg/cardAlt 负责页面与卡片背景的层次,textPrimary/textSecondary/textHint 控制文字的三级信息密度,success/warning/danger 是状态色,最后 ambercaramel 是呼应主题的两个点缀色。这样一套色板,既保证了全应用视觉统一,也为后续的"状态色映射函数"提供了取色素材。

第三,色值的选取本身就在讲故事#B5651D 是烤过的焦糖色,#D4A017 是琥珀的透亮金黄,#6B3410 是糖浆熬老后的深褐,#FBF3E4 是米纸般的暖底。颜色不再是随机的 HEX 值,而是主题语境的延伸。

3.2 页签体系:枚举、页签元数据与内容接口

enum SugarTab {
  PAINT = 0,
  RECIPE = 1,
  COLOR = 2,
  ORDER = 3,
  REVIEW = 4
}

interface STabMeta {
  key: string;
  icon: string;
  label: string;
}

const TAB_LIST: STabMeta[] = [
  { key: 'paint', icon: '🍭', label: '糖画' },
  { key: 'recipe', icon: '📒', label: '配方' },
  { key: 'color', icon: '🎨', label: '糖色' },
  { key: 'order', icon: '📦', label: '订单' },
  { key: 'review', icon: '⭐', label: '客评' }
];

逐点拆解:

其一,enum SugarTab 把五个页签的索引语义化。代码里 this.curTab === SugarTab.PAINTthis.curTab === 0 可读性强得多,而且当页签顺序调整时,枚举名不会跟着错位,这是用枚举替代魔法数字的最大收益。

其二,STabMeta 接口把"一个页签需要哪些信息"固定下来:key 用于 ForEach 的键值生成(保证列表复用时的身份稳定),icon 是 emoji 图标,label 是中文文案。TAB_LIST 数组把五个页签的数据集中管理,底部导航栏直接 ForEach(TAB_LIST) 渲染,将来加一个页签只需往数组里塞一条记录,无需改动渲染逻辑。

其三,紧随其后定义了五组内容实体的接口:PaintItem(糖画:名称、形制、匠级、价格、emoji)、RecipeItem(配方:糖水比、温度、工艺)、SugarColorItem(糖色:色温、用途、色值)、SugarOrderItem(订单:地区、数量、金额)、SugarReviewItem(客评:评分、日期、内容、标签)。这些接口既是数据的"形状契约",也决定了列表项 UI 要展示哪些字段——接口先行,UI 后行,是数据驱动渲染的基础。

3.3 图表数据常量:WEEK_SOLDCOLOR_SHARE

const WEEK_SOLD: WeekSugarMeta[] = [
  { day: '周一', value: 22 },
  { day: '周二', value: 28 },
  { day: '周三', value: 34 },
  { day: '周四', value: 40 },
  { day: '周五', value: 62 },
  { day: '周六', value: 88 },
  { day: '周日', value: 76 }
];

const COLOR_SHARE: ColorShareMeta[] = [
  { name: '金黄糖', pct: 40, color: '#D4A017' },
  { name: '焦糖', pct: 28, color: '#B5651D' },
  { name: '琥珀糖', pct: 20, color: '#E0A458' },
  { name: '深焦糖', pct: 12, color: '#6B3410' }
];

在这里插入图片描述

逐点拆解:

这两组常量是"假数据驱动的可视化"的典型写法。WEEK_SOLD 记录一周七天的销量,value 既用于柱子的高度(.height(w.value)),也用于文字展示;数据里"周六 88 支"的峰值直接决定了页面右上角提示语"周六88支"的来源。COLOR_SHARE 则是糖色占比数据,每项自带 color 字段,使得色块、图例、文字三者颜色天然一致。把图表数据抽成模块级常量而非散落在组件内部,好处是数据与渲染解耦,后续接真实接口时只需替换数据来源。

3.4 数据模型层:@Observed 修饰的 SugarData

@Observed
export class SugarData {
  paints: PaintItem[] = [ /* 12 条糖画数据 */ ];
  recipes: RecipeItem[] = [ /* 12 条配方数据 */ ];
  colors: SugarColorItem[] = [ /* 12 条糖色数据 */ ];
  orders: SugarOrderItem[] = [ /* 12 条订单数据 */ ];
  reviews: SugarReviewItem[] = [ /* 12 条客评数据 */ ];
}

逐点拆解:

这里要重点讲透 @Observed@ObjectLink 的配合。@Observed 装饰器让 SugarData 成为可观察对象:当它的属性(比如 paints 数组)被修改时,所有通过 @ObjectLink 绑定它的子组件都会收到通知并自动重绘。这份代码里,主组件持有 @State data: SugarData = new SugarData(),然后以 SugarPaintContent({ data: this.data, ... }) 的形式传给五个子组件,子组件内部用 @ObjectLink data: SugarData 接收。@State 保证数据在组件树顶层存活,@ObjectLink 保证子组件对数据的修改能"反向通知"其他观察者,这就是"单一数据源、多端同步"的 V 模型。

每个数组恰好 12 条数据,这不是巧合:它让五个页签在视觉上"势均力敌",滚动列表都有足够的内容填充。数据里刻意埋了细节——比如糖龙匠级 95、价格 38 元,糖十二生肖 128 元套装——这些数值后续会被取色函数消费,形成"数值→颜色"的映射链。

3.5 工具函数层:四组取色映射

function getPaintColor(level: number): string {
  if (level >= 95) {
    return '#A8362A';
  } else if (level >= 90) {
    return '#D4A017';
  } else if (level >= 85) {
    return '#B5651D';
  }
  return '#E0A458';
}

function getRecipeColor(skill: string): string {
  if (skill === '拉丝' || skill === '拉白' || skill === '熬色') {
    return '#D4A017';
  } else if (skill === '挂霜' || skill === '琉璃' || skill === '切块') {
    return '#B5651D';
  }
  return '#E0A458';
}

function getOrderColor(amount: number): string {
  if (amount >= 90000) {
    return '#A8362A';
  } else if (amount >= 60000) {
    return '#D4A017';
  } else if (amount >= 40000) {
    return '#B5651D';
  }
  return '#7A5A3A';
}

function getReviewTagColor(tag: string): string {
  if (tag === '拉丝不断' || tag === '远渡飘香' || tag === '秘方地道' || tag === '节庆千单') {
    return '#D4A017';
  } else if (tag === '伴手有面' || tag === '展馆打卡' || tag === '一夜爆单') {
    return '#B5651D';
  } else if (tag === '配茶一绝' || tag === '造型萌' || tag === '日销百件' || tag === '祝寿添彩' || tag === '课堂爆满') {
    return '#E0A458';
  }
  return '#7A5A3A';
}

逐点拆解:

这四个函数是"数值/文本 → 主题色"的映射器,集中体现了阈值分级白名单分级两种映射策略。

getPaintColorgetOrderColor 采用阈值分级:匠级 ≥95 显示深红 #A8362A,≥90 显示琥珀金,≥85 显示焦糖棕,其余显示浅琥珀;订单金额 ≥9 万、≥6 万、≥4 万依次降级。这种写法的妙处在于,同一个颜色同时承担了"视觉分级"与"信息提示"两个职责——列表里扫一眼颜色,就知道哪件糖画最稀有、哪张订单最大。

getRecipeColorgetReviewTagColor 采用白名单分级:把工艺名、客评标签按语义归类到固定色值。白名单写法虽然不通用(每加一个标签都要改函数),但在数据量固定、语义明确的场景下反而最可控——颜色由"业务含义"决定,而不是由数值大小决定。

把取色逻辑收敛成独立函数还有一个隐性收益:颜色计算只发生一次、只在一处,后续如果主题要换色系(比如从焦糖改成抹茶),只需改函数返回值,全应用联动。

3.6 主组件 SugarApp:状态中枢与弹窗构建器

@Entry
@Component
struct SugarApp {
  @State curTab: number = 0;
  @State data: SugarData = new SugarData();
  @State showAddPaint: boolean = false;
  @State showDeleteRecipe: boolean = false;
  @State showEditColor: boolean = false;
  @State delRecipeName: string = '';
  @State editColorName: string = '';
  @State sugarPulse: boolean = false;
  @State flame: boolean = false;
  ...
}

逐点拆解:

SugarApp 是整个应用的状态中枢,@State 变量可以分成三组:

  • 导航状态curTab 记录当前页签索引,驱动内容区条件渲染;
  • 弹窗状态showAddPaint / showDeleteRecipe / showEditColor 三个布尔值控制弹窗显隐,delRecipeName / editColorName 记录"要删除的配方名"“要编辑的糖色名”,让弹窗知道自己在操作谁;
  • 动画状态sugarPulse / flame 控制顶栏糖葫芦的缩放脉冲与火焰的明暗。

这种"状态分组"的习惯很重要:把互不相关的状态拆开,各自独立更新,才能避免一个状态变化触发整棵组件树重绘

接着看三个弹窗构建器中最具代表性的 addPaintModal

@Builder addPaintModal() {
  Stack() {
    this.modalOverlay(() => { this.showAddPaint = false; })
    Column() {
      Row() {
        Text('🍭 新制糖画').fontSize(16).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('✕').onClick(() => { this.showAddPaint = false; })
      }
      ...
      Row() {
        Text('再想想').onClick(() => { this.showAddPaint = false; })
        Text('确认制糖').onClick(() => { this.showAddPaint = false; })
      }
    }
    .width('86%').backgroundColor(COLORS.cardBg).borderRadius(18)
  }
  .width('100%').height('100%').position({ x: 0, y: 0 })
}

逐点拆解:

弹窗的构建是这套代码里最值得反复咀嚼的部分。它把"弹窗"拆成了三层:

第一层是遮罩modalOverlay(onClose) 是一个参数化 @Builder:它接受一个"关闭回调",渲染一个半透明黑色全屏蒙层,点击蒙层即触发回调。注意回调由调用方注入,所以遮罩组件本身完全不关心"关闭后要做什么",只负责"点击了我就要通知你"。这是控制反转在 UI 构建中的体现。

第二层是卡片容器。宽度 86%、白底、圆角 18、最大高度限制 80%,配合遮罩构成经典的居中弹窗形态。constraintSize({ maxHeight: '80%' }) 是鸿蒙特有的尺寸约束写法,防止内容过长时溢出屏幕。

第三层是内容与操作区。标题行用 Column().layoutWeight(1) 作为弹性占位把标题和关闭按钮推到两端;信息行采用"标签 + 值"的双列布局,标签固定宽度、值随内容自适应;底部操作行用 FlexAlign.End 右对齐,主次按钮通过实底(确认)与描边(再想想)区分权重。

值得一提的是,三个弹窗(新增糖画、撤下配方、调校糖色)结构高度同构——都是"标题 + 信息行 + 双按钮",只是文案与字段不同。作者没有把它们压成一个泛化组件,而是各自独立成 @Builder。这个取舍在示例代码里是可接受的:可读性优先于 DRY 原则,三个弹窗各自独立、一目了然,也方便单独调整某个弹窗的布局。

3.7 顶栏 topBar:动态装饰与交互动画

@Builder topBar() {
  Column() {
    Row() {
      Column() {
        Text('🍭 糖画坊').fontSize(20).fontWeight(FontWeight.Bold)
        Text('Sugar · 一勺糖浆 千般造化').fontSize(10)
      }
      Row() {
        Text('🍬').scale({ x: this.sugarPulse ? 1.25 : 0.9, y: ... })
          .animation({ duration: 500, curve: Curve.EaseOut })
          .onClick(() => { this.sugarPulse = !this.sugarPulse; })
        Text('🔥').opacity(this.flame ? 1.0 : 0.4)
          .animation({ duration: 400, curve: Curve.EaseOut })
          .onClick(() => { this.flame = !this.flame; })
        Text('🌡️')
      }
    }
    Row() {
      ForEach([0, 1, 2, 3, 4, 5, 6, 7], (r: number) => { /* 装饰圆点 */ })
    }
    Row() { /* 分割线 + 标语 */ }
  }
  .backgroundColor(COLORS.primaryDark)
}

逐点拆解:

topBar 是"深色底 + 三行结构"的顶栏:第一行是标题与交互小图标,第二行是 8 个交替色圆点组成的装饰条,第三行是"线—标语—线"的分割组件。交互亮点在两个可点击的 emoji 上:

  • 🍬 糖葫芦点击后在 scale 1.25 与 0.9 之间切换,配合 500ms 的 Curve.EaseOut 缓出曲线,形成"鼓起又回落"的呼吸感;
  • 🔥 火焰点击后在透明度 1.0 与 0.4 之间切换,模拟火苗的明暗。

这里的精髓是@State 布尔值驱动属性插值this.sugarPulse ? 1.25 : 0.9 不是一次性取值,而是随着状态切换,由 animation() 在旧值和新值之间补间。声明式框架下做动画,不需要手动操作时间轴,只需声明"目标状态",中间过程交给框架。

3.8 主构建函数 build():页签切换与弹窗挂载

build() {
  Column() {
    this.topBar()
    if (this.curTab === SugarTab.PAINT) {
      SugarPaintContent({ data: this.data, onAdd: () => { this.showAddPaint = true; } })
    } else if (this.curTab === SugarTab.RECIPE) {
      SugarRecipeContent({ data: this.data, onDel: (n: string) => {
        this.delRecipeName = n;
        this.showDeleteRecipe = true;
      } })
    } else if (this.curTab === SugarTab.COLOR) {
      SugarColorContent({ data: this.data, onEdit: (n: string) => {
        this.editColorName = n;
        this.showEditColor = true;
      } })
    } else if (this.curTab === SugarTab.ORDER) {
      SugarOrderContent({ data: this.data })
    } else {
      SugarReviewContent({ data: this.data })
    }
    Column().width('100%').height(3).backgroundColor(COLORS.primary)
    Row() { ForEach(TAB_LIST, (t, idx) => { /* 底部导航 */ }) }
    if (this.showAddPaint) { this.addPaintModal() }
    if (this.showDeleteRecipe) { this.deleteRecipeModal() }
    if (this.showEditColor) { this.editColorModal() }
  }
  .backgroundColor(COLORS.bg)
}

逐点拆解:

build() 的布局是标准的"三段式":顶部顶栏、中间内容区、底部导航栏,弹窗以覆盖层形式挂在最外层 Column 之后。

内容区的 if / else if 链把 curTab 映射到五个子组件,这是条件渲染最直观的形态:切换页签就是改一个 @State,框架自动销毁旧组件、创建新组件。注意回调参数的传递方式——SugarPaintContent 收到 onAdd 回调、SugarRecipeContent 收到 onDel(n)SugarColorContent 收到 onEdit(n),它们统一通过"回调上抛"的方式把子组件的操作意图传回主组件,由主组件统一修改状态。子组件不直接改弹窗状态,只喊"我要加料/我要删配方",具体怎么弹窗由父组件决定,这让组件间通信方向保持单向清晰。

底部导航的渲染同样值得注意:ForEach(TAB_LIST, ...) 遍历页签元数据生成五个导航项,选中态通过 this.curTab === idx 三重联动——文字颜色、加粗权重、背景色一起变化,形成明确的"我被选中"视觉反馈。背景色 COLORS.accent + '1F'带透明度的十六进制拼接'#D4A017' + '1F' 得到 8 位色值,末尾两位 1F 是约 12% 的透明度。这是这套代码里反复使用的小技巧,值得记住。

3.9 通用标签组件 SugarTag

@Component
struct SugarTag {
  @Prop text: string;
  @Prop color: string;

  build() {
    Text(this.text)
      .fontSize(9)
      .fontColor(this.color)
      .padding({ left: 7, right: 7, top: 2, bottom: 2 })
      .backgroundColor(this.color + '1F')
      .borderRadius(9)
      .border({ width: 1, color: this.color + '40' })
  }
}

逐点拆解:

SugarTag 是一个 10 行左右的迷你胶囊标签:文字用 @Prop 传入,颜色由调用方指定,背景用"同色低透明度",边框用"同色中透明度"。@Prop@State 的区别在这里体现得很清楚:@Prop单向传递的只读属性,父组件改值后子组件同步更新,但子组件内部改它不会反向影响父组件,适合这种"纯展示"型小组件。this.color + '1F' 这种透明度拼色的写法让标签永远与传入色保持同色系,不用额外计算浅色版本。

3.10 内容子组件:以 SugarPaintContent 为代表的列表页

@Component
struct SugarPaintContent {
  @ObjectLink data: SugarData;
  onAdd: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        // 卡片一:本周糖画销量柱状图
        Column() {
          Row() {
            Text('📈 本周糖画销量')
            Text('周六88支')
          }
          Row() {
            ForEach(WEEK_SOLD, (w: WeekSugarMeta) => {
              Column() {
                Text(w.value + '')
                Column().width(18).height(w.value).backgroundColor(...)
                Text(w.day)
              }.layoutWeight(1)
            }, (w) => w.day)
          }
          .height(150).alignItems(VerticalAlign.Bottom)
        }
        // 列表头:糖画陈列 + 新增按钮
        Row() {
          Text('🍭 糖画陈列')
          Column().layoutWeight(1)
          Text('点击➕制糖')
          Text('➕').onClick(() => { this.onAdd(); })
        }
        // 列表项
        ForEach(this.data.paints, (p: PaintItem, i: number) => {
          Row() { /* 圆形图标 + 名称标签 + 价格 */ }
          .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
        }, (p, i) => 'pt' + p.name + i)
      }
    }
    .scrollable(ScrollDirection.Vertical).scrollBar(BarState.Off)
  }
}

逐点拆解:

SugarPaintContent 的结构是"统计卡片 + 列表"的复合体,拆开看有三层:

第一层是柱状图卡片。没有引入任何图表库,纯用 ForEach 生成 7 根柱子:每根柱子是一个 Column,内部自上而下是数值文字、矩形色块(宽度固定 18、高度等于销量值)、星期文字。整行 height(150)alignItems(VerticalAlign.Bottom),让所有柱子底部对齐,形成天然坐标系。销量 ≥60 的柱子用强调色,其余用浅色,峰值一目了然——"周六 88 支"的数字和页头提示语互相印证。

第二层是列表头。标题 + 弹性占位 + 提示语 + 加号按钮,onClick 直接调用父组件传入的 this.onAdd() 回调。点击加号,弹窗由主组件打开,子组件只管"报告事件"。

第三层是列表项。每个糖画条目是"圆形色块(emoji + 匠级)| 名称与标签 | 价格"的三段式行布局。圆形色块的背景和描边都来自 getPaintColor(p.level),颜色深浅直接反映匠级高低;名称行下叠两个 SugarTag 展示形制与匠级。列表行背景用 i % 2 === 0斑马纹——偶数列白底、奇数列浅米底,缓解长列表的阅读疲劳。ForEach 的第三个参数是键值生成器,'pt' + p.name + i 保证每条数据有稳定身份,是列表复用与 diff 的基础。

3.11 其余内容子组件:同构下的差异点

剩余四个子组件与 SugarPaintContent 共享同一套骨架,但各有差异:

SugarRecipeContent 的头部卡片换成糖色占比的圆点图——四个色块圆点 + 百分比 + 名称,用 layoutWeight(1) 平分宽度;列表项是"圆形容器 + 配方名(含糖水比、删除按钮)+ 温度工艺行"。删除按钮调用 onDel(r.name) 上抛。

SugarColorContent 没有统计卡片,直接铺满 12 条糖色记录;每条是"色值圆片 + 名称 + 色温用途行"。这里出现了一个细节:色值文字的颜色会判断 c.hex 是否为深色(如 #3A2A1A#4A2C0E#6B3410),深色用主文字色、浅色用色值本身,保证深底浅字、浅底深字的可读性。

SugarOrderContentSugarReviewContent 则是最纯粹的列表页:订单条目展示"emoji + 名称 + 地区数量金额",金额通过 getOrderColor 着色;客评条目展示"头像 + 昵称日期 + 星级 + 标签 + 评价内容",星级用 '⭐'.repeat(r.score) 生成——一行代码把数字评分转成视觉星标,是字符串方法在 UI 中的妙用。

3.12 完整数据流:从点击到重绘

把上面的代码串起来,一条完整的数据流闭环就清晰了。用一张时序图来收束本节:

SugarData 数据类 主组件 SugarApp 内容子组件 SugarPaintContent 用户 SugarData 数据类 主组件 SugarApp 内容子组件 SugarPaintContent 用户 点击 ➕ 按钮 调用 onAdd() 回调 showAddPaint = true 渲染 addPaintModal 弹窗 点击确认/关闭,showAddPaint = false 点击底部页签 无回调,直接改 curTab?(实际由导航 onClick 改 curTab) curTab = 新索引 条件渲染新的内容子组件 @ObjectLink 读取数据渲染列表

这条闭环印证了本项目的核心设计:状态在顶层(主组件),数据在模型(数据类),展示在底层(内容组件),一切变更都通过状态驱动回流。理解了这个闭环,也就理解了 ArkUI 声明式开发的精髓。

四、写在最后

回顾整份代码,它用大约一千行 ArkTS 呈现了一个完整、可交互、有主题质感的非遗糖画应用。抛开题材的趣味性,单从工程角度,这份代码至少有五个值得吸收的经验:

其一,分层清晰。类型与常量、数据模型、工具函数、界面组件四个层次边界分明,每个文件的职责单一,这对任何规模的工程都是健康的地基。

其二,数据驱动渲染。从 @Observed 数据类到 @State 导航状态再到 @ObjectLink 子组件绑定,状态与 UI 之间是单向清晰的映射关系,改数据即改界面,不需要手动操作 DOM 或组件实例。

其三,约定优于配置的视觉体系。一套色板 + 一组取色映射函数,让"数值大小""文本语义"与"颜色深浅"建立可复用的映射,整份代码几乎没有散落的裸色值。

其四,回调上抛的通信模式。子组件通过回调把操作意图上抛给父组件,父组件集中管理弹窗等全局状态,既避免了状态分散,也让子组件保持纯粹。

其五,把动画当状态用。无论是糖葫芦的缩放、火焰的明暗,还是弹窗的淡入,都通过 @State 布尔值加 animation() 完成,声明式动画的优雅在这份代码里体现得淋漓尽致。

当然,代码里也有可以继续演进的地方:比如三个弹窗的重复布局可以考虑抽象成通用弹窗组件;取色函数里的魔法色值可以收敛进 COLORS;斑马纹列表可以封装成通用列表项。但瑕不掩瑜,作为一篇理解 ArkTS 声明式 UI 的教材,这份代码足够完整、足够自洽。

五、技术要点对比表

对比维度 类型/常量层 数据模型层 工具函数层 界面层
核心载体 接口 + enum + 常量数组 @Observed 纯函数 @Entry @Component 结构体
代表元素 SugarPaletteSugarTabTAB_LIST SugarData 五个数据数组 getPaintColor 等四个取色函数 SugarApp 及五个内容子组件
数据方向 静态定义 可被观察、可被修改 输入数据输出颜色 消费数据、驱动渲染
取色策略 色板统一管理 阈值分级 + 白名单分级 调用函数着色
通信方式 @State 顶层持有 无状态 @ObjectLink 绑定 + 回调上抛
典型技巧 透明度十六进制拼接 12 条数据等量填充 数值映射语义色 斑马纹、repeat() 生成星级、layoutWeight 弹性布局
易扩展点 加色、加页签 换真实接口数据 换主题色系 抽通用弹窗/列表组件
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐