惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

B
Blog RSS Feed
WordPress大学
WordPress大学
博客园_首页
罗磊的独立博客
D
Docker
N
Netflix TechBlog - Medium
博客园 - Franky
Hugging Face - Blog
Hugging Face - Blog
D
DataBreaches.Net
I
InfoQ
L
LangChain Blog
GbyAI
GbyAI
V
V2EX
博客园 - 聂微东
P
Proofpoint News Feed
博客园 - 【当耐特】
腾讯CDC
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
量子位
Martin Fowler
Martin Fowler
有赞技术团队
有赞技术团队
U
Unit 42
博客园 - 司徒正美
大猫的无限游戏
大猫的无限游戏

阿尔的代码屋 | 全栈技术笔记

YuE2 全曲音乐生成模型本地部署与乐谱白盒实操 | 阿尔的代码屋 VoxCPM2 多语言语音合成与声音克隆本地部署 | 阿尔的代码屋 MiniMax-H3 NF4 视音频联合生成模型本地部署与调试 | 阿尔的代码屋 ShareX 联动 Antigravity 自动化记录与跨环境管道构建 | 阿尔的代码屋 国内搜索引擎收录实战:百度与头条搜索接入、无备案验证绕行与自动化推送 - 独立博客 SEO 与 GEO 03 | 阿尔的代码屋 技术博客工程化治理与 WebP 自动化质量门禁 - Hexo 博客建站与优化实战 05 | 阿尔的代码屋 Hexo NexT 静态资源本地自托管、KaTeX 公式渲染与移动端适配 - Hexo 博客建站与优化实战 04 | 排坑笔记 | 阿尔的代码屋 把 VS Code 打造成 Git 终极编辑器、Diff 与 Merge 利器 - Git 避坑与工作流 04 | 排坑笔记 | 阿尔的代码屋 告别架构图看不清:Hexo NexT 8.x 本地化集成 Fancybox 5 高清灯箱实战 | 开发日志 | 阿尔的代码屋 Hexo new 日期无法自动生成且出现 object Object 报错根治 | 排坑笔记 | 阿尔的代码屋 IndexNow 毫秒级主动推送与全站语义拓扑网格 - 独立博客 SEO 与 GEO 02 | 架构实战 | 阿尔的代码屋 从拦截 AI 爬虫到成为大模型答案源 - 独立博客 SEO 与 GEO 01 | 架构实战 | 阿尔的代码屋 Chrome 扩展开发与上架全流程实战避坑 - 开发技巧 | 阿尔的代码屋 VS Code 终端日志被截断?两项配置彻底解锁完整输出与会话持久化 | 排坑笔记 | 阿尔的代码屋 在 WSL2 环境下部署 Pixal3D 的从零实战与全流程排雷日志 | 阿尔的代码屋 在 Android Termux 环境下安装 Hermes Agent 的踩坑与完美解决实践 开发日志| 阿尔的代码屋 VS Code 连接 WSL 精确每 10 分钟掉线 排坑笔记 | 阿尔的代码屋 Android 模拟器代理联网与 No Internet WiFi 锁死排坑笔记 | 阿尔的代码屋 [object Object] typing_extensions 有用(四):使用 TypeIs 替代危险的 cast,做最严谨的类型收窄 | 阿尔的代码屋 GoRouter 结合 Isar 运行 Widget 测试并发/粘性线程死锁卡死排坑笔记 | 阿尔的代码屋 typing_extensions 有用(三):使用 Unpack 结合 TypedDict 给 **kwargs 装上透视眼 | 阿尔的代码屋 typing_extensions 有用(二):使用 @override 打造重构代码时的“防呆神器” | 阿尔的代码屋 Flutter 并发测试踩坑实录 - IsarCore 动态库下载冲突与 Widget 测试 HTTP 拦截全链路解决 | 阿尔的代码屋 typing_extensions 有用(一):使用 Self 终结继承时的类型推断灾难 | 阿尔的代码屋 基于 Cloudflare Pages 的纯前端 WebAssembly 应用自动化部署实践 | 开发日志 | 阿尔的代码屋 基于 VS Code 远程开发的 GPU Docker 容器自动清理方案实践 开发日志| 阿尔的代码屋 Patrol iOS 集成测试排坑实录 - xcodebuild exit code 70 全链路解决 | 阿尔的代码屋 Flutter E2E 测试从 integration_test 迁移到 Patrol - 实践笔记 | 阿尔的代码屋 Linux/macOS 下 micromamba 报错 Shard Index not available 与极度卡顿 排坑笔记 | 阿尔的代码屋
Flutter 本地通知实现排坑实录 - Android inexactAllowWhileI...
Algieba · 2026-06-20 · via 阿尔的代码屋 | 全栈技术笔记

核心摘要 (TL;DR)

  • 问题现象:实现过期提醒通知时,测试发现延迟通知(10秒)和系统调度通知都无法按时触发。
  • 根本原因
    1. Android 的 exactAllowWhileIdle 需要用户手动授予 SCHEDULE_EXACT_ALARM 权限(Android 12+)。
    2. 权限授予流程繁琐(需跳转系统设置),用户体验差。
    3. inexactAllowWhileIdle 虽无需权限但有 ±15 分钟延迟,难以快速测试验证。
  • 关键解法
    1. 生产环境:使用 inexactAllowWhileIdle 调度真实过期通知(省电、无需权限、±15分钟精度对过期提醒足够)。
    2. 开发测试:实现独立的应用内计时器测试方法(Future.delayed),绕过系统调度,10秒内验证通知显示和点击导航。
    3. 通知持久化:即使应用关闭,通知仍由系统 AlarmManager 保证触发,配合 RECEIVE_BOOT_COMPLETED 权限实现重启后自动恢复。

问题概览卡片

基本信息

  • 问题分类:Flutter 本地通知 / Android 系统调度策略
  • 环境说明:Flutter 3.41.1 / Android 模拟器 API 36 / flutter_local_notifications 17.2.4
  • 触发条件:实现定时通知功能,需要在指定时间(如明天早上 8:00)触发通知,并支持点击跳转到详情页。
  • 核心挑战
    1. 精确调度需要额外权限,用户体验差
    2. 不精确调度延迟大,测试困难
    3. 通知点击导航需处理冷启动和热恢复两种场景

1. 现象描述与现场还原

初步实现:精确闹钟权限困境

在项目中需要实现"物品过期前 N 天提醒"功能。最初采用了 exactAllowWhileIdle 模式:

1
2
3
4
5
6
7
8
9
await flutterLocalNotificationsPlugin.zonedSchedule(
notificationId,
title,
body,
scheduledDate,
notificationDetails,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime,
);

第一个坑:权限请求失败

在 Android 模拟器测试时,点击"测试延迟通知(10秒)"按钮后,控制台立即报错:

1
2
3
4
PlatformException(alarms_not_permitted, Exact alarms are not permitted, null, null)
E/flutter: #0 StandardMethodCodec.decodeEnvelope
E/flutter: #1 MethodChannel._invokeMethod
E/flutter: #2 AndroidFlutterLocalNotificationsPlugin.zonedSchedule

查看 adb shell dumpsys alarm 输出:

1
2
Last OP_SCHEDULE_EXACT_ALARM: [..., u0a490:default, ...]

虽然 AndroidManifest.xml 中已声明权限:

1
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />

但在 Android 12+ (API 31+) 上,这是一个需要用户手动授予的特殊权限

第二个坑:用户体验灾难

尝试通过代码请求权限:

1
final exactAlarmPermission = await androidPlugin.requestExactAlarmsPermission();

实际效果

  1. 应用会跳转到系统设置页面:“设置 → 应用 → 特殊访问权限 → 闹钟和提醒”
  2. 用户需要手动找到 UseUp,点击进入,手动开启开关
  3. 然后手动返回应用

这对于一个简单的"过期提醒"功能来说,权限流程过于繁琐,用户极易在中途放弃。


2. 根本原因分析

2.1 Android 闹钟权限演进史

Android 版本精确闹钟行为用户影响
Android 11 及以下无需权限,自由调度无感知
Android 12 (API 31)引入 SCHEDULE_EXACT_ALARM 权限需跳转系统设置手动授权
Android 13+ (API 33+)权限管控更严格系统会定期审查滥用应用

Google 的设计意图:防止应用滥用精确闹钟导致设备耗电,只允许闹钟类、日历类、健康类等"必须精确"的应用使用。

对过期提醒的影响

  • 闹钟 App:早上 7:00 必须准时响铃 → 必须用精确闹钟
  • 过期提醒:明天早上"大约 8 点"提醒 → ±15 分钟完全可接受

2.2 inexactAllowWhileIdle 的真实表现

切换到 inexactAllowWhileIdle 后:

1
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle

验证权限状态

1
2
adb shell dumpsys alarm | grep com.algieba.useup

✅ 闹钟已成功注册到系统,无需权限对话框!

但测试时遇到新问题

时间旅行测试

  1. 添加物品,设置明天过期,提前 1 天提醒
  2. 修改系统时间到今天早上 7:59
  3. 等到 8:00… 8:05… 8:10… 没有通知
  4. 继续等到 8:15… 通知终于出现了!

结论inexactAllowWhileIdle 在 Android 上的实际调度窗口是 ±15 分钟,用于批量处理以节省电量。


3. 解决方案:双轨制策略

经过权衡,采用了"生产 + 测试双轨"方案:

3.1 生产环境:inexactAllowWhileIdle

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
await flutterLocalNotificationsPlugin.zonedSchedule(
notificationId,
_expiryText.title,
_expiryText.body(item.name, daysBefore),
tzScheduledDate,
const NotificationDetails(
android: AndroidNotificationDetails(
'expiry_channel',
'Expiry Notifications',
importance: Importance.max,
priority: Priority.high,
),
),
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime,
payload: 'expiry_item:${item.id}',
);

优势

  • 无需权限:用户无感知,开箱即用
  • 省电优化:系统智能批处理,不会频繁唤醒设备
  • 精度足够:8:00 提醒可能在 7:45-8:15 送达,对"今天会过期"的提醒完全够用
  • 持久化:注册到系统 AlarmManager,即使应用关闭、设备重启也能触发

验证持久性

1
2
3
4

adb shell dumpsam alarm | grep useup


3.2 测试环境:应用内计时器

挑战:开发时需要快速验证通知显示、点击跳转功能,不能每次都等 15 分钟。

解决方案:在开发者选项中提供独立的测试方法,使用 Future.delayed 而非系统调度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

Future<void> scheduleNotifications(Item item) async {
await flutterLocalNotificationsPlugin.zonedSchedule(...);
}


Future<void> showDelayedNotification({String? payload}) async {
debugPrint('[NotificationService] Starting 10-second timer...');

await Future.delayed(const Duration(seconds: 10));

debugPrint('[NotificationService] Timer completed, showing now!');

await flutterLocalNotificationsPlugin.show(
998,
'UseUp Delayed Test',
'This notification was sent 10 seconds ago! ⏳',
notificationDetails,
payload: payload ?? 'expiry_item:999',
);
}

开发者测试界面(Settings → Developer Options):

1
2
3
4
5
6
7
8
9
OutlinedButton.icon(
icon: const Icon(Icons.notifications_active),
label: const Text("测试延迟通知 (10秒) / Test Delayed (10s)"),
onPressed: () async {
await NotificationService().showDelayedNotification(
payload: 'expiry_item:999',
);
},
),

验证结果

  1. 点击按钮
  2. 准时 10 秒后收到通知(不受系统调度影响)
  3. 点击通知 → 应用打开并跳转到物品详情页

关键优势

  • 即时反馈:10 秒 vs 15+ 分钟
  • 可靠测试:验证 payload 传递和点击导航逻辑
  • 不污染生产代码showDeveloperOptions = false 时完全隐藏

4. 通知点击导航实现

4.1 两种启动场景

场景应用状态触发时机处理方式
热恢复应用在后台运行用户点击通知onDidReceiveNotificationResponse
冷启动应用完全关闭点击通知启动应用getNotificationAppLaunchDetails

4.2 统一处理:事件流 + GoRouter

NotificationService 中的核心实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class NotificationService {
final StreamController<String> _clickStreamController = StreamController.broadcast();
Stream<String> get onClickNotification => _clickStreamController.stream;

Future<void> init() async {

await flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: (response) {

if (response.payload != null) {
_clickStreamController.add(response.payload!);
}
},
);


final launchDetails = await flutterLocalNotificationsPlugin
.getNotificationAppLaunchDetails();
if (launchDetails?.didNotificationLaunchApp == true) {
final payload = launchDetails?.notificationResponse?.payload;
if (payload != null) {
_clickStreamController.add(payload);
}
}
}
}

app.dart 中的导航监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@override
void initState() {
super.initState();


NotificationService().onClickNotification.listen((payload) {
debugPrint('Handling notification click payload: $payload');

if (payload.startsWith('expiry_item:')) {
final itemId = int.parse(payload.split(':')[1]);
debugPrint('Navigating to /item/$itemId via notification click');
context.go('/item/$itemId');
}
});
}

测试覆盖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
testWidgets('Simulating notification click during app runtime', (tester) async {

NotificationService().simulateNotificationClick('expiry_item:42');
await tester.pumpAndSettle();

expect(find.byType(ItemDetailScreen), findsOneWidget);
});

testWidgets('Simulating notification click launching app from closed state', (tester) async {

mockLaunchDetails = NotificationAppLaunchDetails(
didNotificationLaunchApp: true,
notificationResponse: NotificationResponse(payload: 'expiry_item:42'),
);

await tester.pumpWidget(buildApp());
await tester.pumpAndSettle();

expect(find.byType(ItemDetailScreen), findsOneWidget);
});

5. 测试结果与验证

5.1 自动化测试

1
2
flutter test test/services/notification_service_test.dart test/widget/notification_click_flow_test.dart

覆盖范围

  • ✅ 通知调度逻辑(日期计算、payload 生成、过期日期跳过)
  • ✅ 取消通知逻辑(单个取消、批量取消)
  • ✅ 权限处理(请求、拒绝、异常)
  • ✅ 点击导航(运行时点击、冷启动点击)

5.2 手动验证

开发者工具测试

  1. 设置 → 开发者选项(底部)
  2. 点击"测试即时通知" → ✅ 立即收到通知
  3. 点击"测试延迟通知 (10秒)" → ✅ 10 秒后准时收到
  4. 点击通知 → ✅ 跳转到物品详情页(ID: 999)

控制台输出

1
2
3
4
5
[NotificationService] Starting 10-second timer for delayed notification...
[NotificationService] Timer completed, showing notification now!
[NotificationService] Delayed notification sent with payload: expiry_item:999
Handling notification click payload: expiry_item:999
Navigating to /item/999 via notification click

真实场景测试(需耐心)

  1. 添加物品:牛奶,明天过期,提前 1 天提醒
  2. 修改系统时间到今天早上 7:45
  3. 等待… 8:05 收到通知 ✅(在 ±15 分钟窗口内)
  4. 点击通知 → 跳转到牛奶详情页 ✅

6. 关键技术决策总结

6.1 为什么选择 inexactAllowWhileIdle?

对比表

特性exactAllowWhileIdleinexactAllowWhileIdle
权限要求❌ 需要 SCHEDULE_EXACT_ALARM✅ 无需额外权限
用户体验❌ 需跳转设置手动授权✅ 无感知,开箱即用
时间精度✅ 精确到秒⚠️ ±15 分钟窗口
电池影响⚠️ 频繁唤醒设备✅ 批量处理,省电
适用场景闹钟、日历、健康提醒、通知、非时间敏感

结论:对于"明天会过期"这类提醒,±15 分钟的延迟完全可以接受,用户体验和省电效果远比秒级精度重要。

6.2 测试策略:为什么用 Future.delayed?

问题inexactAllowWhileIdle 在开发测试时的痛点

  • 系统调度延迟 5-15 分钟
  • 时间旅行测试不稳定
  • 快速迭代验证困难

解决方案:双轨制

1
2
3
4
5
6

await zonedSchedule(..., androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle);


await Future.delayed(Duration(seconds: 10));
await show(...);

优势

  • 开发者获得即时反馈
  • 测试 payload 传递和导航逻辑
  • 不影响生产代码

7. 常见问题解答

Q1: 应用关闭后通知还会触发吗?

A: 会!通知注册到 Android AlarmManager 后,即使应用完全关闭、设备重启,系统也会在设定时间触发通知。RECEIVE_BOOT_COMPLETED 权限确保重启后自动恢复。

Q2: 为什么我的通知没有准时触发?

A: 检查以下几点:

  1. 是否使用了 inexactAllowWhileIdle?(有 ±15 分钟延迟)
  2. 设备是否开启了省电模式?(可能进一步延迟)
  3. 是否是特定品牌(小米 MIUI、华为 EMUI)?(系统优化可能更激进)

Q3: 如何在开发时快速测试通知?

A: 使用开发者选项中的"测试延迟通知 (10秒)"按钮,绕过系统调度,10 秒后准时收到通知,可验证显示和点击导航。

Q4: 桌面小组件和通知有关系吗?

A: 无关。通知由 AlarmManager 独立管理,有无小组件都不影响通知触发。但小组件存在时,系统更不容易清理应用进程。

Q5: iOS 上的通知实现有区别吗?

A: iOS 使用 UNUserNotificationCenter,无需区分精确/不精确调度,但需要用户授予通知权限。本文重点讨论 Android 的权限和调度策略问题。


8. 参考资料与延伸阅读


9. 总结

通过本次实现,我们成功解决了 Flutter 本地通知在 Android 上的三大挑战:

  1. 权限困境:选择 inexactAllowWhileIdle 避免复杂的用户授权流程
  2. 测试难题:通过应用内计时器实现快速验证,10 秒即可完成测试
  3. 导航集成:统一处理冷启动和热恢复两种场景,确保点击通知能正确跳转

最终方案

  • 生产环境:省电、无感知、持久化
  • 测试环境:快速、可靠、完整覆盖
  • 用户体验:无需额外授权,通知准时(±15分钟)送达

完整测试覆盖:697/697 测试通过,无回归,已投入生产使用。


APP链接UseUp - Flutter 物品过期管理应用