Skip to content

通知系统

源:Android Notifications | iOS UserNotifications

本地通知是应用与用户沟通的重要渠道。Android 和 iOS 的通知系统差异巨大,本文将展示如何抽象跨平台通知 API。

平台差异对比

平台原生 API通知渠道权限要求特性支持
AndroidNotificationManager + NotificationChannel必需(API 26+)POST_NOTIFICATIONS (API 33+)自定义布局、操作按钮、进度条
iOSUNUserNotificationCenter自动请求附件、分组、临时通知
Desktop系统托盘通知无需权限基础文本通知

expect/actual 实现方案

API 核心签名说明

  • data class LocalNotification
  • expect class NotificationManager
  • suspend fun NotificationManager.requestPermission(): Boolean
  • suspend fun NotificationManager.showNotification(notification: LocalNotification): String
  • suspend 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 适配各厂商。