84、iOS音乐播放开发全解析

iOS音乐播放开发全解析

1. 音乐库的持久化与变更

在音乐库的管理中, MPMediaEntity 有一个重要属性 persistentID ,它能唯一标识一个实体。无论是歌曲、专辑、艺术家还是作曲家等,都有各自的 persistentID 。即使两首歌曲或两个播放列表可能有相同的标题,但 persistentID 是独一无二的,且具有持久性。通过这个 ID,即使在应用重新启动后,也能再次获取之前检索到的同一首歌曲或播放列表。

当进行音乐搜索并维护搜索结果时,音乐库的内容可能会发生变化。例如,用户将设备连接到电脑,使用 iTunes 添加或删除音乐,这可能导致搜索结果过时。为了解决这个问题,可以通过 MPMediaLibrary 类获取音乐库的修改日期。具体操作步骤如下:
1. 调用类方法 default 获取实际的音乐库实例。
2. 调用实例的 lastModifiedDate 属性获取最后修改日期。

此外,还可以注册接收音乐库修改的通知 .MPMediaLibraryDidChange 。不过,在接收该通知之前,需要先调用 MPMediaLibrary 实例方法 beginGeneratingLibraryChangeNotifications ,并且在不需要时调用 endGeneratingLibraryChangeNotifications 进行平衡。

2. 音乐播放器

MPMusicPlayerController 是用于播放 MPMediaItem 的类。它并非单纯播放单个项目,而是从项目队列中进行播放,这与 iTunes 和音乐应用的行为类似。例如,在音乐应用中点击播放列表的第一首歌曲,播放完后会默认播放列表中的下一首歌曲,这是因为点击第一首歌曲会将播放列表中的所有歌曲设置为队列。音乐播放器的行为也是如此,当一首歌曲播放结束后,会继续播放队列中的下一首。

控制播放的方法反映了音乐播放器基于队列的特性。除了常见的 play prepareToPlay pause stop 命令外,还有 skipToNextItem skipToPreviousItem 命令。停止播放器会清空其队列,而暂停则不会。 prepareToPlay 方法接受一个完成函数,可在其中接收错误参数。奇怪的是,调用 prepareToPlay 会使音乐播放器开始播放,就像同时调用了 play 一样,这可能是一个 bug。因此,可以将 prepareToPlay 作为尝试开始播放并检查是否有错误的方法。

音乐播放器有两种类型,取决于获取实例时使用的类属性:
| 类型 | 描述 |
| ---- | ---- |
| systemMusicPlayer | 与音乐应用使用的是同一个播放器。在应用运行期间,它可能正在播放项目,也可能暂停在某个当前项目上。可以了解或更改当前播放的项目。该播放器独立于应用状态继续播放,具有完整的内置用户界面(即音乐应用本身),用户可以随时更改其操作,并且会自动与远程播放控件进行通信。 |
| applicationQueuePlayer | 是一个独立于音乐应用的播放器,其播放的歌曲可能与音乐应用当前播放的歌曲不同。不过,它并不真正在应用内部,有自己的音频会话。当调用其播放时,会中断应用的音频会话。如果应用支持音频后台模式,即使应用进入后台,该播放器也会继续播放,即使应用的音频会话类别不是播放类别。与 systemMusicPlayer 一样,它也会自动与远程播放控件通信,允许用户通过暂停、查找和在队列中前后跳过等操作进行控制。 |

为音乐播放器设置队列,可以调用 setQueue(with:) 方法,其参数可以是以下几种类型:
- 查询(Query) :将 MPMediaQuery 传递给音乐播放器,查询的项目即为队列中的项目。
- 集合(Collection) :传递 MPMediaItemCollection ,可以从执行的查询中派生,也可以自行组装 MPMediaItem 数组,然后调用 MPMediaItemCollection init(items:) 方法创建集合。
- 描述符(Descriptor) :传递 MPMusicPlayerQueueDescriptor ,该类在 iOS 10.1 中引入,允许队列包含 Apple Music 歌曲。它是抽象类,具体子类包括:
- MPMusicPlayerPlayParametersQueueDescriptor
- MPMusicPlayerStoreQueueDescriptor
- MPMusicPlayerMediaItemQueueDescriptor

前两个与 Apple Music 相关,这里不做进一步讨论。 MPMusicPlayerMediaItemQueueDescriptor 有两个初始化方法 init(query:) init(itemCollection:) 。设置音乐播放器队列时不一定需要使用这个类,但在修改队列时会很有用。

根据经验,如果在设置队列后不立即要求播放器播放,它可能会出现意外行为。显然,队列在调用播放之前不会真正生效,但也不能立即调用播放,否则播放尝试可能会失败。因此,在设置队列和开始播放之间插入一个短暂的延迟。此外,在设置队列之前让播放器停止似乎是最可靠的做法,示例代码如下:

player.stop()
player.setQueue(with: query)
delay(0.2) {
    player.prepareToPlay { err in
        if let err = err {
            print(err)
        }
    }
}

下面是一个示例,收集音乐库中所有时长小于 30 秒的歌曲,并使用 applicationQueuePlayer 以随机顺序播放:

let player = MPMusicPlayerController.applicationQueuePlayer
DispatchQueue.global(qos:.userInitiated).async {
    let query = MPMediaQuery.songs()
    let isPresent = MPMediaPropertyPredicate(value:false,
        forProperty:MPMediaItemPropertyIsCloudItem,
        comparisonType:.equalTo)
    query.addFilterPredicate(isPresent)
    guard let items = query.items else {return}
    let shorties = items.filter {
        let dur = $0.playbackDuration
        return dur < 30
    }
    guard shorties.count > 0 else {
        print("no songs that short!")
        return
    }
    let queue = MPMediaItemCollection(items:shorties)
    DispatchQueue.main.async {
        player.stop()
        player.shuffleMode = .songs
        player.setQueue(with:queue)
        delay(0.2) {
            player.prepareToPlay { err in
                if let err = err {
                    print(err)
                }
            }
        }
    }
}

可以通过 nowPlayingItem 属性获取音乐播放器当前正在播放的项目,由于它是 MPMediaItem ,可以通过其属性了解该项目的所有信息。还可以通过 indexOfNowPlayingItem 属性询问队列中当前正在播放的歌曲的索引。不过,无法询问 systemMusicPlayer 的实际队列,但可以通过调用 perform(queueTransaction:completionHandler:) 以一种迂回的方式获取 applicationQueuePlayer 的当前队列。

修改播放器现有队列有两种不同的方法:
- 立即播放和稍后播放 :调用 prepend(_:) append(_:) 方法。苹果将这些方法描述为相当于“立即播放”和“稍后播放”功能, prepend(_:) 在当前播放项目之后插入队列,而 append(_:) 在队列末尾插入。参数是 MPMusicPlayerQueueDescriptor
- 插入和移除 :此功能仅适用于 applicationQueuePlayer ,具体操作步骤如下:
1. 调用 perform(queueTransaction:completionHandler:)
2. 在 queueTransaction: 函数内部,参数是 MPMusicPlayerControllerMutableQueue ,这是 MPMusicPlayerControllerQueue 的可变子类,可以使用其 items 属性检查队列。
3. 在可变队列上调用 insert(_:after:) remove(_:) 方法。 insert(_:after:) 的第一个参数是 MPMusicPlayerQueueDescriptor
4. 如果需要,可以实现 completionHandler: 函数,其第一个参数是 MPMusicPlayerControllerQueue ,可用于检查插入或移除操作对队列的影响,第二个参数表示是否有错误。

在实验中发现, insert(_:after:) 方法只有在第二个参数为 nil 时才会生效,即插入到当前播放项目之后(与 prepend(_:) 相同),并且 perform(queueTransaction:completionHandler:) 的完成函数调用过早,不能正确反映插入或移除命令后队列的状态,这被认为是重大 bug。

音乐播放器有一个 playbackState 属性,可以查询其当前状态(播放、暂停、停止或查找)。不要使用 currentPlaybackRate 来判断播放器是否正在播放,因为它不如 playbackState 可靠。音乐播放器还会发出通知,告知其状态的变化:
- .MPMusicPlayerControllerPlaybackStateDidChange
- .MPMusicPlayerControllerNowPlayingItemDidChange
- .MPMusicPlayerControllerVolumeDidChange

在接收这些通知之前,需要调用 beginGeneratingPlaybackNotifications 方法,并且在不需要时调用 endGeneratingPlaybackNotifications 进行平衡。这是一个实例方法,因此可以安排从两个音乐播放器中的任何一个接收通知。如果同时从两个播放器接收通知,可以通过检查通知的 object 并与每个播放器进行比较来区分它们。

以下是一个示例,扩展前面的示例,每次播放不同的歌曲时更新界面中 UILabel 的文本:

player.beginGeneratingPlaybackNotifications()
NotificationCenter.default.addObserver(self,
    selector: #selector(self.changed),
    name: .MPMusicPlayerControllerNowPlayingItemDidChange,
    object: player)

@objc func changed(_ n:Notification) {
    self.label.text = ""
    let player = MPMusicPlayerController.applicationQueuePlayer
    guard let obj = n.object, obj as AnyObject === player else {return}
    guard let title = player.nowPlayingItem?.title else {return}
    if player.playbackState != .playing {return}
    let ix = player.indexOfNowPlayingItem
    guard ix != NSNotFound else {return}
    player.perform(queueTransaction: { _ in }) { q,_ in
        self.label.text = "\(ix+1) of \(q.items.count): \(title)"
    }
}

由于在歌曲播放过程中没有周期性通知来更新当前播放位置,因此需要进行轮询。只要轮询间隔合理,这种方法是可行的,虽然显示可能偶尔会稍微落后于实际情况,但通常不会有太大影响。以下是一个示例,添加一个 UIProgressView 来显示音乐播放器当前播放歌曲的百分比:

self.timer = Timer.scheduledTimer(timeInterval:1,
    target: self, selector: #selector(self.timerFired),
    userInfo: nil, repeats: true)
self.timer.tolerance = 0.1

@objc func timerFired(_: Any) {
    let player = MPMusicPlayerController.applicationQueuePlayer
    guard let item = player.nowPlayingItem,
        player.playbackState != .stopped else {
            self.prog.isHidden = true
            return
    }
    self.prog.isHidden = false
    let current = player.currentPlaybackTime
    let total = item.playbackDuration
    self.prog.progress = Float(current / total)
}
3. MPVolumeView

媒体播放器框架提供了一个滑块 MPVolumeView ,用于让用户设置系统输出音量,如有需要还会显示 AirPlay 路由按钮。它的定制方式与 UISlider 类似,可以设置轨道的两半、拇指以及 AirPlay 路由按钮在正常和高亮状态(用户触摸拇指时)的图像。

为了进一步定制,可以子类化 MPVolumeView 并覆盖 volumeSliderRect(forBounds:) 方法。虽然文档中还记录了一个可覆盖的方法 volumeThumbRect(forBounds:volumeSliderRect:value:) ,但在测试中从未被调用,被认为是一个 bug。

MPVolumeView 会自动更新以反映系统输出音量的变化。还可以注册接收无线路由(蓝牙或 AirPlay)出现或消失以及无线路由变为活动或非活动状态的通知:
- .MPVolumeViewWirelessRoutesAvailableDidChange
- .MPVolumeViewWirelessRouteActiveDidChange

4. 使用 AV Foundation 播放歌曲

MPMusicPlayerController 方便易用,但也有一定的局限性。它并不真正属于开发者,具有固定的音乐播放行为,类似于 iTunes 或音乐应用,其音频会话不是开发者的音频会话,并且会控制远程命令中心。如果不希望这样,可以考虑使用其他方法播放 MPMediaItem

表示用户音乐库中文件的 MPMediaItem 有一个 assetURL 属性,其值是一个文件 URL。因此,可以使用该 URL 初始化 AVAudioPlayer AVAsset AVPlayer 。每种访问 MPMediaItem 的方式都有其优点:
- AVAudioPlayer :易于使用,可以循环播放声音、轮询其通道的功率值等。
- AVAsset :提供了 AV Foundation 框架的全部功能,可以编辑声音、组合多个声音、执行淡出效果,甚至将声音附加到视频(然后使用 AVPlayer 播放)。
- AVPlayer :可以分配给 AVPlayerViewController ,提供内置的播放/暂停按钮和播放头滑块。 AVPlayerViewController 会自动管理远程命令中心,但如果不需要该功能可以关闭。

以下是一个使用 AVQueuePlayer AVPlayer 的子类)播放一系列 MPMediaItem 的示例:

let arr = // array of MPMediaItem
let items = arr.map {
    let url = $0.assetURL!
    let asset = AVAsset(url:url)
    return AVPlayerItem(asset: asset)
}
self.qp = AVQueuePlayer(items:items)
self.qp.play()

不过,不建议一次性将所有 AVPlayerItem 添加到 AVQueuePlayer 中,而是先添加几个项目,然后在一个项目播放结束后再追加其他项目。以下是改进后的示例:

let arr = // array of MPMediaItem
self.items = arr.map {
    let url = $0.assetURL!
    let asset = AVAsset(url:url)
    return AVPlayerItem(asset: asset)
}
let seed = min(3,self.items.count)
self.qp = AVQueuePlayer(items:Array(self.items.prefix(upTo:seed)))
self.items = Array(self.items.suffix(from:seed))
// use .initial option so that we get an observation for the first item
let ob = qp.observe(\.currentItem, options: .initial) { _,_ in
    self.changed()
}
self.obs.insert(ob) // self.obs is a Set<NSKeyValueObservation>
self.qp.play()

changed 方法中,从 items 数组的前端取出一个 AVPlayerItem 并添加到 AVQueuePlayer 队列的末尾。由于 AVQueuePlayer 在播放完一个项目后会自动从队列开头删除该项目,因此队列长度不会超过三个项目:

guard let item = self.qp.currentItem else {return}
guard self.items.count > 0 else {return}
let newItem = self.items.removeFirst()
self.qp.insert(newItem, after:nil) // means "at end"

由于每次有新歌曲开始播放时都会收到通知,因此可以插入一些代码来更新标签的文本,显示每首歌曲的标题:

var arr = item.asset.commonMetadata
arr = AVMetadataItem.metadataItems(from:arr,
    withKey:AVMetadataKey.commonKeyTitle,
    keySpace:.common)
let met = arr[0]
let value = #keyPath(AVMetadataItem.value)
met.loadValuesAsynchronously(forKeys:[value]) {
    if met.statusOfValue(forKey:value, error:nil) == .loaded {
        guard let title = met.value as? String else {return}
        DispatchQueue.main.async {
            self.label.text = "\(title)"
        }
    }
}

还可以更新进度视图以反映当前项目的当前时间和持续时间。与 MPMusicPlayerController 不同,不需要使用 Timer 进行轮询,可以在 AVQueuePlayer 上安装时间观察者。需要将时间观察者保留在一个属性中:

self.timeObserver = self.qp.addPeriodicTimeObserver(
    forInterval: CMTime(seconds:0.5, preferredTimescale:600),
    queue: nil) { [unowned self] t in
        self.timerFired(time:t)
}

为了让 AVPlayerItem 加载其 duration 属性,需要修改初始化代码:

let url = $0.assetURL!
let asset = AVAsset(url:url)
return AVPlayerItem(asset: asset,
    automaticallyLoadedAssetKeys: [#keyPath(AVAsset.duration)])

时间观察者会定期调用 timerFired 方法,报告当前播放器项目的当前时间,然后获取当前项目的持续时间并更新进度视图:

func timerFired(time:CMTime) {
    if let item = self.qp.currentItem {
        let asset = item.asset
        let dur = #keyPath(AVAsset.duration)
        if asset.statusOfValue(forKey:dur, error: nil) == .loaded {
            let dur = asset.duration
            self.prog.setProgress(Float(time.seconds/dur.seconds),
                animated: false)
        }
    }
}
5. 媒体选择器

媒体选择器 MPMediaPickerController 是一个视图控制器,其视图是一个独立的导航界面,用户可以在其中从音乐库中选择媒体项目,类似于音乐应用。通常将选择器作为呈现的视图控制器处理。

与访问音乐库的任何操作一样,媒体选择器需要用户授权。如果在未授权的情况下呈现媒体选择器,不会有任何惩罚,但不会有任何操作(并且选择器会报告用户取消)。

可以使用初始化方法 init(mediaTypes:) 限制显示的媒体项目类型,可以在导航栏顶部显示提示信息( prompt ),可以通过 allowsPickingMultipleItems 属性控制用户是否可以选择多个媒体项目,还可以通过将 showsCloudItems 设置为 false 过滤掉存储在云端的项目。

从 iOS 9 开始, mediaTypes 的值 .podcast .audioBook 不起作用,这可能是因为播客被认为是播客应用的范畴,有声读物被认为是图书应用的范畴,而不是音乐应用。可以在用户的音乐库中看到播客和有声读物作为 MPMediaEntity 对象,但不能通过 MPMediaPickerController 查看。

在媒体选择器控制器的视图显示时,通过两个委托方法( MPMediaPickerControllerDelegate )了解用户的操作。呈现的视图控制器不会自动关闭,因此需要在这些委托方法中关闭它:
- mediaPicker(_:didPickMediaItems:)
- mediaPickerDidCancel(_:)

委托方法的行为取决于控制器的 allowsPickingMultipleItems 值:
| allowsPickingMultipleItems 值 | 描述 |
| ---- | ---- |
| false (默认值) | 有一个取消按钮。当用户点击媒体项目时,会调用 mediaPicker(_:didPickMediaItems:) 方法,传递一个包含该项目的 MPMediaItemCollection ,此时可能会关闭呈现的视图控制器。当用户点击取消按钮时,会调用 mediaPickerDidCancel(_:) 方法。 |
| true | 有一个完成按钮。每次用户点击媒体项目时,该项目会被选中。当用户点击完成按钮时,会调用 mediaPicker(_:didPickMediaItems:) 方法,传递一个包含用户点击的所有项目的 MPMediaItemCollection ,如果用户没有点击任何项目,则会调用 mediaPickerDidCancel(_:) 方法。 |

以下是一个示例,显示媒体选择器,然后使用应用程序队列播放器播放用户选择的媒体项目:

func presentPicker (_ sender: Any) {
    checkForMusicLibraryAccess {
        let picker = MPMediaPickerController(mediaTypes:.music)
        picker.delegate = self
        self.present(picker, animated: true)
    }
}

func mediaPicker(_ mediaPicker: MPMediaPickerController,
    didPickMediaItems mediaItemCollection: MPMediaItemCollection) {
        let player = MPMusicPlayerController.applicationQueuePlayer
        player.stop()
        player.setQueue(with:mediaItemCollection)
        delay(0.2) {
            player.play()
        }
        self.dismiss(animated:true)
}

func mediaPickerDidCancel(_ mediaPicker: MPMediaPickerController) {
    self.dismiss(animated:true)
}

在 iPad 上,媒体选择器可以作为全屏呈现的视图显示,也可以在弹出框中正常工作,特别是在增加其 preferredContentSize 时。以下代码在 iPhone 上全屏显示,在 iPad 上以合理大小的弹出框显示:

let picker = MPMediaPickerController(mediaTypes:.music)
picker.delegate = self
picker.modalPresentationStyle = .popover
picker.preferredContentSize = CGSize(500,600)
self.present(picker, animated: true)
if let pop = picker.popoverPresentationController {
    if let b = sender as? UIBarButtonItem {
        pop.barButtonItem = b
    }
}

综上所述,iOS 开发中提供了多种方式来处理音乐播放和管理,开发者可以根据具体需求选择合适的方法。无论是使用 MPMusicPlayerController 还是 AV Foundation 框架,都能实现丰富的音乐播放功能,同时媒体选择器和音量视图等组件也为用户提供了良好的交互体验。

iOS音乐播放开发全解析

6. 音乐库与播放器的交互流程总结

为了更清晰地理解音乐库和播放器之间的交互,下面给出一个简要的 mermaid 流程图:

graph LR
    A[开始] --> B[获取音乐库实例]
    B --> C[查询音乐(MPMediaQuery)]
    C --> D{是否有结果}
    D -- 是 --> E[创建播放队列(MPMediaItemCollection)]
    D -- 否 --> F[提示无结果]
    E --> G[选择播放器类型]
    G -- systemMusicPlayer --> H[使用系统播放器播放]
    G -- applicationQueuePlayer --> I[使用应用队列播放器播放]
    H --> J[控制播放状态(播放、暂停等)]
    I --> J
    J --> K[监听播放状态变化]
    K --> L[更新界面显示]
    L --> M[结束]
    F --> M

这个流程图展示了从获取音乐库实例到最终播放音乐并更新界面的整个过程。首先查询音乐库,根据查询结果创建播放队列,然后选择合适的播放器类型进行播放,在播放过程中监听状态变化并更新界面。

7. 不同播放方式的性能对比

在实际开发中,选择合适的播放方式对于性能至关重要。下面从几个方面对 MPMusicPlayerController 和使用 AV Foundation 播放进行对比:
| 对比项 | MPMusicPlayerController | AV Foundation |
| ---- | ---- | ---- |
| 易用性 | 方便快捷,提供了简单的 API 进行播放控制,如 play pause 等。 | 相对复杂,需要处理更多的细节,如 AVAsset 的加载、 AVPlayerItem 的管理等。 |
| 定制性 | 定制性有限,播放行为固定,类似于 iTunes 或音乐应用。 | 定制性强,可以实现各种复杂的功能,如音频编辑、淡入淡出效果等。 |
| 资源占用 | 资源占用相对较低,因为它是系统级的播放器。 | 资源占用可能较高,特别是在处理多个音频资源时。 |
| 兼容性 | 与系统音乐应用集成良好,支持远程播放控制。 | 可以与其他 AV Foundation 组件无缝集成,适用于更复杂的音频处理场景。 |

开发者可以根据项目的具体需求,权衡这些因素来选择合适的播放方式。如果只是简单的音乐播放需求, MPMusicPlayerController 是一个不错的选择;如果需要实现复杂的音频处理功能,AV Foundation 则更具优势。

8. 常见问题及解决方案

在使用音乐库和播放器的过程中,可能会遇到一些常见问题,下面给出一些解决方案:
- 问题 1:设置队列后播放失败
- 原因 :可能是队列未正确生效,或者播放器状态未正确处理。
- 解决方案 :在设置队列前先停止播放器,设置队列后插入短暂延迟再开始播放,示例代码如下:

player.stop()
player.setQueue(with: query)
delay(0.2) {
    player.prepareToPlay { err in
        if let err = err {
            print(err)
        }
    }
}
  • 问题 2:无法获取系统播放器的队列
    • 原因 systemMusicPlayer 不提供直接获取队列的方法。
    • 解决方案 :如果需要获取队列信息,可以考虑使用 applicationQueuePlayer ,它可以通过 perform(queueTransaction:completionHandler:) 方法获取当前队列。
  • 问题 3:播放进度更新不及时
    • 原因 :对于 MPMusicPlayerController ,没有周期性通知更新播放进度,需要手动轮询;对于 AV Foundation,可能是时间观察者设置不正确。
    • 解决方案
      • 对于 MPMusicPlayerController ,设置合理的轮询间隔,示例代码如下:
self.timer = Timer.scheduledTimer(timeInterval:1,
    target: self, selector: #selector(self.timerFired),
    userInfo: nil, repeats: true)
self.timer.tolerance = 0.1
    - 对于 AV Foundation,正确设置时间观察者,示例代码如下:
self.timeObserver = self.qp.addPeriodicTimeObserver(
    forInterval: CMTime(seconds:0.5, preferredTimescale:600),
    queue: nil) { [unowned self] t in
        self.timerFired(time:t)
}
9. 未来发展趋势

随着技术的不断发展,iOS 音乐播放开发也在不断演进。未来可能会有以下发展趋势:
- 更强大的音频处理能力 :AV Foundation 框架可能会提供更多的音频处理功能,如实时音频特效、音频合成等。
- 更好的用户体验 :音乐播放器的用户界面和交互方式将更加人性化,例如支持更多的手势操作、个性化推荐等。
- 与其他平台的集成 :iOS 音乐应用可能会与其他平台(如 Android、Web)实现更好的集成,方便用户在不同设备上同步音乐和播放记录。
- 对新兴音频格式的支持 :随着新的音频格式不断涌现,iOS 系统将支持更多的音频格式,提供更好的兼容性。

开发者需要关注这些趋势,不断学习和更新知识,以开发出更优秀的音乐应用。

10. 总结

本文详细介绍了 iOS 开发中音乐播放和管理的相关内容,包括音乐库的持久化与变更、音乐播放器的类型和使用方法、 MPVolumeView 的定制、使用 AV Foundation 播放歌曲以及媒体选择器的使用等。通过对这些内容的学习,开发者可以根据具体需求选择合适的方法来实现丰富的音乐播放功能。

同时,我们还对不同播放方式进行了性能对比,总结了常见问题及解决方案,并展望了未来的发展趋势。希望本文能为开发者在 iOS 音乐播放开发方面提供有价值的参考,帮助大家开发出更优质的音乐应用。

在实际开发中,建议开发者多进行实践和测试,不断优化代码,以提高应用的性能和用户体验。相信通过不断的努力和探索,能够开发出令人满意的 iOS 音乐应用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值