背景
之前缩略图生成逻辑在前端实现:
- 前端请求缩略图 → 服务器没有
- 前端下载源文件 → 本地生成缩略图 → 上传到服务器
这种方式的问题:
- 每个 UI 端(Flutter、未来的 Web)都需要实现生成逻辑
- 前端需要下载完整源文件,浪费带宽
- 依赖前端的图片处理库(Dart image 包)
设计决策
按需生成 vs 上传时生成
| 方案 | 优点 | 缺点 |
|---|---|---|
| 按需生成 | 不阻塞上传、只生成需要的 | 首次请求稍慢 |
| 上传时生成 | 浏览时已存在 | 阻塞上传流程 |
选择按需生成:使用时可能上传很多图片但从不浏览,按需生成更高效。
新流程
前端请求 getThumbnail(fileId) ↓后端检查 thumbs/{fileId}.enc ↓ 存在 → 解密返回 ↓ 不存在 → 下载源文件 → 解密 → 生成缩略图 → 加密保存 → 返回实现细节
后端改动
添加依赖:
go get github.com/disintegration/imaging修改 downloadThumbnail 函数:
func (s *Server) downloadThumbnail(c *gin.Context) {
// ... 权限检查
// 尝试获取已有缩略图
s3Key := fmt.Sprintf("thumbs/%s.enc", fileID)
data, err := s.s3.DownloadBytes(ctx, s3Key)
if err == nil {
// 存在,解密返回
fileKey := crypto.DeriveFileKey(s.masterKey, fileID+"#thumb")
// ... 解密并返回
return
}
// 不存在,按需生成
// 1. 检查源文件是否是图片
meta, _ := s.loadMetadataIndex(ctx)
fileMeta, ok := meta.Files[fileID]
if !ok || !strings.HasPrefix(fileMeta.MimeType, "image/") {
c.JSON(404, gin.H{"error": "not an image file"})
return
}
// 2. 下载并解密源文件
sourceData, _ := s.s3.DownloadBytes(ctx, fmt.Sprintf("files/%s.enc", fileID))
// ... 解密
// 3. 生成缩略图
thumbData, _ := s.generateThumbnail(decryptedSource.Bytes())
// 4. 加密并保存(失败不影响返回)
thumbKey := crypto.DeriveFileKey(s.masterKey, fileID+"#thumb")
// ... 加密上传
c.Data(200, "image/jpeg", thumbData)
}生成函数:
func (s *Server) generateThumbnail(sourceData []byte) ([]byte, error) {
img, _ := imaging.Decode(bytes.NewReader(sourceData))
// 等比例缩放,最大边 256px
const maxSize = 256
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
var resized *image.NRGBA
if w >= h {
resized = imaging.Resize(img, maxSize, 0, imaging.Lanczos)
} else {
resized = imaging.Resize(img, 0, maxSize, imaging.Lanczos)
}
var buf bytes.Buffer
jpeg.Encode(&buf, resized, &jpeg.Options{Quality: 80})
return buf.Bytes(), nil
}前端改动
简化 _fetchOrGenerateThumbnail:
// 之前:本地缓存 → 服务器 → 下载源文件 → 生成 → 上传
// 之后:本地缓存 → 服务器(后端按需生成)
Future<Uint8List?> _fetchOrGenerateThumbnail(ApiClient api, String fileId) async {
// 先查本地缓存
final cachedThumb = await ThumbnailCacheManager().loadThumbnail(fileId);
if (cachedThumb != null) return cachedThumb;
// 后端按需生成并返回缩略图
final serverThumb = await api.getThumbnail(fileId);
if (serverThumb.isSuccess && serverThumb.data != null) {
final data = Uint8List.fromList(serverThumb.data!);
await ThumbnailCacheManager().saveThumbnail(fileId, data);
return data;
}
return null;
}删除的代码:
ThumbnailCacheManager.generateAndSaveThumbnail()- 不再本地生成_decodeAndResizeThumbnail()- 图片处理函数ApiClient.uploadThumbnail()- 不再由前端上传import 'package:image/image.dart'- 不再需要图片处理库
性能优化保留
前端的以下性能优化全部保留,它们是加载优化而非生成优化:
| 优化 | 作用 |
|---|---|
scrollIdle | 滚动时不加载,停止后才触发 |
VisibilityDetector | 只加载可见区域的缩略图 |
_thumbDelayTimer (800ms) | 延迟加载,防止快速滚动触发大量请求 |
| 本地磁盘缓存 | 减少网络请求 |
| 内存缓存 | 减少磁盘 IO |
关键注意点
- Content-Type:必须设置为
image/jpeg,否则前端无法渲染 - JPEG 编码:使用
jpeg.Encode()而非imaging.Encode(),确保输出格式正确 - 保存失败容错:缩略图保存到 S3 失败不影响返回,下次请求会重新生成
- 导入顺序:Go 需要同时导入
image和image/jpeg包
代码量变化
| 模块 | 变化 |
|---|---|
| 后端 Go | +80 行(生成逻辑) |
| 前端 Dart | -60 行(移除生成+上传) |
| 净变化 | +20 行,但逻辑集中到后端 |
收益
- UI 解耦:换 UI 端不需要重新实现缩略图生成
- 减少依赖:前端不再需要
image包(减小 APK 体积) - 带宽优化:前端不再需要下载完整源文件来生成缩略图
- 维护集中:缩略图质量、尺寸等参数统一在后端调整