Appearance
五、踩坑与调优实录 (Troubleshooting & Optimization)
本章记录开发过程中遇到的真实 Bug 及其排查过程,保留了完整的"发现问题 → 定位根因 → 解决方案"链路,供遇到相同问题的开发者参考。
5.1 【重灾区】SSE 在 Dart Shelf + Nginx 下的死穴
这是整个项目耗时最长、最折磨的 Bug,前后排查十余轮才最终定位根因。
症状
curl -N https://api.todo.wangpudev.com/events/ \
-H "Authorization: Bearer $TOKEN"执行后终端挂起,没有任何输出,也没有报错退出。查看后端日志,连接已经建立([SSE] 用户 xxx 连接),HTTP 响应码也是 200,响应头 Content-Type: text/event-stream 完全正确——但数据就是出不来。
排查过程
第一轮:怀疑 Nginx 缓冲
首先排查 Nginx,在 /events/ 的 location 块加上:
nginx
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
gzip off;重启 Nginx,问题依旧。
第二轮:绕过 Nginx 直接打后端
在 VPS 上直接访问后端 8080 端口,绕过 Nginx:
bash
curl -N http://localhost:8080/events/ \
-H "Authorization: Bearer $TOKEN"同样挂起,没有输出。这说明问题不在 Nginx,而在 Dart 后端本身。
第三轮:检查 shelf_io 的 Stream 处理
最初的 SSE 实现用 StreamController<String> 传给 Response.body,后来发现 shelf 要求 Stream<List<int>>(字节流),改用 StreamController<List<int>> + utf8.encode(),问题依旧。
第四轮:怀疑 StreamController 的订阅时机
StreamController 是单订阅模式,在有 listener 之前调用 add() 数据会丢失。尝试了 Future.microtask()、onListen 回调等方案,依旧无效。
第五轮(定位根因):发现 bufferOutput 的存在
经过大量测试,终于发现了根本原因:Dart 的 HttpResponse.bufferOutput 默认为 true。
这意味着即使调用了 flush(),Dart 运行时仍然会在内部缓冲数据,等到缓冲区满了才真正写入 TCP。SSE 消息体积极小(几十字节),永远不会填满缓冲区,数据就一直卡在 Dart 内部出不来。
同时,shelf_io 内部通过 HttpResponse.addStream() 发送响应体,这个方法对 SSE 的持续推送场景支持不好,无法手动控制 flush 时机。
解决方案
双管齐下:
- 绕过 shelf,直接用 Dart 原生
HttpServer处理 SSE 请求,获得对HttpResponse的直接控制权 - 设置
response.bufferOutput = false,关闭 Dart 内部输出缓冲
dart
server.listen((HttpRequest request) async {
if (request.uri.path.startsWith('/events')) {
await _handleSSE(request); // 原生处理
} else {
await shelf_io.handleRequest(request, handler); // 其他请求走 shelf
}
});
Future<void> _handleSSE(HttpRequest request) async {
final response = request.response;
// 关键:关闭 Dart 内部输出缓冲
// 设置后每次 add() 立即写入 TCP,不经过任何内部缓冲
response.bufferOutput = false;
response.statusCode = HttpStatus.ok;
response.headers
..set('Content-Type', 'text/event-stream; charset=utf-8')
..set('Cache-Control', 'no-cache, no-transform')
..set('X-Accel-Buffering', 'no');
// 此后每次 response.add() 都立即发出,不需要 flush()
response.add(utf8.encode('data: {"type":"connected"}\n\n'));
// ...
}经验总结
| 排查层级 | 工具 | 结论 |
|---|---|---|
| Nginx 层 | curl 直连 8080 绕过代理 | 问题不在 Nginx |
| 框架层 | 替换 StreamController 类型 | 问题不在 shelf 的 Stream 类型 |
| 运行时层 | 发现 bufferOutput 默认值 | 根本原因:Dart 内部缓冲 |
教训:排查网络问题要严格做"链路隔离"——从最底层(直连后端)向上逐层排查,不要在一个层面反复猜测。
5.2 Android 像素溢出(Pixel Overflow)组合拳
开发移动端时遇到了三种不同成因的像素溢出,虽然表现相似,但根因和解法各不相同。
5.2.1 登录页键盘弹起 Bottom Overflow
症状:在 Android 上打开登录页,点击输入框唤起软键盘后,底部出现黄黑警告条:
BOTTOM OVERFLOWED BY 120 PIXELS根因:软键盘弹起后,系统会压缩 Scaffold 的可用高度。登录页的布局高度是按全屏设计的,压缩后内容装不下,底部溢出。
解决方案:用 SingleChildScrollView 包裹整个表单区域,让内容在高度不足时可以滚动:
dart
body: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Column(/* 表单内容 */),
),
),viewInsets.bottom 返回键盘占用的高度,将其作为底部 padding,确保最后一个输入框不被键盘遮挡。
5.2.2 列表项长文本 Right Overflow
症状:待办标题过长时,卡片右侧出现溢出警告,文字超出屏幕边界。
根因:Row 布局中,Text 组件默认会按文本实际宽度伸展,不受父容器约束,导致撑爆整行。
解决方案:用 Expanded 包裹 Text,锁死其可用宽度,配合 TextOverflow.ellipsis 超出时显示省略号:
dart
Row(
children: [
// 优先级色条(固定宽度)
Container(width: 3, ...),
// 文字内容(Expanded 锁死剩余宽度)
Expanded(
child: Text(
todo.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
// 右侧操作按钮(固定宽度)
_CompleteButton(...),
],
)5.2.3 弹窗响应式布局:桌面 Row → 移动端 Column
症状:新建/编辑待办的弹窗在 Android 上,原本横向排列的"优先级选择"和"截止日期选择"两个控件发生溢出。
根因:桌面端屏幕宽,两个控件横向并排没问题;移动端屏幕窄,横向空间不足,Row 中的内容溢出右边界。
解决方案:根据平台动态切换布局方向:
dart
PlatformUtil.isMobile
? Column(children: [priorityPicker, datePicker])
: Row(children: [
Expanded(child: priorityPicker),
SizedBox(width: 12),
Expanded(child: datePicker),
])5.3 Android Release 包网络失败
症状
flutter run 调试模式下应用完全正常,打包成 Release APK 安装后,登录时报错:
SocketException: Failed host lookup: 'api.todo.wangpudev.com'
(OS Error: No address associated with hostname, errno=7)根因
Flutter 在 Debug 模式下会自动注入网络相关权限,Release 模式则严格按照 AndroidManifest.xml 的声明执行。INTERNET 权限未声明,系统拒绝了所有网络请求。
解决方案
在 android/app/src/main/AndroidManifest.xml 的 <manifest> 标签内添加:
xml
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>5.4 flutter_local_notifications 在 Windows 编译报错
症状
引入 flutter_local_notifications 包后,flutter run -d windows 报错:
error C1083: 无法打开包括文件: "atlbase.h": No such file or directory根因
flutter_local_notifications 的 Windows 实现(flutter_local_notifications_windows)依赖 ATL(Active Template Library),这是 Visual Studio 的一个可选 C++ 组件,默认不安装。
尝试在 Visual Studio Installer 中安装 ATL 组件后,问题依旧——因为版本不匹配(安装的是 v141,项目使用的是 v143 工具链)。
解决方案
将通知服务拆分为平台实现类,Android 和 Windows 各自使用不同的通知库:
lib/services/notifications/
├── notification_service.dart ← 抽象接口
├── notification_service_android.dart ← Android 实现(awesome_notifications)
└── notification_service_desktop.dart ← Windows 实现(local_notifier)pubspec.yaml 中移除 flutter_local_notifications,Android 端改用 awesome_notifications(纯 Kotlin 实现,无 C++ 依赖),Windows 端继续用原有的 local_notifier。
ReminderService 在初始化时根据平台选择对应实现:
dart
if (defaultTargetPlatform == TargetPlatform.android) {
_notifier = AndroidNotificationService();
} else {
_notifier = DesktopNotificationService();
}5.5 全栈链路追踪日志系统
痛点
在多端同步场景下,一次操作涉及:前端发起请求 → 后端处理 → SSE 广播 → 另一端接收并同步,链路长,排查问题时很难把前后端日志对应起来。
方案:Correlation ID
每次写操作(增/改/删)时,前端生成一个唯一的 Correlation ID:
dart
static String _newCorrelationId() {
final ts = DateTime.now().millisecondsSinceEpoch;
final suffix = (0xFFFF & (ts ^ (ts >> 16)))
.toRadixString(16).padLeft(4, '0');
return 'cid-$ts-$suffix';
}这个 ID 随请求携带在 X-Correlation-Id Header 中,后端记录请求日志时带上它,广播 SSE 事件时也将其包含在事件 payload 里,接收端处理事件时同样记录。
一次操作的完整日志链路:
# 前端(设备 A 发起修改)
[HTTP] 更新待办 id=xxx cid=cid-1779241894-a3f2
[HTTP] PATCH /todos/:id → 200 (45ms)
# 后端
[HTTP ] PATCH /todos/xxx cid=cid-1779241894-a3f2
[Bcast] todos_updated → userId=yyy connections=2 cid=cid-1779241894-a3f2
# 前端(设备 B 接收)
[SSE ] 收到事件: todos_updated
[SSE ] 触发增量同步 cid=cid-1779241894-a3f2
[Sync] 增量同步完成,更新 1 条三段日志共享同一个 cid,一眼就能看出它们属于同一次操作。
日志系统架构
| 端 | 工具 | 输出目标 | 查看方式 |
|---|---|---|---|
| 前端 | talker | 控制台 + 本地日志文件 | Win11 侧边栏日志按钮 / Android「我的」页面 |
| 后端 | 自研 ServerLogger | 控制台 + /app/logs/ 文件 | GET /logs/ 接口(Basic Auth 保护) |
后端 /logs/ 接口支持按级别过滤(?level=error)、关键字搜索(?q=cid-)、限制条数(?limit=200),并有失败限速机制(5 次失败锁定 15 分钟)防止暴力破解。