涉及功能: 图片预览缓存、视频播放器边距、聊天输入框、长按加速、锁定屏幕、App 重命名
一、问题背景与需求
1.1 我这边用的时候发现的问题
- 图片预览缓存问题:滑到下一张图片后,再滑回上一张,需要重新加载,体验不流畅
- 视频播放器边距问题:横屏/全屏模式下,顶部按钮太贴近屏幕边缘,难以点击
- 聊天输入框抖动:点击录音按钮切换输入状态时,输入框高度变化导致视觉抖动
- 视频功能缺失:缺少长按加速播放和锁定屏幕功能
1.2 功能需求
| 需求 | 优先级 | 复杂度 |
|---|---|---|
| 图片预览缓存优化 | 高 | 低 |
| 视频边距修复 | 高 | 低 |
| 输入框抖动修复 | 中 | 中 |
| 长按加速播放 | 中 | 中 |
| 锁定屏幕 | 中 | 中 |
二、问题分析与方案设计
2.1 图片预览缓存问题
问题根因分析:
// 原代码 - image_preview_page.dart
isLoading: _loadingStates[_imageFiles[index].id] ?? true,问题在于 ?? true 的逻辑:当 _loadingStates 中没有该文件的 key 时,默认返回 true,导致即使图片数据已经在 _imageCache 中,也会显示加载动画。
方案对比:
| 方案 | 优点 | 缺点 | 选择 |
|---|---|---|---|
方案A: == true 替代 ?? true | 简单,null 时返回 false | 首次加载前会显示空白 | ❌ |
| 方案B: 同时检查缓存和加载状态 | 逻辑完整,覆盖所有情况 | 代码稍复杂 | ✅ |
最终方案:
// 修复后
final file = _imageFiles[index];
final isLoading = _loadingStates[file.id] == true && !_imageCache.containsKey(file.id);逻辑解读:
_loadingStates[file.id] == true:正在加载中!_imageCache.containsKey(file.id):缓存中还没有数据- 两者同时满足才显示 loading,否则直接显示缓存的图片
2.2 视频播放器边距问题
问题分析:
从我这边的截图可以看到,横屏模式下右上角的按钮(1.0x 倍速、设置、更多)几乎贴着屏幕边缘,在有刘海屏或曲面屏的设备上更难点击。
media_kit 主题配置分析:
MaterialVideoControlsThemeData 提供以下边距相关属性:
padding: 整体内边距topButtonBarMargin: 顶部按钮栏外边距bottomButtonBarMargin: 底部按钮栏外边距
解决方案:
fullscreen: MaterialVideoControlsThemeData(
// 原有配置
padding: const EdgeInsets.symmetric(horizontal: 16),
// 新增配置
topButtonBarMargin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
bottomButtonBarMargin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
),边距设计考量:
- 左右各 16px,避免按钮贴边
- 顶部 16px,与状态栏保持距离
- 底部 16px,避免与系统手势区冲突
2.3 聊天输入框抖动问题
问题分析:
// 原代码 - chat_page.dart
Expanded(
child: _isVoiceMode
? _buildVoiceRecordButton(...) // 固定高度
: _buildTextInputArea(...), // 可变高度 (minLines: 1, maxLines: 4)
),两个组件的高度计算方式不同:
_buildVoiceRecordButton: 使用SizedBox(height: inputHeight)固定高度_buildTextInputArea: 使用IntrinsicHeight+ConstrainedBox(minHeight: inputHeight),高度可随内容增长
切换时如果输入框有多行内容,高度会突变。
方案对比:
| 方案 | 优点 | 缺点 | 选择 |
|---|---|---|---|
| 方案A: 两者都用固定高度 | 完全无抖动 | 限制输入框功能 | ❌ |
| 方案B: AnimatedSwitcher 淡入淡出 | 平滑过渡,视觉舒适 | 切换有短暂延迟 | ✅ |
| 方案C: AnimatedContainer 高度动画 | 高度平滑变化 | 实现复杂 | ❌ |
最终方案:
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) {
return FadeTransition(opacity: animation, child: child);
},
child: _isVoiceMode
? KeyedSubtree(
key: const ValueKey('voice'),
child: _buildVoiceRecordButton(...),
)
: KeyedSubtree(
key: const ValueKey('text'),
child: _buildTextInputArea(...),
),
),
),关键点:
AnimatedSwitcher提供切换动画KeyedSubtree+ValueKey确保正确识别组件切换FadeTransition使用淡入淡出效果,视觉上更平滑
三、视频播放器功能增强
3.1 长按加速播放
功能设计:
| 项目 | 设计 |
|---|---|
| 触发方式 | 长按视频区域 |
| 加速倍率 | 2.0x(可配置) |
| 恢复时机 | 松开手指 |
| 视觉反馈 | 顶部显示 “2.0x 加速中” 提示 |
| 锁定兼容 | 锁定状态下禁用 |
实现方案:
// 状态变量
bool _isLongPressing = false;
double _speedBeforeLongPress = 1.0;
static const double _longPressSpeed = 2.0;
// 长按开始
void _onLongPressStart() {
if (_isLocked) return; // 锁定时不响应
_speedBeforeLongPress = _currentSpeed; // 保存当前速度
_player.setRate(_longPressSpeed); // 切换到加速
setState(() => _isLongPressing = true);
}
// 长按结束
void _onLongPressEnd() {
if (!_isLongPressing) return;
_player.setRate(_speedBeforeLongPress); // 恢复原速度
setState(() => _isLongPressing = false);
}手势集成:
GestureDetector(
onLongPressStart: (_) => _onLongPressStart(),
onLongPressEnd: (_) => _onLongPressEnd(),
onLongPressCancel: _onLongPressEnd, // 取消时也要恢复
child: MaterialVideoControlsTheme(...),
)加速提示 UI:
if (_isLongPressing)
Positioned(
top: 80,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(TablerIcons.player_play, color: Colors.white, size: 18),
SizedBox(width: 6),
Text('${_longPressSpeed}x 加速中', style: TextStyle(color: Colors.white)),
],
),
),
),
),3.2 锁定屏幕
功能设计:
| 项目 | 设计 |
|---|---|
| 入口位置 | 竖屏:顶部控制栏左侧;横屏:全屏按钮旁边 |
| 锁定状态 | 图标变橙色,底部显示”已锁定” |
| 禁用手势 | 双击快进、滑动进度、亮度、音量 |
| 隐藏控件 | 底部进度条、其他按钮(只保留锁定按钮) |
| 解锁方式 | 点击锁图标 |
状态管理:
bool _isLocked = false;
void _toggleLock() {
setState(() => _isLocked = !_isLocked);
if (_isLocked) {
showAppToast(context, '已锁定,点击锁图标解锁');
}
}手势禁用:
MaterialVideoControlsThemeData(
// 锁定时禁用所有手势
seekOnDoubleTap: !_isLocked,
seekGesture: !_isLocked,
volumeGesture: !_isLocked,
brightnessGesture: !_isLocked,
// 锁定时隐藏底部控件
bottomButtonBar: _isLocked ? [] : const [...],
)锁定按钮:
MaterialCustomButton(
onPressed: _toggleLock,
icon: Icon(
_isLocked ? TablerIcons.lock : TablerIcons.lock_open,
color: _isLocked ? Colors.orange : Colors.white,
),
),锁定状态提示:
if (_isLocked)
Positioned(
bottom: 80,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(TablerIcons.lock, color: Colors.orange, size: 16),
SizedBox(width: 4),
Text('已锁定', style: TextStyle(color: Colors.white70, fontSize: 12)),
],
),
),
),
),四、App 重命名
4.1 修改位置
| 平台 | 文件 | 修改内容 |
|---|---|---|
| Android | android/app/src/main/AndroidManifest.xml | android:label |
| Windows | windows/runner/main.cpp | window.Create() 参数 |
4.2 具体修改
Android:
<!-- 修改前 -->
<application android:label="E2E Pan" ...>
<!-- 修改后 -->
<application android:label="AgentIO" ...>Windows:
// 修改前
if (!window.Create(L"e2eepan_client", origin, size)) {
// 修改后
if (!window.Create(L"AgentIO", origin, size)) {4.3 备选名字方案
在讨论过程中,提供了以下备选方案供参考:
| 名字 | 含义 | 适用场景 |
|---|---|---|
| VaultSync | 保险库 + 同步 | 强调安全存储 |
| CipherNest | 密码 + 巢穴 | 突出加密,有归属感 |
| Enclave | 安全飞地 | 简洁,安全领域常用 |
| Privox | Private + Box | 现代感 |
| Lockr | Lock + er | 简短 |
| Arcanum | 拉丁语”秘密” | 高端神秘 |
五、文件修改清单
| 文件 | 修改类型 | 内容 |
|---|---|---|
image_preview_page.dart | 修改 | 修复缓存判断逻辑 |
video_player_page.dart | 重写 | 添加边距、长按加速、锁定屏幕 |
chat_page.dart | 修改 | AnimatedSwitcher 平滑切换 |
AndroidManifest.xml | 修改 | App 名称改为 AgentIO |
main.cpp (Windows) | 修改 | 窗口标题改为 AgentIO |
六、关键代码变更
6.1 image_preview_page.dart
// 修改前
itemBuilder: (context, index) {
return _ImageViewerItem(
file: _imageFiles[index],
imageData: _imageCache[_imageFiles[index].id],
isLoading: _loadingStates[_imageFiles[index].id] ?? true, // 问题所在
...
);
},
// 修改后
itemBuilder: (context, index) {
final file = _imageFiles[index];
// 只有在明确加载中时才显示 loading,否则检查缓存
final isLoading = _loadingStates[file.id] == true && !_imageCache.containsKey(file.id);
return _ImageViewerItem(
file: file,
imageData: _imageCache[file.id],
isLoading: isLoading,
...
);
},6.2 video_player_page.dart 新增状态
// 长按加速相关
bool _isLongPressing = false;
double _speedBeforeLongPress = 1.0;
static const double _longPressSpeed = 2.0;
// 锁定屏幕相关
bool _isLocked = false;6.3 video_player_page.dart 主体结构
return SafeArea(
child: Stack(
children: [
// 视频播放器主体(包含 GestureDetector 和 MaterialVideoControlsTheme)
Center(
child: GestureDetector(
onLongPressStart: (_) => _onLongPressStart(),
onLongPressEnd: (_) => _onLongPressEnd(),
onLongPressCancel: _onLongPressEnd,
child: MaterialVideoControlsTheme(...),
),
),
// 长按加速提示
if (_isLongPressing) Positioned(...),
// 锁定状态提示
if (_isLocked) Positioned(...),
],
),
);七、测试要点
7.1 图片预览
- 滑动切换图片后,滑回已查看的图片应立即显示(无加载动画)
- 首次加载图片时应显示加载动画
- 缓存应在整个预览会话中保持
7.2 视频播放器
- 横屏模式按钮与屏幕边缘有足够间距
- 长按视频区域开始加速,显示加速提示
- 松开后恢复原速度
- 锁定后所有手势失效
- 锁定状态图标变色,底部显示提示
- 点击锁图标可解锁
7.3 聊天输入框
- 切换录音/文字模式时无明显抖动
- 切换有平滑的淡入淡出效果
八、经验总结
-
状态判断要完整:
?? true这种默认值设计容易导致边界情况问题,应该明确考虑所有状态组合 -
media_kit 主题系统强大:通过
MaterialVideoControlsThemeData可以精细控制各种边距、手势开关 -
AnimatedSwitcher 解决切换抖动:配合
KeyedSubtree可以优雅地处理组件切换动画 -
功能与锁定的协调:新增功能时要考虑与锁定状态的兼容性,锁定时应禁用相关功能
-
多平台命名一致性:修改 App 名称需要同时修改 Android、iOS、Windows、macOS、Linux 等平台的配置文件
九、后续优化方向
- 长按加速倍率可配置:允许我在设置中选择 2x 或 3x
- iOS 平台 App 名称:需要修改
ios/Runner/Info.plist中的CFBundleDisplayName - 图片缓存 LRU 策略:当缓存图片过多时,自动清理最久未使用的图片
- 锁定状态持久化:记住锁定偏好(可选)