☰
鸿蒙NEXT API 12+从零开发音乐播放器保姆级教程
2026/10/7 17:11:11 网站建设 项目流程

如果你最近在折腾鸿蒙开发,大概率和我一样被“HarmonyOS6”这个标题搞得很困惑——系统到底出到几了?SDK用哪个版本?API 12+到底是什么意思?我这次用 HarmonyOS NEXT SDK 5.0.0(12) 从零写了一个音乐播放器,从工程创建到真机运行花了不到两周,中间踩了不少坑。这篇文章把完整过程拆开讲,从环境配置、入口加载、Tabs底部导航,到AVPlayer音频播放、权限申请、后台播放和上架前要处理的签名,全部按保姆级标准来。无论你是刚过鸿蒙应用开发基础认证的新手,还是从 Android/iOS 转过来的老手,照着这份教程都能把一个能用的播放器跑起来。

1. 项目整体设计与开发环境准备

1.1 先弄清楚你手上是哪个鸿蒙SDK

HarmonyOS 的版本命名确实容易让人绕晕。这次写播放器,我用的是 HarmonyOS NEXT SDK,版本号 5.0.0(12),后面的(12)表示 API 12。网上搜“harmonyos next sdk”时会看到“api 12+”这个说法,意思是 API Level 12 及以上,当前 DevEco Studio 中稳定可用的就是这套。标题写“HarmonyOS6”,实际开发时你只需要认准 API 12+ 这条技术线,不要纠结数字,因为应用开发的API差异远比系统版本数字重要。

选 API 12+ 有一个核心原因:它把旧版 FA 模型彻底淘汰了,强制走 Stage 模型,应用入口、组件生命周期、权限管理逻辑都更统一。而且从 API 12 开始,媒体播放相关的 AVPlayer 能力补齐了状态机、音效切换、DRM 保护等,做音乐播放器刚刚好。如果你用旧 API 9 的示例代码去跑,大概率会碰到WindowStage.loadContent不存在、@Entry组件结构对不上这类问题。所以第一步老老实实装最新 SDK,别折腾旧版本。

1.2 DevEco Studio 环境搭建

开发工具我用的 DevEco Studio 当前稳定版,安装包在官网下载,安装后打开配置 SDK。这里有个小坑:SDK 管理器有时候不会自动勾选全部组件,你至少要把HarmonyOS SDK、Toolchains和emulator装上。我自己第一次只装了 SDK,结果创建工程后模拟器列表是空的,重新去 SDK Manager 里补装System-image才解决。

模拟器方面,纯 UI 调试用本地模拟器没问题,但做音频播放建议从一开始就准备真机。模拟器的音频链路和真机差别很大,某些模拟器版本拿到AVPlayer后虽然状态正常,但声音会延迟或完全没有输出。我在教程后面的排坑部分会细说。连接真机时,用 USB 线连接后在 DevEco Studio 的Device File Manager里能看到设备,如果看不到,先在命令行执行hdc list targets,确认手机开启了“开发者模式”和“USB调试”。

1.3 决定用Stage模型和元服务

工程创建时会让你选“Application”还是“Atomic Service”。前者就是普通应用,后者是鸿蒙的元服务。音乐播放器这种工具类产品完全可以做成元服务,用户即点即用,不用完整安装。不过元服务的包名规则、体积限制和上架入口略有不同,新手先创建 standard application 更稳妥。我这次先用普通应用把功能跑通,后续如果要做“鸿蒙元服务”版本,再复制一份工程改配置也不难。

Stage 模型下,一个应用至少包含entry模块,模块内部有EntryAbility,这就是你要加载的第一个页面入口。旧 FA 模型里MainAbility和pages/index的关联方式已经废弃了,API 12+ 统一走windowStage.loadContent来加载。这一点必须理解清楚,因为后面所有页面跳转、路由配置都建立在 Stage 模型的AbilityStage和UIAbility生命周期之上。

2. 项目入口与主界面骨架

2.1 从EntryAbility到WindowStage.loadContent

打开工程后先看entry/src/main/ets/entryability/EntryAbility.ets。核心逻辑集中在onWindowStageCreate方法里,完整代码大致长这样:

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'; import { window } from '@kit.ArkUI'; import { hilog } from '@kit.PerformanceAnalysisKit'; export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { // 应用级初始化可以放这里 } onWindowStageCreate(windowStage: window.WindowStage): void { // 加载主页面 windowStage.loadContent('pages/Index', (err) => { if (err.code) { hilog.error(0x0000, 'MusicPlayer', '加载页面失败: %{public}s', JSON.stringify(err)); return; } hilog.info(0x0000, 'MusicPlayer', '主页面加载成功'); }); } }

这里最关键的是'pages/Index'这个字符串。它指向entry/src/main/ets/pages/Index.ets,但路径里不需要写.ets后缀,也不用写ets/pages前缀。如果你把页面文件放到二级目录如pages/player/Player.ets,这里就要写'pages/player/Player'。我一开始没注意大小写,写成pages/index,结果加载时报错找不到页面,因为文件名大小写必须完全一致。

此外,如果你想让播放页全屏沉浸,可以在loadContent之前调用windowStage.getMainWindow()拿到窗口设置全屏。我在播放器里加了一个自定义背景色,所以没有做系统状态栏适配,直接让页面内容延伸到状态栏之外。具体代码是:

windowStage.getMainWindow((err, mainWindow) => { if (!err) { mainWindow.setWindowLayoutFullScreen(true); } });

注意setWindowLayoutFullScreen会隐藏系统状态栏,但页面顶部你的控件需要自己避开状态栏高度,否则时间、电量会压在标题上。我踩过这个坑,后来干脆不做全屏,只在Index页面正常显示,播放页用 SafeArea 处理。

2.2 用Tabs定制底部导航栏

音乐播放器主界面一般有三个页签:推荐页、歌单页、我的页。最直接的做法是用Tabs组件。HarmonyOS 的Tabs用法和 Android 的BottomNavigationView类似,但更灵活,barBuilder可以完全自定义底部栏。

下面是我在Index.ets里使用的结构:

@Entry @Component struct Index { @State currentTab: number = 0; @Builder tabBuilder(index: number, title: string, icon: ResourceStr) { Column() { Image(icon) .width(24) .height(24) .objectFit(ImageFit.Contain) Text(title) .fontSize(12) .fontColor(this.currentTab === index ? '#FF3B30' : '#999999') } .justifyContentContentCenter() .width('100%') .height('100%') } build() { Tabs({ barPosition: BarPosition.End, index: this.currentTab }) { TabContent() { HomePage() } .tabBar(this.tabBuilder(0, '推荐', $r('app.media.ic_home'))) TabContent() { PlaylistPage() } .tabBar(this.tabBuilder(1, '歌单', $r('app.media.ic_list'))) TabContent() { ProfilePage() } .tabBar(this.tabBuilder(2, '我的', $r('app.media.ic_user'))) } .scrollable(false) .barHeight(56) .onChange((index: number) => { this.currentTab = index; }) } }

barPosition: BarPosition.End表示 TabBar 在底部,这是音乐类 App 的标配。scrollable(false)禁止左右滑动切换,防止用户误滑到其他页签。底部栏高度我设为56,图标用$r('app.media.xxx')引用resources/base/media下的图片资源。

这里有个细节:Tabs的TabContent内不能直接放@Builder,只能放组件或自定义组件。我把首页、歌单页、我的页分别拆成了HomePage、PlaylistPage、ProfilePage三个子组件,这样每个页面各自管理自己的状态,主入口不膨胀。

2.3 RelativeContainer和Flex布局怎么选

做播放器界面时最常见的问题是:卡片和列表项到底用哪种布局?我的经验是,需要“相对于父容器某条边或某个元素对齐”时用RelativeContainer,需要“一行内水平排列多个元素”时用Flex,两个都能用时就选简单直观的那个。

推荐页的“正在播放”卡片我用RelativeContainer做了一个右上角播放按钮:封面图、歌曲名、歌手、播放按钮散落在卡片里,播放按钮要无论封面图尺寸怎样都保持在右上角。代码简化为:

RelativeContainer() { Image(this.currentSong.cover) .width(72) .height(72) .borderRadius(12) .alignRules({ center: { anchor: '__container__', align: HorizontalAlign.Center }, middle: { anchor: '__container__', align: VerticalAlign.Center } }) Text(this.currentSong.name) .fontSize(18) .fontWeight(FontWeight.Bold) .alignRules({ left: { anchor: 'img_cover', align: HorizontalAlign.End } }) Button('播放') .alignRules({ right: { anchor: '__container__', align: HorizontalAlign.End }, bottom: { anchor: '__container__', align: VerticalAlign.Bottom } }) } .width('100%') .height(120) .id('card_container')

alignRules里的__container__是一个特殊锚点,代表父容器。你给子组件设置id后,其他组件也能以它为锚点。比如歌曲名如果要以封面图右侧对齐,就可以写left: { anchor: 'img_cover', align: HorizontalAlign.End }。需要注意的是RelativeContainer要求被锚引的组件必须设置id,而且必须先于引用它的组件在代码中出现,否则编译期不会报错,运行时会闪退。

列表项就简单得多,用Flex水平布局:

Flex({ direction: FlexDirection.Row, alignItems: ItemAlign.Center }) { Image(song.cover) .width(48) .height(48) .borderRadius(8) Column() { Text(song.name) .fontSize(16) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(song.artist) .fontSize(13) .fontColor('#999999') } .alignItems(HorizontalAlign.Start) .layoutWeight(1) Text(song.duration) .fontSize(12) .fontColor('#CCCCCC') } .width('100%') .padding({ left: 16, right: 16, top: 10, bottom: 10 })

layoutWeight(1)就是我说的“权重”,它让中间歌曲信息区域占满剩余空间,右侧时长文本自动右对齐。Flex 在处理列表项时比 RelativeContainer 快得多,因为不需要解析复杂的锚点关系。

3. 音乐播放器核心功能实现

3.1 用AVPlayer拉起音频播放

鸿蒙 API 12+ 的媒体播放核心类是AVPlayer,它在@kit.MediaKit里。整个使用流程可以归纳为:创建实例 → 设置资源 → prepare → play → 监听状态。

我先封装了一个简单的PlayerManager,用单例模式管理播放器,避免页面销毁时播放中断。核心代码:

import { media } from '@kit.MediaKit'; export class PlayerManager { private static instance: PlayerManager | null = null; private avPlayer: media.AVPlayer | null = null; private state: media.AVPlayerState = media.AVPlayerState.IDLE; static getInstance(): PlayerManager { if (!PlayerManager.instance) { PlayerManager.instance = new PlayerManager(); } return PlayerManager.instance; } async play(url: string) { if (!this.avPlayer) { this.avPlayer = await media.createAVPlayer(); this.avPlayer.on('stateChange', (state: media.AVPlayerState) => { this.state = state; }); this.avPlayer.on('error', (err) => { console.error(`AVPlayer error: ${JSON.stringify(err)}`); }); } this.avPlayer.url = url; await this.avPlayer.prepare(); await this.avPlayer.play(); } pause() { this.avPlayer?.pause(); } playNext(url: string) { this.avPlayer?.reset(); this.avPlayer!.url = url; this.avPlayer?.prepare(); this.avPlayer?.play(); } }

这里有一个很重要的点:AVPlayer不是一创建就能play的,它有一个状态机,状态包括idle、initialized、prepared、playing、paused、completed、stopped、released。设置url后进入initialized,调用prepare()后进入prepared,之后才能play()。强烈建议在 UI 层监听stateChange,因为像网络资源加载慢、文件格式不支持等问题都会反映为error事件,不监听的话播放失败你根本不知道原因。

另外,url可以是网络链接,也可以是本地文件路径。本地音频我一般用file://开头,比如file:///data/storage/el2/base/files/test.mp3。网络路径直接传https://地址即可。

3.2 从本地媒体库读取音乐列表

播放器不能光播放单曲,还得能从手机媒体库读出音频列表。HarmonyOS 提供PhotoAccessHelper来访问媒体库,但需要注意权限。

第一步,在entry/src/main/module.json5里声明权限:

{ "module": { "requestPermissions": [ { "name": "ohos.permission.READ_MEDIA" } ] } }

第二步,在页面中动态请求权限,不能只声明不请求。API 12 的权限分系统授权和用户授权,READ_MEDIA属于用户授权类,必须弹窗询问。我用abilityAccessCtrl发起请求:

import { abilityAccessCtrl, Permissions } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit'; async function requestPermission(context: common.UIAbilityContext): Promise<boolean> { const permissions: Array<Permissions> = ['ohos.permission.READ_MEDIA']; const atManager = abilityAccessCtrl.createAtManager(); try { const result = await atManager.requestPermissionsFromUser(context, permissions); const grantStatus = result.authResults[0]; return grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED; } catch (err) { console.error('权限请求失败: ' + JSON.stringify(err)); return false; } }

第三步,用photoAccessHelper获取音频资源。注意媒体库的音频在PhotoAsset的mediaType为mediaType.VIDEO和mediaType.IMAGE之外,还有一个mediaType.AUDIO。获取列表的代码:

import { photoAccessHelper } from '@kit.MediaLibraryKit'; async function loadAudioList(context: common.UIAbilityContext) { const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context); const fetchOp: photoAccessHelper.FetchOptions = { selections: '', selectionArgs: [], sort: { key: 'date_added', isAsc: false } }; const fetchResult = await phAccessHelper.getAssets(fetchOp); const assets = await fetchResult.getAllObjects(); return assets.filter(item => item.mediaType === photoAccessHelper.PhotoViewModes.ALL); }

实际项目中还需要按mediaType过滤出纯音频,这里我偷懒写了示例。如果你只做一个“播放器壳子”,也可以把固定几首网络音频放进rawfile目录,就不用走媒体库权限,开发阶段更快。

3.3 播放进度、歌词和通知栏控制

播放进度我用Slider组件显示,配合一个每秒触发一次的定时器更新。

@State currentTime: number = 0; @State duration: number = 0; private timerId: number = -1; startProgressTimer() { this.timerId = setInterval(() => { if (this.avPlayer) { this.currentTime = this.avPlayer.currentTime; this.duration = this.avPlayer.duration; } }, 1000); }

setInterval在 ArkTS 里没问题,但页面退出时记得clearInterval,否则会内存泄漏。进度条拖动时应该先暂停自动更新,等用户松手后再根据 slider 的onChange事件调用seek,否则一边拖动一边被定时器拉回去,体验很糟糕。

通知栏控制是让播放器具备基本后台能力的关键。HarmonyOS 提供AVSession来统一管理媒体会话,做到锁屏显示封面、通知栏播放/暂停按钮、耳机线控等。引入@kit.AVSessionKit后,创建一个AVSession并设置元数据:

import { avSession } from '@kit.AVSessionKit'; async function createSession() { const session = await avSession.createAVSession(context, 'MusicPlayer', avSession.AVSessionType.AUDIO); await session.setAVMetadata({ title: this.currentSong.name, artist: this.currentSong.artist, album: this.currentSong.album }); await session.setAVPlaybackState({ state: avSession.PlaybackState.PLAYING, position: this.currentTime, duration: this.duration }); }

创建AVSession之后,系统通知栏会和播放器联动,用户在通知栏点暂停,session会发出命令,你还需要监听play、pause事件来相应控制播放器。这一部分是后台播放的基本盘,如果跳过了,应用切到后台后系统可能会暂停播放音轨。

如果你希望应用退到后台仍能持续播放,还需要申请“长任务”权限,使用@kit.BackgroundTasksKit的continuousTaskManager声明audio类型任务。这部分涉及系统资源管理,不展开写,但你需要在module.json5里声明ohos.permission.KEEP_BACKGROUND_RUNNING,并且用户要授权后台任务。我的经验是很多真机测试时权限会弹窗,选择“允许”才能后台继续。

4. 页面之间的数据流转与状态同步

4.1 全局播放器单例还是页面内播放

最初我图省事,把AVPlayer写在HomePage里,结果切到歌单页再返回,播放器就停了,因为页面被Tabs缓存后组件状态不稳定。后来我改成全局单例PlayerManager,所有页面都通过它操作播放器,UI 自己的状态只负责展示。

单例模式在 ArkTS 里写起来很直接,见上面的PlayerManager。但要解决一个问题:播放进度需要多个页面同时更新。比如播放页显示进度条、列表页显示正在播放的歌曲名,如果都去轮询PlayerManager,会很脆。我用了 AppStorage 来做全局状态共享:

AppStorage.setOrCreate('currentSong', { name: '晴天', artist: '周杰伦', cover: $r('app.media.cover_qingtian'), url: 'https://example.com/qingtian.mp3' }); AppStorage.setOrCreate('isPlaying', false);

页面里用@StorageProp('isPlaying')或@StorageLink('isPlaying')来读取和同步状态。@StorageProp是单向同步,页面改变不写回全局;@StorageLink是双向同步。播放器状态这种“多处展示、只在一处改”的场景,用@StorageProp就够了。

4.2 用@Prop和@Link给子组件传参

歌单列表页里,每一个歌曲项可以封装成一个子组件SongItem。父组件通过数组渲染时传原始值,但子组件内部最好不要直接修改父组件数据。如果只是展示,用@Prop接收普通对象;如果子组件要修改父组件的某个状态,比如点击列表项要把当前歌曲传给播放页,那就用回调函数或者@Link。

我的做法是列表项只负责回调onSongClick:

@Component struct SongItem { @Prop song: Song; @Prop isPlaying: boolean = false; onSongClick: () => void = () => {}; build() { Row() { // ... } .onClick(() => { this.onSongClick(); }) } }

在父组件中:

ForEach(this.songList, (song: Song) => { SongItem({ song: song, isPlaying: PlayerManager.getInstance().isCurrentSong(song.id) }) .onSongClick(() => { PlayerManager.getInstance().play(song.url); AppStorage.setOrCreate('currentSong', song); }) }, (song: Song) => song.id)

注意ForEach的第三个参数是一个键值生成器,我用song.id保证列表复用正确。如果歌曲没有唯一id,删除或换序时会出各种怪异问题。

4.3 播放模式切换和歌词滚动

播放模式(单曲循环、列表循环、随机播放)我放在PlayerManager里,用一个枚举控制:

enum PlayMode { ORDER = 1, SINGLE = 2, RANDOM = 3 }

切换模式后,在AVPlayer的stateChange事件监听里判断completed状态,再取下一首。这里最容易被忽略的是 “completed 后不能直接再调play()”,必须先reset()或重新prepare()。我一开始在完成事件里直接play(),状态卡在completed不动,后来看了日志才找到正确顺序:监听completed→ 选择下一首 URL → 调用reset()→ 设置新 URL →prepare()→play()。

歌词滚动属于复杂的自定义组件,这里给一个基础思路:用Scroll组件包含歌词行数组,根据当前播放时间不断计算应该滚动的偏移量,调用ScrollController.scrollTo将当前句歌词移到中间。想做得精细可以等播放器基本稳定后再加。新手不要一上来就写歌词同步,先把播放、暂停、切歌、进度条跑通。

5. 排坑实录与发布前检查

5.1 模拟器没有声音?赶紧换真机

我开发前期一直在本地模拟器上跑,界面正常,但AVPlayer状态走到playing后没有任何声音。查了各种文档,最后在社区里看到有人提模拟器的音频输出有设备兼容限制。我自己的经验是:模拟器只适合调 UI,涉及音频播放、媒体会话、后台长任务这些能力,一律用真机。真机调试时需要开启开发者模式并在 DevEco Studio 的“设备”面板连接华为手机。如果hdc list targets看不到设备,先换一根数据传输线,很多 USB 线只能充电不能传数据。

5.2 hilog 日志定位崩溃和状态异常

写鸿蒙应用,崩溃排查最好的工具是hilog。它可以按标签过滤日志,也可以从应用内把日志打出来。我在PlayerManager里每个关键方法都加了日志:

import { hilog } from '@kit.PerformanceAnalysisKit'; hilog.info(0x0000, 'MusicPlayer', '准备播放: %{public}s', url);

调试时终端执行:

hdc shell hilog | grep MusicPlayer

这样能实时看到AVPlayer的状态变化和错误信息。如果遇到页面闪退,先看onWindowStageCreate里loadContent的err参数,大多数找不到页面、路径错误、组件语法错误都会走到这里。有一次我的页面加载失败是因为在@Builder里写了逻辑判断语句,ArkTS 要求@Builder方法体中只允许布局代码,不允许复杂的业务逻辑,这个错误提示不明显,浪费了不少时间。

5.3 签名配置与上架要用官方渠道

开发阶段用自动签名就行,但发布到真机需要手动签名。DevEco Studio 的 “File → Project Structure → Signing Configs” 里可以勾选 “Automatically generate signature”,前提是你登录了华为账号。生成的文件包括.p12、.cer、.p7b,这些是打包和上架必须的,不要泄露给任何人。配置好后打开 “Build → Build Hap(s)/APP(s)”,产物在entry/build/default/outputs/default/下,.hap是应用包,上架时要把.app包上传到 AppGallery Connect。

关于上架渠道,我见过很多人到处找第三方 HAP 资源站或者解包工具,比如网上流传的hap-store、微信解包之类,我的建议是不要碰。这些来源的包权属不明,还可能被注入恶意代码。音乐播放器涉及用户媒体库和通知栏权限,一旦被恶意利用,结果非常麻烦。老老实实走官方 AGC 上架,既可以发应用,也可以发元服务,审核流程成熟。

5.4 提醒:别被“鸿蒙 PC 版”这类热搜带偏

热词里出现不少“开源鸿蒙 pc 版官网下载”“鸿蒙系统 pc 版安装”之类的内容,和开发音乐播放器没什么关系。开源鸿蒙(OpenHarmony)的 PC 版是另一个技术分支,普通应用开发不需要去下载 ISO 刷机。同样,网上关于“鸿蒙系统回退”“旧机型升级”的话题也不是应用开发者该关心的。你只需要关注 DevEco Studio、SDK 版本、API 文档和真机调试,足够把播放器做好。如果看到“鸿蒙开发基础认证”“底部导航栏”“RelativeContainer Flex Tabs”这些词,说明你已经在正确的学习路线上,继续刷官方文档就行。

发布前最后检查一遍:权限是否最少化,只申请READ_MEDIA和后台长任务;音频资源是否有版权;AVPlayer在页面销毁时是否正确release;通知栏的封面图是否压缩过。这些细节决定你上架后用户会不会给差评。

我个人在实际操作中的体会是:鸿蒙开发最大的门槛不是 ArkTS 语法,而是很多能力散落在不同 Kit 里,你需要花时间把MediaKit、AVSessionKit、AbilityKit、ArkUI的关系理清楚。写播放器是一个特别好的练习项目,因为它强迫你同时接触状态管理、媒体能力、权限系统和后台任务。如果你能把这个项目完整跑通,再去啃其他鸿蒙应用就轻松多了。最后再分享一个小技巧:调试后台播放时,一定要在真机上按 Home 键退到桌面,观察通知栏和锁屏界面是否正常,很多模拟器不会暴露这类问题。祝你把播放器尽快跑起来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询