前言
平时用网易云音乐也比较多,就决定做一个鸿蒙版本体验下。也记录下自己在开发过程中的尝试和思考。
项目背景
- 项目主要参考的是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 上,如果对实现细节感兴趣,可以查看项目源码。