本文介绍了Android闹钟应用的开发流程,包含三个核心部分:
-
权限配置:详细说明了Manifest文件中需要添加的权限声明(如SCHEDULE_EXACT_ALARM、FOREGROUND_SERVICE等),以及服务注册方法,特别强调Android 12+的兼容性问题。
-
UI设计:采用ConstraintLayout构建极简界面,包含时钟显示区、闹钟设置区和状态提示区。提供了详细的视觉规范(色彩、字体、间距)和交互设计建议(输入验证、按钮反馈、主题适配)。
-
核心功能实现:
- 使用ActivityResultLauncher处理权限请求
- 通过Coroutine实现实时时钟更新
- 完整闹钟设置逻辑(时间验证、Calendar处理)
- 前台服务实现持续响铃功能(含通知渠道创建和铃声控制)
- 兼容不同Android版本的API调用方式
代码示例包含MainActivity和AlarmService的完整实现,覆盖了权限检查、时间处理、前台服务等关键功能模块。
(一)配置相关权限(和核心需求有关,注意这里有很多版本兼容问题)
在Manifest 中的以下代码下面
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
>
添加权限
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<!-- 基础前台服务权限(Android 8.0+ 必需) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- 细分权限:媒体播放类型(适配Android 12+,与AlarmService的foregroundServiceType对应) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- 可选:闹钟类型权限(若后续调整服务类型为alarm,可添加) -->
<!-- <uses-permission android:name="android.permission.FOREGROUND_SERVICE_ALARM" /> -->
在Manifest 中的<application></application>内部,
注册服务
<!-- 注册闹钟服务 -->
<service
android:name=".AlarmService"
android:foregroundServiceType="mediaPlayback" />
<!-- 声明前台服务类型(Android 12+) -->
<!-- 闹钟广播接收器 -->
<receiver android:name=".MainActivity$AlarmReceiver"
android:exported="false"/>
(二)设计和构建UI(可以先写一个低配版UI,后续再优化)
一、明确 UI 核心模块与布局逻辑
这个极简闹钟 App 的 UI 只需要 3 个核心模块,推荐用 ConstraintLayout 替代 LinearLayout 来实现更灵活的适配:
1. 顶部时钟展示区:占屏幕上半部分 60%,大字号居中显示,突出核心功能
2. 中部闹钟设置区:输入框 + 按钮横向排列,与时钟区保持合理间距
3. 底部辅助提示区(可选):显示闹钟状态(如“下次响铃时间”)
二、具体视觉与交互设计规范
1. 色彩设计(极简风格)
- 主色调:选 1 种低饱和度纯色(如 #2196F3 蓝色 / #4CAF50 绿色),用于按钮、时钟高亮
- 背景色:浅色模式用 #FFFFFF ,深色模式用 #121212 (适配系统深色主题)
- 文字色:时钟文字用 #000000 (浅)/ #FFFFFF (深),辅助文字用 #757575 灰色
2. 字体与尺寸设计
- 时钟文字: textSize="72sp" ,加粗( textStyle="bold" ),用无衬线字体( fontFamily="sans-serif" )
- 输入框提示文字: textSize="20sp" ,输入文字 22sp
- 按钮文字: textSize="18sp" ,加粗,内边距 padding="12dp 24dp"
3. 交互细节设计
- 输入框:限制只能输入时间格式( inputType="time" ),添加清除按钮(点击清空内容)
- 按钮:设置按压效果( backgroundTint 加 rippleColor ),禁用状态(输入格式错误时灰显)
- 时钟:支持点击切换 12/24 小时制,切换时有淡入淡出动画
三、进阶优化建议
1. 深色主题适配:在 res/values-night 下创建 colors.xml ,配置深色模式的颜色值
2. 动画效果:给时钟添加数字变化的淡入动画,按钮点击添加缩放反馈
3. 状态提示:添加 TextView 显示“闹钟已设置 08:00”,闹钟触发时显示提示
检查是否有UI文件,没有就按以下结构创建一个

完整UI代码如下:
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="20dp"
>
<!-- 数字时钟显示 -->
<TextView
android:id="@+id/tvClock"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="00:00:00"
android:textSize="72sp"
android:textStyle="bold"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.3"/>
<!-- 闹钟设置区域 -->
<EditText
android:id="@+id/etAlarmTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:hint="HH:MM"
android:inputType="time"
android:textSize="22sp"
app:layout_constraintTop_toBottomOf="@id/tvClock"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/btnSetAlarm"
app:layout_constraintHorizontal_bias="0.5"/>
<Button
android:id="@+id/btnSetAlarm"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="设置闹钟"
android:textSize="18sp"
android:paddingHorizontal="24dp"
android:paddingVertical="12dp"
app:layout_constraintTop_toTopOf="@id/etAlarmTime"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/etAlarmTime"/>
</androidx.constraintlayout.widget.ConstraintLayout>
(三)后端代码创作,添加相关依赖(如果不知道怎么操作可以看我的相关笔记)
一般流程如下:
1.在onCreate中初始化控件
// 初始化控件
tvClock = findViewById(R.id.tvClock)
etAlarmTime = findViewById(R.id.etAlarmTime)
btnSetAlarm = findViewById(R.id.btnSetAlarm)
2.在onCreate中写下核心需求块(几个简单未写入的方法帮助缕清思路)
可以分别按照UI控件,事件流程,生命周期来组织思路
// 关键修复:初始化闹钟权限请求Launcher(必须在使用前调用) initAlarmPermissionLauncher()
// 启动时钟刷新 startClockUpdate()
// 设置闹钟按钮点击事件
btnSetAlarm.setOnClickListener {
// 先检查权限,再设置闹钟
checkAlarmPermissionAndSet()
}
3.然后便是所有方法和类的扩展,这里面的逻辑一般较细就不多扩展了,想了解可以搜一下
private lateinit var alarmPermissionLauncher: ActivityResultLauncher<Intent>
//lateinit:延迟初始化关键字
//ActivityResultLauncher<Intent>
// 启动「带返回结果」的操作(如权限请求、跳转 Activity 并接收返回值、启动系统服务等)。
//Intent 用于封装权限请求信息或跳转意图信息
/** 初始化闹钟权限请求的Launcher */
private fun initAlarmPermissionLauncher() {
alarmPermissionLauncher = registerForActivityResult(
// 契约:启动Activity并接收结果
ActivityResultContracts.StartActivityForResult()
) { result ->
// 权限设置页返回后的回调逻辑
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
if (alarmManager.canScheduleExactAlarms()) {
// 用户已开启权限,执行设置闹钟
Toast.makeText(this, "权限已开启,正在设置闹钟", Toast.LENGTH_SHORT).show()
setupAlarm()
} else {
// 用户未开启权限
Toast.makeText(this, "未开启精确闹钟权限,无法设置闹钟", Toast.LENGTH_SHORT).show()
}
}
}
}
下面是完整代码
MainActivity.kt
package com.example.clockapp
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Calendar
import java.util.Date
import java.util.Locale
class MainActivity : AppCompatActivity() {
private lateinit var tvClock: TextView
private lateinit var etAlarmTime: EditText
private lateinit var btnSetAlarm: Button
@RequiresApi(Build.VERSION_CODES.M)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 初始化控件
tvClock = findViewById(R.id.tvClock)
etAlarmTime = findViewById(R.id.etAlarmTime)
btnSetAlarm = findViewById(R.id.btnSetAlarm)
// 关键修复:初始化闹钟权限请求Launcher(必须在使用前调用)
initAlarmPermissionLauncher()
// 启动时钟刷新(兼容所有版本)
startClockUpdate()
// 设置闹钟按钮点击事件
btnSetAlarm.setOnClickListener {
// 先检查权限,再设置闹钟
checkAlarmPermissionAndSet()
}
}
/** 每秒刷新时钟(兼容低版本) */
// 直接使用lifecycleScope,无需手动取消
private fun startClockUpdate() {
val formatter = DateTimeFormatter.ofPattern("HH:mm:ss", Locale.getDefault())
lifecycleScope.launch {
while (isActive) {
val currentTime = LocalTime.now().format(formatter)
tvClock.text = currentTime
delay(1000)
}
}
}
private lateinit var alarmPermissionLauncher: ActivityResultLauncher<Intent>
//lateinit:延迟初始化关键字
//ActivityResultLauncher<Intent>
// 启动「带返回结果」的操作(如权限请求、跳转 Activity 并接收返回值、启动系统服务等)。
//Intent 用于封装权限请求信息或跳转意图信息
/** 初始化闹钟权限请求的Launcher */
private fun initAlarmPermissionLauncher() {
alarmPermissionLauncher = registerForActivityResult(
// 契约:启动Activity并接收结果
ActivityResultContracts.StartActivityForResult()
) { result ->
// 权限设置页返回后的回调逻辑
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
if (alarmManager.canScheduleExactAlarms()) {
// 用户已开启权限,执行设置闹钟
Toast.makeText(this, "权限已开启,正在设置闹钟", Toast.LENGTH_SHORT).show()
setupAlarm()
} else {
// 用户未开启权限
Toast.makeText(this, "未开启精确闹钟权限,无法设置闹钟", Toast.LENGTH_SHORT).show()
}
}
}
}
/** 检查闹钟权限(适配Android 12+) */
@RequiresApi(Build.VERSION_CODES.M)
private fun checkAlarmPermissionAndSet() {
// Android 12+(API 31)需要SCHEDULE_EXACT_ALARM权限
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
if (!alarmManager.canScheduleExactAlarms()) {
// 权限未授予,跳转到系统设置页
Toast.makeText(this, "需要开启“精确闹钟”权限才能设置闹钟", Toast.LENGTH_SHORT).show()
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
// 3. 启动权限设置页(替代startActivityForResult)
alarmPermissionLauncher.launch(intent)
return
}
}
// 权限已授予,直接设置闹钟
setupAlarm()
}
/** 设置闹钟核心逻辑(不变,兼容所有版本) */
private fun setupAlarm() {
val input = etAlarmTime.text.toString().trim()
if (input.isEmpty() || !input.contains(":")) {
Toast.makeText(this, "请输入正确格式:HH:MM", Toast.LENGTH_SHORT).show()
return
}
val timeParts = input.split(":")
if (timeParts.size != 2) {
Toast.makeText(this, "格式错误!示例:08:30", Toast.LENGTH_SHORT).show()
return
}
val hour = timeParts[0].toIntOrNull()
val minute = timeParts[1].toIntOrNull()
if (hour == null || minute == null || hour !in 0..23 || minute !in 0..59) {
Toast.makeText(this, "时间超出范围!小时0-23,分钟0-59", Toast.LENGTH_SHORT).show()
return
}
val calendar = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
if (timeInMillis < System.currentTimeMillis()) {
add(Calendar.DAY_OF_YEAR, 1)
}
}
val intent = Intent(this, AlarmReceiver::class.java)
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val pendingIntent = PendingIntent.getBroadcast(this, 0, intent, flags)
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
calendar.timeInMillis,
pendingIntent
)
} else {
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
calendar.timeInMillis,
pendingIntent
)
}
val alarmTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(calendar.time)
Toast.makeText(this, "闹钟已设置为:$alarmTime", Toast.LENGTH_LONG).show()
}
/** 闹钟广播接收器 */
class AlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
context?.let {
// 启动闹钟服务
val serviceIntent = Intent(it, AlarmService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
it.startForegroundService(serviceIntent)
} else {
it.startService(serviceIntent)
}
Toast.makeText(it, "⏰ 闹钟响铃啦!", Toast.LENGTH_LONG).show()
}
}
}
}
AlarmService.kt
package com.example.clockapp
import android.app.Service
import android.content.Intent
import android.media.MediaPlayer
import android.os.Build
import android.os.IBinder
import android.app.NotificationManager
import android.app.NotificationChannel
import android.media.RingtoneManager
import android.app.PendingIntent
import androidx.core.app.NotificationCompat
class AlarmService : Service() {
private lateinit var mediaPlayer: MediaPlayer
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
// 初始化MediaPlayer(使用系统闹钟铃声)
mediaPlayer = MediaPlayer.create(this, RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM))
mediaPlayer.isLooping = true // 循环播放
}
// 只保留一个onStartCommand()方法,整合所有逻辑
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// 1. 先处理“关闭闹钟”的动作逻辑(优先级最高)
if (intent?.action == "CLOSE_ALARM") {
stopSelf() // 关闭当前服务
return START_NOT_STICKY // 服务关闭后,无需系统重启
}
// 2. (Android 8.0+)先创建通知渠道,再构建通知
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
"ALARM_CHANNEL",
"闹钟通知",
NotificationManager.IMPORTANCE_HIGH
)
channel.description = "闹钟响铃通知,点击关闭铃声"
// 注册通知渠道
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
// 3. 构建前台服务通知
val notification = NotificationCompat.Builder(this, "ALARM_CHANNEL")
.setContentTitle("闹钟响铃")
.setContentText("点击关闭闹钟")
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm) // 系统自带闹钟图标
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // 锁屏可见
.setContentIntent(getCloseIntent()) // 点击通知关闭闹钟
.build()
// 4. 启动前台服务,避免被系统杀死
startForeground(1, notification)
// 5. 开始播放闹钟铃声
mediaPlayer.start()
// 6. 返回服务重启策略(闹钟服务被杀死后,系统尝试重启)
return START_STICKY
}
// 点击通知关闭闹钟的PendingIntent
private fun getCloseIntent(): PendingIntent {
val intent = Intent(this, AlarmService::class.java).apply {
action = "CLOSE_ALARM"
}
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
return PendingIntent.getService(this, 0, intent, flags)
}
override fun onDestroy() {
super.onDestroy()
// 补充:服务销毁时,强制停止铃声播放并释放资源,避免内存泄漏
if (::mediaPlayer.isInitialized) { // 判空,避免未初始化导致崩溃
mediaPlayer.stop()
mediaPlayer.release()
}
}
}
注意事项!:
我的代码中MainActivity 继承了 AppCompatActivity(AppCompat 相关类),需要检查当前应用使用的主题不是 Theme.AppCompat 及其子类主题,否则会导致 AppCompat 控件(如 AppCompatButton)无法正常初始化,进而引发活动启动失败。
项目大概率使用了默认的 Theme.MaterialComponents(非 AppCompat 直接子类)或 android:Theme(原生主题),与 AppCompat 类不兼容,解决方案如下:
方案 1:使用 Theme.MaterialComponents.DayNight.DarkActionBar 主题(推荐,AppCompat 兼容)
步骤 1:修改 res/values/styles.xml(定义兼容主题)
打开 app/src/main/res/values/styles.xml,确保应用主题继承自 Theme.MaterialComponents 或 Theme.AppCompat:
xml
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- 基础应用主题:继承 Theme.MaterialComponents(兼容 AppCompat,推荐) -->
<style name="Theme.Notepad" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- 自定义主题属性(可选,如标题栏颜色、窗口背景等) -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryDark">@color/purple_700</item>
<item name="colorAccent">@color/teal_200</item>
<!-- 关闭 AppCompat 主题的默认ActionBar(若使用自定义布局或无ActionBar) -->
<!-- <item name="windowActionBar">false</item> -->
<!-- <item name="windowNoTitle">true</item> -->
</style>
<!-- 可选:无ActionBar主题(若项目不需要标题栏) -->
<style name="Theme.Notepad.NoActionBar" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryDark">@color/purple_700</item>
<item name="colorAccent">@color/teal_200</item>
</style>
</resources>
步骤 2:修改 AndroidManifest.xml(为应用 / Activity 指定兼容主题)
打开 app/src/main/AndroidManifest.xml,在 <application> 节点或 <activity> 节点中指定上述主题:
xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.notepad">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
<!-- 核心:指定兼容主题,对应 styles.xml 中的 Theme.Notepad -->
android:theme="@style/Theme.Notepad">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
方案 2:直接使用 Theme.AppCompat 系列主题(兜底,纯 AppCompat 兼容)
若不想使用 Material Components 主题,可直接继承 Theme.AppCompat 系列主题,步骤与方案 1 一致,仅修改 styles.xml 的父主题:
xml
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- 基础应用主题:直接继承 Theme.AppCompat(纯 AppCompat 兼容) -->
<style name="Theme.Notepad" parent="Theme.AppCompat.DayNight.DarkActionBar">
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryDark">@color/purple_700</item>
<item name="colorAccent">@color/teal_200</item>
</style>
</resources>
此外,你还需要:
步骤 1:在 libs.versions.toml 中添加 Material Components 依赖
打开 gradle/libs.versions.toml,补充 Material Components 的版本和库配置:
toml
[versions]
# 新增:Material Components 版本
material = "1.12.0"
[libraries]
# 新增:Material Components 库
androidx-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
步骤 2:在 app/build.gradle 中引入该依赖
在 dependencies 节点中添加:
gradle
dependencies {
// 其他依赖不变...
implementation(libs.androidx.material) // 引入 Material Components 库
}
UI展示


410

被折叠的 条评论
为什么被折叠?



