背景
我这边用的时候发现上传大文件后 App 数据显著增大(从几十 MB 增长到 1.45GB),重启 App 也无法释放。通过 ADB 诊断发现问题根源是 file_picker 插件在 Android 上的临时文件复制机制。
一、问题诊断
1.1 ADB 诊断命令
# 查看应用数据目录结构
adb shell "run-as com.e2eepan.e2eepan_client ls -la"
# 查看各目录大小
adb shell "run-as com.e2eepan.e2eepan_client find . -type d -exec du -sh {} \; 2>/dev/null"1.2 诊断结果
./cache/file_picker = 98M ← 罪魁祸首!./cache/file_picker/1766306947863 = 98M./cache/go_tmp = 3.5K./app_flutter/tmp = 3.5K1.3 问题根源
Android 上 file_picker 使用 content:// URI 选择文件时,由于 Dart 无法直接读取 content://,插件会将文件复制到 cache/file_picker/ 目录。上传完成后这些临时文件未被清理。
二、Android 存储规范
2.1 目录结构
/data/data/<package>/├── files/ → context.filesDir → 持久文件(本地数据)├── cache/ → context.cacheDir → 缓存文件(系统可清理)│ ├── thumbnails/ → 缩略图缓存│ ├── file_picker/→ file_picker 临时文件│ └── go_tmp/ → Go 核心 multipart 临时文件├── app_flutter/ → getApplicationDocumentsDirectory()│ └── logs/ → Go 核心日志└── code_cache/ → Flutter 引擎缓存2.2 最佳实践
- 临时文件放在 cache 目录的特定子目录
- 开发者负责清理,不应依赖系统自动清理
- 上传完成后立即删除临时文件
- App 启动时清理残留
三、跨平台清理架构设计
3.1 架构图
┌─────────────────────────────────────────────────────────────────────┐│ 跨平台临时文件清理架构 │├─────────────────────────────────────────────────────────────────────┤│ ││ ┌─ Flutter 客户端 ──────────────────────────────────────────────┐ ││ │ │ ││ │ file_picker 临时文件 (Android/iOS) │ ││ │ ├── _cleanupTempFile() 单文件上传完 → 删除临时文件 │ ││ │ ├── _cleanupFilePickerCache() 全部上传完 → 统一清理兜底 │ ││ │ └── _initCoreAndNetwork() App启动时 → 清理残留 │ ││ │ │ ││ └───────────────────────────────────────────────────────────────┘ ││ ││ ┌─ Go 核心 (Android Embedded) ─────────────────────────────────┐ ││ │ │ ││ │ multipart 临时文件 (cache/go_tmp/) │ ││ │ ├── RemoveAll() 每个请求完成 → 清理临时文件 │ ││ │ └── cleanupTempDir() 核心启动时 → 清理整个 go_tmp/ 目录 │ ││ │ │ ││ └───────────────────────────────────────────────────────────────┘ ││ ││ ┌─ Go 核心 (Windows Desktop) ──────────────────────────────────┐ ││ │ │ ││ │ multipart 临时文件 (系统 TEMP 目录) │ ││ │ └── RemoveAll() 每个请求完成 → 清理临时文件 │ ││ │ (系统临时目录由 Windows 自动管理) │ ││ │ │ ││ └───────────────────────────────────────────────────────────────┘ ││ │└─────────────────────────────────────────────────────────────────────┘四、Flutter 客户端实现
4.1 添加 file_picker 导入
import 'package:file_picker/file_picker.dart';4.2 单文件清理 - 上传完成后立即删除
/// 清理单个临时文件(仅清理 cache 目录下的文件,避免误删文件)
void _cleanupTempFile(String filePath) {
try {
// 只清理 cache 目录下的临时文件,避免误删文件
if (filePath.contains('/cache/') || filePath.contains('\\cache\\')) {
final file = io.File(filePath);
if (file.existsSync()) {
file.deleteSync();
debugPrint('[TempCleanup] Deleted: $filePath');
}
}
} catch (e) {
debugPrint('[TempCleanup] Failed to delete file: $e');
}
}4.3 批量清理 - 所有上传完成后兜底
/// 统一清理 file_picker 产生的所有临时文件(跨平台兼容)
Future<void> _cleanupFilePickerCache() async {
try {
final cleared = await FilePicker.platform.clearTemporaryFiles();
debugPrint('[TempCleanup] FilePicker cache cleared: $cleared');
} catch (e) {
debugPrint('[TempCleanup] FilePicker cleanup failed: $e');
}
}4.4 清理时机
// 在 _startUploadJob 完成时
_runningUploads--;
// 单文件清理:上传完成后立即删除临时文件
_cleanupTempFile(job.filePath);
// 检查是否所有上传已完成,是则调用 file_picker 的统一清理作为兜底
if (_runningUploads == 0 && _uploadQueue.isEmpty) {
_cleanupFilePickerCache();
}4.5 启动时清理
Future<void> _initCoreAndNetwork() async {
await _loadPreferences();
await _ensureCoreModeLoaded();
await _maybeStartEmbeddedCore();
await _checkInitialNetworkStatus();
// 启动时清理上次运行可能残留的临时文件(跨平台)
_cleanupFilePickerCache();
}五、Go 核心实现
5.1 请求级清理 - MultipartForm.RemoveAll()
在 server.go 的三个上传 handler 中添加:
func (s *Server) uploadFile(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file provided"})
return
}
defer file.Close()
// 清理 multipart 临时文件(大文件上传时 Go 会在 TMPDIR 创建临时文件)
defer func() {
if c.Request.MultipartForm != nil {
c.Request.MultipartForm.RemoveAll()
}
}()
// ... 处理上传
}5.2 启动级清理 - cleanupTempDir()
// cleanupTempDir 清理临时目录中的所有文件
// Gin 上传大文件时会在 TMPDIR 创建 multipart-xxx 临时文件
// 上传完成后 Gin 不会自动删除,需要我们在启动时清理
func cleanupTempDir(tmpDir string) {
if tmpDir == "" {
return
}
// 如果目录存在,删除并重建
if _, err := os.Stat(tmpDir); err == nil {
if err := os.RemoveAll(tmpDir); err != nil {
log.Printf("Warning: cannot cleanup temp directory: %v", err)
} else {
log.Printf("Cleaned up temp directory: %s", tmpDir)
}
}
}5.3 TMPDIR 设置
func Start(cfg *CoreConfig) error {
// ...
// 设置临时目录(优先使用 CacheDir,否则回退到 LogDir/tmp)
if cfg != nil {
var tmpDir string
if cfg.CacheDir != "" {
// 使用缓存目录下的 go_tmp 子目录
tmpDir = filepath.Join(cfg.CacheDir, "go_tmp")
} else if cfg.LogDir != "" {
// 回退到日志目录下的 tmp 子目录
tmpDir = filepath.Join(cfg.LogDir, "tmp")
}
if tmpDir != "" {
// 启动时清理上次运行留下的临时文件
cleanupTempDir(tmpDir)
if err := os.MkdirAll(tmpDir, 0755); err == nil {
os.Setenv("TMPDIR", tmpDir)
log.Printf("Set TMPDIR to: %s", tmpDir)
}
}
}
// ...
}六、S3 DeletePrefix 批量删除优化
6.1 原实现问题
原来的 DeletePrefix 使用 RemoveObject 逐个删除,效率低:
// 旧代码
for obj := range listCh {
s.client.RemoveObject(ctx, s.bucket, obj.Key, minio.RemoveObjectOptions{})
}6.2 新实现 - 批量删除
使用 minio-go 的 RemoveObjects API 批量删除:
func (s *S3Client) DeletePrefix(ctx context.Context, prefix string) error {
// 列出所有要删除的对象
listCh := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{
Prefix: prefix,
Recursive: true,
})
// 转换为删除对象通道
objectsCh := make(chan minio.ObjectInfo)
go func() {
defer close(objectsCh)
for obj := range listCh {
if obj.Err != nil {
continue
}
objectsCh <- obj
}
}()
// 批量删除
errorCh := s.client.RemoveObjects(ctx, s.bucket, objectsCh, minio.RemoveObjectsOptions{})
// 检查删除错误
for err := range errorCh {
if err.Err != nil {
return fmt.Errorf("failed to delete %s: %w", err.ObjectName, err.Err)
}
}
return nil
}七、代码清理
7.1 删除未使用的变量
mobile.go 中的 cacheDir 变量被声明但从未使用:
// 删除前
var (
mu sync.Mutex
httpSrv *http.Server
logFile *os.File
logFilePath string
server *api.Server
cacheDir string // 保存缓存目录路径 ← 未使用
)
// 删除后
var (
mu sync.Mutex
httpSrv *http.Server
logFile *os.File
logFilePath string
server *api.Server
)八、验证结果
修复后通过 ADB 验证:
# 清理前
./cache/file_picker = 98M
# 启动 App 后(启动时清理生效)
./cache/file_picker = 7.0K ✓九、经验总结
-
file_picker 的隐藏复制行为: Android 上选择文件时,file_picker 会复制文件到 cache 目录,开发者必须手动清理
-
双重保障策略:
- 即时清理(上传完成后删除单个文件)
- 兜底清理(所有上传完成后调用 clearTemporaryFiles)
- 启动清理(App 启动时清理残留)
-
安全清理原则: 只清理 cache 目录下的文件,通过路径检查避免误删个人文件
-
Go multipart 临时文件: Gin 框架处理大文件上传时会在 TMPDIR 创建临时文件,必须显式调用
RemoveAll()清理 -
TMPDIR 的正确位置: Android 上应使用 cache 目录(可被系统清理),而不是 app_flutter 目录(本地数据)
文件变更清单
-
client/lib/core/state/app_state.dart- 添加
file_picker导入 - 添加
_cleanupTempFile()单文件清理 - 添加
_cleanupFilePickerCache()批量清理 - 启动时调用清理
- 添加
-
core/mobile/mobile.go- 删除未使用的
cacheDir变量 cleanupTempDir()启动时清理
- 删除未使用的
-
core/internal/api/server.go- 三处上传 handler 添加
MultipartForm.RemoveAll()
- 三处上传 handler 添加
-
core/internal/storage/s3.goDeletePrefix改为批量删除