Skip to content

五、踩坑与调优实录 (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 时机。

解决方案

双管齐下

  1. 绕过 shelf,直接用 Dart 原生 HttpServer 处理 SSE 请求,获得对 HttpResponse 的直接控制权
  2. 设置 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 分钟)防止暴力破解。