在这里插入图片描述

上图是「猫猫大作战」成就殿堂页面在 HarmonyOS 模拟器上的真实效果:顶部导航栏、成就进度卡片(含进度环)、「全部 / 已解锁 / 未解锁」标签栏,以及成就卡片列表。本文完整拆解它的实现。

前言

成就系统是游戏类应用的留存神器:它把「得分」「连击」「进化」「局数」等行为数据翻译成看得见的里程碑,激励玩家持续游玩。在「猫猫大作战」里,成就殿堂页面(AchievementPage.ets)承担了这一职责,它同时用到了 Circle 描边画进度环List 复用成就卡片@Builder 封装卡片标签栏条件样式自定义 Toast分享面板

本篇围绕这个真实页面,讲清楚五件事:

  1. 成就数据模型与 AchievementManager 单例怎么设计;
  2. Circle 组件如何用 strokeDashArray + strokeDashOffset 画出进度环;
  3. 成就卡片用 @Builder 怎么做到一处定义、N 处复用;
  4. 三标签筛选 + 自定义 Toast + 分享面板的完整交互;
  5. 成就解锁状态如何与 preferences 持久化闭环。

提示:本文假设你已经掌握 ArkTS 基础语法与 @State@Builder 等装饰器用法,不再重复环境搭建。项目源码位于 dazhuozha/entry/src/main/ets/pages/AchievementPage.etscomponents/AchievementManager.ets

一、成就系统的功能拆解

1.1 页面职责与五层结构

成就殿堂页面从上到下分为五层:

层级区域用到的组件/特性
第 1 层顶部导航栏(返回 + 标题 + 分享)RowButtonBlank.shadow
第 2 层成就进度卡片(解锁数 + 进度环)CirclestrokeDashArraystrokeDashOffset
第 3 层标签栏(全部 / 已解锁 / 未解锁)ForEachDivider、条件样式
第 4 层成就列表ListListItem@Builder 卡片
第 5 层Toast 与分享面板(浮层)StacktransitionanimateTo
build() {
  Stack({ alignContent: Alignment.Bottom }) {
    // 主内容
    Column() {
      // 第 1 层:顶部导航栏
      Row() { /* 返回 + 标题 + 分享 */ }

      // 第 2 层:成就进度卡片
      Column() { /* 🏆 计数 + 进度环 */ }

      // 第 3 层:标签栏
      Row() { /* ForEach 三个标签 */ }

      // 第 4 层:成就列表
      List({ space: 8 }) { ForEach(...) }
    }

    // 第 5 层:浮层
    if (this.toastData.visible) { this.ToastComponent() }
    if (this.showShareDialog) { this.ShareSheet() }
  }
}

提示:页面根容器是 Stack({ alignContent: Alignment.Bottom })——主内容填满全屏,Toast 和分享面板作为浮层叠在上面。Stack 是 ArkUI 叠层布局的唯一选择,本系列第 9 篇讲过它的 9 位对齐与 zIndex。

1.2 数据从哪里来:AchievementManager 单例

成就数据由 AchievementManager 单例管理,页面只做渲染:

import { AchievementManager, Achievement, AchievementType } from '../components/AchievementManager';
import { DataStorage } from '../components/DataStorage';

@Entry
@Component
struct AchievementPage {
  @State achievements: Achievement[] = [];
  @State unlockedCount: number = 0;
  @State totalCount: number = 0;
  @State selectedTab: number = 0; // 0: 全部, 1: 已解锁, 2: 未解锁
  // ...

  private achievementManager = AchievementManager.getInstance();
  // ...
}

加载流程在 aboutToAppear 里完成:初始化存储 → 读统计 → 恢复成就 → 刷新 UI

async aboutToAppear() {
  this.shareManager.setContext(getContext(this) as Context);

  // 加载成就数据
  const storage = DataStorage.getInstance();
  await storage.init(getContext(this) as Context);

  const stats = await storage.getStats();
  this.achievementManager.loadAchievements(stats.achievements);

  this.achievements = this.achievementManager.getAllAchievements();
  this.unlockedCount = this.achievements.filter(a => a.unlocked).length;
  this.totalCount = this.achievements.length;
}

stats.achievements 是已解锁成就的 id 字符串数组,loadAchievements 把它们回填到单例里。页面本身不修改成就状态,只负责展示——职责边界清晰。

二、成就数据模型与单例设计

2.1 成就类型枚举

成就一共有 14 个,用枚举定义 id:

// 成就类型
export enum AchievementType {
  SCORE_1000 = 'score_1000',       // 得分达到1000
  SCORE_5000 = 'score_5000',       // 得分达到5000
  SCORE_10000 = 'score_10000',     // 得分达到10000
  COMBO_3 = 'combo_3',             // 连击3次
  COMBO_5 = 'combo_5',             // 连击5次
  COMBO_10 = 'combo_10',           // 连击10次
  LEVEL_MEDIUM = 'level_medium',   // 合并出中猫
  LEVEL_LARGE = 'level_large',     // 合并出大猫
  LEVEL_XLARGE = 'level_xlarge',   // 合并出特大猫
  LEVEL_SUPER = 'level_super',     // 合并出超级猫
  LEVEL_LEGENDARY = 'level_legendary', // 合并出传奇猫
  GAMES_10 = 'games_10',           // 游戏10局
  GAMES_50 = 'games_50',           // 游戏50局
  GAMES_100 = 'games_100',         // 游戏100局
}

枚举值用小写短横线字符串,好处是直接可作存储 key、可序列化,不会因数值变化破坏历史数据。

2.2 成就接口与配置表

// 成就定义
export interface Achievement {
  id: AchievementType;
  name: string;
  description: string;
  icon: string;
  unlocked: boolean;
  unlockTime?: number;
}

// 成就配置
export const AchievementConfigs: Record<string, Achievement> = {
  [AchievementType.SCORE_1000]: {
    id: AchievementType.SCORE_1000,
    name: '初露锋芒',
    description: '单局得分达到1000分',
    icon: '🌟',
    unlocked: false
  },
  // ... 其余 13 个
};

14 个成就全部以声明式配置集中维护,新增成就只需在 AchievementConfigs 加一项 + 在检查函数加一条判断。unlocked 初始为 false,实际状态从持久化恢复。

2.3 单例构造与解锁回调

export class AchievementManager {
  private static instance: AchievementManager;
  private achievements: Map<AchievementType, Achievement> = new Map();
  private onAchievementUnlocked?: (achievement: Achievement) => void;

  static getInstance(): AchievementManager {
    if (!AchievementManager.instance) {
      AchievementManager.instance = new AchievementManager();
    }
    return AchievementManager.instance;
  }

  constructor() {
    Object.values(AchievementConfigs).forEach(config => {
      const achievement: Achievement = {
        id: config.id,
        name: config.name,
        description: config.description,
        icon: config.icon,
        unlocked: false
      };
      this.achievements.set(config.id, achievement);
    });
  }

  // 设置成就解锁回调
  setUnlockCallback(callback: (achievement: Achievement) => void): void {
    this.onAchievementUnlocked = callback;
  }
  // ...
}

Map<AchievementType, Achievement> 保证 O(1) 查询setUnlockCallback 让游戏页可以订阅「成就解锁」事件弹出动画。

三、Circle 进度环的实现

3.1 进度环的视觉结构

进度卡片右侧是一个圆环,显示解锁百分比。它由两个圆环叠成:灰色底环 + 蓝色进度环:

Stack() {
  // 背景圆环
  Circle()
    .width(80)
    .height(80)
    .fill(Color.Transparent)
    .stroke('#E8E8E8')
    .strokeWidth(8)

  // 进度圆环
  Circle()
    .width(80)
    .height(80)
    .fill(Color.Transparent)
    .stroke('#3498DB')
    .strokeWidth(8)
    .strokeDashArray([Math.PI * 80])
    .strokeDashOffset(Math.PI * 80 * (1 - this.unlockedCount / this.totalCount))

  // 百分比文字
  Text(`${Math.round(this.unlockedCount / this.totalCount * 100)}%`)
    .fontSize(16)
    .fontWeight(FontWeight.Bold)
    .fontColor('#3498DB')
}
.layoutWeight(1)

提示:Circle 组件默认是实心圆,画「环」必须配合 fill(Color.Transparent)(透明填充)+ stroke()(描边色)+ strokeWidth()(描边粗细)。

3.2 strokeDashArray 与 strokeDashOffset 的数学原理

进度环的精髓在两条属性:

属性作用本项目取值
strokeDashArray定义「画一段、空一段」的虚线模式[Math.PI * 80]
strokeDashOffset虚线起始偏移量,负值逆时针回退Math.PI * 80 * (1 - 进度)

推导过程:

  1. 圆环周长 = π × 直径 = π × 80
  2. strokeDashArray([π × 80]) 让「实线段 = 整圆周长」,即先画一个完整的圆;
  3. strokeDashOffset(π × 80 × (1 - 进度)) 把起点往后偏移,剩余可见的实线长度 = 周长 - 偏移 = 周长 × 进度
  4. 进度 = 43% 时,可见弧长 = 整圆的 43%,看起来就是一个「环形进度条」。
// 进度环数学:可见弧长 = 周长 × 进度
const circumference = Math.PI * 80;                    // 周长
const progress = this.unlockedCount / this.totalCount; // 0~1
// strokeDashArray = [circumference]
// strokeDashOffset = circumference * (1 - progress)

关键点strokeDashOffset 的单位与周长一致(都是 vp),偏移量越大,可见弧越短。这个公式可以直接复用到任何 Circle 进度环。

四、成就卡片:@Builder 封装

4.1 卡片结构

每个成就是一张卡片,用 @Builder 封装成 AchievementCard

@Builder
AchievementCard(achievement: Achievement) {
  Row() {
    // 成就图标(圆形底 + Emoji)
    Column() {
      Text(achievement.icon).fontSize(32)
    }
    .width(60).height(60)
    .backgroundColor(achievement.unlocked ? '#F0F9FF' : '#F5F5F5')
    .borderRadius(30)
    .justifyContent(FlexAlign.Center)

    // 成就信息
    Column() {
      Row() {
        Text(achievement.name).fontSize(16).fontWeight(FontWeight.Bold)
        if (achievement.unlocked) {
          Text('✓').fontSize(14).fontColor('#2ECC71').margin({ left: 8 })
        }
      }
      Text(achievement.description).fontSize(12).fontColor('#7F8C8D').margin({ top: 4 })
      if (achievement.unlocked && achievement.unlockTime) {
        Text(`解锁时间: ${this.formatTime(achievement.unlockTime)}`)
          .fontSize(10).fontColor('#95A5A6').margin({ top: 4 })
      }
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 16 })

    // 右侧状态:✓ 或 🔒
    if (achievement.unlocked) {
      Text('✓').fontSize(24).fontColor('#2ECC71')
    } else {
      Text('🔒').fontSize(24)
    }
  }
  .width('100%').padding(16)
  .backgroundColor('#FFFFFF').borderRadius(12)
  .margin({ top: 8 })
  .shadow({ radius: 2, color: 'rgba(0,0,0,0.05)', offsetY: 1 })
}

卡片内部有三处已解锁 / 未解锁的双态展示:

  1. 图标底色:解锁 #F0F9FF(浅蓝)vs 未解锁 #F5F5F5(浅灰);
  2. 名称旁:解锁显示绿色
  3. 右侧状态:解锁 vs 未解锁 🔒

4.2 列表复用

列表用 List + ForEach + AchievementCard 组合:

List({ space: 8 }) {
  ForEach(this.getFilteredAchievements(), (achievement: Achievement) => {
    ListItem() {
      this.AchievementCard(achievement)
    }
  })
}
.width('100%')
.layoutWeight(1)
.padding({ left: 16, right: 16, top: 8, bottom: 16 })

提示:@Builder 方法(this.AchievementCard(achievement))在 ListItem 里被反复调用,卡片内部无 @State、纯展示,性能开销极低。如果卡片数量继续膨胀,可升级为 @Reusable(本系列第 67 篇)。

五、标签栏:全部 / 已解锁 / 未解锁

5.1 ForEach 动态标签

三个标签由一个数组生成,选中态用 selectedTab === index 判断:

Row() {
  ForEach(['全部', '已解锁', '未解锁'], (tab: string, index: number) => {
    Column() {
      Text(tab)
        .fontSize(14)
        .fontColor(this.selectedTab === index ? '#3498DB' : '#7F8C8D')
        .fontWeight(this.selectedTab === index ? FontWeight.Bold : FontWeight.Normal)

      // 选中态下划线
      if (this.selectedTab === index) {
        Divider()
          .width(20).height(2).color('#3498DB')
          .margin({ top: 4 })
      }
    }
    .layoutWeight(1)
    .onClick(() => {
      this.selectedTab = index;
    })
  })
}
.width('100%').height(48)
.backgroundColor('#FFFFFF')
.margin({ top: 16, left: 16, right: 16 })
.borderRadius(12)

两个细节值得注意:

  1. 下划线用 Divider 条件渲染:选中才出现,未选中直接 if 掉,不占位;
  2. 点击切换 selectedTab:状态一改,标签样式和列表内容同时更新。

5.2 筛选逻辑

getFilteredAchievements(): Achievement[] {
  if (this.selectedTab === 0) {
    return this.achievements;                          // 全部
  } else if (this.selectedTab === 1) {
    return this.achievements.filter(a => a.unlocked);  // 已解锁
  } else {
    return this.achievements.filter(a => !a.unlocked); // 未解锁
  }
}

筛选是派生数据,不是状态——每次渲染时根据 selectedTab 现算,干净无副作用。

六、解锁检查逻辑:AchievementManager 的四个 check

游戏页在合适的时机调用检查函数,命中即解锁。以得分成就为例:

// 检查得分成就
checkScoreAchievement(score: number): Achievement[] {
  const unlocked: Achievement[] = [];

  if (score >= 1000) {
    const achievement = this.unlock(AchievementType.SCORE_1000);
    if (achievement) unlocked.push(achievement);
  }
  if (score >= 5000) {
    const achievement = this.unlock(AchievementType.SCORE_5000);
    if (achievement) unlocked.push(achievement);
  }
  if (score >= 10000) {
    const achievement = this.unlock(AchievementType.SCORE_10000);
    if (achievement) unlocked.push(achievement);
  }

  return unlocked;
}

四个检查函数覆盖四类行为:

函数触发时机示例成就
checkScoreAchievement(score)单局结束初露锋芒 / 得分大师
checkComboAchievement(combo)连击结算连击新手 / 连击大师
checkLevelAchievement(level)合并升级猫咪进化 / 传奇诞生
checkGamesAchievement(totalGames)局数累计游戏新手 / 游戏大师

核心的 unlockprivate 方法,保证只有检查函数能改状态:

// 解锁成就
private unlock(type: AchievementType): Achievement | null {
  const achievement = this.achievements.get(type);
  if (achievement && !achievement.unlocked) {
    achievement.unlocked = true;
    achievement.unlockTime = Date.now();

    // 触发回调
    if (this.onAchievementUnlocked) {
      this.onAchievementUnlocked(achievement);
    }

    return achievement;
  }
  return null;
}

unlock 的幂等保护很关键:已解锁的成就不会二次触发回调,避免连击结算时反复弹动画。

七、自定义 Toast 组件

页面没有用系统 promptAction.showToast,而是手写了一个带状态色的 Toast,支持成功/失败/信息三种类型:

// Toast提示类型
enum ToastType {
  SUCCESS = 'success',
  ERROR = 'error',
  INFO = 'info'
}

// Toast提示数据
interface ToastData {
  message: string;
  type: ToastType;
  visible: boolean;
}

渲染组件:

@Builder
ToastComponent() {
  Row() {
    Row() {
      // 图标:✓ / ✗ / ℹ
      Text(this.toastData.type === ToastType.SUCCESS ? '✓' :
           this.toastData.type === ToastType.ERROR ? '✗' : 'ℹ')
        .fontSize(18).fontColor(Color.White).margin({ right: 8 })

      // 消息
      Text(this.toastData.message).fontSize(14).fontColor(Color.White)
    }
    .padding({ left: 16, right: 16, top: 12, bottom: 12 })
    .backgroundColor(
      this.toastData.type === ToastType.SUCCESS ? '#2ECC71' :
      this.toastData.type === ToastType.ERROR ? '#E74C3C' : '#3498DB'
    )
    .borderRadius(24)
    .shadow({ radius: 8, color: 'rgba(0,0,0,0.2)', offsetY: 4 })
  }
  .width('100%')
  .justifyContent(FlexAlign.Center)
  .margin({ bottom: 100 })
  .transition(TransitionEffect.OPACITY.animation({ duration: 300 }))
}

显示与自动隐藏用定时器控制:

private showToast(message: string, type: ToastType = ToastType.INFO) {
  this.toastData = { message, type, visible: true };

  // 清除之前的定时器
  if (this.toastTimer !== -1) {
    clearTimeout(this.toastTimer);
  }

  // 2秒后自动隐藏
  this.toastTimer = setTimeout(() => {
    this.toastData.visible = false;
  }, 2000);
}

提示:TransitionEffect.OPACITY.animation({ duration: 300 }) 给 Toast 加了 300ms 的淡入淡出。if (this.toastData.visible) 为 true 时组件出现带入场动画,为 false 时离场。手动 Toast 相比系统 Toast 的优势是可控的样式与动画

八、分享面板:ShareSheet

8.1 面板结构

分享面板是底部弹出的半屏面板,包含平台图标按钮和取消按钮:

@Builder
ShareSheet() {
  Column() {
    // 遮罩层(点击关闭)
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.4)')
      .onClick(() => { this.hideShareSheet(); })
  }
  .width('100%').height('100%')
  .position({ x: 0, y: 0 })

  // 分享对话框
  Column() {
    // 拖动指示器
    Row().width(40).height(4).backgroundColor('#D0D0D0').borderRadius(2)

    // 标题
    Text('分享成就').fontSize(20).fontWeight(FontWeight.Bold)
    Text(`我在猫猫大作战中已解锁 ${this.unlockedCount}/${this.totalCount} 个成就!`)
      .fontSize(14).fontColor('#7F8C8D').margin({ top: 8 })

    // 平台图标(两行布局)
    Row() {
      ForEach(getSharePlatforms().slice(0, 3), (platformInfo: SharePlatformInfo) => {
        this.PlatformButton(platformInfo)
      })
    }
    .width('100%').justifyContent(FlexAlign.SpaceEvenly).margin({ bottom: 16 })

    Row() {
      ForEach(getSharePlatforms().slice(3, 5), (platformInfo: SharePlatformInfo) => {
        this.PlatformButton(platformInfo)
      })
    }
    .width('100%').justifyContent(FlexAlign.SpaceEvenly)

    // 取消按钮
    Button('取消')
      .fontSize(16).fontColor('#7F8C8D')
      .backgroundColor('#F5F5F5').borderRadius(24)
      .width('90%').height(48)
      .margin({ top: 20, bottom: 20 })
      .onClick(() => { this.hideShareSheet(); })
  }
  .width('100%')
  .backgroundColor('#FFFFFF')
  .borderRadius({ topLeft: 24, topRight: 24 })
  .position({ x: 0, y: '100%' })
  .translate({ y: -360 })
  .scale({ x: this.dialogAnimationScale, y: this.dialogAnimationScale })
  .opacity(this.dialogAnimationOpacity)
  .transition(TransitionEffect.OPACITY.animation({ duration: 300 }))
}

8.2 入场/离场动画

面板的弹出与收起用 animateTo 控制缩放与透明度:

// 显示分享对话框(带动画)
private showShareSheet() {
  this.showShareDialog = true;
  animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
    this.dialogAnimationScale = 1;
    this.dialogAnimationOpacity = 1;
  });
}

// 隐藏分享对话框(带动画)
private hideShareSheet() {
  animateTo({ duration: 200, curve: Curve.EaseIn }, () => {
    this.dialogAnimationScale = 0.9;
    this.dialogAnimationOpacity = 0;
  });

  setTimeout(() => {
    this.showShareDialog = false;
    this.dialogAnimationScale = 0;
    this.dialogAnimationOpacity = 0;
  }, 200);
}

8.3 平台按钮与分享动作

@Builder
PlatformButton(platformInfo: SharePlatformInfo) {
  Column() {
    Column() {
      Text(platformInfo.icon).fontSize(32)
    }
    .width(64).height(64)
    .backgroundColor(platformInfo.color + '20') // 添加透明度
    .borderRadius(32)
    .justifyContent(FlexAlign.Center)
    .border({ width: 2, color: platformInfo.color })

    Text(platformInfo.name).fontSize(12).fontColor('#7F8C8D').margin({ top: 8 })
  }
  .onClick(async () => {
    if (this.isSharing) return; // 防止重复点击
    this.isSharing = true;
    // ... 按平台类型调用 shareManager 分享
  })
}

platformInfo.color + '20'颜色透明度后缀技巧#3498DB20 表示 20% 透明度的主题色,用来做图标底色的同色系浅底,视觉统一又省事。

九、成就持久化闭环

成就状态最终要落盘,下次启动才能恢复。存储层在 DataStorage

// 保存统计数据
async saveStats(stats: GameStats): Promise<void> {
  if (!this.preferences) return;
  try {
    const statsData = JSON.stringify(stats);
    await this.preferences.put('gameStats', statsData);
    await this.preferences.flush();
  } catch (error) {
    console.error('Failed to save stats:', error);
  }
}

// 获取统计数据
async getStats(): Promise<GameStats> {
  const defaultStats: GameStats = {
    highScore: 0, totalGames: 0, totalScore: 0,
    maxCombo: 0, highestLevel: CatLevel.SMALL,
    achievements: []
  };
  if (!this.preferences) return defaultStats;
  // ... get + JSON.parse
}

完整闭环:

  1. 游戏页触发 checkScoreAchievement 等检查 → unlock 置位 + 回调弹动画;
  2. 存档时 achievementManager.exportAchievements() 导出已解锁 id 数组 → saveStats 落盘;
  3. 成就页 aboutToAppeargetStats()loadAchievements() 恢复 → 渲染。
// 导出成就数据(存档用)
exportAchievements(): string[] {
  return this.getUnlockedAchievements().map(a => a.id);
}

// 加载成就数据(恢复用)
loadAchievements(data: string[]): void {
  data.forEach(id => {
    const achievement = this.achievements.get(id as AchievementType);
    if (achievement) {
      achievement.unlocked = true;
    }
  });
}

导出是「存 id 数组」,恢复是「按 id 回填」——只持久化最小信息(id),完整配置仍从 AchievementConfigs 读取,避免重复存储。

十、踩坑记录:进度环不显示 / 圆环变形

10.1 忘写 fill(Color.Transparent)

Circle 默认有填充色,直接画进度环会得到一个实心圆

// ❌ 错误:实心圆,进度环效果消失
Circle().width(80).height(80).stroke('#3498DB').strokeWidth(8)

// ✅ 正确:透明填充 + 描边
Circle().width(80).height(80)
  .fill(Color.Transparent)
  .stroke('#3498DB').strokeWidth(8)

10.2 进度永远 0% 或 100%

strokeDashOffset 算错会导致进度环「全空」或「全满」:

// ❌ 常见错误:方向搞反
.strokeDashOffset(Math.PI * 80 * (this.unlockedCount / this.totalCount))
// 进度越大偏移越大 → 可见弧越短 → 效果反了

// ✅ 正确:1 - 进度 才是剩余可见比例
.strokeDashOffset(Math.PI * 80 * (1 - this.unlockedCount / this.totalCount))

提示:调试进度环时先固定 progress = 0.5,期望看到「半圆」。不是半圆就检查 strokeDashArray 是否等于周长、strokeDashOffset 方向是否反了。

十一、性能与最佳实践

  1. Map 存成就、O(1) 查询AchievementManagerMap<AchievementType, Achievement>,14 个成就无所谓,但扩展到上百个时比数组 find 快一个量级。
  2. 纯展示卡片不要放 @StateAchievementCard@Builder 纯函数式渲染,内部无状态,复用成本最低。需要交互再拆成独立 @Component
  3. 筛选用派生函数而不是存副本getFilteredAchievements() 每次现算,绝不维护一个「筛选后数组」的 @State——避免两处数据不同步。
  4. 解锁回调只触发一次unlock 里的 !achievement.unlocked 判断保证幂等,连击结算高频调用也不会重复弹动画。
  5. 持久化只存最小信息:存 id 数组、读配置表,是「配置与状态分离」的典型实践。

总结

本篇以「猫猫大作战」成就殿堂页面为锚点,完整拆解了成就系统的五个层次:数据模型(枚举 + 配置表 + 单例)、进度环Circle + strokeDashArray/strokeDashOffset 的数学推导)、卡片复用@Builder 双态样式)、交互(标签筛选、自定义 Toast、分享面板动画)与持久化闭环(导出 id 数组、恢复回填)。核心方法论:状态只在数据层变更,UI 永远跟随 @State 派生渲染

下一篇预告:我们将继续「猫猫大作战」的页面级实战,拆解设置页面的开关项、语言切换与数据管理——Switch 组件、preferences 设置项封装与多语言资源切换的综合演练。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

Logo

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

更多推荐