December 30, 2025
6 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

涉及文件:

  • client/lib/ui/chat_page.dart
  • client/lib/ui/files_page.dart
  • client/lib/ui/image_preview_page.dart
  • client/lib/ui/video_player_page.dart
  • client/lib/ui/widgets/file_action_sheet.dart

一、聊天输入框高度稳定性重构

1.1 问题描述

我这边用的时候发现:切换录音模式和键盘模式时,输入框高度会发生变化,产生视觉抖动。

1.2 问题根源

两个模式的布局策略不一致:

// 录音按钮 - 固定高度
SizedBox(height: inputHeight)  // 固定 44
 
// 文字输入区 - 可变高度
ConstrainedBox(
  constraints: BoxConstraints(minHeight: inputHeight),
  child: IntrinsicHeight(
    child: Container(
      child: TextField(maxLines: 4, minLines: 1),
    ),
  ),
)

当输入内容多行文字后切换到录音模式,高度从多行突然变回单行。

1.3 方案选择

方案描述优点缺点选择
A两者都用固定高度完全无抖动限制多行输入
BAnimatedContainer 高度动画平滑过渡实现复杂,仍有变化
C保持可变高度,增加动画保留多行功能治标不治本

最终选择方案 A:参考微信输入框设计,固定单行高度,多行内容在内部滚动。

1.4 实现代码

/// 构建文字输入区域
/// 采用固定高度设计,避免录音/键盘切换时高度变化
Widget _buildTextInputArea(...) {
  // 固定高度容器,与录音按钮保持一致
  return SizedBox(
    height: inputHeight,  // 固定 44
    child: Container(
      decoration: BoxDecoration(...),
      child: Row(
        children: [
          // Markdown 模式标识
          if (_isMarkdownMode) ...,
          // 输入框(单行显示,多行内容内部滚动)
          Expanded(
            child: TextField(
              maxLines: 1,  // 固定单行,保持高度稳定
              // ...
            ),
          ),
          // 发送按钮
          Padding(...),
        ],
      ),
    ),
  );
}

关键改动

  1. 移除 IntrinsicHeightConstrainedBox
  2. 使用 SizedBox(height: inputHeight) 固定高度
  3. TextField.maxLines4 改为 1
  4. 移除 Align(alignment: Alignment.bottomRight) 包裹发送按钮

二、FilesPage dispose 错误修复

2.1 问题描述

控制台报错:

Looking up a deactivated widget's ancestor is unsafe.
At this point the state of the widget's element tree is no longer stable.

错误发生在 _FilesPageState.dispose() 中。

2.2 问题根源

dispose() 中使用 context.read<AppState>()

@override
void dispose() {
  context.read<AppState>().removeListener(_onStateChanged);  // 错误!
  // ...
}

dispose() 时,widget 已经 deactivated,不能再通过 context 查找 ancestor。

2.3 解决方案

initState 中保存 AppState 引用,在 dispose 中使用保存的引用:

class _FilesPageState extends State<FilesPage> {
  AppState? _appState;  // 保存引用
 
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _appState = context.read<AppState>();
      _appState!.addListener(_onStateChanged);
    });
  }
 
  @override
  void dispose() {
    _appState?.removeListener(_onStateChanged);  // 使用保存的引用
    super.dispose();
  }
}

2.4 Flutter 生命周期注意事项

initState → didChangeDependencies → build → ... → deactivate → dispose
此时 context 已不安全

最佳实践

  • didChangeDependencies() 中获取依赖(如 Provider.of
  • 保存需要在 dispose() 中使用的引用
  • 或使用 WidgetsBindingObserver 替代 listener

三、图片捏合手势冲突优化

3.1 问题描述

我这边用的时候发现:图片两指捏合放大时,经常误滑到上一张或下一张图片。

3.2 之前的方案(失败)

使用缩放状态回调:

// 旧方案
bool _isZoomed = false;
 
// _ImageViewerItem 中
onZoomChanged: (zoomed) {
  setState(() => _isZoomed = zoomed);
}
 
// PageView
physics: _isZoomed
    ? const NeverScrollableScrollPhysics()
    : const PageScrollPhysics(),

问题:回调在 onInteractionEnd 时触发,开始捏合时图片可能还没放大。

3.3 成熟项目方案

参考 Medium 文章:How to Resolve Gesture Conflicts in Flutter

核心思路:检测触点数量,而非缩放状态

3.4 最终实现

class _ImagePreviewPageState extends State<ImagePreviewPage> {
  // 触点数量检测(多指操作时禁用 PageView 滑动)
  int _pointerCount = 0;
 
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Listener(
        // 检测触点数量
        onPointerDown: (_) => setState(() => _pointerCount++),
        onPointerUp: (_) => setState(() => _pointerCount--),
        onPointerCancel: (_) => setState(() => _pointerCount--),
        child: Stack(
          children: [
            PageView.builder(
              // 多指操作时禁用滑动
              physics: _pointerCount >= 2
                  ? const NeverScrollableScrollPhysics()
                  : const PageScrollPhysics(),
              // ...
            ),
          ],
        ),
      ),
    );
  }
}

3.5 方案对比

方案触发时机可靠性使用体验
缩放状态回调手势结束后差,捏合初期仍会误滑
触点数量检测手指触碰瞬间好,立即阻止滑动

3.6 为什么使用 Listener 而非 GestureDetector

触摸事件处理层级:
Listener (最底层,原始触摸事件)
GestureDetector (手势识别,参与竞争)
InteractiveViewer / PageView (高级交互组件)
  • Listener 不参与手势竞争,可以同时检测触点
  • GestureDetector 会与 InteractiveViewer 产生冲突

四、视频菜单横屏溢出修复

4.1 问题描述

横屏模式下,字幕菜单、音轨菜单、比例菜单底部溢出屏幕。

4.2 解决方案

统一采用滚动布局:

showModalBottomSheet(
  context: context,
  backgroundColor: Colors.grey[900],
  isScrollControlled: true,  // 允许自定义高度
  constraints: BoxConstraints(
    maxHeight: MediaQuery.of(context).size.height * 0.7,  // 最大 70%
  ),
  builder: (ctx) => SafeArea(
    child: SingleChildScrollView(  // 内容可滚动
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [...],
      ),
    ),
  ),
);

已修复的菜单

  • _showSubtitleMenu() - 字幕菜单
  • _showAudioTrackMenu() - 音轨菜单
  • _showAspectMenu() - 画面比例菜单

五、长按加速弹窗动画移除

5.1 我这边用的时候发现

长按加速弹窗不需要弹出动画,应该直接出现、松手直接消失。

5.2 原实现

// 旧代码 - 有缩放动画
TweenAnimationBuilder<double>(
  tween: Tween(begin: 0.8, end: 1.0),
  duration: const Duration(milliseconds: 300),
  curve: Curves.easeOut,
  builder: (context, scale, child) {
    return Transform.scale(
      scale: scale,
      child: Container(...),
    );
  },
)

5.3 新实现

// 新代码 - 无动画,直接显示
Container(
  padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
  decoration: BoxDecoration(
    color: const Color(0xFF2196F3),
    borderRadius: BorderRadius.circular(24),
    // ...
  ),
  child: Row(...),
)

设计决策:长按加速是实时反馈,动画会带来延迟感。参考 B站、YouTube 的实现。


六、文件操作菜单缩略图修复

6.1 问题描述

从图片预览页面打开文件操作菜单时,左侧缩略图一直显示加载指示器。

6.2 问题根源

// FileActionSheet._buildThumbnail()
Widget _buildThumbnail(BuildContext context) {
  final thumb = thumbnailCache[file.id];
  if (thumb != null) {
    return Image.memory(thumb, ...);
  }
  // 问题:缩略图不存在时,永久显示加载指示器
  return CircularProgressIndicator(...);  // ← 永远转圈
}

ImagePreviewPage 调用 FileOperationService.showFileActions() 时没有传递缩略图缓存。

6.3 解决方案

缩略图不存在时,显示文件类型图标而非加载指示器:

Widget _buildThumbnail(BuildContext context) {
  final type = getFileType(file.name);
  // 尝试从缓存中获取缩略图
  if (!file.isDir && (type == AppFileType.image || type == AppFileType.video)) {
    final thumb = thumbnailCache[file.id];
    if (thumb != null) {
      return ClipRRect(
        borderRadius: BorderRadius.circular(8),
        child: Image.memory(thumb, width: 40, height: 40, fit: BoxFit.cover),
      );
    }
    // 缩略图不在缓存中,显示文件类型图标代替加载指示器
  }
  return Icon(_getIcon(), color: _getIconColor(), size: 32);
}

七、代码清理

7.1 清理内容

  1. 移除的代码

    • TweenAnimationBuilder(长按加速动画)
    • onZoomChanged 回调和 _onInteractionStart 方法
    • IntrinsicHeight + ConstrainedBox 布局(输入框重构)
    • 缩略图永久加载指示器
  2. 保留的 debugPrint

    • 这些是有用的运行时日志,用于追踪问题
    • 生产环境可通过日志级别控制

7.2 验证结果

$ flutter analyze lib\
No issues found!
 
$ dart fix --dry-run lib\
Nothing to fix!

八、BLASTBufferQueue 错误说明

8.1 错误日志

E/BLASTBufferQueue: acquireNextBufferLocked: Can't acquire next buffer.
Already acquired max frames 4 max:2 + 2

8.2 原因分析

这是 Android 系统级别的 SurfaceView 缓冲区警告

  • 与 media_kit 视频播放器底层渲染相关
  • 表示图形缓冲区已满,无法获取更多帧
  • 通常在高帧率视频或快速操作时出现

8.3 处理建议

  • 不是应用代码问题,是 Android 图形系统与 mpv 渲染器的交互特性
  • 不影响使用体验,可以忽略
  • 如需减少日志,可在 release 版本中过滤系统日志

九、技术要点总结

9.1 Flutter dispose 安全实践

// ❌ 错误:在 dispose 中使用 context
@override
void dispose() {
  context.read<AppState>().removeListener(...);
}
 
// ✅ 正确:保存引用
AppState? _appState;
 
@override
void initState() {
  WidgetsBinding.instance.addPostFrameCallback((_) {
    _appState = context.read<AppState>();
  });
}
 
@override
void dispose() {
  _appState?.removeListener(...);
}

9.2 多指手势检测

使用 Listener 而非 GestureDetector

Listener(
  onPointerDown: (_) => pointerCount++,
  onPointerUp: (_) => pointerCount--,
  onPointerCancel: (_) => pointerCount--,
  child: PageView(
    physics: pointerCount >= 2
        ? NeverScrollableScrollPhysics()
        : PageScrollPhysics(),
  ),
)

9.3 BottomSheet 横屏适配

showModalBottomSheet(
  isScrollControlled: true,
  constraints: BoxConstraints(maxHeight: height * 0.7),
  builder: (ctx) => SafeArea(
    child: SingleChildScrollView(
      child: Column(mainAxisSize: MainAxisSize.min, ...),
    ),
  ),
);

9.4 输入框高度稳定

固定高度 + 单行显示 = 切换模式时无抖动:

SizedBox(
  height: fixedHeight,
  child: TextField(maxLines: 1),
)