December 21, 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.

背景

我这边用的时候发现上传大文件后 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.5K

1.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 最佳实践

  1. 临时文件放在 cache 目录的特定子目录
  2. 开发者负责清理,不应依赖系统自动清理
  3. 上传完成后立即删除临时文件
  4. 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

九、经验总结

  1. file_picker 的隐藏复制行为: Android 上选择文件时,file_picker 会复制文件到 cache 目录,开发者必须手动清理

  2. 双重保障策略:

    • 即时清理(上传完成后删除单个文件)
    • 兜底清理(所有上传完成后调用 clearTemporaryFiles)
    • 启动清理(App 启动时清理残留)
  3. 安全清理原则: 只清理 cache 目录下的文件,通过路径检查避免误删个人文件

  4. Go multipart 临时文件: Gin 框架处理大文件上传时会在 TMPDIR 创建临时文件,必须显式调用 RemoveAll() 清理

  5. 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()
  • core/internal/storage/s3.go

    • DeletePrefix 改为批量删除