Android实现倒计时停止播放音乐功能(附带源码)

一、项目介绍

1. 背景与动机

在许多音乐播放应用、白噪音软件或学习辅助工具中,用户往往需要在入睡或专注时设置一个定时关闭(倒计时),以避免音乐整夜播放消耗电量或打扰休息。实现这一功能,需要在后台维护一个倒计时定时器,并在倒计时结束时暂停或停止当前播放的音频流。

本项目旨在提供一个高可用、可配置的定时停止音乐模块,具备以下特性:

  • 支持秒级小时级倒计时

  • 后台运行:即使应用切换至后台,定时依然生效

  • 界面反馈:实时显示剩余时间

  • 可暂停/取消倒计时

  • 播放控制:倒计时结束后自动暂停或停止播放,并可选择是否释放资源

  • 易于组件化复用,可集成到任意媒体播放场景

2. 功能目标

  1. 界面层:提供一个带有输入倒计时时长开始/暂停按钮、显示剩余时间的播放界面

  2. 业务层:封装一个 CountdownStopService 前台服务,管理倒计时与音乐播放

  3. 播放层:使用 MediaPlayerExoPlayer 播放本地或网络音乐,并支持暂停/停止

  4. 通知层:在前台服务通知中展示剩余时间和操作按钮

  5. 生命周期管理:在应用进程被系统回收后,重启服务并恢复倒计时

  6. 配置扩展:可在设置中配置默认倒计时时长、倒计时结束行为(停止/暂停/静音切换)


二、相关知识

  1. 倒计时实现

    • CountDownTimer:Android 原生简单倒计时

    • Handler + Runnable:自定义精度控制

    • WorkManager:可用于更可靠的定时任务,支持进程重启后继续

  2. 前台服务(Foreground Service)

    • Android O+ 要求长时间运行的后台任务使用前台服务,并伴随通知

    • 通过 startForeground() 保持服务存活

  3. 媒体播放

    • MediaPlayer:系统原生简单音频播放

    • ExoPlayer:更强大的开源播放器,支持流式播放、缓存

  4. 通知交互

    • 构建自定义通知布局,显示剩余时间与操作按钮

    • 通过 PendingIntent 响应通知按钮点击,控制服务

  5. 数据持久化

    • SharedPreferences 存储倒计时剩余时间和状态,便于进程重启恢复

    • onSaveInstanceState() + ViewModel 保存 UI 状态

  6. 组件化与解耦

    • 使用 Service + BroadcastReceiver 分离倒计时逻辑和 UI

    • 通过事件总线(LiveData/EventBus)通知 Activity 更新 UI


三、实现思路

方案概览

  1. 界面层

    • MainActivity:展示播放器界面和倒计时控件(EditText 输入时长、Button 开始/暂停、TextView 显示剩余)

  2. 服务层

    • CountdownStopService:前台服务,包含倒计时逻辑和媒体控制接口

  3. 通知层

    • 构建带有“剩余时间”、“停止倒计时”按钮的前台通知

  4. 倒计时逻辑

    • 使用 CountDownTimer 实现秒级倒计时

    • onTick() 中通过 BroadcastLiveData 更新 UI

    • onFinish() 中调用 mediaPlayer.pause()mediaPlayer.stop()

  5. 媒体播放

    • 对接 MediaPlayer,在服务中管理其生命周期

    • 提供 play(url)pause()stop()release()

流程步骤

  1. 用户在 MainActivity 输入倒计时时长(分钟/秒),点击“开始倒计时”

  2. Activity 调用 startService(Intent(this, CountdownStopService::class.java).apply { putExtra("duration", ...) }) 并绑定服务

  3. 服务 onStartCommand() 中读取时长,初始化 CountDownTimerstartForeground()

  4. CountDownTimer 每秒回调,通过 sendBroadcast()MutableLiveData 通知 Activity 更新剩余时间

  5. 通知栏同时显示剩余时间,并提供“取消倒计时”按钮

  6. 倒计时结束时,服务执行 mediaPlayer.pause(),从通知中移除服务

  7. 用户可在 Activity 中点击“停止播放”或“取消倒计时”按钮,调用服务对应方法


四、环境与依赖

// app/build.gradle
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'

android {
  compileSdkVersion 34
  defaultConfig {
    applicationId "com.example.countdownmusicstop"
    minSdkVersion 23
    targetSdkVersion 34
    versionCode 1
    versionName "1.0"
  }
  buildFeatures { viewBinding true }
  kotlinOptions { jvmTarget = "1.8" }
}

dependencies {
  implementation 'androidx.appcompat:appcompat:1.6.1'
  implementation 'androidx.core:core-ktx:1.10.1'
  implementation 'com.google.android.material:material:1.9.0'
}

五、整合代码

// =======================================================
// 文件: AndroidManifest.xml
// 描述: 注册 Service、请求前台服务权限
// =======================================================
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.countdownmusicstop">

  <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>

  <application
      android:allowBackup="true"
      android:label="倒计时停止音乐"
      android:theme="@style/Theme.AppCompat.Light.NoActionBar">
    <activity android:name=".MainActivity" android:exported="true"/>
    <service
        android:name=".CountdownStopService"
        android:exported="false"
        android:foregroundServiceType="mediaPlayback"/>
  </application>
</manifest>


// =======================================================
// 文件: res/layout/activity_main.xml
// 描述: 界面布局:音乐播放 + 倒计时控件
// =======================================================
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:padding="16dp"
    android:gravity="center_horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

  <!-- 音乐控制 -->
  <Button
      android:id="@+id/btn_play"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:text="播放音乐"/>

  <Button
      android:id="@+id/btn_pause"
      android:layout_marginTop="8dp"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:text="暂停音乐"/>

  <!-- 倒计时输入 -->
  <LinearLayout
      android:orientation="horizontal"
      android:layout_marginTop="24dp"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content">

    <EditText
        android:id="@+id/et_minutes"
        android:layout_width="60dp"
        android:layout_height="wrap_content"
        android:inputType="number"
        android:hint="分钟"/>

    <TextView
        android:layout_marginStart="8dp"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="分"/>

    <EditText
        android:id="@+id/et_seconds"
        android:layout_marginStart="16dp"
        android:layout_width="60dp"
        android:layout_height="wrap_content"
        android:inputType="number"
        android:hint="秒"/>

    <TextView
        android:layout_marginStart="8dp"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="秒"/>
  </LinearLayout>

  <Button
      android:id="@+id/btn_start_countdown"
      android:layout_marginTop="16dp"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:text="开始倒计时"/>

  <Button
      android:id="@+id/btn_cancel_countdown"
      android:layout_marginTop="8dp"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:text="取消倒计时"/>

  <TextView
      android:id="@+id/tv_remaining"
      android:layout_marginTop="24dp"
      android:textSize="24sp"
      android:text="剩余时间:00:00"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"/>
</LinearLayout>


// =======================================================
// 文件: CountdownStopService.kt
// 描述: 前台服务,管理倒计时和音乐播放
// =======================================================
package com.example.countdownmusicstop

import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.media.MediaPlayer
import android.os.*
import androidx.core.app.NotificationCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager

class CountdownStopService : Service() {

  companion object {
    const val ACTION_TICK = "com.example.countdown.TICK"
    const val ACTION_FINISH = "com.example.countdown.FINISH"
    const val EXTRA_REMAIN_MS = "remain_ms"
    const val EXTRA_DURATION = "duration_ms"
    const val NOTIF_CHANNEL = "countdown_channel"
    const val NOTIF_ID = 1001
  }

  private var mediaPlayer: MediaPlayer? = null
  private var countDownTimer: CountDownTimer? = null
  private var totalMs: Long = 0L

  override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    intent?.let {
      if (it.hasExtra(EXTRA_DURATION)) {
        // 启动倒计时
        totalMs = it.getLongExtra(EXTRA_DURATION, 0L)
        startForegroundService()
        startCountdown(totalMs)
      } else {
        // 可能是取消倒计时或播放控制
      }
    }
    return START_NOT_STICKY
  }

  private fun startForegroundService() {
    val nm = getSystemService(NotificationManager::class.java)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
      val chan = NotificationChannel(
        NOTIF_CHANNEL,
        "倒计时通知",
        NotificationManager.IMPORTANCE_LOW
      )
      nm.createNotificationChannel(chan)
    }
    val notif: Notification = NotificationCompat.Builder(this, NOTIF_CHANNEL)
      .setContentTitle("倒计时停止音乐")
      .setSmallIcon(R.drawable.ic_timer)
      .setOnlyAlertOnce(true)
      .setOngoing(true)
      .build()
    startForeground(NOTIF_ID, notif)
  }

  private fun startCountdown(durationMs: Long) {
    countDownTimer?.cancel()
    countDownTimer = object : CountDownTimer(durationMs, 1000) {
      override fun onTick(millisUntilFinished: Long) {
        // 更新通知和广播
        updateNotification(millisUntilFinished)
        broadcastTick(millisUntilFinished)
      }
      override fun onFinish() {
        // 停止音乐
        mediaPlayer?.pause()
        stopForeground(true)
        broadcastFinish()
        stopSelf()
      }
    }.start()
  }

  private fun updateNotification(remainMs: Long) {
    val text = formatTime(remainMs)
    val notif = NotificationCompat.Builder(this, NOTIF_CHANNEL)
      .setContentTitle("倒计时停止音乐")
      .setContentText("剩余时间:$text")
      .setSmallIcon(R.drawable.ic_timer)
      .setOnlyAlertOnce(true)
      .setOngoing(true)
      .build()
    (getSystemService(NotificationManager::class.java))
      .notify(NOTIF_ID, notif)
  }

  private fun broadcastTick(remainMs: Long) {
    Intent(ACTION_TICK).also {
      it.putExtra(EXTRA_REMAIN_MS, remainMs)
      LocalBroadcastManager.getInstance(this).sendBroadcast(it)
    }
  }

  private fun broadcastFinish() {
    Intent(ACTION_FINISH).also {
      LocalBroadcastManager.getInstance(this).sendBroadcast(it)
    }
  }

  private fun formatTime(ms: Long): String {
    val totalSec = ms / 1000
    val min = totalSec / 60
    val sec = totalSec % 60
    return String.format("%02d:%02d", min, sec)
  }

  fun playMusic(resId: Int) {
    mediaPlayer?.release()
    mediaPlayer = MediaPlayer.create(this, resId).apply { isLooping = true; start() }
  }

  fun pauseMusic() {
    mediaPlayer?.pause()
  }

  override fun onDestroy() {
    super.onDestroy()
    countDownTimer?.cancel()
    mediaPlayer?.release()
  }

  override fun onBind(intent: Intent?): IBinder? = null
}


// =======================================================
// 文件: MainActivity.kt
// 描述: 界面层,控制播放和倒计时
// =======================================================
package com.example.countdownmusicstop

import android.content.*
import android.os.Bundle
import android.os.IBinder
import androidx.appcompat.app.AppCompatActivity
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.example.countdownmusicstop.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

  private lateinit var binding: ActivityMainBinding
  private var serviceIntent: Intent? = null
  private val receiver = object : BroadcastReceiver() {
    override fun onReceive(ctx: Context?, intent: Intent?) {
      when (intent?.action) {
        CountdownStopService.ACTION_TICK -> {
          val ms = intent.getLongExtra(CountdownStopService.EXTRA_REMAIN_MS, 0L)
          binding.tvRemaining.text = "剩余时间:" + formatTime(ms)
        }
        CountdownStopService.ACTION_FINISH -> {
          binding.tvRemaining.text = "倒计时结束"
        }
      }
    }
  }

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    binding = ActivityMainBinding.inflate(layoutInflater)
    setContentView(binding.root)

    // 注册广播
    LocalBroadcastManager.getInstance(this).apply {
      registerReceiver(receiver, IntentFilter(CountdownStopService.ACTION_TICK))
      registerReceiver(receiver, IntentFilter(CountdownStopService.ACTION_FINISH))
    }

    serviceIntent = Intent(this, CountdownStopService::class.java)

    // 播放/暂停按钮
    binding.btnPlay.setOnClickListener {
      serviceIntent?.let { it1 ->
        startService(it1)
        // 通过 ServiceConnection 或静态方法playMusic向服务发送命令
        sendBroadcast(Intent("CMD_PLAY"))
      }
    }
    binding.btnPause.setOnClickListener {
      sendBroadcast(Intent("CMD_PAUSE"))
    }

    // 开始倒计时
    binding.btnStartCountdown.setOnClickListener {
      val min = binding.etMinutes.text.toString().toLongOrNull() ?: 0L
      val sec = binding.etSeconds.text.toString().toLongOrNull() ?: 0L
      val totalMs = (min * 60 + sec) * 1000
      serviceIntent?.putExtra(CountdownStopService.EXTRA_DURATION, totalMs)
      startService(serviceIntent)
    }

    // 取消倒计时
    binding.btnCancelCountdown.setOnClickListener {
      sendBroadcast(Intent("CMD_CANCEL_COUNTDOWN"))
      binding.tvRemaining.text = "倒计时已取消"
    }
  }

  private fun formatTime(ms: Long): String {
    val totalSec = ms / 1000
    val min = totalSec / 60
    val sec = totalSec % 60
    return String.format("%02d:%02d", min, sec)
  }

  override fun onDestroy() {
    super.onDestroy()
    LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver)
  }
}

六、代码解读

  1. CountdownStopService

    • 继承 Service,在 onStartCommand() 读取倒计时时长,调用 startCountdown()

    • 使用 CountDownTimer 每秒回调 onTick(),在通知栏和通过 LocalBroadcast 更新 UI;

    • onFinish() 中调用 mediaPlayer.pause() 并停止前台服务;

  2. 前台通知

    • 在服务启动时调用 startForeground(),创建低优先级通知;

    • 在每次 onTick() 更新通知内容,展示剩余时间;

  3. MainActivity

    • 通过 LocalBroadcastManager 接收服务广播,更新界面 TextView

    • 用户通过按钮输入倒计时时长后调用 startService()

    • 播放/暂停按钮通过广播或绑定实现对服务中 MediaPlayer 的控制(示例中简化为广播);

  4. 布局与交互

    • activity_main.xml 包含播放、暂停、倒计时输入、开始/取消倒计时及剩余时间显示;

    • 可扩展为更优雅的 MVVM 架构,使用 ViewModel + LiveData,进一步解耦 UI 与服务;


七、性能与优化

  1. 精度与续命

    • CountDownTimeronTick() 间隔最低为 1s,如需更高精度可结合 HandlerAlarmManager

  2. 持久化恢复

    • 在服务被系统杀死后使用 START_REDELIVER_INTENT 保证重试,或使用 WorkManager 做更可靠的续命;

  3. 播放控制改进

    • 可在服务中实现 BinderMessenger 进行双向通信,而非广播;

  4. UI 更新

    • 使用 LiveData 而非广播,更符合 Jetpack 架构;


八、项目总结与拓展

总结

通过本教程,您已掌握如何在 Android 中:

  • 使用前台服务和通知保持倒计时活跃

  • 利用 CountDownTimer 实现秒级倒计时

  • 在倒计时结束时自动暂停音乐

  • 通过广播或数据驱动更新 UI

拓展方向

  1. 静默背景播放:结合 AudioFocus 管理,确保服务在后台可继续播放或暂停;

  2. 睡眠模式:在倒计时结束时淡出音量,而非立即停止,提高用户体验;

  3. 断点续传:倒计时中断后可保存剩余时间,下次进入自动恢复;

  4. MVVM 重构:使用 ViewModel + LiveData + DataBinding 解耦 UI 与业务逻辑;


九、FAQ

Q1:CountDownTimer 在后台会失效吗?
A1:在前台服务模式下不会;若需保证长期运行,可结合 AlarmManagerWorkManager

Q2:倒计时结束如何更优雅地淡出音乐?
A2:可使用 ValueAnimatorMediaPlayer.setVolume() 做线性或曲线动画。

Q3:如何保证服务不被系统杀掉?
A3:Android O+ 前台服务需调用 startForeground() 并提供通知;也可使用 JobSchedulerWorkManager

Q4:通知栏按钮如何实现“取消倒计时”?
A4:在 NotificationCompat 中添加 addAction(),配置点击 PendingIntent 发送“CMD_CANCEL_COUNTDOWN”广播。

Q5:使用 ExoPlayer 代替 MediaPlayer 有什么好处?
A5:ExoPlayer 更稳定、支持 DASH/HLS、缓存策略、自定义渲染,可更灵活地控制音频流。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值