睿诚科技协会

android 网络音乐播放

我们将使用现代 Android 开发技术栈,包括 KotlinJetpack Compose(UI)、ExoPlayer(核心播放引擎)和 WorkManager(后台播放)。

android 网络音乐播放-图1
(图片来源网络,侵删)

核心技术选型

为什么选择这些技术?

  • ExoPlayer: 这是 Google 官方推荐的高级媒体播放器,它功能强大、高度可定制、性能优异,并且支持各种媒体格式(包括 HLS, DASH)、DRM 和广告,相比 MediaPlayer,ExoPlayer 是异步的,不会阻塞 UI 线程,是网络播放的不二之选。
  • Jetpack Compose: 现代 Android UI 工具包,让我们可以用更声明式、更简洁的代码来构建 UI,减少样板代码。
  • WorkManager: 用于处理后台任务,当用户将应用退到后台或锁屏时,我们需要让音乐继续播放,WorkManager 可以确保播放服务在后台稳定运行,并且能很好地处理系统资源回收(如低内存情况)。
  • MediaPlayerService: 一个前台服务,用于在后台播放音乐,使用前台服务可以防止应用被系统杀死,并且必须在状态栏显示一个通知。
  • MediaSession: 这是连接 UI 和播放服务的桥梁,它允许你通过锁屏、通知、甚至是蓝牙耳机来控制播放,MediaSession 会创建一个标准的媒体控制器,其他应用(如 Android Auto)可以通过它来与你的播放器交互。

项目结构

一个清晰的项目结构有助于代码维护:

com.example.musicplayer/
├── data/
│   ├── model/          // 数据模型,如 Song, Playlist
│   └── repository/     // 数据仓库,负责从网络获取数据
├── di/                 // 依赖注入 (可选,但推荐)
├── service/
│   └── MusicService.kt // 音乐播放服务
├── ui/
│   ├── theme/          // Compose 主题
│   └── screen/
│       ├── HomeScreen.kt      // 主界面,显示播放列表
│       └── PlayerScreen.kt    // 播放器界面
├── util/
│   └ Constants.kt      // 常量,如 API URL
└── MainActivity.kt     // 入口 Activity

实现步骤

添加依赖

app/build.gradle.kts (或 build.gradle) 文件中添加必要的依赖:

// build.gradle.kts
dependencies {
    // ExoPlayer
    implementation("androidx.media3:media3-exoplayer:1.4.1") // 核心播放器
    implementation("androidx.media3:media3-exoplayer-hls:1.4.1") // 支持 HLS 流
    implementation("androidx.media3:media3-ui:1.4.1") // ExoPlayer 的默认 UI 组件
    // WorkManager
    implementation("androidx.work:work-runtime-ktx:2.9.1")
    // 其他 Jetpack Compose 依赖...
}

创建数据模型

定义一个简单的歌曲数据类。

android 网络音乐播放-图2
(图片来源网络,侵删)
// data/model/Song.kt
data class Song(
    val id: String,
    val title: String,
    val artist: String,
    val streamUrl: String, // 网络音频流地址
    val coverUrl: String? = null, // 封面图片地址
    val duration: Long = 0 // 歌曲时长 (毫秒)
)

创建 MusicService (核心)

这是整个播放器的核心,它负责播放逻辑、处理 MediaSession 的回调,并管理通知。

// service/MusicService.kt
class MusicService : Service(), Player.Listener {
    private lateinit var exoPlayer: ExoPlayer
    private lateinit var mediaSession: MediaSessionCompat
    private lateinit var notificationBuilder: NotificationCompat.Builder
    private var isForegroundService = false
    // 当前播放列表和索引
    private var playList: List<Song> = emptyList()
    private var currentIndex = 0
    // 从 Intent 中获取数据
    private fun getSongFromIntent(intent: Intent?): Song? {
        return intent?.getParcelableExtra("SONG")
    }
    override fun onCreate() {
        super.onCreate()
        initializePlayer()
        initializeMediaSession()
        initializeNotification()
    }
    private fun initializePlayer() {
        exoPlayer = ExoPlayer.Builder(this).build().apply {
            addListener(this@MusicService)
            setMediaItem(MediaItem.fromUri(getSongFromIntent(intent)?.streamUrl ?: ""))
            prepare()
        }
    }
    private fun initializeMediaSession() {
        val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON).apply {
            setClass(this@MusicService, MediaButtonReceiver::class.java)
        }
        val mediaButtonReceiverPendingIntent = PendingIntent.getBroadcast(
            this, 0, mediaButtonIntent, PendingIntent.FLAG_IMMUTABLE
        )
        mediaSession = MediaSessionCompat(this, "MusicService").apply {
            setCallback(object : MediaSessionCompat.Callback() {
                override fun onPlay() {
                    exoPlayer.play()
                }
                override fun onPause() {
                    exoPlayer.pause()
                }
                override fun onSkipToNext() {
                    // ... 实现下一首逻辑
                }
                override fun onSkipToPrevious() {
                    // ... 实现上一首逻辑
                }
                override fun onStop() {
                    stopSelf()
                }
            })
            setPlaybackState(PlaybackStateCompat.STATE_PLAYING)
            setActive(true)
            setMediaButtonReceiver(mediaButtonReceiverPendingIntent)
        }
    }
    private fun initializeNotification() {
        val notificationIntent = Intent(this, MainActivity::class.java)
        val pendingIntent = PendingIntent.getActivity(
            this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
        )
        notificationBuilder = NotificationCompat.Builder(this, "music_channel")
            .setSmallIcon(R.drawable.ic_music_note) // 替换为你自己的图标
            .setContentTitle("正在播放")
            .setContentText("艺术家")
            .setContentIntent(pendingIntent)
            .setStyle(androidx.media3.session.MediaStyleCompat.Builder(mediaSession)
                .setMediaSession(mediaSession)
                .setShowActionsInCompactView(0, 1, 2) // 设置通知栏紧凑模式下的按钮
                .build())
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
            .setOnlyAlertOnce(true)
    }
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val song = getSongFromIntent(intent)
        if (song != null) {
            // 更新播放列表和当前歌曲
            playList = listOf(song)
            currentIndex = 0
            exoPlayer.setMediaItem(MediaItem.fromUri(song.streamUrl))
            exoPlayer.prepare()
            exoPlayer.play()
        }
        // 启动为前台服务,显示通知
        if (!isForegroundService) {
            startForeground(1, notificationBuilder.build())
            isForegroundService = true
        }
        return START_STICKY
    }
    override fun onPlaybackStateChanged(state: Player.State) {
        super.onPlaybackStateChanged(state)
        when (state) {
            Player.STATE_READY -> {
                updateNotification()
            }
            Player.STATE_ENDED -> {
                // 播放结束,处理下一首或停止
            }
            else -> {}
        }
    }
    private fun updateNotification() {
        val song = playList.getOrNull(currentIndex)
        if (song != null) {
            notificationBuilder.setContentTitle(song.title)
            notificationBuilder.setContentText(song.artist)
            // 更新通知
            val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            notificationManager.notify(1, notificationBuilder.build())
        }
    }
    override fun onDestroy() {
        super.onDestroy()
        mediaSession.release()
        exoPlayer.release()
    }
    override fun onBind(intent: Intent?): IBinder? {
        return null // 我们不绑定服务,通过 MediaSession 控制
    }
}

注意: 你还需要在 AndroidManifest.xml 中声明这个服务,并请求必要的权限。

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <!-- 防止屏幕休眠导致播放暂停 -->
<application ...>
    <service android:name=".service.MusicService" />
</application>

创建 UI (使用 Jetpack Compose)

PlayerScreen.kt

@Composable
fun PlayerScreen(song: Song, onBack: () -> Unit) {
    val context = LocalContext.current
    var isPlaying by remember { mutableStateOf(false) }
    // 获取 MediaController 来控制播放
    val mediaController = rememberMediaController()
    LaunchedEffect(song) {
        mediaController.setSessionToken(song.sessionToken) // 假设 Song 类有 sessionToken
    }
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        // 封面图片
        Image(
            painter = rememberAsyncImagePainter(song.coverUrl),
            contentDescription = null,
            modifier = Modifier.size(250.dp)
        )
        Spacer(modifier = Modifier.height(32.dp))
        // 歌曲信息
        Text(text = song.title, style = MaterialTheme.typography.h5)
        Text(text = song.artist, style = MaterialTheme.typography.body1)
        Spacer(modifier = Modifier.height(48.dp))
        // 播放/暂停按钮
        IconButton(onClick = {
            if (isPlaying) {
                mediaController.pause()
            } else {
                mediaController.play()
            }
            isPlaying = !isPlaying
        }) {
            Icon(
                imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
                contentDescription = if (isPlaying) "Pause" else "Play",
                modifier = Modifier.size(64.dp)
            )
        }
    }
}
// 辅助函数:获取 MediaController
@Composable
fun rememberMediaController(): MediaController {
    val context = LocalContext.current
    val mediaController = remember { MediaController(context) }
    return mediaController
}

如何启动服务和 UI?

android 网络音乐播放-图3
(图片来源网络,侵删)

当用户点击一首歌时,你需要:

  1. 启动服务

    val intent = Intent(context, MusicService::class.java).apply {
        putExtra("SONG", selectedSong)
    }
    ContextCompat.startForegroundService(context, intent)
  2. 导航到 PlayerScreen,并将歌曲信息传递过去。

处理后台播放和 WorkManager

当应用完全退出或锁屏时,UI 不可见,但服务仍在运行,ExoPlayer 和 MediaSession 会确保通知和控制依然可用。

WorkManager 在这里主要用于更复杂的后台任务,

  • 定期检查更新:检查用户喜欢的歌曲是否有新版本。
  • 下载播放列表:如果需要离线播放,可以使用 WorkManager 来管理下载任务。

对于单纯的保持播放ForegroundService 已经足够强大,但如果你想做一些“后台任务”,WorkManager 是更好的选择,因为它更省电且更可靠。

处理耳机和蓝牙事件

为了让音乐在耳机拔出时自动暂停,可以在 MusicService 中注册一个 BroadcastReceiver

// 在 MusicService 的 onCreate() 中注册
private var headsetPlugReceiver: BroadcastReceiver? = null
private fun registerHeadsetPlugReceiver() {
    headsetPlugReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            if (intent.action == Intent.ACTION_HEADSET_PLUG) {
                val state = intent.getIntExtra("state", -1)
                if (state == 0) { // 拔出
                    exoPlayer.pause()
                }
            }
        }
    }
    registerReceiver(headsetPlugReceiver, IntentFilter(Intent.ACTION_HEADSET_PLUG))
}
// 在 onDestroy() 中注销
override fun onDestroy() {
    super.onDestroy()
    headsetPlugReceiver?.let { unregisterReceiver(it) }
    // ... 其他清理代码
}

总结与最佳实践

  1. 始终使用 ExoPlayer: 不要再使用 MediaPlayer 来处理网络流媒体。
  2. 使用 MediaSession: 这是实现标准媒体控制(通知、锁屏、车载)的关键。
  3. 使用 ForegroundService: 这是保证后台播放稳定性的标准做法。
  4. UI 与服务解耦: UI 通过 MediaControllerMusicService 交互,而不是直接绑定,这样 UI 可以随时被销毁而不会影响播放。
  5. 错误处理: ExoPlayer 会抛出各种异常(如网络错误、解析错误),务必在 Player.Listener 中处理它们,并向用户展示友好的提示。
  6. 资源管理: 在 onDestroy() 中正确释放 ExoPlayerMediaSession 的资源,避免内存泄漏。
  7. 性能优化: 对于封面图片等网络资源,使用 GlideCoil 这样的图片加载库,它们能很好地处理缓存和内存管理。

通过以上步骤,你就可以构建一个功能完善、体验流畅的 Android 网络音乐播放器了,这只是一个基础框架,你可以在此基础上添加播放列表、均衡器、歌词同步等更高级的功能。

分享:
扫描分享到社交APP
上一篇
下一篇