A
第 15 章JAVA40 分钟

通知 Notification

掌握通知渠道、基本通知构建、丰富样式(大图/大文本/收件箱)、PendingIntent 点击跳转、进度通知、前台服务通知与 Android 13+ 通知权限

学习目标

  • 理解 Android 8+ 必需的 NotificationChannel 与各配置项作用
  • 用 NotificationCompat 构建基本通知并发布到指定渠道
  • 实现大图、大文本、收件箱、消息样式通知
  • 通过 PendingIntent 实现点击跳转与 Action 按钮
  • 实现进度通知与前台服务常驻通知
  • 在 Android 13+ 申请 POST_NOTIFICATIONS 权限

学习目标

通知是 Android 与用户“非实时屏幕”沟通的核心渠道,无论是消息推送、下载进度、还是前台服务常驻都离不开它。本章目标:

  • 理解 Android 8.0+ 强制要求的 NotificationChannel;
  • 用 NotificationCompat 构建基本通知并发布;
  • 应用 BigTextStyle / BigPictureStyle / InboxStyle / MessagingStyle 等样式;
  • 用 PendingIntent 实现点击跳转、Action 按钮;
  • 实现进度条通知、前台服务常驻通知;
  • 在 Android 13+ 申请 POST_NOTIFICATIONS 权限。

通知渠道 NotificationChannel

1. 为什么必须有渠道

从 Android 8.0(API 26)起,所有通知必须归属某个渠道。若不创建渠道直接发通知,系统会静默丢弃且不报错。渠道一旦创建就不能修改 importance,但可在系统设置页由用户调整。

2. 创建渠道

public class NotificationHelper {

    public static final String CHANNEL_GENERAL = "channel_general";
    public static final String CHANNEL_DOWNLOAD = "channel_download";
    public static final String CHANNEL_MESSAGE = "channel_message";
    public static final String CHANNEL_FOREGROUND = "channel_foreground";

    public static void createChannels(Context context) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            NotificationManager nm =
                    (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);

            NotificationChannel general = new NotificationChannel(
                    CHANNEL_GENERAL, "常规通知",
                    NotificationManager.IMPORTANCE_DEFAULT);
            general.setDescription("普通提醒");
            general.enableVibration(true);
            general.setShowBadge(true);

            NotificationChannel download = new NotificationChannel(
                    CHANNEL_DOWNLOAD, "下载进度",
                    NotificationManager.IMPORTANCE_LOW);
            download.setDescription("低优先级下载进度");
            download.setSound(null, null);

            NotificationChannel message = new NotificationChannel(
                    CHANNEL_MESSAGE, "即时消息",
                    NotificationManager.IMPORTANCE_HIGH);
            message.setDescription("高优先级消息,带声音");
            message.enableLights(true);
            message.setLightColor(Color.RED);

            NotificationChannel foreground = new NotificationChannel(
                    CHANNEL_FOREGROUND, "前台服务",
                    NotificationManager.IMPORTANCE_LOW);
            foreground.setDescription("正在运行的后台服务");
            foreground.setShowBadge(false);

            nm.createNotificationChannels(Arrays.asList(general, download, message, foreground));
        }
    }
}

3. 在 Application 中初始化

public class App extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        NotificationHelper.createChannels(this);
    }
}

基本通知构建

1. Gradle 依赖

dependencies {
    implementation 'androidx.core:core:1.13.1'
}

2. 基本通知

public class MainActivity extends AppCompatActivity {

    private static final int NOTIFY_ID_BASIC = 1001;

    private void sendBasicNotification() {
        Intent intent = new Intent(this, DetailActivity.class);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
        PendingIntent pi = PendingIntent.getActivity(this, 0, intent,
                PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);

        Notification notification = new NotificationCompat.Builder(this,
                NotificationHelper.CHANNEL_GENERAL)
                .setSmallIcon(R.drawable.ic_notification) // 必填,单色透明 PNG
                .setContentTitle("标题")
                .setContentText("这是一条普通通知")
                .setAutoCancel(true) // 点击后自动消失
                .setContentIntent(pi)
                .setPriority(NotificationCompat.PRIORITY_DEFAULT)
                .build();

        NotificationManagerCompat.from(this).notify(NOTIFY_ID_BASIC, notification);
    }
}

必须传 FLAG_IMMUTABLE 或 FLAG_MUTABLE(Android 12+),否则抛 IllegalArgumentException。无内部修改需求时优先用 FLAG_IMMUTABLE,更安全。

丰富样式

1. BigTextStyle

Notification noti = new NotificationCompat.Builder(this, CHANNEL_GENERAL)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle("文章更新")
        .setStyle(new NotificationCompat.BigTextStyle()
                .bigText("这是一段超长正文内容,通知展开时会完整显示,"
                        + "默认折叠状态下只显示 setContentText 的内容。"
                        + "继续展开即可看到完整正文……"))
        .build();

2. BigPictureStyle

Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.banner);
Notification noti = new NotificationCompat.Builder(this, CHANNEL_GENERAL)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle("新封面发布")
        .setStyle(new NotificationCompat.BigPictureStyle()
                .bigPicture(bitmap)
                .bigLargeIcon(null))
        .build();

3. InboxStyle

Notification noti = new NotificationCompat.Builder(this, CHANNEL_GENERAL)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle("3 条未读消息")
        .setStyle(new NotificationCompat.InboxStyle()
                .addLine("Alice:明天一起吃饭吗?")
                .addLine("Bob:项目文档已更新")
                .addLine("Carol:下班路上小心")
                .setSummaryText("3 条未读"))
        .build();

4. MessagingStyle(适合 IM)

Notification noti = new NotificationCompat.Builder(this, CHANNEL_MESSAGE)
        .setSmallIcon(R.drawable.ic_notification)
        .setStyle(new NotificationCompat.MessagingStyle("我")
                .addMessage(new NotificationCompat.MessagingStyle.Message(
                        "你今天有空吗?", System.currentTimeMillis(), "Alice"))
                .addMessage(new NotificationCompat.MessagingStyle.Message(
                        "项目有点问题想和你聊聊", System.currentTimeMillis() + 1000, "Alice")))
        .build();

点击跳转与 Action 按钮

1. 点击 PendingIntent 跳转

private PendingIntent buildContentIntent() {
    Intent intent = new Intent(this, MessageActivity.class);
    intent.putExtra("extra_message_id", "msg_001");
    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
    return PendingIntent.getActivity(this, 0, intent,
            PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
}

2. Action 按钮

Intent replyIntent = new Intent(this, ReplyActionReceiver.class);
replyIntent.setAction("ACTION_REPLY");
replyIntent.putExtra("extra_message_id", "msg_001");
PendingIntent replyPi = PendingIntent.getBroadcast(this, 1, replyIntent,
        PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);

NotificationAction action = new NotificationAction.Builder(
        R.drawable.ic_reply, "回复", replyPi).build();

Notification noti = new NotificationCompat.Builder(this, CHANNEL_MESSAGE)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle("来自 Alice 的新消息")
        .setContentText("你今天有空吗?")
        .addAction(action) // Android 14+ 单独 Action 已废弃,可用 setStyle 内 RemoteInput
        .build();

3. Direct Reply(Android 7+)

RemoteInput remoteInput = new RemoteInput.Builder("key_reply_text")
        .setLabel("回复")
        .build();

Intent replyIntent = new Intent(this, ReplyActionReceiver.class);
PendingIntent replyPi = PendingIntent.getBroadcast(this, 2, replyIntent,
        PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);

NotificationCompat.Action replyAction = new NotificationCompat.Action.Builder(
        R.drawable.ic_reply, "回复", replyPi)
        .addRemoteInput(remoteInput)
        .setAllowGeneratedReplies(true)
        .build();

Notification noti = new NotificationCompat.Builder(this, CHANNEL_MESSAGE)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle("Alice")
        .setContentText("你今天有空吗?")
        .addAction(replyAction)
        .build();

注意:使用 Direct Reply 必须用 FLAG_MUTABLE,否则无法在回复时修改 PendingIntent extras。

进度通知

public class DownloadService extends Service {

    private static final int NOTIFY_ID_DOWNLOAD = 2001;
    private NotificationManagerCompat mNm;
    private final Handler mHandler = new Handler(Looper.getMainLooper());

    @Override
    public void onCreate() {
        super.onCreate();
        mNm = NotificationManagerCompat.from(this);
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        new Thread(() -> {
            for (int progress = 0; progress <= 100; progress += 10) {
                updateProgress(progress);
                try {
                    Thread.sleep(500);
                } catch (InterruptedException ignored) { }
            }
            stopSelf();
        }).start();
        return START_NOT_STICKY;
    }

    private void updateProgress(int progress) {
        Notification noti = new NotificationCompat.Builder(this,
                NotificationHelper.CHANNEL_DOWNLOAD)
                .setSmallIcon(R.drawable.ic_notification)
                .setContentTitle("正在下载")
                .setContentText(progress + "%")
                .setProgress(100, progress, progress == 0)
                .setOnlyAlertOnce(true)
                .build();
        mNm.notify(NOTIFY_ID_DOWNLOAD, noti);
    }

    @Override
    public IBinder onBind(Intent intent) { return null; }
}

前台服务通知

public class MusicService extends Service {

    private static final int NOTIFY_ID_FG = 3001;

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        Notification noti = new NotificationCompat.Builder(this,
                NotificationHelper.CHANNEL_FOREGROUND)
                .setSmallIcon(R.drawable.ic_notification)
                .setContentTitle("正在播放")
                .setContentText("标题音乐")
                .setOngoing(true) // 不能滑动清除
                .build();

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
            // Android 14+ 必须指定 foregroundServiceType
            startForeground(NOTIFY_ID_FG, noti,
                    ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
        } else {
            startForeground(NOTIFY_ID_FG, noti);
        }
        return START_STICKY;
    }

    @Override
    public IBinder onBind(Intent intent) { return null; }
}
<!-- AndroidManifest.xml -->
<service
    android:name=".MusicService"
    android:exported="false"
    android:foregroundServiceType="mediaPlayback" />

Android 13+ 通知权限

private final ActivityResultLauncher<String> mRequestNoti =
        registerForActivityResult(new ActivityResultContracts.RequestPermission(),
                granted -> {
                    if (granted) {
                        sendBasicNotification();
                    } else {
                        Toast.makeText(this, "通知权限被拒绝",
                                Toast.LENGTH_SHORT).show();
                    }
                });

private void ensureNotificationPermissionAndSend() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        if (ContextCompat.checkSelfPermission(this,
                Manifest.permission.POST_NOTIFICATIONS)
                != PackageManager.PERMISSION_GRANTED) {
            mRequestNoti.launch(Manifest.permission.POST_NOTIFICATIONS);
            return;
        }
    }
    sendBasicNotification();
}

取消通知

NotificationManagerCompat.from(this).cancel(NOTIFY_ID_BASIC);          // 取消单个
NotificationManagerCompat.from(this).cancelAll();                      // 取消所有

// Android 8+ 取消整个渠道下所有通知(慎用)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
    nm.deleteNotificationChannel(CHANNEL_DOWNLOAD);
}

常见坑与最佳实践

  1. 忘了创建 Channel 直接 notify:API 26+ 上通知静默丢失,且不会报错,调试非常痛苦。在 Application onCreate 中统一创建。
  2. setSmallIcon 用彩色图标:会在状态栏显示成一团色块。必须用单色透明 PNG。
  3. PendingIntent.FLAG_UPDATE_CURRENT 漏写:复用旧 PendingIntent 时新 extra 不生效,导致跳转参数错乱。
  4. PendingIntent 未指定 FLAG_IMMUTABLE/MUTABLE:Android 12+ 直接抛异常崩溃。
  5. 使用已废弃的 Notification 构造器与 setLatestEventInfo:API 23+ 已废弃,全部用 NotificationCompat.Builder。
  6. 进度通知频繁刷新导致震动:必须加 setOnlyAlertOnce(true),避免每次更新都响铃。
  7. foregroundServiceType 未声明:Android 14+ 强制要求,否则 startForeground 抛 MissingForegroundServiceTypeException,且必须在 Manifest 中声明对应权限(如 FOREGROUND_SERVICE_MEDIA_PLAYBACK)。
  8. 通知 ID 冲突:每条不同语义的通知应用不同 ID,否则后者会覆盖前者。同一会话/下载可用同一 ID 实现“更新”。
  9. BigPictureStyle 加载大图同步解码:会卡 UI 线程,应使用 Glide/Coil 异步加载。

章节小结

  • NotificationChannel 是 Android 8+ 的硬性要求,importance 与 sound/vibration 等通过渠道统一管理;
  • 用 NotificationCompat.Builder + setSmallIcon + setContentTitle 构建基本通知;
  • 五种样式(BigText / BigPicture / Inbox / Messaging / Media)覆盖常见场景;
  • 点击 PendingIntent 与 Direct Reply(RemoteInput)实现交互;
  • 进度通知配合 setProgress + setOnlyAlertOnce;前台服务通知 Android 14+ 必须声明 foregroundServiceType;
  • Android 13+ 须运行时申请 POST_NOTIFICATIONS。

下一章预告

第 16 章将进入 推送 Firebase Cloud Messaging:FCM 接入、google-services.json 配置、FirebaseMessagingService、Token 获取与刷新、前台/后台消息处理、消息类型与 Topic 订阅。