鸿蒙原生应用实战(四):我的追剧与统计页 —— 三态Tab与数据可视化

前言

上一篇我们完成了搜索页和详情页。本篇将实现最后两个功能页面——我的追剧页(MyListPage)统计页(StatsPage)

这两个页面代表着从"浏览"到"管理"的转变,涉及更多交互逻辑:

  • 多Tab状态管理(在看/想看/看完)
  • 列表数据的增删改
  • 数据可视化展示
  • 成就徽章系统

一、我的追剧页(MyListPage)完整实现

1.1 页面功能分析

MyListPage
├── 顶部导航栏:返回 + 标题"我的追剧" + 管理按钮
├── Tab切换栏:在看 | 想看 | 看完
├── 筛选结果列表:根据选中Tab展示对应剧集
│     ├── 有数据:剧集卡片列表
│     └── 无数据:空状态引导页
└── 快速操作区:批量导入 / 导出列表 / 排序 / 统计

1.2 数据结构

interface MyDrama {
  id: number;
  title: string;
  genre: string;
  episodes: number;           // 总集数
  watchedEpisodes: number;    // 已看集数
  status: string;             // "连载中"/"已完结"
  rating: number;             // 评分
  progress: number;           // 进度百分比 0-100
  myStatus: string;           // 我的状态:"在看"/"想看"/"看完" (核心字段)
  addedDate: string;          // 添加日期
}

核心字段 myStatus:它决定了剧集属于哪个Tab。三个Tab的值分别为 "在看""想看""看完"

1.3 状态变量与初始化

@Component
struct MyListPage {
  @State currentTab: string = '在看';       // 当前选中的Tab
  @State myDramas: MyDrama[] = [];          // 全部追剧数据
  tabs: string[] = ['在看', '想看', '看完'];

  aboutToAppear(): void {
    this.initMyDramas();
  }

  initMyDramas(): void {
    this.myDramas = [
      { id: 1, title: '星落凝成糖', genre: '古装仙侠', episodes: 40, watchedEpisodes: 12,
        status: '连载中', rating: 47, progress: 30, myStatus: '在看', addedDate: '2025-01-10' },
      // ... 在看4部 + 想看3部 + 看完3部 = 10条数据
    ];
  }
}

数据分布策略

  • “在看”(4部):正在追的剧,progress 13%-44%不等
  • “想看”(3部):还没开始看,progress均为0%
  • “看完”(3部):已看完,progress均为100%

1.4 Tab过滤

getFilteredDramas(): MyDrama[] {
  return this.myDramas.filter((item: MyDrama) => item.myStatus === this.currentTab);
}

与搜索页的filter对比

  • 搜索页:从全量数据中多次过滤(关键词 + 分类 + 状态)
  • 我的追剧页:按Tab精确匹配 myStatus 字段,一次过滤

1.5 顶部导航栏

@Builder buildHeader() {
  Column() {
    Row() {
      Text('←').fontSize(20).fontColor('#333333')
        .onClick(() => { router.back(); })
      Blank()
      Text('我的追剧').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#1A1A2E')
      Blank()
      Text('管理').fontSize(14).fontColor('#FF6B35')  // 预留功能入口
    }
    .width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })

    // Tab切换
    Row() {
      ForEach(this.tabs, (tab: string) => {
        Column() {
          Text(tab).fontSize(14)
            .fontColor(this.currentTab === tab ? '#FFFFFF' : '#666666')
            .padding({ left: 20, right: 20, top: 6, bottom: 6 })
            .backgroundColor(this.currentTab === tab ? '#FF6B35' : '#F0F0F0')
            .borderRadius(16)
        }
        .margin({ right: 10 })
        .onClick(() => { this.currentTab = tab; })
      }, (tab: string) => tab)
    }
    .width('100%').padding({ left: 16, top: 8, bottom: 8 })
  }
  .width('100%').backgroundColor('#FFFFFF').padding({ bottom: 8 })
}

Tab样式区别:这里与详情页的Tab样式不同——

  • 详情页Tab:文字 + 下划线指示器(类似浏览器标签)
  • 我的追剧Tab:胶囊形状按钮(类似分类标签)

不同场景选择不同的Tab样式,视觉上给用户明确的区分。

1.6 剧集卡片

@Builder buildDramaCard(item: MyDrama) {
  Row() {
    // 封面占位
    Stack() {
      Column().width(60).height(80).backgroundColor('#E8E8E8').borderRadius(6)
      Text('🎬').fontSize(24)
    }.width(60).height(80)

    Column() {
      Row() {
        Text(item.title).fontSize(15).fontWeight(FontWeight.Medium).fontColor('#1A1A2E')
        Blank()
        Text(item.rating.toString()).fontSize(12)
          .fontColor(item.rating >= 48 ? '#E74C3C' : '#F39C12')
      }.width('100%')

      Text(item.genre).fontSize(12).fontColor('#999999').margin({ top: 4 })

      Row() {
        Text(item.status).fontSize(11).fontColor(Color.White)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .backgroundColor(this.getStatusColor(item.status)).borderRadius(6)
        Text(`更新至${item.watchedEpisodes}`).fontSize(11).fontColor('#BBBBBB').margin({ left: 8 })
      }.margin({ top: 4 })

      // 进度条 + 百分比
      Row() {
        Progress({ value: item.progress, total: 100, style: ProgressStyle.Linear })
          .width('70%').height(4).color('#FF6B35').backgroundColor('#F0F0F0').borderRadius(2)
        Text(`${item.progress}%`).fontSize(11).fontColor('#FF6B35').margin({ left: 6 })
      }.width('100%').margin({ top: 4 })
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 10 })
  }
  .width('100%').padding(12).backgroundColor('#FFFFFF').borderRadius(10).margin({ top: 8 })
  .onClick(() => {
    router.pushUrl({ url: 'pages/DetailPage', params: { dramaId: item.id } });
  })
}

与搜索页卡片的区别

  • 搜索页:显示年份、导演、主演等完整信息
  • 我的追剧:突出进度条和已看集数,更关注追剧进度

1.7 空状态设计(重要!)

@Builder buildEmptyState() {
  Column() {
    Text('📺').fontSize(48)
    Text('暂无剧集').fontSize(16).fontColor('#999999').margin({ top: 12 })
    Text('去首页或搜索添加你想追的剧吧').fontSize(13).fontColor('#CCCCCC').margin({ top: 8 })

    Row() {
      Text('去首页').fontSize(14).fontColor(Color.White)
        .padding({ left: 24, right: 24, top: 8, bottom: 8 })
        .backgroundColor('#FF6B35').borderRadius(18)
        .onClick(() => { router.pushUrl({ url: 'pages/Index' }); })

      Text('去搜索').fontSize(14).fontColor(Color.White)
        .padding({ left: 24, right: 24, top: 8, bottom: 8 })
        .backgroundColor('#3498DB').borderRadius(18).margin({ left: 12 })
        .onClick(() => { router.pushUrl({ url: 'pages/SearchPage' }); })
    }.margin({ top: 20 })
  }
  .width('100%').padding({ top: 60 }).alignItems(HorizontalAlign.Center)
}

空状态的三要素

  1. 图标:友好的Emoji图标,缓解用户"无数据"的失落感
  2. 文案:说明 + 引导,“暂无剧集"→"去首页或搜索添加你想追的剧吧”
  3. 操作按钮:提供直接跳转的入口,降低用户操作路径

何时显示空状态

if (this.getFilteredDramas().length === 0) {
  this.buildEmptyState()      // 当前Tab没有数据
} else {
  // 显示卡片列表
}

这里的"空状态"不是真的没有数据,而是当前Tab没有匹配的剧集。比如"想看"Tab下没有数据时,显示空状态引导用户去搜索。

1.8 快速操作区

@Builder buildStatusActions() {
  Column() {
    Text('快速操作').fontSize(15).fontWeight(FontWeight.Bold)
      .fontColor('#1A1A2E').width('100%').padding({ left: 16, top: 12 })

    Row() {
      Column() {
        Text('📥').fontSize(24); Text('批量导入').fontSize(11).fontColor('#666666').margin({ top: 4 })
      }.layoutWeight(1).alignItems(HorizontalAlign.Center)

      Column() {
        Text('📤').fontSize(24); Text('导出列表').fontSize(11).fontColor('#666666').margin({ top: 4 })
      }.layoutWeight(1).alignItems(HorizontalAlign.Center)

      Column() {
        Text('🔄').fontSize(24); Text('排序').fontSize(11).fontColor('#666666').margin({ top: 4 })
      }.layoutWeight(1).alignItems(HorizontalAlign.Center)

      Column() {
        Text('📊').fontSize(24); Text('统计').fontSize(11).fontColor('#666666').margin({ top: 4 })
      }.layoutWeight(1).alignItems(HorizontalAlign.Center)
      .onClick(() => { router.pushUrl({ url: 'pages/StatsPage' }); })
    }
    .width('100%').padding({ left: 16, right: 16, top: 12, bottom: 12 })
  }
  .width('100%').backgroundColor('#FFFFFF').borderRadius(10)
  .margin({ top: 12, left: 16, right: 16 })
}

这个区域使用 Emoji 作为图标,布局均匀分布。目前是静态展示,后续可以对接实际功能。

1.9 主布局

build(): void {
  Column() {
    this.buildHeader()

    Scroll() {
      Column() {
        if (this.getFilteredDramas().length === 0) {
          this.buildEmptyState()
        } else {
          Text(`${this.getFilteredDramas().length}`)
            .fontSize(12).fontColor('#999999')
            .width('100%').padding({ left: 16, top: 12 })

          ForEach(this.getFilteredDramas(), (item: MyDrama) => {
            this.buildDramaCard(item)
          }, (item: MyDrama) => item.id.toString() + item.myStatus)
        }

        this.buildStatusActions()
      }
      .width('100%').padding({ bottom: 20 })
    }
    .scrollable(ScrollDirection.Vertical)
    .layoutWeight(1).width('100%')
    .backgroundColor('#F5F5F5')
  }
  .width('100%').height('100%')
}

ForEach的key设计

(item: MyDrama) => item.id.toString() + item.myStatus

这里的key不仅包含 id,还拼接了 myStatus。原因:当用户切换Tab时,同一部剧出现在不同Tab中,加上 myStatus 后缀确保key在不同Tab中唯一。如果只用 id,切换Tab时Diff算法可能错误地复用列表项。


二、统计页(StatsPage)完整实现

2.1 页面功能分析

StatsPage
├── 顶部导航栏:返回 + 标题"我的追剧报告" + 分享按钮
├── 统计概览卡片(4个指标):累计追剧 / 总集数 / 总时长 / 完成率
├── 追剧进度总览:环形图 + 在看/看完/想看数量
├── 类型分布图:带进度条的类型占比
├── 月度追剧趋势:柱状图
├── 成就徽章:已解锁/未解锁徽章
└── 底部语录:追剧是一种生活方式

2.2 数据结构

interface StatsItem {
  label: string;        // 标签名(如"累计追剧")
  value: string;        // 数值(如"8")
  unit: string;         // 单位(如"部")
  color: ResourceStr;   // 颜色
}

interface GenreStat {
  name: string;         // 类型名
  count: number;        // 数量
  percent: number;      // 百分比
  color: ResourceStr;   // 颜色
}

interface Badge {
  icon: string;         // 徽章图标
  title: string;        // 徽章名
  desc: string;         // 描述
  unlocked: boolean;    // 是否已解锁
}

interface MonthData {
  month: string;        // 月份
  count: number;        // 该月追剧数量
}

2.3 统计数据计算

@Component
struct StatsPage {
  @State statsItems: StatsItem[] = [];
  @State genreStats: GenreStat[] = [];
  @State badges: Badge[] = [];
  @State monthData: MonthData[] = [];
  @State totalHours: number = 0;
  @State completedCount: number = 0;
  @State watchingCount: number = 0;
  @State totalEpisodes: number = 0;

  calculateStats(): void {
    this.totalEpisodes = 342;
    this.completedCount = 3;
    this.watchingCount = 4;
    this.totalHours = 256;

    this.statsItems = [
      { label: '累计追剧', value: '8', unit: '部', color: '#FF6B35' },
      { label: '总集数', value: this.totalEpisodes.toString(), unit: '集', color: '#3498DB' },
      { label: '总时长', value: this.totalHours.toString(), unit: '小时', color: '#2ECC71' },
      { label: '完成率', value: '37.5', unit: '%', color: '#9B59B6' }
    ];

    const colors: ResourceStr[] = ['#FF6B35', '#3498DB', '#2ECC71', '#E74C3C',
      '#F39C12', '#9B59B6', '#1ABC9C', '#E91E63'];
    const genreNames: string[] = ['古装', '都市', '悬疑', '青春', '科幻', '年代', '爱情', '其他'];
    const counts: number[] = [3, 2, 1, 1, 1, 1, 1, 1];
    const total: number = 11;

    this.genreStats = [];
    for (let i: number = 0; i < genreNames.length; i++) {
      this.genreStats.push({
        name: genreNames[i],
        count: counts[i],
        percent: Math.round((counts[i] / total) * 100),
        color: colors[i]
      });
    }

    this.badges = [
      { icon: '🏆', title: '追剧达人', desc: '累计追剧超过5部', unlocked: true },
      { icon: '⏰', title: '夜猫子', desc: '凌晨追剧超过10次', unlocked: true },
      { icon: '🎯', title: '全勤王', desc: '完整看完3部剧', unlocked: true },
      { icon: '🔥', title: '追更先锋', desc: '追5部以上连载剧', unlocked: false }
    ];

    this.monthData = [
      { month: '11月', count: 2 },
      { month: '12月', count: 3 },
      { month: '1月', count: 3 }
    ];
  }
}

统计数据的计算方式

  • 当前为静态模拟数据
  • 后续可从后端API获取
  • 也可在客户端根据追剧列表实时计算

2.4 统计概览卡片

@Builder buildStatsCards() {
  Column() {
    Row() {
      ForEach(this.statsItems, (item: StatsItem, index?: number) => {
        Column() {
          Text(item.value).fontSize(22).fontWeight(FontWeight.Bold)
            .fontColor(item.color as string)
          Text(`${item.label}(${item.unit})`).fontSize(11)
            .fontColor('#999999').margin({ top: 4 })
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
        .padding({ top: 8, bottom: 8 })
      }, (item: StatsItem) => item.label)
    }.width('100%')
  }
  .width('100%').padding(12).backgroundColor('#FFFFFF')
  .borderRadius(10).margin({ top: 12, left: 16, right: 16 })
}

四个指标等分宽度,每个指标大号数字 + 小字标签。item.color as string 是类型断言,因为 ResourceStr 在用作fontColor时需要转换。

2.5 追剧进度总览

@Builder buildProgressCard() {
  Column() {
    Text('追剧进度总览').fontSize(15).fontWeight(FontWeight.Bold)
      .fontColor('#1A1A2E').width('100%')

    Row() {
      // 左侧:仿环形图(使用Stack叠加)
      Stack() {
        Column().width(80).height(80).borderRadius(40)
          .backgroundColor('#F0F0F0')
        Column() {
          Text('8').fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FF6B35')
          Text('部剧').fontSize(11).fontColor('#999999')
        }
      }.width(80).height(80)

      // 右侧:在看/已完结/想看数量
      Column() {
        Row() {
          Column().width(10).height(10).borderRadius(5).backgroundColor('#FF6B35')
          Text(` 在看 ${this.watchingCount}`).fontSize(13).fontColor('#333333')
          Blank()
        }.width('100%').margin({ top: 6 })

        Row() {
          Column().width(10).height(10).borderRadius(5).backgroundColor('#2ECC71')
          Text(` 已完结 ${this.completedCount}`).fontSize(13).fontColor('#333333')
          Blank()
        }.width('100%').margin({ top: 6 })

        Row() {
          Column().width(10).height(10).borderRadius(5).backgroundColor('#3498DB')
          Text(` 想看 1部`).fontSize(13).fontColor('#333333')
          Blank()
        }.width('100%').margin({ top: 6 })
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 16 })
    }.width('100%').margin({ top: 12 })
  }
  .width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(10)
  .margin({ top: 12, left: 16, right: 16 }).alignItems(HorizontalAlign.Start)
}

仿环形图实现

  • 80x80的圆形灰色背景
  • 中心显示"8部剧"
  • 可以用 Circle 组件替代 Column 实现真正环形进度条

右侧图例说明:三个小圆点 + 文字组成图例,分别对应三种状态,颜色与主色调保持一致。

2.6 类型分布条状图

@Builder buildGenreChart() {
  Column() {
    Text('类型分布').fontSize(15).fontWeight(FontWeight.Bold)
      .fontColor('#1A1A2E').width('100%')

    ForEach(this.genreStats, (item: GenreStat) => {
      Row() {
        // 圆点图例
        Column().width(10).height(10).borderRadius(5).backgroundColor(item.color as string)
        Text(item.name).fontSize(13).fontColor('#333333').margin({ left: 8 }).width(40)

        // 进度条
        Stack() {
          Column().width('100%').height(14).backgroundColor('#F0F0F0').borderRadius(7)
          Column().width(`${item.percent}%`).height(14)
            .backgroundColor(item.color as string).borderRadius(7)
            .alignSelf(ItemAlign.Start)  // 从左向右填充
        }
        .layoutWeight(1).margin({ left: 8, right: 8 })

        // 右侧数值
        Text(`${item.count}`).fontSize(12).fontColor('#999999')
          .width(30).textAlign(TextAlign.End)
      }
      .width('100%').margin({ top: 8 })
    }, (item: GenreStat) => item.name)
  }
  .width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(10)
  .margin({ top: 12, left: 16, right: 16 }).alignItems(HorizontalAlign.Start)
}

水平条状图的实现原理

Stack (100%宽度)
  ├── Column (100%, #F0F0F0) ← 背景条
  └── Column (item.percent%, item.color) ← 前景填充条
        └── alignSelf(ItemAlign.Start) 确保从左开始

每一行由四个元素构成:圆点图例 → 类型名 → 进度条 → 数值。八种类型从上到下排列,颜色各不相同,整体形成一个色彩丰富的类型分布图。

2.7 月度追剧趋势(柱状图)

@Builder buildMonthlyChart() {
  Column() {
    Text('月度追剧趋势').fontSize(15).fontWeight(FontWeight.Bold)
      .fontColor('#1A1A2E').width('100%')

    Row() {
      ForEach(this.monthData, (month: MonthData) => {
        Column() {
          Text(month.month).fontSize(12).fontColor('#999999')

          // 柱状条:高度 = count × 20
          Column().width(30).height(month.count * 20)
            .backgroundColor('#FF6B35').borderRadius(4).margin({ top: 8 })

          Text(`${month.count}`).fontSize(12).fontColor('#FF6B35')
            .fontWeight(FontWeight.Medium).margin({ top: 4 })
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
      }, (month: MonthData) => month.month)
    }
    .width('100%').height(120)
    .alignItems(VerticalAlign.Bottom)  // 柱状图从底部对齐
    .margin({ top: 12 })
  }
  .width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(10)
  .margin({ top: 12, left: 16, right: 16 }).alignItems(HorizontalAlign.Start)
}

柱状图实现要点

  • alignItems(VerticalAlign.Bottom) 让所有柱子底部对齐
  • 柱子高度 month.count * 20 是简单线性映射
  • 每个月三行:月份标签 → 柱状条 → 数值标签

这是最简单直观的柱状图实现,实际项目中可以用 Canvas 组件绘制更复杂的图表。

2.8 成就徽章系统

@Builder buildBadgesSection() {
  Column() {
    Text('成就徽章').fontSize(15).fontWeight(FontWeight.Bold)
      .fontColor('#1A1A2E').width('100%')

    Row() {
      ForEach(this.badges, (badge: Badge) => {
        Column() {
          Text(badge.icon).fontSize(28)
            .grayscale(badge.unlocked ? 0 : 1)  // 未解锁变灰
          Text(badge.title).fontSize(11)
            .fontColor(badge.unlocked ? '#333333' : '#CCCCCC')
            .margin({ top: 4 })
          Text(badge.desc).fontSize(9)
            .fontColor(badge.unlocked ? '#999999' : '#DDDDDD')
            .margin({ top: 2 })
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
      }, (badge: Badge) => badge.title)
    }
    .width('100%').margin({ top: 12 })
  }
  .width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(10)
  .margin({ top: 12, left: 16, right: 16 }).alignItems(HorizontalAlign.Start)
}

徽章系统的视觉设计

  • 已解锁:彩色图标 + 黑色标题 + 灰色描述
  • 未解锁:灰度图标(grayscale(1))+ 浅灰标题 + 浅灰描述

grayscale 滤镜:ArkTS的Image和Text组件支持 grayscale 属性,设置1时完全变灰,设置0时恢复原色。这是实现"锁定/解锁"视觉差异的快捷方式。

2.9 底部语录

@Builder buildQuoteSection() {
  Column() {
    Text('🎬').fontSize(32)
    Text('追剧是一种生活方式').fontSize(14).fontColor('#666666').margin({ top: 8 })
    Text('每一个故事都是一段旅程').fontSize(12).fontColor('#999999').margin({ top: 4 })
  }
  .width('100%').padding(20).backgroundColor('#FFFFFF').borderRadius(10)
  .margin({ top: 12, left: 16, right: 16 }).alignItems(HorizontalAlign.Center)
}

这个区域增加了一些人文气息,让统计页不是冷冰冰的数据展示,而是一种有温度的记录。

2.10 主布局

build(): void {
  Column() {
    this.buildHeader()
    Scroll() {
      Column() {
        this.buildStatsCards()
        this.buildProgressCard()
        this.buildGenreChart()
        this.buildMonthlyChart()
        this.buildBadgesSection()
        this.buildQuoteSection()
      }
      .width('100%').padding({ bottom: 30 })
    }
    .scrollable(ScrollDirection.Vertical)
    .layoutWeight(1).width('100%')
    .backgroundColor('#F5F5F5')
  }
  .width('100%').height('100%')
}

六个区块垂直排列在一个Scroll中,用户上下滑动浏览全部统计数据。每个区块用白底 + 圆角卡片独立展示,视觉清晰。


三、ArkTS状态管理的更多思考

3.1 @State vs 普通变量

特性 @State 普通变量
触发UI刷新 ✅ 是 ❌ 否
初始化时机 aboutToAppear时 声明时/构造函数
适用场景 UI直接依赖的数据 内部计算、常量

3.2 数组变更的刷新机制

// ✅ 直接修改数组元素(能触发刷新)
this.myDramas[i].myStatus = '看完';

// ✅ 重新赋值整个数组(能触发刷新)
this.myDramas = [...this.myDramas];

// ❌ 数组方法如 push 可能不触发刷新
this.myDramas.push(newItem);  // 部分版本不支持触发刷新

最佳实践:修改数组后重新赋值,确保UI刷新。

3.3 条件渲染的选择

// 方式一:if/else
if (condition) { buildA() } else { buildB() }

// 方式二:visibility
Column().visibility(condition ? Visibility.Visible : Visibility.None)

选择建议

  • if/else:组件完全不同,切换时完全重建/销毁
  • visibility:组件结构相同,仅切换显隐
  • 列表页的空状态建议用 if/else,因为内容差异大

四、设计思路总结

4.1 我的追剧页的设计目标

  1. 三态管理:帮助用户分类管理所有剧集
  2. 进度可视化:每个卡片都显示进度条,一目了然
  3. 操作引导:空状态时主动引导用户去首页或搜索
  4. 扩展性:快速操作区预留了未来功能扩展位

4.2 统计页的设计目标

  1. 数据仪表盘:核心数据集中展示,快速了解整体情况
  2. 多维分析:从数量、时长、类型、月度趋势多角度分析
  3. 成就驱动:徽章系统激励用户持续使用
  4. 情感化设计:底部语录让数据更有温度

在这里插入图片描述

五、篇末总结

本篇我们完成了最后两个页面,核心内容包括:

  1. ✅ @State状态驱动的三Tab列表管理
  2. ✅ 空状态的三要素设计(图标 + 文案 + 操作按钮)
  3. ✅ ForEach key的唯一性设计
  4. ✅ 统计页的六个数据区块构建
  5. ✅ 水平条状图与柱状图的自定义实现
  6. ✅ 成就徽章系统的grayscale灰度控制
  7. ✅ @State状态管理的最佳实践

下一篇是本系列的完结篇,将讲解:

  • hvigor构建命令的使用
  • 严格模式下的常见编译错误
  • 代码混淆配置
  • 从开发到上架的完整流程

文章索引:

Logo

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

更多推荐