You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

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)
}

几个重要注意点

  1. 图标替换:代码里的R.drawable.ic_timerR.drawable.ic_stop要换成你自己项目里的图标哦
  2. 重复播报:如果要倒计时结束后每隔3秒重复播报,在服务的timeLeft == 0分支里加while(true)循环即可
  3. 权限问题:Android 13+一定要请求POST_NOTIFICATIONS权限,不然前台服务启动会失败
  4. 资源释放:服务销毁时一定要调用tts.shutdown(),避免内存泄漏

这样改完之后,不管你切后台还是锁屏,计时器都会在后台持续运行,通知栏还会显示当前倒计时,用户也能通过通知栏停止计时器,完全符合Android的后台规则~

内容来源于stack exchange

火山引擎 最新活动