Android 14 适配实战:3大隐私变更与5个关键API适配代码示例

Android 14 适配实战:3大隐私变更与5个关键API适配代码示例

1. 精确闹钟权限的精细化控制

Android 14对精确闹钟权限(SCHEDULE_EXACT_ALARM)的管理更加严格。这项变更实际上从Android 12(API 31)就已引入,但在Android 14上执行得更加彻底。精确闹钟通常用于需要严格时间控制的场景,如医疗提醒或重要会议通知。

适配步骤:

  1. 首先在AndroidManifest.xml中声明权限:
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
  1. 检查当前是否拥有权限:
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { // 没有权限,引导用户前往设置 val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM) intent.data = Uri.parse("package:$packageName") startActivity(intent) }
  1. 监听权限状态变化:
private val alarmPermissionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action == AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED) { // 重新检查权限状态 checkAlarmPermission() } } } override fun onResume() { super.onResume() registerReceiver(alarmPermissionReceiver, IntentFilter(AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED)) } override fun onPause() { super.onPause() unregisterReceiver(alarmPermissionReceiver) }

替代方案考虑:

如果应用不需要精确到毫秒级别的触发时间,可以考虑使用以下替代API:

不精确API适用场景
setAndAllowWhileIdle()允许在低电耗模式下触发
setWindow()允许系统在时间窗口内优化触发时机
set()最基本的闹钟设置方法

提示:在Android 14上,即使用户拒绝了精确闹钟权限,应用仍然可以使用setAlarmClock()设置可见的闹钟,这会在状态栏显示闹钟图标。

2. 前台服务类型的强制声明

Android 14要求所有前台服务必须明确声明其类型,这是对Android 10引入的前台服务类型特性的强化。这项变更有助于系统更好地管理资源分配和电池优化。

适配流程:

  1. 在AndroidManifest.xml中为每个Service声明前台服务类型:
<service android:name=".MyLocationService" android:foregroundServiceType="location" android:exported="false" />
  1. 添加对应的权限声明(根据服务类型):
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
  1. 启动服务时指定类型:
val serviceIntent = Intent(this, MyLocationService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { startForegroundService(serviceIntent, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) } else { startForegroundService(serviceIntent) }

Android 14新增的前台服务类型:

服务类型对应权限典型用例
healthFOREGROUND_SERVICE_HEALTH健康监测应用
remoteMessagingFOREGROUND_SERVICE_REMOTE_MESSAGING远程消息推送
shortServiceFOREGROUND_SERVICE_SHORT_SERVICE短期后台任务
specialUseFOREGROUND_SERVICE_SPECIAL_USE特殊用例
systemExempted系统豁免应用

常见问题处理:

如果忘记声明前台服务类型,系统会抛出MissingForegroundServiceTypeException。解决方法是在清单文件中添加正确的android:foregroundServiceType属性。

3. 部分媒体访问权限的细化管理

Android 14引入了更细粒度的媒体访问控制,允许用户仅授予应用访问特定照片和视频的权限,而不是整个媒体库。这是对Android 13媒体权限分化的进一步改进。

权限适配策略:

  1. 更新AndroidManifest.xml:
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
  1. 实现权限请求逻辑:
private fun requestMediaPermissions() { val permissionsToRequest = mutableListOf<String>() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { permissionsToRequest.add(READ_MEDIA_VISUAL_USER_SELECTED) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { permissionsToRequest.addAll(arrayOf( READ_MEDIA_IMAGES, READ_MEDIA_VIDEO )) } else { permissionsToRequest.add(READ_EXTERNAL_STORAGE) } permissionLauncher.launch(permissionsToRequest.toTypedArray()) }
  1. 检查权限状态:
fun checkMediaPermissions(): Boolean { return when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && (hasPermission(READ_MEDIA_IMAGES) || hasPermission(READ_MEDIA_VIDEO)) -> { // 拥有完整访问权限 true } Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && hasPermission(READ_MEDIA_VISUAL_USER_SELECTED) -> { // 只有部分访问权限 showMediaSelection() false } Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU && hasPermission(READ_EXTERNAL_STORAGE) -> { // 旧版本完整访问权限 true } else -> { // 无权限 false } } }

最佳实践建议:

考虑使用Android的照片选择器(PhotoPicker)API,它可以完全绕过权限请求流程:

val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> // 处理用户选择的媒体文件 uri?.let { handleSelectedMedia(it) } } fun launchPhotoPicker() { pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) }

4. 动态广播接收器的导出行为强制声明

Android 14要求所有动态注册的广播接收器必须明确声明其导出行为,这是对Android 13相关变更的强化执行。这项安全改进旨在防止无意中导出广播接收器导致的安全漏洞。

适配代码示例:

  1. 导出型广播接收器(允许其他应用发送广播):
context.registerReceiver( myReceiver, intentFilter, ContextCompat.RECEIVER_EXPORTED )
  1. 非导出型广播接收器(仅接收本应用或系统发送的广播):
context.registerReceiver( myReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED )
  1. 系统广播接收器(无需声明导出行为):
// 系统广播会自动处理,无需额外标志 context.registerReceiver( mySystemReceiver, IntentFilter(Intent.ACTION_BATTERY_LOW) )

常见问题排查:

如果忘记声明导出行为,在Android 14上会收到如下错误:

SecurityException: One of RECEIVER_EXPORTED or RECEIVER_NOT_EXPORTED should be specified when a receiver isn't being registered exclusively for system broadcasts

注意:使用LocalBroadcastManager注册的接收器不受此变更影响,因为它们本来就是应用内通信机制。

5. 隐式Intent的组件导出限制

Android 14进一步加强了对隐式Intent的限制,要求目标组件必须显式声明为导出(android:exported="true"),否则系统会抛出异常。这项变更有助于防止组件劫持攻击。

适配方案对比:

场景旧实现新实现
启动ActivitystartActivity(Intent("com.example.ACTION"))startActivity(Intent("com.example.ACTION").apply {package = context.packageName})
启动ServicestartService(Intent("com.example.SERVICE_ACTION"))startService(Intent().apply {action = "com.example.SERVICE_ACTION"component = ComponentName(context, MyService::class.java)})
绑定ServicebindService(Intent("com.example.BIND_ACTION"), ...)bindService(Intent().apply {action = "com.example.BIND_ACTION"component = ComponentName(context, MyService::class.java)}, ...)

实际代码示例:

  1. 启动内部Activity的正确方式:
val intent = Intent("com.example.action.APP_ACTION").apply { package = packageName } startActivity(intent)
  1. 启动内部Service的正确方式:
val serviceIntent = Intent(this, MyInternalService::class.java).apply { action = "com.example.action.SERVICE_ACTION" } startService(serviceIntent)
  1. 处理PendingIntent:
val pendingIntent = PendingIntent.getActivity( this, 0, Intent(this, InternalActivity::class.java), PendingIntent.FLAG_IMMUTABLE )

兼容性考虑:

对于需要支持旧版本Android的应用,可以创建兼容方法:

fun createExplicitIntent(context: Context, action: String): Intent { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { Intent(action).apply { package = context.packageName } } else { Intent(action) } }