6.6 KiB
6.6 KiB
Android VPN 权限弹窗关闭后 UI 状态不同步问题修复
问题描述
在 Android 平台上,当用户第一次打开 App 后点击连接按钮时,系统会弹出 VPN 权限请求弹窗。如果用户点击取消或关闭这个弹窗,会出现以下问题:
- 现象: UI 上的 Switch 开关没有正确回到关闭状态,仍然显示为打开
- 实际情况: 节点已经尝试连接(但因权限被拒绝而失败)
- 用户体验: UI 状态与实际连接状态不一致,造成困惑
问题根源分析
1. Android 原生层面
在 android/app/src/main/kotlin/com/hiddify/hiddify/bg/VPNService.kt 文件中:
override fun openTun(options: TunOptions): Int {
if (prepare(this) != null) error("android: missing vpn permission") // 第76行
// ...
}
当用户拒绝 VPN 权限时,prepare(this) 返回非空值,代码会抛出 "android: missing vpn permission" 错误。
2. Flutter 服务层面
在 lib/app/services/singbox_imp/kr_sing_box_imp.dart 文件中的 kr_start() 方法:
修复前的问题:
- 当启动失败时,虽然设置了
kr_status.value = SingboxStopped() - 但没有强制触发状态观察者的更新
- 导致 UI 层的
Obx观察者可能无法及时响应状态变化
3. Controller 层面
在 lib/app/modules/kr_home/controllers/kr_home_controller.dart 的 kr_toggleSwitch() 方法中:
修复前的问题:
- 异常捕获后只记录了日志
- 没有主动触发 UI 状态同步
- 导致 Switch 开关状态与实际连接状态不一致
修复方案
修改 1: 增强 SingBox 服务层的状态刷新
文件: lib/app/services/singbox_imp/kr_sing_box_imp.dart:427-465
修改内容:
Future<void> kr_start() async {
kr_status.value = SingboxStarting();
try {
// ... 启动逻辑 ...
await kr_singBox.start(_cutPath, kr_configName, false).map(
(r) {
KRLogUtil.kr_i('✅ SingBox 启动成功', tag: 'SingBox');
},
).mapLeft((err) {
KRLogUtil.kr_e('❌ SingBox 启动失败: $err', tag: 'SingBox');
// 确保状态重置为Stopped,触发UI更新
kr_status.value = SingboxStopped();
// 强制刷新状态以触发观察者 ← 新增
kr_status.refresh();
throw err;
}).run();
} catch (e, stackTrace) {
KRLogUtil.kr_e('💥 SingBox 启动异常: $e', tag: 'SingBox');
KRLogUtil.kr_e('📚 错误堆栈: $stackTrace', tag: 'SingBox');
// 确保状态重置为Stopped,触发UI更新
kr_status.value = SingboxStopped();
// 强制刷新状态以触发观察者 ← 新增
kr_status.refresh();
rethrow;
}
}
关键改动:
- 在两处异常处理中都添加了
kr_status.refresh()调用 - 确保即使状态值相同(都是 Stopped),也能强制触发观察者更新
修改 2: 增强 Controller 的异常处理
文件: lib/app/modules/kr_home/controllers/kr_home_controller.dart:529-558
修改内容:
void kr_toggleSwitch(bool value) async {
// 如果正在切换中,直接返回
if (kr_isSwitching) {
KRLogUtil.kr_i('正在切换中,忽略本次操作', tag: 'HomeController');
return;
}
try {
kr_isSwitching = true;
if (value) {
await KRSingBoxImp.instance.kr_start();
// 添加延迟验证,确保状态正确更新
Future.delayed(const Duration(seconds: 2), () {
kr_forceSyncConnectionStatus();
});
} else {
await KRSingBoxImp.instance.kr_stop();
}
} catch (e) {
KRLogUtil.kr_e('切换失败: $e', tag: 'HomeController');
// 当启动失败时(如VPN权限被拒绝),强制同步状态 ← 新增
Future.delayed(const Duration(milliseconds: 100), () {
kr_forceSyncConnectionStatus();
});
} finally {
// 确保在任何情况下都会重置标志
kr_isSwitching = false;
}
}
关键改动:
- 在
catch块中添加了状态强制同步逻辑 - 延迟 100ms 调用
kr_forceSyncConnectionStatus()确保状态同步到 UI - 这样当 VPN 权限被拒绝导致启动失败时,UI 会正确回退到断开状态
技术细节
状态流转过程
-
用户点击 Switch 开启
- UI 调用
kr_toggleSwitch(true) - Controller 调用
KRSingBoxImp.instance.kr_start() - SingBox 服务设置状态为
SingboxStarting()
- UI 调用
-
VPN 权限被拒绝场景
- Android 原生层
VPNService.openTun()抛出权限错误 - 错误传播到 SingBox 服务层
kr_start()的异常处理捕获错误- 设置状态为
SingboxStopped()并调用refresh() - Controller 的异常处理触发
kr_forceSyncConnectionStatus()
- Android 原生层
-
UI 状态同步
kr_status.refresh()强制触发 GetX 的Obx观察者kr_forceSyncConnectionStatus()确保所有 UI 状态变量同步- Switch 开关状态通过
kr_isConnected.value回退到false
为什么需要 refresh() ?
GetX 的响应式系统通过值比较来决定是否通知观察者:
- 如果
kr_status.value从Starting变为Stopped,会自动通知 - 但某些边界情况下,如果状态转换没有被正确捕获,调用
refresh()可以强制通知所有观察者,不管值是否改变
为什么需要延迟调用?
Future.delayed(const Duration(milliseconds: 100))确保异步操作链完成- 避免在状态还在转换过程中就尝试同步,导致读取到中间状态
测试验证
测试场景 1: 首次启动拒绝权限
- 安装 App 后首次启动
- 点击连接按钮
- 系统弹出 VPN 权限请求
- 点击"取消"或直接关闭弹窗
- 预期结果: Switch 自动回到关闭状态
测试场景 2: 二次启动(已授权)
- 用户之前已授权 VPN 权限
- 点击连接按钮
- 预期结果: 正常连接,Switch 保持开启状态
测试场景 3: 撤销权限后重连
- 在系统设置中撤销 VPN 权限
- 返回 App 点击连接
- 系统再次弹出权限请求
- 拒绝权限
- 预期结果: Switch 自动回到关闭状态
影响范围
- 影响平台: 仅 Android 平台
- 影响功能: VPN 连接开关
- 向后兼容: 完全兼容,不影响已有功能
- 性能影响: 无明显性能影响,仅在异常情况下多执行一次状态同步
相关文件
lib/app/services/singbox_imp/kr_sing_box_imp.dart- SingBox 服务层lib/app/modules/kr_home/controllers/kr_home_controller.dart- 主页控制器lib/app/modules/kr_home/views/kr_home_connection_info_view.dart- Switch UI 组件android/app/src/main/kotlin/com/hiddify/hiddify/bg/VPNService.kt- Android VPN 服务
修复日期
2025-10-03
修复作者
collins
OmnTech 提供技术支持