Android后台运行倒计时语音提醒计时器应用问题求助
Android后台运行倒计时语音提醒计时器应用问题求助
嘿,我看了你的代码,问题出在计时器和界面生命周期绑定上啦!你现在用的LaunchedEffect是Compose自带的协程,它和你的TimerScreen界面绑定在一起——当你切到其他App或者锁屏,Compose的界面会进入暂停状态,对应的协程也会被挂起,倒计时自然就停了。而且从Android 8.0开始,系统对后台进程的限制特别严,普通后台任务根本没法长时间跑,所以要实现后台持续运行的计时器,必须用前台服务(Foreground Service),这玩意儿有通知栏提示,系统知道是用户主动需要的,不会随便杀掉它。
接下来我给你一步步讲怎么改:
首先,给App加权限和声明服务
先去AndroidManifest.xml里加这几行:
<!-- 前台服务必需的权限 --> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- Android 13+ 发通知需要这个权限 --> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <application ...> <!-- 声明我们要用到的前台服务 --> <service android:name=".TimerForegroundService" android:foregroundServiceType="dataSync" /> </application>
然后,写前台服务的逻辑
创建一个叫TimerForegroundService的类,专门负责后台倒计时、语音播报,用协程处理倒计时,还要显示通知:
import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build import android.os.IBinder import android.speech.tts.TextToSpeech import androidx.core.app.NotificationCompat import androidx.lifecycle.LifecycleService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.Locale class TimerForegroundService : LifecycleService() { // 协程作用域,专门处理后台任务 private val serviceScope = CoroutineScope(Dispatchers.IO + Job()) private lateinit var tts: TextToSpeech private var timerJob: Job? = null private var initialMinutes = 0 private var timeLeft = 0 // 剩余秒数 private var isRunning = false // 定义和Activity通信的Action常量 companion object { const val CHANNEL_ID = "TIMER_SERVICE_CHANNEL" const val NOTIFICATION_ID = 1001 const val ACTION_START = "ACTION_START_TIMER" const val ACTION_STOP = "ACTION_STOP_TIMER" const val EXTRA_MINUTES = "EXTRA_MINUTES" } override fun onCreate() { super.onCreate() // 初始化TTS,和你原来的逻辑一样 tts = TextToSpeech(this) { status -> if (status == TextToSpeech.SUCCESS) { tts.language = Locale("spa") } } // 创建通知渠道(Android 8.0+必须) createNotificationChannel() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onStartCommand(intent, flags, startId) intent?.let { when (it.action) { // 启动计时器 ACTION_START -> { initialMinutes = it.getIntExtra(EXTRA_MINUTES, 0) if (initialMinutes > 0) { timeLeft = initialMinutes * 60 startTimer() // 启动前台服务,必须传通知 startForeground(NOTIFICATION_ID, buildNotification()) } } // 停止计时器 ACTION_STOP -> { stopTimer() stopSelf() // 销毁服务 } } } // 服务被系统杀死后自动重启 return START_STICKY } private fun startTimer() { isRunning = true timerJob = serviceScope.launch { while (isRunning && timeLeft > 0) { delay(1000) timeLeft-- // 实时更新通知里的倒计时 updateNotification() // 倒计时结束 if (timeLeft == 0) { isRunning = false val msg = "Your $initialMinutes minute timer is done" tts.speak(msg, TextToSpeech.QUEUE_FLUSH, null, "timer_done") // 如果要重复播报,就在这里加循环: // while (true) { // delay(3000) // tts.speak(msg, TextToSpeech.QUEUE_FLUSH, null, "timer_done") // } } } } } private fun stopTimer() { isRunning = false timerJob?.cancel() tts.stop() tts.shutdown() // 释放TTS资源 } override fun onDestroy() { stopTimer() super.onDestroy() } // 创建通知渠道 private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( CHANNEL_ID, "计时器服务", NotificationManager.IMPORTANCE_LOW // 低优先级,不打扰用户 ).apply { description = "在后台保持计时器运行" } val notificationManager = getSystemService(NotificationManager::class.java) notificationManager.createNotificationChannel(channel) } } // 构建前台通知 private fun buildNotification(): Notification { // 点击通知回到App val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.let { PendingIntent.getActivity( this, 0, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } // 通知栏的停止按钮 val stopIntent = Intent(this, TimerForegroundService::class.java).apply { action = ACTION_STOP } val stopPendingIntent = PendingIntent.getService( this, 1, stopIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("计时器运行中") .setContentText(getFormattedTime()) .setSmallIcon(R.drawable.ic_timer) // 换成你自己的图标哦 .setContentIntent(launchIntent) .addAction( R.drawable.ic_stop, // 停止图标 "停止计时器", stopPendingIntent ) .setPriority(NotificationCompat.PRIORITY_LOW) .build() } // 更新通知内容 private fun updateNotification() { val notificationManager = getSystemService(NotificationManager::class.java) notificationManager.notify(NOTIFICATION_ID, buildNotification()) } // 格式化倒计时字符串 private fun getFormattedTime(): String { val mm = timeLeft / 60 val ss = timeLeft % 60 return String.format("%02d:%02d", mm, ss) } override fun onBind(intent: Intent): IBinder? { return null } }
最后,修改你的Compose TimerScreen,和服务通信
把原来的LaunchedEffect全部删掉,改成调用前台服务:
import android.Manifest import android.content.Context import android.content.Intent import android.os.Build import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.rememberCoroutineScope import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun TimerScreen() { val context = LocalContext.current var inputText by remember { mutableStateOf("") } var isServiceRunning by remember { mutableStateOf(false) } // 请求通知权限(Android 13+必需) val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() ) { granted -> if (granted) { startTimerService(context, inputText.toIntOrNull() ?: 0) isServiceRunning = true } } Column( modifier = Modifier .fillMaxSize() .background(Color(0xFFF5F5DC)) .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( "How many minutes would you like to time?", fontSize = 20.sp, fontWeight = MaterialTheme.typography.headlineMedium.fontWeight, color = Color.Black ) Spacer(Modifier.height(32.dp)) OutlinedTextField( value = inputText, onValueChange = { if (it.all(Char::isDigit)) inputText = it }, label = { Text("Minutes") }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.width(120.dp), enabled = !isServiceRunning ) Spacer(Modifier.height(64.dp)) Row( horizontalArrangement = Arrangement.spacedBy(16.dp) ) { // 启动按钮 Button( onClick = { val minutes = inputText.toIntOrNull() ?: 0 if (minutes > 0) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // Android 13+ 先请求通知权限 if (ContextCompat.checkSelfPermission( context, Manifest.permission.POST_NOTIFICATIONS ) != android.content.pm.PackageManager.PERMISSION_GRANTED ) { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } else { startTimerService(context, minutes) isServiceRunning = true } } else { startTimerService(context, minutes) isServiceRunning = true } } }, enabled = !isServiceRunning && inputText.isNotBlank(), colors = ButtonDefaults.buttonColors(containerColor = Color.Green) ) { Text("Start", color = Color.Black) } // 停止按钮 Button( onClick = { stopTimerService(context) isServiceRunning = false inputText = "" }, enabled = isServiceRunning, colors = ButtonDefaults.buttonColors(containerColor = Color.Red) ) { Text("Stop", color = Color.White) } } } } // 启动计时器服务的工具方法 private fun startTimerService(context: Context, minutes: Int) { val intent = Intent(context, TimerForegroundService::class.java).apply { action = TimerForegroundService.ACTION_START putExtra(TimerForegroundService.EXTRA_MINUTES, minutes) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ContextCompat.startForegroundService(context, intent) } else { context.startService(intent) } } // 停止计时器服务的工具方法 private fun stopTimerService(context: Context) { val intent = Intent(context, TimerForegroundService::class.java).apply { action = TimerForegroundService.ACTION_STOP } context.startService(intent) }
几个重要注意点
- 图标替换:代码里的
R.drawable.ic_timer和R.drawable.ic_stop要换成你自己项目里的图标哦 - 重复播报:如果要倒计时结束后每隔3秒重复播报,在服务的
timeLeft == 0分支里加while(true)循环即可 - 权限问题:Android 13+一定要请求
POST_NOTIFICATIONS权限,不然前台服务启动会失败 - 资源释放:服务销毁时一定要调用
tts.shutdown(),避免内存泄漏
这样改完之后,不管你切后台还是锁屏,计时器都会在后台持续运行,通知栏还会显示当前倒计时,用户也能通过通知栏停止计时器,完全符合Android的后台规则~
内容来源于stack exchange




