通知系统
源:Android Notifications | iOS UserNotifications
本地通知是应用与用户沟通的重要渠道。Android 和 iOS 的通知系统差异巨大,本文将展示如何抽象跨平台通知 API。
平台差异对比
| 平台 | 原生 API | 通知渠道 | 权限要求 | 特性支持 |
|---|---|---|---|---|
| Android | NotificationManager + NotificationChannel | 必需(API 26+) | POST_NOTIFICATIONS (API 33+) | 自定义布局、操作按钮、进度条 |
| iOS | UNUserNotificationCenter | 无 | 自动请求 | 附件、分组、临时通知 |
| Desktop | 系统托盘通知 | 无 | 无需权限 | 基础文本通知 |
expect/actual 实现方案
API 核心签名说明
data class LocalNotificationexpect class NotificationManagersuspend fun NotificationManager.requestPermission(): Booleansuspend fun NotificationManager.showNotification(notification: LocalNotification): Stringsuspend fun NotificationManager.cancelNotification(id: String)suspend fun NotificationManager.cancelAllNotifications()
标准代码块
kotlin
data class LocalNotification(
val id: String,
val title: String,
val body: String,
val badge: Int? = null,
val sound: String? = null,
val data: Map<String, String> = emptyMap()
)
expect class NotificationManager {
suspend fun requestPermission(): Boolean
suspend fun showNotification(notification: LocalNotification): String
suspend fun cancelNotification(id: String)
suspend fun cancelAllNotifications()
}
// 业务层使用
class NotificationService(private val manager: NotificationManager) {
suspend fun sendOrderNotification(orderId: String, message: String) {
// 先请求权限
val hasPermission = manager.requestPermission()
if (!hasPermission) {
println("Notification permission denied")
return
}
// 发送通知
val notification = LocalNotification(
id = "order_$orderId",
title = "订单更新",
body = message,
badge = 1,
data = mapOf("orderId" to orderId)
)
manager.showNotification(notification)
}
suspend fun clearOrderNotification(orderId: String) {
manager.cancelNotification("order_$orderId")
}
}kotlin
import android.app.NotificationChannel
import android.app.NotificationManager as AndroidNotificationManager
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
actual class NotificationManager(private val context: Context) {
private val notificationManager = NotificationManagerCompat.from(context)
companion object {
private const val CHANNEL_ID = "default_channel"
private const val CHANNEL_NAME = "默认通知"
}
init {
createNotificationChannel()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
AndroidNotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "应用默认通知渠道"
}
val systemManager = context.getSystemService(Context.NOTIFICATION_SERVICE)
as AndroidNotificationManager
systemManager.createNotificationChannel(channel)
}
}
actual suspend fun requestPermission(): Boolean = withContext(Dispatchers.Main) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Android 13+ 需要检查权限
notificationManager.areNotificationsEnabled()
} else {
// 低版本默认允许
true
}
}
actual suspend fun showNotification(notification: LocalNotification): String =
withContext(Dispatchers.IO) {
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(notification.title)
.setContentText(notification.body)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
// 设置角标(需要特定 Launcher 支持)
notification.badge?.let {
builder.setNumber(it)
}
// 设置声音
if (notification.sound != null) {
builder.setSound(null) // 自定义声音需要额外处理
}
val notificationId = notification.id.hashCode()
notificationManager.notify(notificationId, builder.build())
notification.id
}
actual suspend fun cancelNotification(id: String) = withContext(Dispatchers.IO) {
val notificationId = id.hashCode()
notificationManager.cancel(notificationId)
}
actual suspend fun cancelAllNotifications() = withContext(Dispatchers.IO) {
notificationManager.cancelAll()
}
}kotlin
import platform.UserNotifications.*
import platform.Foundation.*
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
actual class NotificationManager {
private val center = UNUserNotificationCenter.currentNotificationCenter()
actual suspend fun requestPermission(): Boolean = suspendCancellableCoroutine { continuation ->
center.requestAuthorizationWithOptions(
UNAuthorizationOptionAlert or UNAuthorizationOptionSound or UNAuthorizationOptionBadge
) { granted, error ->
continuation.resume(granted && error == null)
}
}
actual suspend fun showNotification(notification: LocalNotification): String =
suspendCancellableCoroutine { continuation ->
val content = UNMutableNotificationContent().apply {
setTitle(notification.title)
setBody(notification.body)
notification.badge?.let {
setBadge(NSNumber(int = it))
}
if (notification.sound != null) {
setSound(UNNotificationSound.defaultSound())
}
if (notification.data.isNotEmpty()) {
setUserInfo(notification.data.toNSDictionary())
}
}
val trigger = UNTimeIntervalNotificationTrigger.triggerWithTimeInterval(
timeInterval = 0.1,
repeats = false
)
val request = UNNotificationRequest.requestWithIdentifier(
identifier = notification.id,
content = content,
trigger = trigger
)
center.addNotificationRequest(request) { error ->
if (error == null) {
continuation.resume(notification.id)
} else {
continuation.resume("")
}
}
}
actual suspend fun cancelNotification(id: String) {
center.removePendingNotificationRequestsWithIdentifiers(listOf(id))
center.removeDeliveredNotificationsWithIdentifiers(listOf(id))
}
actual suspend fun cancelAllNotifications() {
center.removeAllPendingNotificationRequests()
center.removeAllDeliveredNotifications()
}
}
// 扩展函数
private fun Map<String, String>.toNSDictionary(): NSDictionary {
val dict = NSMutableDictionary()
forEach { (key, value) ->
dict.setObject(value, forKey = key)
}
return dict
}kotlin
actual class NotificationManager {
// Desktop 简化实现,仅打印日志
actual suspend fun requestPermission(): Boolean {
return true
}
actual suspend fun showNotification(notification: LocalNotification): String {
println("📢 Notification: ${notification.title} - ${notification.body}")
// 可选:使用 AWT TrayIcon 显示系统托盘通知
// 或集成 Java 9+ 的 java.awt.SystemTray
return notification.id
}
actual suspend fun cancelNotification(id: String) {
println("🔕 Cancel notification: $id")
}
actual suspend fun cancelAllNotifications() {
println("🔕 Cancel all notifications")
}
}代码封装示例
以下是带通知点击处理和深度链接的完整封装:
kotlin
// commonMain
data class NotificationAction(
val id: String,
val title: String,
val deepLink: String? = null
)
data class RichNotification(
val id: String,
val title: String,
val body: String,
val badge: Int? = null,
val imageUrl: String? = null,
val actions: List<NotificationAction> = emptyList(),
val data: Map<String, String> = emptyMap()
)
interface NotificationHandler {
suspend fun requestPermission(): Boolean
suspend fun show(notification: RichNotification)
suspend fun cancel(id: String)
fun setNotificationClickListener(listener: (String, Map<String, String>) -> Unit)
}第三方库推荐
Moko Resources Notifications
推荐库
实际上目前 KMP 生态中缺少成熟的跨平台通知库,推荐自实现基础功能。
对比方案
| 方案 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| 自实现 | 完全可控、轻量 | 需处理平台差异 | 简单通知需求 |
| 平台原生 API | 功能完整 | 无法跨平台 | 需要高级特性 |
依赖补充
Android 通知依赖
kotlin
kotlin {
sourceSets {
androidMain.dependencies {
implementation("androidx.core:core-ktx:1.12.0")
}
}
}groovy
kotlin {
sourceSets {
androidMain {
dependencies {
implementation "androidx.core:core-ktx:1.12.0"
}
}
}
}toml
[versions]
androidx-core = "1.12.0"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }最新版本查看链接:https://developer.android.com/jetpack/androidx/releases/core
iOS Info.plist 配置(可选)
如需自定义通知声音,需在 Info.plist 添加声音文件:
xml
<!-- 无需特殊配置,UNUserNotificationCenter 自动处理 -->实战坑点
Android 通知渠道必须创建
NotificationChannel 缺失
Android 8.0+ 未创建通知渠道会导致通知静默失败,不会抛出异常。
解决方案:
在初始化时创建默认渠道:
kotlin
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
"default",
"默认通知",
NotificationManager.IMPORTANCE_DEFAULT
)
notificationManager.createNotificationChannel(channel)
}Android 13+ 权限请求
POST_NOTIFICATIONS 权限
Android 13+ 必须请求 POST_NOTIFICATIONS 权限,否则通知不会显示。
权限请求代码:
kotlin
// AndroidManifest.xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
// 运行时请求
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissionManager.requestPermission(Permission.NOTIFICATIONS)
}iOS 通知权限拒绝
无法重新弹窗
iOS 用户拒绝通知权限后,再次调用 requestAuthorization 不会弹窗。
解决方案:
在请求前检查状态,并引导用户跳转设置:
kotlin
center.getNotificationSettingsWithCompletionHandler { settings ->
when (settings?.authorizationStatus) {
UNAuthorizationStatusDenied -> {
// 引导用户跳转设置
openAppSettings()
}
UNAuthorizationStatusNotDetermined -> {
// 可以请求权限
}
else -> {}
}
}Android 通知 ID 冲突
::: caution ID 类型转换 Android 通知 ID 是 Int,String ID 通过 hashCode() 转换可能冲突。 :::
更好的方案:
kotlin
private val notificationIds = mutableMapOf<String, Int>()
private var nextId = 1
fun getNotificationId(stringId: String): Int {
return notificationIds.getOrPut(stringId) { nextId++ }
}iOS 通知不显示
触发器时间过短
UNTimeIntervalNotificationTrigger 的时间间隔不能为 0,最小值为 0.1 秒。
正确设置:
kotlin
val trigger = UNTimeIntervalNotificationTrigger.triggerWithTimeInterval(
timeInterval = 0.1, // 最小 0.1 秒
repeats = false
)Android 角标不生效
厂商定制
Android 原生不支持角标,仅部分厂商 Launcher(小米、华为)支持。
解决方案:
使用第三方库如 ShortcutBadger 适配各厂商。