HarmonyOS 6 ArkUI V2 实战:水墨启动页的 3 段错峰入场动画与 setInterval 进度条

1、前言

🎉 「古诗学习宝」已上架华为应用市场! 零广告 / 零内购 / 277 首小学必背古诗全收录,专为小学生打造的鸿蒙原生古诗学习工具。

👉 点击下载 / 更新「古诗学习宝」

如果觉得好用,烦请在应用市场帮忙点个五星好评 🌟,您的支持是我持续更新的最大动力!

启动页(Splash Screen)是用户对一款 App 的第一印象。3 秒钟里,要做完 3 件事:

  1. 品牌传达:Logo + 标题 + 一句话价值主张
  2. 入场仪式感:让用户从「我在点开一个 App」过渡到「我在欣赏一个产品」
  3. 后台预热 :偷偷加载首页数据 / Service cache / 用户配置,到主页面 0 等待

很多团队的启动页是这样的:一张大图 PNG 静止 2 秒 → 直接跳 Home。毫无设计感。还有一些团队过度堆叠动画:6 个元素同时 5 种动画一起触发------视觉爆炸、用户根本看不清重点。

「古诗学习宝」的启动页走**「水墨意境 + 错峰入场动画 + 可跳过」**路线:

  • 水墨背景图 + 左下竹叶素材 + 远山纹理 = 古典氛围底
  • 水墨书卷圆形 Logo 200ms 起 600ms 弹入(scale + opacity)
  • 品牌字「古诗学习宝」 500ms 起 700ms 上浮淡入(translateY + opacity)
  • 副标题装饰线 + 一行价值主张 900ms 起 600ms 淡入
  • 160vp 进度条 3 秒匀速 0 → 100%,配「正在为你打开诗书 · · ·」呼吸文案
  • 「跳过」胶囊 让赶时间的用户随时进入主页面
  • 3 秒自动跳转 ,timer 在 aboutToDisappear 里 cleanup 防泄漏

整套动画不到 200 行代码,0 三方依赖 ,纯 ArkUI V2 + getUIContext().animateTo + setInterval 搭出来。本文用 wqsy 上架版本的真实源码,把启动页 3 段错峰动画、进度条 setInterval 实现、timer 生命周期管理、@Local 动画状态绑定、Stack 多层合成 一次性讲透,照搬即可用到任何鸿蒙项目。


2、整体架构

2.1 启动页的 5 个分层

复制代码
┌─────────────────────────────────────────────────────────┐
│ Layer 0  背景图  Image($r('splash_bg')) 100% 全屏        │
│          宣纸底 + 远山水墨 + 左下竹叶                      │
├─────────────────────────────────────────────────────────┤
│ Layer 1  Logo 圆     ── 200ms 起 600ms 弹入             │
│          Image foreground.png 160×160 borderRadius:80   │
│          + 12vp 投影                                     │
│          + sealOpacity / sealScale 状态驱动              │
├─────────────────────────────────────────────────────────┤
│ Layer 2  品牌字 + 装饰 ── 500ms 起 700ms 上浮淡入        │
│          Text "古诗学习宝" 40vp 加粗绿 #436444            │
│          + titleOpacity / titleTranslateY                │
├─────────────────────────────────────────────────────────┤
│ Layer 3  副标题 + 价值主张 ── 900ms 起 600ms 淡入         │
│          "教材同步 · 启蒙必备" + "古诗学习 · 背诵 · 赏析" │
│          + subtitleOpacity 驱动                          │
├─────────────────────────────────────────────────────────┤
│ Layer 4  进度条 + 跳过按钮                                │
│          160vp 进度条 setInterval 每 30ms 推进            │
│          "正在为你打开诗书 · · ·"                         │
│          [跳过] 胶囊 onClick → goHome()                  │
└─────────────────────────────────────────────────────────┘

时间轴:
   t=0      触发 aboutToAppear,全部 opacity=0
   t=200    Layer 1 印章开始弹入(duration 600)
   t=500    Layer 2 标题开始上浮(duration 700)
   t=800    Layer 1 完成
   t=900    Layer 3 副标题开始淡入(duration 600)
   t=1200   Layer 2 完成
   t=1500   Layer 3 完成
   t=3000   setTimeout 触发 goHome() → onDone() 关闭 Splash

2.2 动画三大维度对照

维度 API 用在哪 关键参数
命令式属性动画 getUIContext().animateTo(options, () => state++) Logo 弹入 / 标题上浮 / 副标题淡入 duration / curve / delay
隐式属性动画 .animation({ duration, curve }) 修饰符 不用(启动页选命令式更可控) ---
自定义帧驱动 setInterval 定时改 @Local progress 3 秒进度条 30ms 间隔,100 帧/3s

2.3 项目结构

复制代码
entry/src/main/ets/
├── pages/
│   ├── Index.ets                  # Splash 覆盖在 Navigation 之上
│   └── SplashPage.ets             # ★ 本文核心:水墨启动页
└── resources/base/media/
    ├── splash_bg.png              # 宣纸 + 远山 + 竹叶背景图
    └── foreground.png             # 圆形 Logo 提亮版本

3、效果展示

3.1 完整启动页静态

打开应用第一眼:

  • 顶部留白(status bar + 50vp)
  • 中央 Logo 圆:水墨书卷图案,160vp 圆,带 12vp 主绿透明阴影
  • 品牌字「古诗学习宝」 :40vp 加粗、绿色 #436444、字距 4vp
  • 装饰副标题「--- 教材同步 · 启蒙必备 ---」两端各 40vp 横线
  • 副副标题「古诗学习 · 背诵 · 赏析」灰色小字
  • 进度条:160vp 长 3vp 厚,3 秒走完
  • **「正在为你打开诗书 · · ·」**呼吸感文案
  • [跳过] 胶囊按钮(轻米色背景)

3.2 三段错峰入场(时序示意)

复制代码
t=0ms     ┃ ████████████████████████ 全部 opacity=0,屏幕只有背景图
          ┃
t=200ms   ┃ ⚪ Logo 圆开始弹入(scale 0.5→1, opacity 0→1, EaseOut)
          ┃
t=500ms   ┃ 古诗学习宝  标题开始上浮(translateY 30→0, opacity 0→1)
          ┃
t=800ms   ┃ ⚪ Logo 弹入完成
          ┃ 古诗学习宝  标题上浮中
          ┃
t=900ms   ┃ --- 教材同步 · 启蒙必备 ---  副标题开始淡入
          ┃
t=1200ms  ┃ ⚪ Logo 完成
          ┃ 古诗学习宝  标题完成
          ┃ --- 教材同步 · 启蒙必备 ---  副标题中
          ┃
t=1500ms  ┃ 全部入场完成,进度条已走 50%
          ┃
t=3000ms  ┃ 进度条 100% → setTimeout 触发 goHome() → 切到主页面

错峰节奏感让用户能"看清每一个元素"------而不是 6 个元素一起爆出。


4、核心实现详解

4.1 第一步:组件骨架 + @Local 动画状态

typescript 复制代码
@ComponentV2
export struct SplashPage {
  @Param pathStack: NavPathStack = new NavPathStack();
  @Event onDone: () => void = () => {};

  // 动画状态(@Local 是 V2 reactive 字段,改动自动重渲染)
  @Local titleOpacity: number = 0;
  @Local titleTranslateY: number = 30;
  @Local subtitleOpacity: number = 0;
  @Local sealOpacity: number = 0;
  @Local sealScale: number = 0.5;
  @Local progress: number = 0;

  // 副作用 - 普通成员变量(不需 reactive)
  private timerId: number = -1;
  private progressTimerId: number = -1;
  private jumped: boolean = false;

  // ... build / aboutToAppear / aboutToDisappear / goHome
}

6 个 @Local 状态拆解

字段 初始 目标 驱动什么
sealOpacity 0 1 Logo 圆透明度
sealScale 0.5 1 Logo 圆缩放
titleOpacity 0 1 标题透明度
titleTranslateY 30 0 标题 y 轴位移(向上浮)
subtitleOpacity 0 1 副标题 + 进度条 + 跳过 一起淡入
progress 0 1 进度条进度

3 个 private 字段

  • timerId:3 秒自动跳转的 setTimeout 句柄
  • progressTimerId:进度条 setInterval 句柄
  • jumped:双重跳转保护(按跳过 + 自动 3s 同时触发的边界)

坑点 1 :动画状态用 @Local(响应式),副作用句柄用普通 private(非响应式)。timerId 改了不需要重渲染 ,放 @Local 反而浪费 diff。

4.2 第二步:aboutToAppear 启动三段动画

typescript 复制代码
aboutToAppear(): void {
  // ① 印章弹入(scale + opacity 同时变化)
  this.getUIContext().animateTo(
    { duration: 600, curve: Curve.EaseOut, delay: 200 },
    () => {
      this.sealOpacity = 1;
      this.sealScale = 1;
    }
  );

  // ② 标题上浮淡入
  this.getUIContext().animateTo(
    { duration: 700, curve: Curve.EaseOut, delay: 500 },
    () => {
      this.titleOpacity = 1;
      this.titleTranslateY = 0;
    }
  );

  // ③ 副标题淡入
  this.getUIContext().animateTo(
    { duration: 600, curve: Curve.EaseOut, delay: 900 },
    () => { this.subtitleOpacity = 1; }
  );

  // ④ 进度条 0 → 1(3 秒)
  const startTs = Date.now();
  this.progressTimerId = setInterval(() => {
    const dt = (Date.now() - startTs) / 3000;
    this.progress = Math.min(dt, 1);
    if (this.progress >= 1 && this.progressTimerId !== -1) {
      clearInterval(this.progressTimerId);
      this.progressTimerId = -1;
    }
  }, 30);

  // ⑤ 3 秒后自动跳转
  this.timerId = setTimeout(() => {
    this.goHome();
  }, 3000);
}

animateTo 5 个关键参数

参数 作用 推荐
duration 动画时长 ms 入场 600~800
curve 速度曲线 EaseOut(先快后慢,自然)
delay 起始延迟 ms 错峰 200/500/900
iterations 循环次数 默认 1(一次)
tempo 速度倍率 默认 1.0

回调函数 就是「目标状态 」------animateTo 自动从当前值补间到回调里赋的值。

坑点 2 :必须用 this.getUIContext().animateTo(...)不能用全局 animateTo 。HarmonyOS 6 之后全局 animateTo 已废弃,多 UI 实例下会找不到正确的 context。
坑点 3Curve.EaseOut 用在"入场"动画里效果最好(先快后慢,元素"落下"的感觉)。Curve.EaseIn用在"退场"(先慢后快,元素"飞走")。Linear 用在进度条。Spring 用在按钮反馈。

4.3 第三步:进度条 setInterval 实现

typescript 复制代码
const startTs = Date.now();
this.progressTimerId = setInterval(() => {
  const dt = (Date.now() - startTs) / 3000;
  this.progress = Math.min(dt, 1);
  if (this.progress >= 1 && this.progressTimerId !== -1) {
    clearInterval(this.progressTimerId);
    this.progressTimerId = -1;
  }
}, 30);

为什么不用 animateTo ------ 进度条的核心需求是「3 秒内匀速从 0 走到 100% 」,并且 progress 这个值会影响 width 计算(width: 160 * this.progress)。如果用 animateTo

typescript 复制代码
// ❌ 不推荐
this.getUIContext().animateTo({ duration: 3000, curve: Curve.Linear }, () => {
  this.progress = 1;
});

问题是:animateTo 内部是隐式插值,渲染节流后 progress 不是真实的"每帧值",UI 上看进度可能跳变。而 setInterval 30ms 触发一次,精确控制每帧的 width,体验更稳。

30ms 间隔的原因

  • 60fps = 16.67ms / 帧
  • 30ms ≈ 33fps,比屏幕刷新慢一倍------节省 50% 帧数,肉眼无感
  • 100 帧 / 3s 进度条体感流畅,更密反而是浪费

坑点 4setInterval 必须在 progress >= 1 时 clearInterval,否则永远跑下去。双判断 this.progressTimerId !== -1 防止重复 clear(虽然 clear 同一个 id 是幂等的,但避免无意义调用)。

4.4 第四步:aboutToDisappear 清理 timer

typescript 复制代码
aboutToDisappear(): void {
  if (this.timerId !== -1) {
    clearTimeout(this.timerId);
    this.timerId = -1;
  }
  if (this.progressTimerId !== -1) {
    clearInterval(this.progressTimerId);
    this.progressTimerId = -1;
  }
}

为什么 cleanup 重要? ------ 用户按"跳过"提前关闭时:

  • Splash 组件 aboutToDisappear 被触发
  • setTimeout(goHome, 3000) 还没到
  • 如果不 cleanup,3 秒后 setTimeout 仍然触发 goHome() → 调用 onDone() 改 Index.showSplash → 但此时 Splash 已经销毁了 → 状态错乱 / 内存泄漏

特别是 setInterval不 clear 就永远跑 ------背景态下也跑、用户切回前台也跑,电量杀手

坑点 5@ComponentV2 的生命周期回调是 aboutToAppear / aboutToDisappear,不是 React 的 componentWillUnmount。命名风格 ≈ Vue。

4.5 第五步:双重跳转保护 goHome

typescript 复制代码
goHome(): void {
  if (this.jumped) return;          // ⭐ 幂等保护
  this.jumped = true;

  if (this.timerId !== -1) {
    clearTimeout(this.timerId);
    this.timerId = -1;
  }
  if (this.progressTimerId !== -1) {
    clearInterval(this.progressTimerId);
    this.progressTimerId = -1;
  }

  this.onDone();                    // 通知父组件关闭 Splash
}

边界 case

  1. 用户在 t=1500ms 按「跳过」→ goHome 第 1 次执行
  2. timer 在 t=3000ms 触发 goHome 第 2 次执行
  3. 若没有 if (this.jumped) return:onDone 被调两次 → Index.showSplash 被设两次 false(没问题)→ 但其他副作用(埋点 / 动画)可能重复

幂等保护是写鸿蒙生命周期方法的好习惯。

4.6 第六步:build 视图 - Stack 多层 + 状态绑定

typescript 复制代码
build() {
  Stack() {
    // Layer 0:背景图
    Image($r('app.media.splash_bg'))
      .width('100%')
      .height('100%')
      .objectFit(ImageFit.Cover)

    Column() {
      Blank().layoutWeight(1)

      // Layer 1:Logo 圆 - 用动画状态驱动
      Image($r('app.media.foreground'))
        .width(160)
        .height(160)
        .borderRadius(80)
        .objectFit(ImageFit.Cover)
        .shadow({
          radius: 12,
          color: '#0D4A5A3E',
          offsetX: 0,
          offsetY: 2,
        })
        .opacity(this.sealOpacity)                       // ⭐ 绑定 @Local
        .scale({ x: this.sealScale, y: this.sealScale }) // ⭐ 绑定 @Local

      // Layer 2:品牌字 - 上浮淡入
      Text('古诗学习宝')
        .fontSize(40)
        .fontWeight(FontWeight.Bold)
        .fontColor($r('app.color.primary'))
        .letterSpacing(4)
        .margin({ top: 32 })
        .opacity(this.titleOpacity)                      // ⭐
        .translate({ y: this.titleTranslateY })          // ⭐

      // Layer 3:副标题 + 装饰线
      Row({ space: 8 }) {
        Column().width(40).height(1).backgroundColor($r('app.color.primary_dim'))
        Text('教材同步 · 启蒙必备')
          .fontSize($r('app.float.fs_subtitle'))
          .fontColor($r('app.color.text_body'))
          .letterSpacing(2)
        Column().width(40).height(1).backgroundColor($r('app.color.primary_dim'))
      }
      .margin({ top: 12 })
      .opacity(this.subtitleOpacity)                     // ⭐

      Text('古诗学习 · 背诵 · 赏析')
        .fontSize($r('app.float.fs_caption'))
        .fontColor($r('app.color.text_sub'))
        .letterSpacing(4)
        .margin({ top: 8 })
        .opacity(this.subtitleOpacity)                   // ⭐

      Blank().layoutWeight(1)

      // Layer 4:进度条 + 文案
      Column() {
        Stack({ alignContent: Alignment.Start }) {
          Column()                                       // 底条(灰)
            .width(160).height(3)
            .borderRadius(2)
            .backgroundColor($r('app.color.divider'))
          Column()                                       // 进度条(绿)
            .width(160 * this.progress)                  // ⭐ 绑定 progress
            .height(3)
            .borderRadius(2)
            .backgroundColor($r('app.color.primary'))
        }
        .width(160).height(3)

        Text('正在为你打开诗书 · · ·')
          .fontSize($r('app.float.fs_caption'))
          .fontColor($r('app.color.text_sub'))
          .margin({ top: 12 })
      }
      .opacity(this.subtitleOpacity)                     // ⭐ 跟副标题一起淡入

      // 跳过按钮
      Text('跳过')
        .fontSize($r('app.float.fs_caption'))
        .fontColor($r('app.color.text_sub'))
        .padding({ left: 16, right: 16, top: 6, bottom: 6 })
        .backgroundColor('#80EFE9D8')                    // 半透明米色胶囊
        .borderRadius(16)
        .margin({ top: 32, bottom: 48 })
        .opacity(this.subtitleOpacity)
        .onClick(() => this.goHome())
    }
    .width('100%')
    .height('100%')
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 80 })
  }
  .width('100%')
  .height('100%')
  .backgroundColor($r('app.color.surface'))
}

视图层 4 个关键点

  1. Stack 多层叠加:背景图 + 内容 Column,互不影响布局
  2. .opacity(this.x) 直接绑定:x 变 → Image / Text 透明度跟着变
  3. .scale({ x, y }) 同时绑定两轴:单独绑 scaleX 会导致 Y 不变形(看起来"压扁")
  4. .translate({ y }) 不影响其他元素 :等价于 CSS transform: translateY不触发布局重排

坑点 6.translate.offset 都是位移,但 .offset 只影响视觉位置不影响布局;.translate 通过 transform 实现,性能更好且支持 z 轴。入场动画用 .translate({ y }) 更合适

4.7 第七步:Index.ets 集成 Splash 覆盖层

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local pathStack: NavPathStack = new NavPathStack();
  @Local showSplash: boolean = true;       // ⭐ 控制 Splash 显示
  @Local favVersion: number = 0;
  @Local avatarRef: string = '';

  async aboutToAppear(): Promise<void> {
    const ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;
    // 偷偷预热 4 个 Service,启动页"显示动画"期间数据已 ready
    await Pref.init(ctx);
    await FavoriteService.load();
    await RecordService.load();
    await PlanService.load();
    this.avatarRef = await Pref.getString(StorageKey.UserAvatar, '');
  }

  build() {
    Stack() {
      // 主框架(永远存在,splash 显示时被覆盖)
      Navigation(this.pathStack) {
        MainTabsPage({
          pathStack: this.pathStack,
          favVersion: this.favVersion,
          avatarRef: this.avatarRef,
          onAvatarChange: (ref) => this.onAvatarChange(ref),
        })
      }
      .navDestination(this.PageMap)
      .hideTitleBar(true)
      .mode(NavigationMode.Stack)

      // ⭐ 启动页覆盖层(关键技巧)
      if (this.showSplash) {
        SplashPage({
          pathStack: this.pathStack,
          onDone: () => { this.showSplash = false; },
        })
      }
    }
    .width('100%')
    .height('100%')
  }
}

为什么 Splash 不走 Navigation 而是 Stack 覆盖? ------ 关键设计:

  1. 主框架始终挂载:MainTabsPage 在 Splash 期间已经渲染完成(虽然被遮挡),用户跳过的一瞬间无需重渲染
  2. 数据并行预热 :Index.aboutToAppear 一启动就 load 4 个 Service,和 Splash 动画并行------3 秒走完时数据全部就绪
  3. 关闭无路由切换showSplash = false 直接卸载覆盖层,无 NavPathStack pop 操作,没有路由抖动

坑点 7 :如果 Splash 走 pathStack.pushPathByName('SplashPage'),关闭就要 pop(),但主框架还没渲染------会出现"白屏 → 主页"闪烁。覆盖层方案没有这个问题。


5、完整数据流分析

复制代码
应用冷启
    │
    ▼
EntryAbility.onCreate
    └─ WindowStage.loadContent('pages/Index')
        │
        ▼
Index.aboutToAppear
    ├─ await Pref.init(ctx)
    ├─ await FavoriteService.load()    ─┐
    ├─ await RecordService.load()       ├─ 并行预热(serially awaited
    └─ await PlanService.load()        ─┘   for safety, 但磁盘 IO 快)
    │
    ▼
Index.build
    └─ Stack
        ├─ Navigation { MainTabsPage(...) }   ← 已挂载,被遮挡
        └─ if (showSplash) SplashPage(...)    ← 覆盖层
─────────────────────────────────────────────────────────────────
SplashPage.aboutToAppear (t=0)
    ├─ animateTo({duration:600, curve:EaseOut, delay:200}, () => {
    │     sealOpacity=1; sealScale=1
    │   })
    ├─ animateTo({duration:700, curve:EaseOut, delay:500}, () => {
    │     titleOpacity=1; titleTranslateY=0
    │   })
    ├─ animateTo({duration:600, curve:EaseOut, delay:900}, () => {
    │     subtitleOpacity=1
    │   })
    ├─ progressTimerId = setInterval(updateProgress, 30)
    │     每帧:progress = Math.min((now-start)/3000, 1)
    └─ timerId = setTimeout(goHome, 3000)
─────────────────────────────────────────────────────────────────
t=200ms - Logo 弹入
    └─ V2 reactive 检测到 sealOpacity / sealScale 变化
    └─ Image 重渲染:scale 0.5→1, opacity 0→1
─────────────────────────────────────────────────────────────────
t=500ms - 标题上浮
    └─ titleOpacity / titleTranslateY 变化
    └─ Text 重渲染:translateY 30→0, opacity 0→1
─────────────────────────────────────────────────────────────────
t=900ms - 副标题淡入(含进度条文案 + 跳过按钮)
    └─ subtitleOpacity: 0→1
    └─ Row / Text / Column / Button 5 个元素同时重渲染
─────────────────────────────────────────────────────────────────
t=0~3000ms - 进度条
    └─ setInterval 每 30ms 触发
        └─ progress: 0 → 0.01 → 0.02 → ... → 1
        └─ Column.width 同步变化:0 → 1.6 → 3.2 → ... → 160 vp
─────────────────────────────────────────────────────────────────
分支 A:用户在 t=1500ms 按「跳过」
    └─ 跳过按钮 onClick → goHome()
        ├─ this.jumped = true
        ├─ clearTimeout(timerId)        ← 取消自动跳转
        ├─ clearInterval(progressTimerId) ← 停掉进度条
        └─ this.onDone()
            └─ Index.showSplash = false
                └─ Splash 卸载 → aboutToDisappear
                    ├─ clearTimeout (双保险,已 -1 不再调用)
                    └─ clearInterval (双保险)
                └─ Stack 重新布局,MainTabsPage 暴露
─────────────────────────────────────────────────────────────────
分支 B:用户什么都没做,t=3000ms 自动跳转
    └─ setTimeout 触发 goHome()
        └─ 同上流程
─────────────────────────────────────────────────────────────────
主框架显示
    └─ MainTabsPage 已经在 Splash 期间渲染好
    └─ HomeView / favVersion=0 / avatarRef='' / Service cache 全部就绪
    └─ 用户看到首页:0 ms 延迟

观察点:

  1. 数据预热和动画并行 :3 秒 Splash 动画期间,4 个 Service load 完毕。对用户来说"3 秒钟感觉很短",对开发者来说"3 秒钟很长,足够初始化一切"
  2. 错峰节奏 200/500/900 :让 Logo → 标题 → 副标题 顺次入场,用户能"看清每个元素"
  3. 进度条用 setInterval 而非 animateTo:精确控制每帧 width,体验更稳。
  4. 双重 cleanup :goHome 内部 clear + aboutToDisappear cleanup = 永不泄漏 timer
  5. 覆盖层方案 :主框架始终挂载,关闭瞬间 0 等待

6、代码分析与优化建议

6.1 现有实现的亮点

  • 错峰入场 3 段:Logo / 标题 / 副标题 视觉节奏感强,专业感拉满
  • animateTo + @Local 联动 :1 行 animateTo 触发整段动画,无需手写 frame 循环
  • setInterval 精控进度条:30ms 间隔 = 节能 + 流畅
  • timer 双重 cleanupaboutToDisappear 兜底,goHome 主动清理,杜绝泄漏
  • 覆盖层不走路由 :主框架并行预热,关闭无白屏
  • 可跳过:尊重用户时间,胶囊按钮一眼可见

6.2 可优化点

优化 1:背景图用 PixelMap 异步解码

问题Image($r('app.media.splash_bg')) 同步加载大图,第一次 Splash 可能有 100ms 黑屏

改进 :在 EntryAbility.onCreate 里预解码

typescript 复制代码
import { image } from '@kit.ImageKit';

// EntryAbility.onCreate
const ctx = this.context;
const fd = ctx.resourceManager.getRawFdSync('splash_bg.png');
const decoder = image.createImageSource(fd.fd);
const px = await decoder.createPixelMap();
AppStorage.setOrCreate('splash_bg_pixelmap', px);

// SplashPage.build
Image(AppStorage.get<image.PixelMap>('splash_bg_pixelmap'))
  .width('100%').height('100%')

第二次冷启时图片已经在内存,0 解码延迟

优化 2:用 keyframeAnimateTo 实现"弹性入场"

问题 :当前 Logo 用 Curve.EaseOut 平滑弹入,少了"弹一下"的拟物感

改进

typescript 复制代码
this.getUIContext().keyframeAnimateTo({ iterations: 1, delay: 200 }, [
  { duration: 300, curve: Curve.Sharp,   event: () => { this.sealScale = 1.15; this.sealOpacity = 1; } },
  { duration: 200, curve: Curve.EaseOut, event: () => { this.sealScale = 0.95; } },
  { duration: 150, curve: Curve.EaseOut, event: () => { this.sealScale = 1.0; } },
]);

放大 → 微缩 → 回正三段,模仿"印章盖下去"的弹力感

优化 3:进度条根据真实预热进度走

问题 :当前进度条与真实加载进度无关(纯装饰),用户慢网下加载完了进度条还在走。

改进:把 Service load 拆成 4 段,每段完成时推进 25%:

typescript 复制代码
async aboutToAppear(): Promise<void> {
  this.progress = 0;
  await Pref.init(ctx);
  this.progress = 0.25;
  await FavoriteService.load();
  this.progress = 0.5;
  await RecordService.load();
  this.progress = 0.75;
  await PlanService.load();
  this.progress = 1;
}

取舍 :4 个 Service 本地 IO 都很快(毫秒级),用户根本看不到分段;纯装饰版本反而更稳定 ------ 真实方案适合需要拉网络的 App。

优化 4:跳过按钮的"延迟可点"

问题 :当前跳过按钮和副标题一起 t=900ms 出现。0~900ms 内用户想跳也跳不了

改进 :把跳过按钮独立动画,t=300ms 提前出现

typescript 复制代码
@Local skipBtnOpacity: number = 0;

aboutToAppear() {
  // ...其他动画
  this.getUIContext().animateTo(
    { duration: 400, curve: Curve.EaseOut, delay: 300 },
    () => { this.skipBtnOpacity = 1; }
  );
}

Text('跳过')
  .opacity(this.skipBtnOpacity)
  .onClick(() => this.goHome())
优化 5:暗色模式适配

问题:当前白宣纸底色在暗色模式下太亮。

改进

typescript 复制代码
.backgroundColor($r('app.color.surface'))     // 已是资源引用 ✅
// 但 Logo 圆的阴影色硬编码:
.shadow({ radius: 12, color: '#0D4A5A3E', ... })

// 改成
.shadow({ radius: 12, color: $r('app.color.shadow_primary'), ... })

resources/dark/element/color.json 里定义暗色对应值。

6.3 生产环境 Checklist

检查项 说明
动画状态用 @Local,timer 句柄用 private 区分 reactive / 非 reactive
所有 animateTo 用 getUIContext().animateTo 全局版本已废弃
入场用 Curve.EaseOut、退场用 EaseIn、进度条用 Linear 曲线选型
错峰延迟 ≥ 300ms 让用户看清节奏 200/500/900 是典型值
setInterval 必须配对 clearInterval 否则永远跑
aboutToDisappear 兜底清理 timer 防泄漏
goHome 加幂等保护 if (jumped) return 防重入
Splash 走覆盖层而非路由 主框架并行预热
跳过按钮提前可点 尊重快用户
大图 PixelMap 预解码 避免首启黑屏

7、关键 API 速查

API 作用
getUIContext().animateTo({duration, curve, delay}, () => state++) 命令式属性动画
getUIContext().keyframeAnimateTo({iterations}, frames[]) 多段关键帧动画
setInterval(cb, ms) / clearInterval(id) 帧驱动定时器
setTimeout(cb, ms) / clearTimeout(id) 单次延迟
Curve.EaseOut / EaseIn / Linear / Spring 速度曲线
.opacity(0~1) 透明度
.scale({ x, y }) 缩放
.translate({ x, y }) 位移(不触发布局)
.shadow({ radius, color, offsetX, offsetY }) 投影
Stack 多层叠加 背景图 + 内容覆盖
aboutToAppear / aboutToDisappear V2 生命周期
@Local 响应式 / private 非响应式 状态分类
image.createImageSource + createPixelMap 大图预解码
Indicator.dot() 进度指示器(Swiper 同款)

8、总结

本文以「古诗学习宝」上架版本的真实源码为案例,系统讲解了 HarmonyOS 6 ArkUI V2 项目中水墨启动页 + 错峰入场动画 + 自动跳转 + 覆盖层架构的工程化方案:

  1. 5 层 Stack 视图分层 :Layer 0 背景图 / Layer 1 Logo 圆 / Layer 2 品牌字 / Layer 3 副标题 + 装饰 / Layer 4 进度条 + 跳过------每层独立动画互不干扰。

  2. 3 段错峰入场动画 :印章 200ms 起 600ms / 标题 500ms 起 700ms / 副标题 900ms 起 600ms------错峰节奏让用户看清每个元素,专业感拉满。

  3. animateTo + @Local 联动 :1 行 animateTo@Local 状态、V2 reactive 自动重渲染、opacity / scale / translateY 流畅插值------无需手写 frame 循环

  4. setInterval 精控进度条 :30ms 间隔 ≈ 33fps,比 60fps 节能 50%、肉眼无感 ;用 (now - start) / 3000 算实时进度,精确到帧。

  5. timer 双重 cleanupgoHome() 内部 clear + aboutToDisappear 兜底 = 永不泄漏if (jumped) return 幂等保护防重入。

  6. 覆盖层架构 :Splash 不走 Navigation 路由而是 Stack { Navigation(...); if (showSplash) SplashPage(...) }------主框架并行预热,关闭瞬间 0 等待

  7. 7 个真坑写进 Checklist:@Local vs private 区分 / 必须用 getUIContext().animateTo / Curve 选型 / setInterval 必须 clear / cleanup / goHome 幂等 / Splash 不走路由------任意踩错都会导致动画异常或泄漏。

  8. 5 个优化方向:PixelMap 预解码 / keyframeAnimateTo 弹性入场 / 真实进度推进 / 跳过按钮提前可点 / 暗色模式适配------直接对接生产规范。

完整代码 ≈ 200 行 ,0 三方依赖,已在华为应用市场上架版本经历过真实小学生用户验证。把这套**「错峰动画 + setInterval + 覆盖层」模式吃透,任何需要"启动页 / 引导页 / 沉浸式入场"**的鸿蒙应用------电商首启 / 阅读引导 / 教育 App 欢迎页------都能 1 天搭起来。

建议结合官方文档《animateTo》《keyframeAnimateTo》《Image / PixelMap》一起读,再用 DevEco Profiler 验证动画 60fps 稳定性,启动页工程基线就稳了。


🎁 下载体验

**「古诗学习宝」**已上架华为应用市场,搜索 古诗学习宝 即可下载。冷启动那 3 秒水墨入场动画正是本文讲解的实现,欢迎下载亲身感受 🌟。

📚 文中所有代码均来自上架生产版本(SplashPage.ets / Index.ets),未做删改美化。

👋 欢迎在评论区交流启动页动画的工程经验,我会优先回复。

相关推荐
心疼你的一切1 小时前
从零到一:鸿蒙健康监测应用的全流程开发实录
人工智能·华为·harmonyos·鸿蒙·鸿蒙系统
想你依然心痛1 小时前
HarmonyOS 6(API 23)实战:基于Face AR情绪反馈与Body AR手势操控的“光影剪辑台“——PC端沉浸式影视后期系统
华为·ar·harmonyos·悬浮导航·沉浸光感
leon_teacher2 小时前
HarmonyOS 6 Navigation 实战:NavPathStack 路由架构与 onShown 跨页状态同步方案
华为·架构·harmonyos
号码认证服务2 小时前
企业固话号码认证能覆盖哪些手机品牌?支持华为、小米、OPPO、vivo等机型
服务器·网络·经验分享·python·华为·智能手机·云计算
云水一下2 小时前
下一代防火墙(NGFW)完全解析:从入门到华为eNSP模拟器实战
网络·华为·下一代防火墙
KKei16382 小时前
Flutter for OpenHarmony 编程技能树APP技术文章
flutter·华为·harmonyos
想你依然心痛2 小时前
HarmonyOS 6(API 23)实战:基于Face AR呼吸监测与Body AR姿态引导的“静界空间“——PC端沉浸式冥想疗愈系统
华为·ar·harmonyos·悬浮导航·沉浸光感
KKei16382 小时前
Flutter for OpenHarmony 个人财务管理与记账APP
flutter·华为·harmonyos
nashane3 小时前
HarmonyOS 6学习:Web组件与JavaScript交互的三大高频问题与终极解决方案
前端·学习·harmonyos