一、项目介绍
1. 背景与动机
在许多音乐播放应用、白噪音软件或学习辅助工具中,用户往往需要在入睡或专注时设置一个定时关闭(倒计时),以避免音乐整夜播放消耗电量或打扰休息。实现这一功能,需要在后台维护一个倒计时定时器,并在倒计时结束时暂停或停止当前播放的音频流。
本项目旨在提供一个高可用、可配置的定时停止音乐模块,具备以下特性:
-
支持秒级到小时级倒计时
-
后台运行:即使应用切换至后台,定时依然生效
-
界面反馈:实时显示剩余时间
-
可暂停/取消倒计时
-
播放控制:倒计时结束后自动暂停或停止播放,并可选择是否释放资源
-
易于组件化和复用,可集成到任意媒体播放场景
2. 功能目标
-
界面层:提供一个带有输入倒计时时长、开始/暂停按钮、显示剩余时间的播放界面
-
业务层:封装一个
CountdownStopService
前台服务,管理倒计时与音乐播放 -
播放层:使用
MediaPlayer
或ExoPlayer
播放本地或网络音乐,并支持暂停/停止 -
通知层:在前台服务通知中展示剩余时间和操作按钮
-
生命周期管理:在应用进程被系统回收后,重启服务并恢复倒计时
-
配置扩展:可在设置中配置默认倒计时时长、倒计时结束行为(停止/暂停/静音切换)
二、相关知识
-
倒计时实现
-
CountDownTimer
:Android 原生简单倒计时 -
Handler
+Runnable
:自定义精度控制 -
WorkManager:可用于更可靠的定时任务,支持进程重启后继续
-
-
前台服务(Foreground Service)
-
Android O+ 要求长时间运行的后台任务使用前台服务,并伴随通知
-
通过
startForeground()
保持服务存活
-
-
媒体播放
-
MediaPlayer
:系统原生简单音频播放 -
ExoPlayer
:更强大的开源播放器,支持流式播放、缓存
-
-
通知交互
-
构建自定义通知布局,显示剩余时间与操作按钮
-
通过
PendingIntent
响应通知按钮点击,控制服务
-
-
数据持久化
-
SharedPreferences
存储倒计时剩余时间和状态,便于进程重启恢复 -
onSaveInstanceState()
+ViewModel
保存 UI 状态
-
-
组件化与解耦
-
使用
Service
+BroadcastReceiver
分离倒计时逻辑和 UI -
通过事件总线(LiveData/EventBus)通知 Activity 更新 UI
-
三、实现思路
方案概览
-
界面层
-
MainActivity
:展示播放器界面和倒计时控件(EditText 输入时长、Button 开始/暂停、TextView 显示剩余)
-
-
服务层
-
CountdownStopService
:前台服务,包含倒计时逻辑和媒体控制接口
-
-
通知层
-
构建带有“剩余时间”、“停止倒计时”按钮的前台通知
-
-
倒计时逻辑
-
使用
CountDownTimer
实现秒级倒计时 -
在
onTick()
中通过Broadcast
或LiveData
更新 UI -
在
onFinish()
中调用mediaPlayer.pause()
或mediaPlayer.stop()
-
-
媒体播放
-
对接
MediaPlayer
,在服务中管理其生命周期 -
提供
play(url)
、pause()
、stop()
、release()
-
流程步骤
-
用户在
MainActivity
输入倒计时时长(分钟/秒),点击“开始倒计时” -
Activity 调用
startService(Intent(this, CountdownStopService::class.java).apply { putExtra("duration", ...) })
并绑定服务 -
服务
onStartCommand()
中读取时长,初始化CountDownTimer
并startForeground()
-
CountDownTimer
每秒回调,通过sendBroadcast()
或MutableLiveData
通知 Activity 更新剩余时间 -
通知栏同时显示剩余时间,并提供“取消倒计时”按钮
-
倒计时结束时,服务执行
mediaPlayer.pause()
,从通知中移除服务 -
用户可在 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)
}
}
六、代码解读
-
CountdownStopService
-
继承
Service
,在onStartCommand()
读取倒计时时长,调用startCountdown()
; -
使用
CountDownTimer
每秒回调onTick()
,在通知栏和通过LocalBroadcast
更新 UI; -
在
onFinish()
中调用mediaPlayer.pause()
并停止前台服务;
-
-
前台通知
-
在服务启动时调用
startForeground()
,创建低优先级通知; -
在每次
onTick()
更新通知内容,展示剩余时间;
-
-
MainActivity
-
通过
LocalBroadcastManager
接收服务广播,更新界面TextView
; -
用户通过按钮输入倒计时时长后调用
startService()
; -
播放/暂停按钮通过广播或绑定实现对服务中
MediaPlayer
的控制(示例中简化为广播);
-
-
布局与交互
-
activity_main.xml
包含播放、暂停、倒计时输入、开始/取消倒计时及剩余时间显示; -
可扩展为更优雅的 MVVM 架构,使用
ViewModel
+LiveData
,进一步解耦 UI 与服务;
-
七、性能与优化
-
精度与续命
-
CountDownTimer
的onTick()
间隔最低为 1s,如需更高精度可结合Handler
或AlarmManager
;
-
-
持久化恢复
-
在服务被系统杀死后使用
START_REDELIVER_INTENT
保证重试,或使用WorkManager
做更可靠的续命;
-
-
播放控制改进
-
可在服务中实现
Binder
或Messenger
进行双向通信,而非广播;
-
-
UI 更新
-
使用
LiveData
而非广播,更符合 Jetpack 架构;
-
八、项目总结与拓展
总结
通过本教程,您已掌握如何在 Android 中:
-
使用前台服务和通知保持倒计时活跃
-
利用
CountDownTimer
实现秒级倒计时 -
在倒计时结束时自动暂停音乐
-
通过广播或数据驱动更新 UI
拓展方向
-
静默背景播放:结合
AudioFocus
管理,确保服务在后台可继续播放或暂停; -
睡眠模式:在倒计时结束时淡出音量,而非立即停止,提高用户体验;
-
断点续传:倒计时中断后可保存剩余时间,下次进入自动恢复;
-
MVVM 重构:使用
ViewModel
+LiveData
+DataBinding
解耦 UI 与业务逻辑;
九、FAQ
Q1:CountDownTimer
在后台会失效吗?
A1:在前台服务模式下不会;若需保证长期运行,可结合 AlarmManager
或 WorkManager
。
Q2:倒计时结束如何更优雅地淡出音乐?
A2:可使用 ValueAnimator
对 MediaPlayer.setVolume()
做线性或曲线动画。
Q3:如何保证服务不被系统杀掉?
A3:Android O+ 前台服务需调用 startForeground()
并提供通知;也可使用 JobScheduler
或 WorkManager
。
Q4:通知栏按钮如何实现“取消倒计时”?
A4:在 NotificationCompat
中添加 addAction()
,配置点击 PendingIntent
发送“CMD_CANCEL_COUNTDOWN”广播。
Q5:使用 ExoPlayer 代替 MediaPlayer
有什么好处?
A5:ExoPlayer 更稳定、支持 DASH/HLS、缓存策略、自定义渲染,可更灵活地控制音频流。