HarmonyOS PC实战系列之音乐播放器的状态设计——六个 @State 变量如何驱动完整播放逻辑
文章目录
- 六个核心状态
- 派生状态用 get 访问器
- 进度状态的双向绑定
- 播放模式的轮转切换
- 完整代码
- 为什么不用 @State 存当前歌曲对象
- 数组状态的更新
- 小结
做过播放器的人都知道:UI 搭起来不难,难的是状态管理。播放/暂停、进度、音量、收藏、播放模式……这些状态之间互相关联,一个变了其他的要跟着响应。
拿 HarmonyOS PC 端的音乐播放器来说,最小可用的状态集需要六个变量。把这六个变量设计清楚,播放器的核心交互就基本完成了。
六个核心状态
@StateisPlaying:boolean=false// 是否正在播放@StatecurrentIndex:number=0// 当前歌曲索引@Stateprogress:number=0// 播放进度 0~1@Statevolume:number=0.7// 音量 0~1@StateisFavorite:boolean=false// 当前歌曲收藏状态@StateplayMode:'sequence'|'random'|'loop'='sequence'// 播放模式每个状态职责单一,互不重叠。
派生状态用 get 访问器
当前歌曲对象不需要单独的@State,用get访问器从currentIndex派生:
getcurrentSong():SongItem{returnthis.songs[this.currentIndex]}这样currentIndex改变,所有用到this.currentSong的地方自动刷新,不需要手动同步。
这是 ArkTS 状态设计的基本原则:存储最小的原始状态,其他的派生。不要存两份能互相推导的数据,否则两份数据不同步时就出 bug 了。
进度状态的双向绑定
进度条拖拽改变progress,同时progress也在模拟播放时自动递增。这是典型的"可写可读"状态:
// 用户拖拽时更新进度Slider({value:this.progress*100}).onChange((v)=>{this.progress=v/100})// 显示已播放时间Text(this.formatTime(Math.floor(this.progress*this.currentSong.duration)))注意progress存的是 0~1 的浮点数,不是秒数。这样和Slider的百分比换算只在一处做,其他地方统一用progress * duration换算成秒。
播放模式的轮转切换
播放模式三选一,用轮转逻辑比 if-else 优雅:
togglePlayMode(){constmodes:Array<'sequence'|'random'|'loop'>=['sequence','random','loop']constidx=modes.indexOf(this.playMode)this.playMode=modes[(idx+1)%3]}点一次按钮就切到下一个模式,不需要写三个 if 分支。
完整代码
interfaceSongItem{id:numbertitle:stringartist:stringalbum:stringduration:numberthemeColor:string}constDEFAULT_SONG:SongItem={id:1,title:'星辰大海',artist:'黄霄雲',album:'破晓',duration:243,themeColor:'#6366F1'}@Entry@Componentstruct PcMusicPlayerPage{@StatecurrentIndex:number=0@StatecurrentSong:SongItem=DEFAULT_SONG@StateisPlaying:boolean=false@Stateprogress:number=0.35@Statevolume:number=0.7@StateisFavorite:boolean=false@StateshowLyrics:boolean=true@StateplayMode:'sequence'|'random'|'loop'='sequence'songs:SongItem[]=[DEFAULT_SONG,{id:2,title:'少年',artist:'梦然',album:'少年',duration:258,themeColor:'#3B82F6'},{id:3,title:'起风了',artist:'买辣椒也用券',album:'起风了',duration:312,themeColor:'#10B981'},{id:4,title:'平凡之路',artist:'朴树',album:'猎户星座',duration:296,themeColor:'#F59E0B'},{id:5,title:'追光者',artist:'岑宁儿',album:'你好,旧时光',duration:267,themeColor:'#EF4444'},]syncCurrentSong(){this.currentSong=this.songs[this.currentIndex]||DEFAULT_SONG}formatTime(seconds:number):string{constm=Math.floor(seconds/60)consts=Math.floor(seconds%60)return`${m}:${s<10?'0':''}${s}`}prevSong(){this.currentIndex=(this.currentIndex-1+this.songs.length)%this.songs.lengththis.syncCurrentSong()this.progress=0}nextSong(){this.currentIndex=(this.currentIndex+1)%this.songs.lengththis.syncCurrentSong()this.progress=0}@BuildersongListItem(song:SongItem,index:number){Row({space:12}){Text(`${index+1}`).fontSize(12).fontColor('#9CA3AF').width(20).textAlign(TextAlign.Center)Column({space:2}){Text(song.title).fontSize(14).fontColor(index===this.currentIndex?song.themeColor:'#1F2937').fontWeight(index===this.currentIndex?FontWeight.Medium:FontWeight.Normal).maxLines(1).textOverflow({overflow:TextOverflow.Ellipsis})Text(song.artist).fontSize(11).fontColor('#9CA3AF')}.layoutWeight(1).alignItems(HorizontalAlign.Start)Text(this.formatTime(song.duration)).fontSize(12).fontColor('#9CA3AF')}.width('100%').padding({left:16,right:16,top:12,bottom:12}).backgroundColor(index===this.currentIndex?`${song.themeColor}10`:Color.Transparent).onClick(()=>{this.currentIndex=indexthis.syncCurrentSong()this.progress=0})}build(){Column({space:0}){// 主体区域(左侧歌单 + 中间播放 + 右侧歌词)Row({space:0}){// 左侧歌单列表Column({space:0}){Row(){Text('播放列表').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#111827')Text(`${this.songs.length}首`).fontSize(12).fontColor('#9CA3AF')}.width('100%').justifyContent(FlexAlign.SpaceBetween).padding({left:20,right:20,top:20,bottom:16})Divider().strokeWidth(1).color('#F3F4F6')Scroll(){Column(){ForEach(this.songs,(song:SongItem,index:number)=>{this.songListItem(song,index)})}}.layoutWeight(1)}.width(240).height('100%').backgroundColor(Color.White).border({width:{right:1},color:'#F3F4F6'})// 中间播放主区Column({space:0}){Scroll(){Column({space:32}){// 封面Text('').aspectRatio(1).width(240).borderRadius(20).backgroundColor(this.currentSong.themeColor).shadow({radius:40,color:`${this.currentSong.themeColor}50`,offsetY:16})// 歌曲信息Column({space:6}){Row({space:12}){Text(this.currentSong.title).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#111827').layoutWeight(1)Text(this.isFavorite?'❤️':'🤍').fontSize(22).onClick(()=>{this.isFavorite=!this.isFavorite})}Text(this.currentSong.artist+' · '+this.currentSong.album).fontSize(14).fontColor('#6B7280')}.width('100%').alignItems(HorizontalAlign.Start)// 进度条Column({space:8}){Slider({value:this.progress*100,min:0,max:100}).width('100%').selectedColor(this.currentSong.themeColor).onChange((v)=>{this.progress=v/100})Row(){Text(this.formatTime(Math.floor(this.progress*this.currentSong.duration))).fontSize(12).fontColor('#9CA3AF')Text(this.formatTime(this.currentSong.duration)).fontSize(12).fontColor('#9CA3AF')}.width('100%').justifyContent(FlexAlign.SpaceBetween)}// 控制按钮Row(){Text(this.playMode==='random'?'🔀':this.playMode==='loop'?'🔂':'🔁').fontSize(20).fontColor(this.playMode!=='sequence'?this.currentSong.themeColor:'#6B7280').onClick(()=>{constmodes:Array<'sequence'|'random'|'loop'>=['sequence','random','loop']constidx=modes.indexOf(this.playMode)this.playMode=modes[(idx+1)%3]})Text('⏮').fontSize(28).fontColor('#374151').onClick(()=>{this.prevSong()})Text(this.isPlaying?'⏸':'▶').fontSize(44).fontColor(this.currentSong.themeColor).onClick(()=>{this.isPlaying=!this.isPlaying})Text('⏭').fontSize(28).fontColor('#374151').onClick(()=>{this.nextSong()})Text('🔊').fontSize(20).fontColor('#6B7280')}.width('100%').justifyContent(FlexAlign.SpaceBetween).padding({left:16,right:16})// 音量Row({space:12}){Text('🔈').fontSize(16).fontColor('#9CA3AF')Slider({value:this.volume*100,min:0,max:100}).layoutWeight(1).selectedColor(this.currentSong.themeColor).onChange((v)=>{this.volume=v/100})Text('🔊').fontSize(16).fontColor('#9CA3AF')}.width('100%')}.padding({left:48,right:48,top:40,bottom:40}).alignItems(HorizontalAlign.Center)}.layoutWeight(1)}.layoutWeight(1).height('100%').backgroundColor('#FAFAFA')// 右侧歌词面板if(this.showLyrics){Column({space:0}){Text('歌词').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#111827').padding({left:20,top:20,bottom:16}).width('100%')Divider().strokeWidth(1).color('#F3F4F6')Scroll(){Column({space:8}){ForEach(['前方的路虽然遥远','我也要一步一步走下去','星辰大海是我的方向','即使迷雾遮住了视线','心中有光指引我前行','每一步都算数','不停歇不放弃','终将到达彼岸',''],(line:string)=>{Text(line).fontSize(13).fontColor('#6B7280').lineHeight(28).textAlign(TextAlign.Center).width('100%')})}.padding({left:16,right:16,top:24,bottom:24})}.layoutWeight(1)}.width(280).height('100%').backgroundColor(Color.White).border({width:{left:1},color:'#F3F4F6'})}}.layoutWeight(1).width('100%')}.width('100%').height('100%').constraintSize({minWidth:800})}}为什么不用 @State 存当前歌曲对象
很多人第一反应是@State currentSong: SongItem = ...。但这样就有两份数据:currentIndex和currentSong,切歌时必须同时更新两个,不同步就出 bug。
用get currentSong()派生,切歌只改currentIndex一个变量,currentSong自动跟着变,没有数据不一致的风险。
数组状态的更新
点击收藏时,直接修改songs[i].isLiked不会触发 UI 刷新,因为 ArkTS 的响应式系统追踪的是songs这个引用,不是数组内部的属性变化。
正确做法是替换整个数组:
this.songs=this.songs.map(s=>s.id===id?{...s,isLiked:!s.isLiked}:s)这是 ArkTS 状态更新的标准模式。每次"修改"其实是创建一个新数组,引用变了,响应式系统检测到变化,UI 刷新。
小结
这篇的核心是状态设计的思维方式:存最小的原始状态,其他的派生;更新状态时替换引用而不是修改内部属性。这两条原则在 ArkTS 里反复适用,记住了就能避开大部分状态管理的坑。
