涉及文件:
client/lib/ui/chat_page.dartclient/lib/ui/files_page.dartclient/lib/ui/image_preview_page.dartclient/lib/ui/video_player_page.dartclient/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 | 两者都用固定高度 | 完全无抖动 | 限制多行输入 | ✅ |
| B | AnimatedContainer 高度动画 | 平滑过渡 | 实现复杂,仍有变化 | ❌ |
| 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(...),
],
),
),
);
}关键改动:
- 移除
IntrinsicHeight和ConstrainedBox - 使用
SizedBox(height: inputHeight)固定高度 TextField.maxLines从4改为1- 移除
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 清理内容
-
移除的代码:
TweenAnimationBuilder(长按加速动画)onZoomChanged回调和_onInteractionStart方法IntrinsicHeight+ConstrainedBox布局(输入框重构)- 缩略图永久加载指示器
-
保留的 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 + 28.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),
)