写鸿蒙播放器的文章很多,但绝大多数只做一件事:在固定尺寸的手机屏上把封面、进度条、控制按钮摆好看。一旦设备变成折叠屏,或者用户希望“每首歌都有自己的氛围”,这种单形态、写死配色的播放器就立刻显得单薄。

这次要挑战两个更难一点的诉求:形态自适应——同一个播放器在折叠态和展开态下呈现完全不同的信息密度;沉浸光感——背景随当前歌曲封面“变色”,让每首歌都有自己的“氛围光”。

一、先拆层,再写代码

最常踩的坑是:把所有东西都塞进一个@ComponentV2组件里。状态、日期计算、颜色派生、网络请求全堆在build()周围,最后组件变成一大团,改一个颜色都怕碰到业务逻辑。

正确做法是把纯逻辑和响应式渲染彻底分开。纯逻辑层不依赖ArkUI,只导出函数和接口;@ComponentV2组件只管响应式渲染和交互。

├── PlayerModel.ets      # 纯逻辑:歌单数据、播放状态机、进度推进
├── ThemePalette.ets     # 纯逻辑:预置主色 → 派生毛玻璃渐变主题
├── ImmersionPlayer.ets  # @ComponentV2 主组件:状态中枢 + 取色主题 + 形态分发
├── MiniPlayer.ets       # @ComponentV2 折叠态迷你播放器
├── ExpandedPlayer.ets   # @ComponentV2 展开态双栏沉浸
└── Index.ets            # 模块导出

三个原则:

  • 纯逻辑层禁止import ArkUI——PlayerModel.ets和ThemePalette.ets里没有@Component、没有build()、没有Text/Column。它们是普通TypeScript函数,可以在ohosTest下直接被import和断言,不需要起UI运行时。
  • 组件层只管响应式渲染——ImmersionPlayer持有@Local状态机,点击“下一首”不会自己去currentIndex++,而是调用next(this.state)拿到新对象再赋值。
  • 演示页只管装配——不写业务逻辑,只做三件事:监听系统折叠态、初始化全屏沉浸、把数据喂给播放器。

@ComponentV2不等于把所有东西都写进V2组件。组件要响应式,但业务推导最好保持纯函数。

二、折叠态监听:让UI跟着屏幕形态走

折叠屏适配的核心是监听屏幕折叠状态。display模块提供了foldStatusChange事件:

display.on('foldStatusChange', (curFoldStatus: display.FoldStatus) => {
  // FoldStatus.FOLDED → 折叠态,展示MiniPlayer
  // FoldStatus.EXPANDED → 展开态,展示ExpandedPlayer
  // FoldStatus.HALF_FOLDED → 半折叠态,特殊处理
})

拿到折叠状态后,通过@Local状态控制渲染分支:

@ComponentV2
struct ImmersionPlayer {
  @Local foldStatus: display.FoldStatus = display.FoldStatus.FOLDED
  
  build() {
    if (this.foldStatus === display.FoldStatus.FOLDED) {
      MiniPlayer({...})   // 紧凑迷你播放器
    } else {
      ExpandedPlayer({...}) // 双栏沉浸式
    }
  }
}

关键设计:折叠态和展开态不是简单缩放,而是呈现完全不同的信息密度-1。折叠态(外屏)只展示封面、歌名和基础控制按钮;展开态(内屏)展示双栏布局——左侧大封面+歌词滚动,右侧歌单列表+控制面板。

三、FolderStack:半折叠态的优雅方案

除了全折叠和全展开,折叠屏还有半折叠态(悬停状态)。ArkUI提供了FolderStack组件专门处理这个场景。

FolderStack作为容器组件,承载播放器的所有功能组件。在半折叠态下,它会自动将需要移动到上屏的子组件产生动态效果。不需要手动计算偏移量,组件自己处理了折叠屏的物理特性。

FolderStack() {
  // 上屏内容:封面大图、歌词
  Column() { ... }
  // 下屏内容:控制按钮、进度条
  Column() { ... }
}

四、沉浸光感:让每首歌都有自己的“氛围光”

“沉浸光感”不是截图取色,而是材质+模糊层的组合-。

核心思路:封面主色 → 色板派生 → 毛玻璃渐变主题

ThemePalette.ets负责纯逻辑的颜色派生:

// 从封面图片提取主色(使用image模块的像素分析)
function extractDominantColor(imagePixelMap: PixelMap): string {
  // 采样、聚类、取主色
}

// 派生整套主题
function deriveTheme(mainColor: string): Theme {
  return {
    primary: mainColor,
    gradientStart: lighten(mainColor, 0.2),
    gradientEnd: darken(mainColor, 0.3),
    blurColor: mainColor + '40',  // 半透明毛玻璃
    textColor: contrastColor(mainColor)
  }
}

切歌时背景如果直接变色会显得突兀,需要配合过渡动画。animateTo可以解决这个问题:

animateTo({ duration: 500, curve: Curve.EaseInOut }, () => {
  this.currentTheme = deriveTheme(newCoverColor)
})

关于“沉浸光感”的官方推荐方案:优先使用HDS(Harmony Design System)的material做材质;普通ArkUI也可以用backgroundBlurStyle/foregroundBlurStyle/backdropBlur配合半透明背景和渐变遮罩。

五、三个容易踩的坑

坑1:取色变色出现闪烁。 封面加载需要时间,直接取色会导致切歌瞬间背景突变。解决方案:预加载下一首歌的封面并提前取色,在切歌动画期间完成主题切换。

坑2:动画在异步回调中失效。 @ComponentV2下在promise.then中使用animateTo可能没有过渡动画-。原因是动画执行时机与Promise异步回调未正确同步,animateTo需要在UI线程中执行。解决方案:用uiContext.runScopedTask包裹,或者用@Watch监听状态变化触发动画。

坑3:折叠态和展开态共用一套状态。 切换形态时播放进度、歌单索引、循环模式需要保持一致。建议把播放状态抽到PlayerModel.ets中作为单例,两个形态组件共享同一份数据源。

六、总结

这个播放器Demo的核心不是炫技,而是用正确的方式组织代码

  • @ComponentV2只管响应式渲染,业务逻辑交给纯函数
  • display.foldStatusChange监听折叠状态,FolderStack处理半折叠态
  • 沉浸光感 = 封面取色 + 色板派生 + 毛玻璃材质 + 过渡动画

遇到“折叠屏布局不一致”诉求,引出折叠态监听;发现切歌时背景突兀,引出动态取色主题;取色变色出现闪烁,引出形态切换动效。每个技术点都有问题驱动——不只是知道有这些API,更知道在什么场景下该用、用了会有什么坑。