Flutter PopScope 返回拦截完整指南:易错点+正确姿势+实战示例
从 Flutter 3.16 起,老朋友 WillPopScope 基本可以退休了,官方推荐用新的 PopScope 来处理返回逻辑,原因很简单:适配 Android 14 的 Predictive Back(预测返回) 。(Flutter 文档)
很多同学一换成 PopScope,就开始遇到各种问题:
- 对话框弹出来了但页面直接退出
- 返回后报 "context 已经不存在 / disposed"
- 甚至触发
'_debugLocked': is not true之类的异常 (Stack Overflow)
本篇就来系统梳理:
PopScope的核心概念- 开发中几个高频易错点
- 一个推荐的「正确流程」思路
- 最后给一个全新的示例: "带未保存提示的笔记编辑页"
一、PopScope 是什么,和 WillPopScope 有什么不同?
先看官方文档对 PopScope 的定义:
PopScope用于控制当某个 route 尝试被 pop 时的表现;
canPop决定是否允许 pop;
onPopInvokedWithResult回调会在「尝试 pop 之后」被调用。(Flutter API)
再结合 Android 14 的 Predictive Back:
- Android 14 的返回手势,一开始就会触发「预览」动画,系统需要提前知道 这次返回是否有效。(Flutter 文档)
- 旧的
WillPopScope是Future<bool>,要先等你做完异步操作(比如弹对话框再看用户选什么)才能决定要不要 pop,这和 Predictive Back 的机制不兼容。(开发者备忘录)
所以现在逻辑变成:
- 是否允许返回 :由
canPop决定(同步、提前告诉系统) - 返回尝试发生之后我想做点别的事 :在
onPopInvokedWithResult里处理(通知性质)
onPopInvokedWithResult 的两个参数:(Flutter API)
-
didPop:这次 pop 有没有真正发生true:route 已经从导航栈里移除了false:这次 pop 被拦住了(比如canPop == false)
-
result:这次 pop 带出去的返回值(类似Navigator.pop(result))
二、几个非常容易踩的坑(易错点)
易错点 1:以为 PopScope 还能「异步决定要不要返回」
很多人想沿用 WillPopScope 的写法:
rust
PopScope(
canPop: true, // 以为配合 callback 就能拦截
onPopInvokedWithResult: (didPop, result) async {
final confirm = await showDialog(...);
if (!confirm) {
// 以为这里能"取消"这次返回
}
},
);
问题在于:PopScope 不等你!
canPop: true时,route 会被立刻 pop 掉onPopInvokedWithResult只是「事后通知」:pop 已经发生 or 已被拒绝
真正决定权在 canPop,不是在回调里。(Flutter API)
易错点 2:不判断 didPop,页面已经被关掉还在用 context
典型写法:
scss
PopScope(
canPop: false,
onPopInvokedWithResult: (_, __) {
// 不管三七二十一,直接用 context 打开弹窗
openExitDialog(context);
},
);
流程是这样的:
-
用户按返回 ⇒ 因为
canPop == false,这次 pop 被拦截didPop == false时,你用context开弹窗 ✔️ 没问题
-
之后你在别的地方(比如弹窗里)又调用了一次
Navigator.pop()关页面- 这次 pop 成功了 ⇒
didPop == true onPopInvokedWithResult(didPop: true, ...)再被调用一次- 你又用这个已被
dispose的context开弹窗 ⇒ 各种错:Looking up a deactivated widget's ancestor之类
- 这次 pop 成功了 ⇒
正确做法:
第一行就筛掉已经 pop 成功的情况:
csharp
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return; // ✅ route 已经关掉了,啥都别干
// 下面才是你真正的拦截逻辑
}
这个模式也是多数教程/文章推荐的套路。(blog.shukebeta.com)
易错点 3:回调里写 async,但不检查 mounted
onPopInvokedWithResult 里非常容易写成:
scss
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final confirm = await showDialog(...); // 这里是异步的
// ⚠️ 等用户点完按钮回来时,这个 State 有可能已经被销毁
doSomethingWith(context);
}
如果在你等待的这段时间里,用户通过别的入口关掉了这个页面,你再用 context 就会出错。
正确流程:
kotlin
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final confirm = await showDialog(...);
if (!mounted) return; // ✅ 先确认 State 还活着
if (!confirm) return;
// 这里再安全地使用 context / setState
}
易错点 4:直接在回调里连环 Navigator.pop(),触发 _debugLocked
有些人会这么写:
scss
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final confirm = await showDialog(...);
if (confirm) {
Navigator.of(context).pop(); // 这次是关弹窗
Navigator.of(context).pop(); // 这次是关页面
}
}
但 PopScope 的机制是在「一次 pop 尝试」完成后再调用回调,如果你在回调里再发起嵌套 pop,很容易出现导航锁相关的异常('_debugLocked': is not true 等)(Stack Overflow)
推荐方案 :
让弹窗自己 pop(result),外层只接收 true/false,然后只做「一次真正的 pop」。
易错点 5:canPop: true 还指望能弹"确认退出"
如果你写的是:
scss
PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, result) {
showDialog(...);
},
);
那结果一定是:
- 页面直接被 pop 走
- 回调在"页面已经被移除"之后才被调用
- 此时
context已经失效,你要么看不到弹窗,要么直接报错
要拦截返回,一定要从 canPop: false 开始。
三、推荐的「正确流程」思路
目标场景:
用户在某个页面编辑内容,按返回键时如果有未保存内容,弹出"确认退出"对话框;确认后再退出页面。
推荐流程:
-
在页面
State里维护一个_canPop,默认false:- 表示"当前不允许直接 pop",所有返回都要先经过我们确认
-
在
PopScope上:-
canPop: _canPop -
onPopInvokedWithResult:if (didPop) return;------ 确保只处理「被拦截」的那一次await打开确认对话框- 用户确认退出 ⇒
setState(() => _canPop = true)
然后调用一次Navigator.pop(),这次就会真的退出
-
-
在 async 返回后,一律加上
if (!mounted) return;保护
这套模式兼容:
- Android 14 的预测返回(Predictive Back)(Flutter 文档)
- 普通的实体返回键 / AppBar 返回按钮
- 代码里主动
Navigator.pop的情况
四、全新示例:带未保存提示的笔记编辑页
下面是一个完整示例:
- 页面有一个文本输入框(编辑笔记)
- 未保存时按返回,会弹出确认弹窗
- 选择"离开"后才真正退出当前页
php
import 'package:flutter/material.dart';
class NoteEditPage extends StatefulWidget {
const NoteEditPage({super.key});
@override
State<NoteEditPage> createState() => _NoteEditPageState();
}
class _NoteEditPageState extends State<NoteEditPage> {
final TextEditingController _controller = TextEditingController();
bool _hasSaved = false;
bool _canPop = false; // ✅ 一開始不允許直接返回
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/// 打開確認退出彈窗,返回 true 表示確認離開
Future<bool> _showExitConfirmDialog() async {
// 如果內容已保存或沒有輸入,直接允許返回
if (_hasSaved || _controller.text.trim().isEmpty) {
return true;
}
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogCtx) {
return AlertDialog(
title: const Text('確認離開?'),
content: const Text('有未保存的內容,離開後將丟失。'),
actions: [
TextButton(
onPressed: () {
Navigator.of(dialogCtx).pop(false);
},
child: const Text('取消'),
),
TextButton(
onPressed: () {
Navigator.of(dialogCtx).pop(true);
},
child: const Text('不保存並離開'),
),
],
);
},
);
return result == true;
}
void _saveNote() {
// TODO: 在這裡執行實際的保存邏輯(調後端 / 本地存儲等)
setState(() {
_hasSaved = true;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已保存')),
);
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: _canPop,
onPopInvokedWithResult: (didPop, result) async {
// 1️⃣ 如果這次 pop 已經發生(路由已被移除),什麼都不要做
if (didPop) return;
// 2️⃣ 彈出確認對話框(可能是異步)
final shouldExit = await _showExitConfirmDialog();
// 3️⃣ 異步回來後確認 State 是否還活著
if (!mounted) return;
if (shouldExit) {
// 4️⃣ 用一次"真正的 pop"結束頁面
setState(() {
_canPop = true; // 先允許 pop
});
Navigator.of(context).pop(_hasSaved); // 可以把是否已保存作為 result 傳出去
}
},
child: Scaffold(
appBar: AppBar(
title: const Text('編輯筆記'),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _controller,
maxLines: 6,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: '在這裡輸入內容...',
),
onChanged: (_) {
// 只要有修改,就認為當前是"未保存"
setState(() {
_hasSaved = false;
});
},
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: _saveNote,
child: const Text('保存'),
),
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text(
_hasSaved ? '狀態:已保存' : '狀態:未保存',
style: TextStyle(
color: _hasSaved ? Colors.green : Colors.red,
),
),
),
],
),
),
),
);
}
}
上面这个页面的行为:
-
第一次按返回键 / Android Back / AppBar 返回:
- 因为
_canPop == false⇒ 系统 pop 被拦截 didPop == false⇒ 进入回调,弹出确认对话框
- 因为
-
用户选择「不保存并离开」:
- 弹窗通过
Navigator.pop(true)把结果传回 _showExitConfirmDialog返回true- 设置
_canPop = true,再手动Navigator.pop(_hasSaved) - 第二次 pop 成功 ⇒
didPop == true,但我们在回调里直接return,不再做任何事
- 弹窗通过
整个流程干净、可控,而且兼容 Predictive Back。(blog.shukebeta.com)
小结
-
PopScope是为适配 Android 14 预测返回而设计的新组件,决策在canPop,不是回调里 。(Flutter 文档) -
onPopInvokedWithResult是"事后通知",每一次 pop 尝试都会调,一定要先看didPop。 -
回调里有
await的话,记得加if (!mounted) return;。 -
推荐模式:
- 默认
canPop = false didPop == false时弹确认对话框- 用户确认 ⇒ 把
canPop改成true,再手动Navigator.pop(...)
- 默认