Публикация была переведена автоматически. Исходный язык: Русский
Есть типичная история, которая рано или поздно приходит в любой продукт, где пользователь отправляет медиа.
Все начинается красиво:
- пользователь снял видео
- нажал “Загрузить”
- видит прогресс
- все счастливы
А потом реальность:
- лифт, метро, подземный паркинг
- LTE превращается в EDGE
- Wi‑Fi отваливается ровно на 87%
- приложение уходит в фон, OS его выгружает
- пользователь открывает снова и видит “0%”
И дальше самый короткий, но самый дорогой диалог:
- “Почему оно снова с нуля?”
- “Потому что… так получилось”
Спойлер: это не “так получилось”. Это архитектура так устроена.
Наивная реализация обычно выглядит так:
final bytes = await File(path).readAsBytes();
await dio.post('/upload', data: bytes);Что может пойти не так?
- readAsBytes() может попытаться загнать в память сотни мегабайт
- загрузка рвется и начинается сначала
- пользователь платит трафиком дважды
- на сервер прилетают дубли
- прогресс становится ложью (потому что UI не знает, что реально на сервере уже есть)
- приложение перезапустили - состояние потеряно
И главное: если вы не сделали протокол возобновления, “возобновлять” просто нечего.
Если сказать по-честному, требования обычно такие:
- грузить большие файлы (100 МБ, 500 МБ, 2 ГБ - не важно)
- уметь продолжить после обрыва сети
- уметь продолжить после убийства приложения
- показывать честный прогресс
- не плодить дубли на сервере
- не блокировать UI и не жечь память
Это уже не “кнопка загрузить”. Это маленькая система.
Flutter тут не волшебная палочка. Магия начинается с контракта клиент-сервер.
Самый практичный вариант (который легко реализуется и на бэке) - chunked upload:
- Клиент создает сессию загрузки
POST /uploads -> сервер отвечает:
{
"uploadId": "u_123",
"chunkSize": 1048576,
"alreadyUploadedBytes": 0
}- Клиент отправляет чанки (кусочки файла)
PUT /uploads/{uploadId}/chunks/{index}
- index - номер чанка
- заголовки типа:X-Chunk-Size X-Chunk-Sha256 (опционально, но очень полезно) X-Idempotency-Key (если хотите железобетонные ретраи)
- X-Chunk-Size
- X-Chunk-Sha256 (опционально, но очень полезно)
- X-Idempotency-Key (если хотите железобетонные ретраи)
- Клиент коммитит загрузку
POST /uploads/{uploadId}/complete
Сервер склеивает/проверяет и возвращает финальный объект (URL, id, метаданные).
Важный момент: сервер должен уметь ответить “сколько байт уже принято”. Иначе после рестарта вы снова будете гадать.
Если приложение может умереть, значит состояние должно пережить смерть приложения.
Самый простой подход - “таблица загрузок” в SQLite (drift/sqflite - не важно).
Храним:
- id - локальный uuid задачи
- localPath - путь к файлу
- sizeBytes
- uploadedBytes - сколько уже подтверждено сервером
- uploadId - id сессии на сервере
- chunkSize
- status - queued/uploading/paused/failed/done
- attempt и nextAttemptAt для ретраев
Пример на drift (укороченно):
import 'package:drift/drift.dart';
class Uploads extends Table {
TextColumn get id => text()(); // uuid
TextColumn get localPath => text()();
IntColumn get sizeBytes => integer()();
IntColumn get uploadedBytes => integer().withDefault(const Constant(0))();
TextColumn get uploadId => text().nullable()(); // server session id
IntColumn get chunkSize => integer().nullable()();
TextColumn get status => text()(); // queued|uploading|paused|failed|done
IntColumn get attempt => integer().withDefault(const Constant(0))();
IntColumn get nextAttemptAtMs => integer().nullable()();
DateTimeColumn get updatedAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}Это скучно, но это та часть, без которой “resume” превращается в маркетинговое слово.
Ключевой момент: не грузить весь файл в RAM.
Читаем через RandomAccessFile:
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
class ChunkReader {
ChunkReader(this.file);
final File file;
Future<void> readChunks({
required int startOffset,
required int chunkSize,
required Future<void> Function(int index, int offset, Uint8List bytes) onChunk,
}) async {
final raf = await file.open();
try {
final size = await file.length();
var offset = startOffset;
var index = offset ~/ chunkSize;
while (offset < size) {
final len = min(chunkSize, size - offset);
await raf.setPosition(offset);
final bytes = await raf.read(len);
if (bytes.isEmpty) break;
await onChunk(index, offset, Uint8List.fromList(bytes));
offset += bytes.length;
index += 1;
}
} finally {
await raf.close();
}
}
}Плюс: стабильно, экономно по памяти.
Минус: нужно аккуратно обрабатывать ошибки и перезапуски.
Если сеть плохая, ретраи будут. Не "возможно", а будут будьте уверены.
Значит, отправка чанка должна быть идемпотентной: повторная отправка того же чанка не должна создавать дубль.
Самый простой практический вариант:
- у чанка есть (uploadId, index)
- клиент шлет sha256 чанка
- сервер, если чанк уже принят, сравнивает хеш и отвечает "ok"
На клиенте:
import 'package:crypto/crypto.dart';
import 'dart:convert';
String sha256Hex(Uint8List bytes) {
final digest = sha256.convert(bytes);
return digest.toString();
}Важно: хеш можно считать в isolate, если чанки большие и устройство слабое. Но в большинстве случаев 1-4 МБ на чанк нормально живут и в основном изоляте, если вы не делаете это 10 параллельных загрузок.
Теперь собираем все вместе: берем запись из БД, если нет uploadId - создаем сессию, потом грузим чанки с uploadedBytes, обновляем прогресс, ретраим с backoff.
Пример (сильно упрощенный, но рабочая идея):
import 'dart:io';
import 'package:dio/dio.dart';
class ResumableUploader {
ResumableUploader(this.dio);
final Dio dio;
Future<UploadSession> createSession({
required int sizeBytes,
required String fileName,
}) async {
final resp = await dio.post('/uploads', data: {
'sizeBytes': sizeBytes,
'fileName': fileName,
});
return UploadSession(
uploadId: resp.data['uploadId'] as String,
chunkSize: resp.data['chunkSize'] as int,
alreadyUploadedBytes: resp.data['alreadyUploadedBytes'] as int,
);
}
Future<void> uploadChunk({
required String uploadId,
required int index,
required Uint8List bytes,
required String sha256,
}) async {
await dio.put(
'/uploads/$uploadId/chunks/$index',
data: bytes,
options: Options(
headers: {
'Content-Type': 'application/octet-stream',
'X-Chunk-Sha256': sha256,
},
),
);
}
Future<void> complete(String uploadId) async {
await dio.post('/uploads/$uploadId/complete');
}
}
class UploadSession {
UploadSession({
required this.uploadId,
required this.chunkSize,
required this.alreadyUploadedBytes,
});
final String uploadId;
final int chunkSize;
final int alreadyUploadedBytes;
}Если пользователь выбрал 20 файлов, нельзя запускать 20 параллельных upload-ов.
На практике нормально:
- 1-2 параллельные загрузки на мобильных
- остальные в очереди
Простейший ограничитель параллелизма без тяжелых зависимостей:
import 'dart:async';
class Semaphore {
Semaphore(this._max);
final int _max;
int _current = 0;
final _waiters = <Completer<void>>[];
Future<void> acquire() async {
if (_current < _max) {
_current++;
return;
}
final c = Completer<void>();
_waiters.add(c);
await c.future;
_current++;
}
void release() {
_current--;
if (_waiters.isNotEmpty) {
_waiters.removeAt(0).complete();
}
}
}И используем:
final sem = Semaphore(2);
Future<void> runUploadTask(Future<void> Function() task) async {
await sem.acquire();
try {
await task();
} finally {
sem.release();
}
}Теперь самое важное: обновление БД после каждого успешно принятого чанка.
Скетч, как это выглядит:
Future<void> processUpload(UploadRecord rec) async {
final file = File(rec.localPath);
final size = await file.length();
// 1) create/resume session
var uploadId = rec.uploadId;
var chunkSize = rec.chunkSize;
var uploadedBytes = rec.uploadedBytes;
if (uploadId == null || chunkSize == null) {
final session = await uploader.createSession(
sizeBytes: size,
fileName: file.uri.pathSegments.last,
);
uploadId = session.uploadId;
chunkSize = session.chunkSize;
// сервер мог уже иметь кусок (если мы падали после create)
uploadedBytes = session.alreadyUploadedBytes;
await repo.updateSession(
id: rec.id,
uploadId: uploadId,
chunkSize: chunkSize,
uploadedBytes: uploadedBytes,
status: 'uploading',
);
} else {
await repo.updateStatus(id: rec.id, status: 'uploading');
}
// 2) upload chunks from offset
final reader = ChunkReader(file);
await reader.readChunks(
startOffset: uploadedBytes,
chunkSize: chunkSize,
onChunk: (index, offset, bytes) async {
final hash = sha256Hex(bytes);
await retryWithBackoff(() async {
await uploader.uploadChunk(
uploadId: uploadId!,
index: index,
bytes: bytes,
sha256: hash,
);
});
final newUploaded = offset + bytes.length;
await repo.updateProgress(id: rec.id, uploadedBytes: newUploaded);
},
);
// 3) finalize
await uploader.complete(uploadId);
await repo.updateStatus(id: rec.id, status: 'done');
}Где repo - это ваш слой работы с SQLite (drift/sqflite), а retryWithBackoff - нормальные ретраи.
Очень простой вариант:
import 'dart:math';
import 'dart:async';
Future<T> retryWithBackoff<T>(
Future<T> Function() fn, {
int maxAttempts = 5,
Duration baseDelay = const Duration(milliseconds: 400),
}) async {
Object? lastError;
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (e) {
lastError = e;
final pow2 = 1 << (attempt - 1);
final jitterMs = Random().nextInt(250);
final delay = Duration(
milliseconds: baseDelay.inMilliseconds * pow2 + jitterMs,
);
await Future.delayed(delay);
}
}
throw lastError!;
}Да, это не серебряная пуля, но это резко снижает "магические падения" на нестабильных сетях.
Трюк простой:
- прогресс не живет в памяти
- прогресс живет в БД
- UI подписан на поток из БД
На drift это выглядит примерно так:
Stream<List<UploadRecord>> watchActiveUploads() => db.watchUploads(
whereStatusIn: const ['queued', 'uploading', 'failed', 'paused'],
);В UI:
class UploadsPanel extends StatelessWidget {
const UploadsPanel({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<List<UploadRecord>>(
stream: repo.watchActiveUploads(),
builder: (context, snapshot) {
final items = snapshot.data ?? const [];
if (items.isEmpty) return const SizedBox.shrink();
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, i) {
final u = items[i];
final progress = u.sizeBytes == 0 ? 0.0 : (u.uploadedBytes / u.sizeBytes);
return ListTile(
title: Text(u.localPath.split('/').last),
subtitle: Text('${u.status} - ${(progress * 100).toStringAsFixed(1)}%'),
trailing: SizedBox(
width: 120,
child: LinearProgressIndicator(value: progress.clamp(0.0, 1.0)),
),
);
},
);
},
);
}
}Теперь перезапуск приложения не обнуляет прогресс, потому что он не в оперативке.
- Файл может измениться. Пользователь может удалить/переместить файл, галерея может пересобрать кеш. Поэтому стоит хранить sizeBytes и lastModified, и перед продолжением проверять, что файл тот же.
- “В фоне пусть догрузится” - не всегда реально. iOS и Android не обещают вам бесконечную фоновую работу. Можно делать best-effort (WorkManager/BackgroundFetch), но если нужен “железный” background upload, иногда приходится уходить в нативные фоновые загрузки (на iOS - URLSession background) и дергать их из Flutter через платформенные каналы или готовые плагины.
- Сервер тоже должен быть идемпотентным. Если сервер при повторной отправке чанка создает дубль - клиентские ретраи превращаются в генератор мусора.
- Не делайте параллелизм по чанкам "на максимум". Хочется ускорить, но мобильная сеть часто упирается в latency и радиоканал. 1-2 файла параллельно обычно лучше, чем 6.
Возобновляемая загрузка больших файлов в Flutter - это не "флажок".
Это:
- протокол сессии + чанков
- локальное состояние (SQLite), которое переживает смерть приложения
- аккуратное чтение файла без RAM-суицида
- идемпотентные ретраи
- честный UI, подписанный на данные, а не на память
И после этого у вас появляется важная вещь: пользователь перестает видеть "0% после 87%". А это, как ни странно, иногда важнее любой анимации и любого красивого UI.
Есть типичная история, которая рано или поздно приходит в любой продукт, где пользователь отправляет медиа.
Все начинается красиво:
- пользователь снял видео
- нажал “Загрузить”
- видит прогресс
- все счастливы
А потом реальность:
- лифт, метро, подземный паркинг
- LTE превращается в EDGE
- Wi‑Fi отваливается ровно на 87%
- приложение уходит в фон, OS его выгружает
- пользователь открывает снова и видит “0%”
И дальше самый короткий, но самый дорогой диалог:
- “Почему оно снова с нуля?”
- “Потому что… так получилось”
Спойлер: это не “так получилось”. Это архитектура так устроена.
Наивная реализация обычно выглядит так:
final bytes = await File(path).readAsBytes();
await dio.post('/upload', data: bytes);Что может пойти не так?
- readAsBytes() может попытаться загнать в память сотни мегабайт
- загрузка рвется и начинается сначала
- пользователь платит трафиком дважды
- на сервер прилетают дубли
- прогресс становится ложью (потому что UI не знает, что реально на сервере уже есть)
- приложение перезапустили - состояние потеряно
И главное: если вы не сделали протокол возобновления, “возобновлять” просто нечего.
Если сказать по-честному, требования обычно такие:
- грузить большие файлы (100 МБ, 500 МБ, 2 ГБ - не важно)
- уметь продолжить после обрыва сети
- уметь продолжить после убийства приложения
- показывать честный прогресс
- не плодить дубли на сервере
- не блокировать UI и не жечь память
Это уже не “кнопка загрузить”. Это маленькая система.
Flutter тут не волшебная палочка. Магия начинается с контракта клиент-сервер.
Самый практичный вариант (который легко реализуется и на бэке) - chunked upload:
- Клиент создает сессию загрузки
POST /uploads -> сервер отвечает:
{
"uploadId": "u_123",
"chunkSize": 1048576,
"alreadyUploadedBytes": 0
}- Клиент отправляет чанки (кусочки файла)
PUT /uploads/{uploadId}/chunks/{index}
- index - номер чанка
- заголовки типа:X-Chunk-Size X-Chunk-Sha256 (опционально, но очень полезно) X-Idempotency-Key (если хотите железобетонные ретраи)
- X-Chunk-Size
- X-Chunk-Sha256 (опционально, но очень полезно)
- X-Idempotency-Key (если хотите железобетонные ретраи)
- Клиент коммитит загрузку
POST /uploads/{uploadId}/complete
Сервер склеивает/проверяет и возвращает финальный объект (URL, id, метаданные).
Важный момент: сервер должен уметь ответить “сколько байт уже принято”. Иначе после рестарта вы снова будете гадать.
Если приложение может умереть, значит состояние должно пережить смерть приложения.
Самый простой подход - “таблица загрузок” в SQLite (drift/sqflite - не важно).
Храним:
- id - локальный uuid задачи
- localPath - путь к файлу
- sizeBytes
- uploadedBytes - сколько уже подтверждено сервером
- uploadId - id сессии на сервере
- chunkSize
- status - queued/uploading/paused/failed/done
- attempt и nextAttemptAt для ретраев
Пример на drift (укороченно):
import 'package:drift/drift.dart';
class Uploads extends Table {
TextColumn get id => text()(); // uuid
TextColumn get localPath => text()();
IntColumn get sizeBytes => integer()();
IntColumn get uploadedBytes => integer().withDefault(const Constant(0))();
TextColumn get uploadId => text().nullable()(); // server session id
IntColumn get chunkSize => integer().nullable()();
TextColumn get status => text()(); // queued|uploading|paused|failed|done
IntColumn get attempt => integer().withDefault(const Constant(0))();
IntColumn get nextAttemptAtMs => integer().nullable()();
DateTimeColumn get updatedAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}Это скучно, но это та часть, без которой “resume” превращается в маркетинговое слово.
Ключевой момент: не грузить весь файл в RAM.
Читаем через RandomAccessFile:
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
class ChunkReader {
ChunkReader(this.file);
final File file;
Future<void> readChunks({
required int startOffset,
required int chunkSize,
required Future<void> Function(int index, int offset, Uint8List bytes) onChunk,
}) async {
final raf = await file.open();
try {
final size = await file.length();
var offset = startOffset;
var index = offset ~/ chunkSize;
while (offset < size) {
final len = min(chunkSize, size - offset);
await raf.setPosition(offset);
final bytes = await raf.read(len);
if (bytes.isEmpty) break;
await onChunk(index, offset, Uint8List.fromList(bytes));
offset += bytes.length;
index += 1;
}
} finally {
await raf.close();
}
}
}Плюс: стабильно, экономно по памяти.
Минус: нужно аккуратно обрабатывать ошибки и перезапуски.
Если сеть плохая, ретраи будут. Не "возможно", а будут будьте уверены.
Значит, отправка чанка должна быть идемпотентной: повторная отправка того же чанка не должна создавать дубль.
Самый простой практический вариант:
- у чанка есть (uploadId, index)
- клиент шлет sha256 чанка
- сервер, если чанк уже принят, сравнивает хеш и отвечает "ok"
На клиенте:
import 'package:crypto/crypto.dart';
import 'dart:convert';
String sha256Hex(Uint8List bytes) {
final digest = sha256.convert(bytes);
return digest.toString();
}Важно: хеш можно считать в isolate, если чанки большие и устройство слабое. Но в большинстве случаев 1-4 МБ на чанк нормально живут и в основном изоляте, если вы не делаете это 10 параллельных загрузок.
Теперь собираем все вместе: берем запись из БД, если нет uploadId - создаем сессию, потом грузим чанки с uploadedBytes, обновляем прогресс, ретраим с backoff.
Пример (сильно упрощенный, но рабочая идея):
import 'dart:io';
import 'package:dio/dio.dart';
class ResumableUploader {
ResumableUploader(this.dio);
final Dio dio;
Future<UploadSession> createSession({
required int sizeBytes,
required String fileName,
}) async {
final resp = await dio.post('/uploads', data: {
'sizeBytes': sizeBytes,
'fileName': fileName,
});
return UploadSession(
uploadId: resp.data['uploadId'] as String,
chunkSize: resp.data['chunkSize'] as int,
alreadyUploadedBytes: resp.data['alreadyUploadedBytes'] as int,
);
}
Future<void> uploadChunk({
required String uploadId,
required int index,
required Uint8List bytes,
required String sha256,
}) async {
await dio.put(
'/uploads/$uploadId/chunks/$index',
data: bytes,
options: Options(
headers: {
'Content-Type': 'application/octet-stream',
'X-Chunk-Sha256': sha256,
},
),
);
}
Future<void> complete(String uploadId) async {
await dio.post('/uploads/$uploadId/complete');
}
}
class UploadSession {
UploadSession({
required this.uploadId,
required this.chunkSize,
required this.alreadyUploadedBytes,
});
final String uploadId;
final int chunkSize;
final int alreadyUploadedBytes;
}Если пользователь выбрал 20 файлов, нельзя запускать 20 параллельных upload-ов.
На практике нормально:
- 1-2 параллельные загрузки на мобильных
- остальные в очереди
Простейший ограничитель параллелизма без тяжелых зависимостей:
import 'dart:async';
class Semaphore {
Semaphore(this._max);
final int _max;
int _current = 0;
final _waiters = <Completer<void>>[];
Future<void> acquire() async {
if (_current < _max) {
_current++;
return;
}
final c = Completer<void>();
_waiters.add(c);
await c.future;
_current++;
}
void release() {
_current--;
if (_waiters.isNotEmpty) {
_waiters.removeAt(0).complete();
}
}
}И используем:
final sem = Semaphore(2);
Future<void> runUploadTask(Future<void> Function() task) async {
await sem.acquire();
try {
await task();
} finally {
sem.release();
}
}Теперь самое важное: обновление БД после каждого успешно принятого чанка.
Скетч, как это выглядит:
Future<void> processUpload(UploadRecord rec) async {
final file = File(rec.localPath);
final size = await file.length();
// 1) create/resume session
var uploadId = rec.uploadId;
var chunkSize = rec.chunkSize;
var uploadedBytes = rec.uploadedBytes;
if (uploadId == null || chunkSize == null) {
final session = await uploader.createSession(
sizeBytes: size,
fileName: file.uri.pathSegments.last,
);
uploadId = session.uploadId;
chunkSize = session.chunkSize;
// сервер мог уже иметь кусок (если мы падали после create)
uploadedBytes = session.alreadyUploadedBytes;
await repo.updateSession(
id: rec.id,
uploadId: uploadId,
chunkSize: chunkSize,
uploadedBytes: uploadedBytes,
status: 'uploading',
);
} else {
await repo.updateStatus(id: rec.id, status: 'uploading');
}
// 2) upload chunks from offset
final reader = ChunkReader(file);
await reader.readChunks(
startOffset: uploadedBytes,
chunkSize: chunkSize,
onChunk: (index, offset, bytes) async {
final hash = sha256Hex(bytes);
await retryWithBackoff(() async {
await uploader.uploadChunk(
uploadId: uploadId!,
index: index,
bytes: bytes,
sha256: hash,
);
});
final newUploaded = offset + bytes.length;
await repo.updateProgress(id: rec.id, uploadedBytes: newUploaded);
},
);
// 3) finalize
await uploader.complete(uploadId);
await repo.updateStatus(id: rec.id, status: 'done');
}Где repo - это ваш слой работы с SQLite (drift/sqflite), а retryWithBackoff - нормальные ретраи.
Очень простой вариант:
import 'dart:math';
import 'dart:async';
Future<T> retryWithBackoff<T>(
Future<T> Function() fn, {
int maxAttempts = 5,
Duration baseDelay = const Duration(milliseconds: 400),
}) async {
Object? lastError;
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (e) {
lastError = e;
final pow2 = 1 << (attempt - 1);
final jitterMs = Random().nextInt(250);
final delay = Duration(
milliseconds: baseDelay.inMilliseconds * pow2 + jitterMs,
);
await Future.delayed(delay);
}
}
throw lastError!;
}Да, это не серебряная пуля, но это резко снижает "магические падения" на нестабильных сетях.
Трюк простой:
- прогресс не живет в памяти
- прогресс живет в БД
- UI подписан на поток из БД
На drift это выглядит примерно так:
Stream<List<UploadRecord>> watchActiveUploads() => db.watchUploads(
whereStatusIn: const ['queued', 'uploading', 'failed', 'paused'],
);В UI:
class UploadsPanel extends StatelessWidget {
const UploadsPanel({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<List<UploadRecord>>(
stream: repo.watchActiveUploads(),
builder: (context, snapshot) {
final items = snapshot.data ?? const [];
if (items.isEmpty) return const SizedBox.shrink();
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, i) {
final u = items[i];
final progress = u.sizeBytes == 0 ? 0.0 : (u.uploadedBytes / u.sizeBytes);
return ListTile(
title: Text(u.localPath.split('/').last),
subtitle: Text('${u.status} - ${(progress * 100).toStringAsFixed(1)}%'),
trailing: SizedBox(
width: 120,
child: LinearProgressIndicator(value: progress.clamp(0.0, 1.0)),
),
);
},
);
},
);
}
}Теперь перезапуск приложения не обнуляет прогресс, потому что он не в оперативке.
- Файл может измениться. Пользователь может удалить/переместить файл, галерея может пересобрать кеш. Поэтому стоит хранить sizeBytes и lastModified, и перед продолжением проверять, что файл тот же.
- “В фоне пусть догрузится” - не всегда реально. iOS и Android не обещают вам бесконечную фоновую работу. Можно делать best-effort (WorkManager/BackgroundFetch), но если нужен “железный” background upload, иногда приходится уходить в нативные фоновые загрузки (на iOS - URLSession background) и дергать их из Flutter через платформенные каналы или готовые плагины.
- Сервер тоже должен быть идемпотентным. Если сервер при повторной отправке чанка создает дубль - клиентские ретраи превращаются в генератор мусора.
- Не делайте параллелизм по чанкам "на максимум". Хочется ускорить, но мобильная сеть часто упирается в latency и радиоканал. 1-2 файла параллельно обычно лучше, чем 6.
Возобновляемая загрузка больших файлов в Flutter - это не "флажок".
Это:
- протокол сессии + чанков
- локальное состояние (SQLite), которое переживает смерть приложения
- аккуратное чтение файла без RAM-суицида
- идемпотентные ретраи
- честный UI, подписанный на данные, а не на память
И после этого у вас появляется важная вещь: пользователь перестает видеть "0% после 87%". А это, как ни странно, иногда важнее любой анимации и любого красивого UI.