鸿蒙版网易云音乐

13 阅读2分钟

前言

平时用网易云音乐也比较多,就决定做一个鸿蒙版本体验下。也记录下自己在开发过程中的尝试和思考。

项目背景

  • 项目主要参考的是iOS版网易云音乐的交互和视觉。
  • 项目中集成了 AI 写歌词功能,作为技术尝试。

效果图:

首页心动侧栏搜索
笔记个人主页状态栏通知卡片

技术栈:

  • 开发语言: ArkTS
  • 开发框架: HarmonyOS SDK
  • 网络请求: @ohos/axios (v2.2.6)
  • AI 模型: DeepSeek API(用于 AI 歌词创作功能)
  • 状态管理: 使用V2版本的状态管理修饰器

项目思考与实践

1. 瀑布流布局的实现

看了官方文档,可以用 WaterFlow 组件,但实际用起来发现有些限制。比如列数固定、高度计算麻烦这些。

后来借鉴了 iOS 的 UICollectionView 的实现思路,自己封装了一个 WaterFlowDataSource。主要实现了 IDataSource 接口,支持数据的增删改查,并且通过监听器模式来通知 UI 更新:

@ObservedV2
export class WaterFlowDataSource<T extends WaterFlowItem> implements IDataSource {
  @Trace items: T[] = [];
  private listeners: DataChangeListener[] = [];

  private notifyDataReloaded() {
    this.listeners.forEach((listener) => {
      listener.onDataReloaded();
    })
  }

  private notifyDataAdd(idx: number) {
    this.listeners.forEach((listener) => {
      listener.onDataAdd(idx);
    })
  }

  private notifyDataDelete(idx: number) {
    this.listeners.forEach((listener) => {
      listener.onDataDelete(idx);
    })
  }

  private notifyDataChange(idx: number) {
    this.listeners.forEach((listener) => {
      listener.onDataChange(idx);
    })
  }

  private notifyDatasetChange(operations: DataOperation[]) {
    this.listeners.forEach((listener) => {
      listener.onDatasetChange(operations)
    })
  }

  totalCount(): number {
    return this.items.length;
  }

  getData(index: number): T {
    return this.items[index];
  }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    const idx = this.listeners.indexOf(listener);
    if (idx >= 0) {
      this.listeners.splice(idx, 1);
    }
  }

  addFirstItem(item: T) {
    this.items.unshift(item);
    this.notifyDataAdd(0);
  }

  addLastItem(item: T) {
    this.items.push(item);
    this.notifyDataAdd(this.items.length - 1);
  }

  addItem(item: T, index: number) {
    this.items.splice(index, 0, item);
    this.notifyDataAdd(index);
  }

  addItems(items: T[], index?: number) {
    if (!items || items.length === 0) {
      return;
    }

    // 如果 index 未提供、为负数或超出范围,从尾部添加
    if (index === undefined || index < 0 || index >= this.items.length) {
      const startIdx = this.items.length;
      this.items.push(...items);
      this.notifyDataAdd(startIdx);
    } else {
      // 如果传入了有效的 index,在指定位置插入
      this.items.splice(index, 0, ...items);
      this.notifyDataAdd(index);
    }
  }

  deleteFirstItem() {
    this.items.splice(0, 1);
    this.notifyDataDelete(0);
  }

  deleteLastItem() {
    const deleteIndex = this.items.length - 1;
    this.items.pop();
    this.notifyDataDelete(deleteIndex);
  }

  deleteItem(index: number) {
    this.items.splice(index, 1);
    this.notifyDataDelete(index);
  }

  deleteAllItems() {
    if (this.items.length === 0) {
      return;
    }
    this.items.splice(0, this.items.length);
    this.notifyDataReloaded();
  }

  updateItem(item: T, startIdx: number) {
    if (startIdx >= 0 && startIdx < this.items.length && this.items.length > 0) {
      this.items[startIdx] = item;
      this.notifyDataChange(startIdx);
    } else {
      Logger.info({ domain: 'WaterFlowDataSource' },
        `Index ${startIdx} out of bounds. Array length: ${this.items.length}`)
    }
  }

  updateItems(items: T[], startIdx: number) {
    if (!items || items.length === 0) {
      return;
    }

    if (startIdx < 0 || startIdx >= this.items.length || this.items.length === 0) {
      Logger.info({ domain: 'WaterFlowDataSource' },
        `Start index ${startIdx} out of bounds. Array length: ${this.items.length}`)
      return;
    }

    const maxCnt = this.items.length - startIdx;
    const updateCnt = Math.min(items.length, maxCnt);
    if (updateCnt === 0) {
      return;
    }

    try {
      const operations: DataOperation[] = [];
      for (let idx = 0; idx < updateCnt; idx++) {
        const currentIdx = startIdx + idx;
        this.items[currentIdx] = items[idx];
        operations.push({
          type: DataOperationType.CHANGE,
          index: currentIdx
        } as DataOperation)
      }
      if (operations.length > 0) {
        this.notifyDatasetChange(operations);
        return;
      }
    } catch (error) {
      Logger.info({ domain: 'WaterFlowDataSource' },
        `Update dataset error:${json.stringify(error)}`);
    }
  }
}

2. 状态管理的问题

ArkTS 的状态管理 v2 推出了一些新的修饰器,需要摸索尝试。刚开始用的时候,经常遇到瀑布流数据更新了但 UI 不刷新的问题。

后来发现是需要使用 UIUtils.makeObserved。比如在 ViewModel 中:

@ObservedV2
export class BaseWaterFlowViewModel {
  // 数据源
  protected dataSource: WaterFlowDataSource<WaterFlowItem> = new WaterFlowDataSource();
  //
  protected waterFlowSections: WaterFlowSections = UIUtils.makeObserved(new WaterFlowSections());

  /**
   * 获取数据源
   */
  getDataSource(): WaterFlowDataSource<WaterFlowItem> {
    return this.dataSource;
  }

  /**
   * 获取 sections 配置
   */
  getSections(): WaterFlowSections {
    return this.waterFlowSections;
  }
}

dataSource 设为 private 然后通过 getter 暴露,这样能保证数据的一致性,避免外部直接修改导致的状态不同步。而 waterFlowSections 需要用 UIUtils.makeObserved 包装一下,这样 UI 才能响应数据变化。

3. 音乐播放的实现

音乐播放用的是 @ohos.multimedia.media 模块。 我封装了两个类:

  • MusicPlayerController,用于 App 应用内的播控
  • AVSessionManager,用于系统的播控(锁屏、通知栏这些)
class MusicPlayerController {
  playbackState: MusicPlaybackState =
    AppStorageV2.connect(MusicPlaybackState, 'MusicPlaybackState', () => new MusicPlaybackState())!
  mPlayer: media.AVPlayer | null = null;

  async init() {
    if (this.mPlayer) {
      return;
    }

    try {
      this.mPlayer = await media.createAVPlayer();
      this.setupEvents();
      Logger.info({ domain: 'Playback' }, `Player created, current state: ${this.mPlayer.state}`);
    } catch (error) {
      Logger.error({ domain: 'Playback' }, `Init failed: ${JSON.stringify(error)}`);
    }
  }

  // ... 其他方法
}

系统播控这块:

export class AVSessionManager {
  session: avSession.AVSession | undefined;
  private context: common.UIAbilityContext | undefined;
  playbackState: MusicPlaybackState =
    AppStorageV2.connect(MusicPlaybackState, 'MusicPlaybackState', () => new MusicPlaybackState())!

  async init(context: common.UIAbilityContext) {
    this.context = context;
    try {
      this.session = await avSession.createAVSession(context, 'musicPlayer', 'audio');
      await this.session.activate();
      this.registerControlCommands();
    } catch (error) {
      Logger.info({ domain: 'AVSession' }, `init error:${JSON.stringify(error)}`);
    }
  }
  // ... 其他方法
}

结语

做这个项目主要是为了尝试和实践 HarmonyOS 开发,项目中的代码风格并不是很统一,主要是思考后的不同风格的尝试。

项目代码都在 GitHub 上,如果对实现细节感兴趣,可以查看项目源码。


项目地址: GitHub - HarmonyOS-NetEase-Cloud-Music