Android inexactAllowWhileIdle 调度策略与本地通知测试 - Flutter 测试实战 05

问题概览卡片

基本信息

  • 问题分类: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, ...]
# u0a490 是 UseUp 的 UID,状态为 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
# 输出:u0a490:com.algieba.useup +6ms running, 2 wakeups

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

但测试时遇到新问题

时间旅行测试

  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
# 输出:*walarm*:com.algieba.useup/...ScheduledNotificationReceiver
# 验证:闹钟仍在系统中

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
// 生产代码使用 zonedSchedule
Future<void> scheduleNotifications(Item item) async {
await flutterLocalNotificationsPlugin.zonedSchedule(...);
}

// 测试代码使用 Future.delayed(仅限 Debug 模式)
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) {
// 场景 1: 应用运行时点击通知
if (response.payload != null) {
_clickStreamController.add(response.payload!);
}
},
);

// 场景 2: 从通知冷启动应用
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'); // GoRouter 导航
}
});
}

测试覆盖

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
# 结果:20/20 tests passed

覆盖范围

  • 通知调度逻辑(日期计算、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 物品过期管理应用