Suponha que você precise fazer um aplicativo de diagnóstico dermatológico que utiliza imagens de lesões cutâneas para avaliações rápidas por IA e também para análises detalhadas por especialistas. Os seguintes requisitos lhe são repassados:
- O paciente deve enviar uma foto para uma triagem rápida usando IA
- O paciente deve enviar uma ou mais fotos para uma análise mais detalhada por um dermatologista
- Após um mês, o paciente deve enviar uma foto para monitoramento do tratamento sugerido pelo dermatologista
Você consegue implementar o sistema em Flutter, mas alguns pacientes reclamam estar demorando muito para enviar uma foto (possivelmente por conta de uma conexão lenta de internet). Então lhe é sugerido salvar as imagens primeiro localmente, e depois pedir para o usuário enviá-las manualmente quando houver uma melhor conexão com a internet.
O problema
Para Android, existem bibliotecas no Flutter que facilitam esse processo:
path_provider
para encontrar o
diretório específico da aplicação no armazenamento do dispositivo,
workmanager
para executar tarefas
em segundo plano, e a biblioteca embutida dart:io
para criar diretórios e ler arquivos do
sistema operacional.
No entanto, o Flutter Web não possui um suporte aprimorado a processamento em plano de fundo, além de não permitir acessar os diretórios reais do dispositivo, inclusive como descrito na própria documentação:
Only non-web Flutter apps, command-line scripts, and servers can import and use
dart:io
, not web apps.
Portanto, é preciso encontrar uma maneira simplificada e centralizada de realizar este gerenciamento de arquivos.
Uma solução
Me deparei com este mesmo problema em uma aplicação que desenvolvi há uns meses.
A primeira parte da solução é encontrar bibliotecas que não tenham restrição de execução na web.
Para os dois casos mencionados na seção anterior, não há o que ser feito: é
uma limitação atual do Flutter e a solução é não executar código que inclua
elementos dessas bibliotecas na plataforma web. Isso não significa que você
tenha que removê-las: o Flutter permite adicionar bibliotecas não compatíveis
com a web (como por exemplo, path_provider
e workmanager
) em seu
projeto Flutter que suporta web, assim como permite importá-las. O que não
é permitido é acessar métodos ou funções com implementações específicas,
recebendo a exceção
“Unsupported operation: _Namespace”
caso seja utilizado a dart:io
ou a exceção
“UnimplementedError”
para as demais bibliotecas.
A biblioteca universal_io
pode ser utilizada para substituir a biblioteca dart:io
, incluindo a
maior parte de seus elementos, mas de uma maneira multiplataforma e que não causa conflitos. Seu uso principal é a classe
Platform
, que permite identificar a plataforma atual de onde
a aplicação está rodando.
A biblioteca cross_file
é mais especializada para gerenciamento de arquivos, inclusive utilizada pela
biblioteca file_picker
para seleção de arquivos. Seu uso principal é a classe
XFile
, que abstrai arquivos na web para Uint8List
s
e arquivos locais para File
s.
A segunda parte da solução é mais complicada, pois envolve criar uma arquitetura escalável para criação de arquivos locais, upload desses arquivos e suas futuras remoções.
Após gastar algumas horas pensando no assunto, cheguei na seguinte arquitetura, que apelidei de SPC (statement-procedure-command):
sealed class StorageStatement {}
sealed class StorageProcedure {}
class StorageCommand {
final StorageStatement statement;
final StorageProcedure procedure;
const StorageCommand({
required this.statement,
required this.procedure,
});
}
abstract class StorageManager {
Future<void> execute(StorageStatement statement);
Stream<StorageCommand> evaluatePendingCommands();
Future<void> commit(StorageCommand action);
}
Instruções
A classe StorageStatement
representa uma instrução de upload a ser realizada no
sistema. Por exemplo, nos requisitos acima, existem três
instruções: upload de uma foto para triagem; upload de uma ou mais fotos
para diagnóstico; e upload de uma foto para monitoramento do tratamento.
Dessa forma, as classes para representar estas declarações podem ser
definidas como
import 'package:cross_file/cross_file.dart' show XFile;
sealed class StorageStatement {}
class UploadScreeningImageStatement implements StorageStatement {
final String patientId;
final XFile file;
const UploadScreeningImageStatement({
required this.patientId,
required this.file,
);
}
class UploadDiagnosticImagesStatement implements StorageStatement {
final String patientId;
final List<XFile> files;
const UploadDiagnosticImagesStatement({
required this.patientId,
required this.files,
);
}
class UploadTreatmentImageStatement implements StorageStatement {
final String patientId;
final int year;
final int month;
final XFile file;
const UploadTreatmentImageStatement({
required this.patientId,
required this.year,
required this.month,
required this.file,
});
}
Note que esta classe não implementa nenhuma operação de armazenamento, sendo apenas conceitual.
Procedimentos
A classe StorageProcedure
representa um procedimento a ser realizado após
uma operação de upload ter sido concluída com sucesso. Por exemplo, depois
que um arquivo salvo localmente já estiver na nuvem, não há necessidade de
tê-lo armazenado no dispositivo, então uma classe para representar a remoção
deste arquivo pode ser definida como
class RemoveFileStorageProcedure implements StorageProcedure {
final String path;
const RemoveFileStorageProcedure({required this.path});
}
Note que uma instrução pode envolver um ou mais arquivos (como a instrução de diagnóstico) que serão armazenados localmente por um diretório, por exemplo. Neste caso, após os arquivos deste diretório forem enviados para a nuvem, pode-se executar um procedimento para excluir o diretório e todos os seus arquivos, que pode ser definido como
class RemoveDirectoryStorageProcedure implements StorageProcedure {
final String path;
const RemoveDirectoryStorageProcedure({required this.path});
}
Por fim, pode existir o caso de que não haja nenhuma ação pós-upload (como nenhum arquivo a ser limpo, por exemplo). Este procedimento pode ser definido como
class NoActionStorageProcedure implements StorageProcedure {
const NoActionStorageProcedure();
}
Note que esta classe também não representa nenhuma operação de I/O, sendo apenas conceitual.
Comandos
Não é necessário implementar a classe StorageCommand
por ser uma classe concreta. Essa classe
relaciona instruções com seus respectivos procedimentos. Por exemplo, os possíveis relacionamentos
do sistema são:
- a instrução
UploadScreeningImageStatement
com o procedimentoRemoveFileStorageProcedure
- a instrução
UploadDiagnosticImagesStatement
com o procedimentoRemoveDirectoryStorageProcedure
- a instrução
UploadTreatmentImageStatement
com o procedimentoRemoveFileStorageProcedure
Gerenciador
A classe StorageManager
é a interface a ser utilizada para o gerenciamento desses arquivos locais e remotos.
Deve haver uma implementação dessa classe sem utilizar armazenamento local (feita para web) e outra implementação utilizando o armazenamento local (feito para mobile ou desktop).
Desta forma, o código inicial para o StorageManager
pode ser escrito da seguinte forma:
abstract class StorageManager {
const factory StorageManager() = _StorageManager;
const factory StorageManager.buffered(
StorageManager manager
) = _BufferedStorageManager;
}
class _StorageManager implements StorageManager {
const _StorageManager();
// ...
}
class _BufferedStorageManager implements StorageManager {
final StorageManager manager;
const _BufferedStorageManager(this.manager);
// ...
}
Note que, para reutilização de código, foi usado o padrão de design
decorator na segunda classe para adicionar a
funcionalidade de armazenamento local a um StorageManager
já existente. Dessa forma, é possível
adicionar ou não a funcionalidade com o simples trecho de código:
import 'package:universal_io/universal_io.dart' show Platform;
StorageManager get storageManager {
StorageManager manager = const StorageManager();
if (Platform.isAndroid) {
return StorageManager.buffered(manager);
}
return manager;
}
Agora, podemos implementar os métodos da interface em cada uma das subclasses.
Persistência
O método commit
recebe como parâmetro um StorageCommand
a ser executado.
Ao implementar esse método, deve-se obrigatoriamente persistir o arquivo na
nuvem e, caso se aplique, executar o método de limpeza pós-upload, descritos
respectivamente pelo StorageStatement
e pelo StorageProcedure
contidos no
StorageCommand
recebido.
Uma implementação sem armazenamento local para este método pode ser
simplesmente enviar o arquivo descrito pela instrução do comando para a
nuvem, utilizando por exemplo a biblioteca http
:
import 'package:http/http.dart' as http;
import 'package:cross_file/cross_file.dart' show XFile;
class _StorageManager implements StorageManager {
// ...
Future<void> commit(StorageCommand command) async {
switch (command.statement) {
case UploadScreeningImageStatement(:String patientId, :XFile file):
final String url = 'https://example.com/api/patients/$patientId/screening';
final http.MultipartRequest request = http.MultipartRequest('POST', Uri.parse(url));
request.files.add(http.MultipartFile.fromBytes('file', await file.readAsBytes()));
await request.send();
case UploadDiagnosticImagesStatement(:String patientId, :List<XFile> files):
final String url = 'https://example.com/api/patients/$patientId/diagnostic';
final http.MultipartRequest request = http.MultipartRequest('POST', Uri.parse(url));
for (int i = 0; i < files.length; i++) {
final XFile file = files[i];
request.files.add(http.MultipartFile.fromBytes('file$i', await file.readAsBytes()));
}
await request.send();
// TODO Implementar para a instrução de tratamento
}
}
// ...
}
Como esta implementação do StorageManager
não utiliza armazenamento local,
não é necessário realizar nenhuma ação pós-upload, simplesmente podendo
ignorar o campo StorageProcedure
do objeto command
.
Já para a implementação com armazenamento local para este método deve
levar em consideração a ação pós-upload, realizando uma ação com base no
valor da StorageProcedure
:
import 'package:universal_io/io.dart';
class _BufferedStorageManager implements StorageManager {
final StorageManager manager;
// ...
Future<void> commit(StorageCommand command) async {
// Executa o procedimento original de `commit`
await manager.commit(command);
// Executa a ação pós-upload
switch (command.procedure) {
case RemoveFileStorageProcedure(:String path):
await File(path).delete();
case RemoveDirectoryStorageProcedure(:String path):
await Directory(path).delete(recursive: true);
case NoActionStorageProcedure():
return;
}
}
// ...
}
Execução
O método execute
recebe como parâmetro um StorageStatement
a ser
executado. Ao implementar esse método, deve-se considerar a ação a ser
realizada no sistema quando o usuário finaliza a inserção das fotos: deve ser
enviado para a nuvem diretamente ou deve ser armazenada localmente para envio
posterior?
Uma implementação sem armazenamento local considera o envio direto para a
nuvem, que basicamente é a mesma implementação do método commit
:
class _StorageManager implements StorageManager {
// ...
Future<void> execute(StorageStatement statement) async {
await commit(StorageCommand(
statement: statement,
procedure: const NoActionStorageProcedure(),
));
}
// ...
}
Já uma implementação com armazenamento local considera o salvamento local sem enviar (por enquanto) nenhuma requisição para o servidor em nuvem:
import 'package:universal_io/io.dart';
import 'package:cross_file/cross_file.dart' show XFile;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart' as pp;
class _BufferedStorageManager implements StorageManager {
// ...
Future<void> execute(StorageStatement statement) async {
final Directory dir = await pp.getApplicationDocumentsDirectory();
final String cacheDirPath = p.join(dir.path, 'cache');
switch (statement) {
case UploadScreeningImageStatement(:String patientId, :XFile file):
final String path = p.join(cacheDirPath, 'screenings');
await file.saveTo(p.join(path, patientId));
case UploadDiagnosticImagesStatement(:String patientId, :List<XFile> files):
final String path = p.join(cacheDirPath, 'diagnostics', patientId);
for (XFile file in files) {
await file.saveTo(p.join(path, p.basename(file.path)));
}
// TODO Implementar para a instrução de tratamento
}
}
// ...
}
Busca
O método evaluatePendingCommands
não recebe nada como parâmetro e deve
retornar quais StorageStatement
s ainda não foram persistidos em nuvem ao
chamar o método execute
.
Na implementação sem armazenamento local, na prática, todos os
StorageStatement
s são persistidos ao chamar execute
. Dessa forma, basta
retornar uma Stream
vazia:
class _StorageManager implements StorageManager {
// ...
Stream<StorageCommand> evaluatePendingCommands() => const Stream.empty();
// ...
}
Na implementação com armazenamento local, no entanto, é preciso verificar
no armazenamento do dispositivo, na pasta definida na implementação do execute
,
quais arquivos ainda estão presentes:
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart' as pp;
class _BufferedStorageManager implements StorageManager {
// ...
Stream<StorageCommand> evaluatePendingCommands() async* {
final Directory dir = await pp.getApplicationDocumentsDirectory();
// Encontra arquivos locais no diretório de triagem
final Directory screeningsDir = Directory(p.join(dir.path, 'screenings'));
await for (FileSystemEntity entity in screeningsDir.list()) {
final FileStat stat = await entity.stat();
// Filtra apenas arquivos no diretório
if (stat.type != FileSystemEntityType.file) continue;
final String path = entity.absolute.path;
final StorageStatement statement = UploadScreeningImageStatement(
// Busca o nome do paciente pelo nome do arquivo na pasta
patientId: p.basename(path),
// Cria um `XFile` referenciando o arquivo na pasta
file: XFile(path),
);
// Informa que o arquivo local deve ser removido após o upload
final StorageProcedure procedure = RemoveFileStorageProcedure(
file: path,
);
yield StorageCommand(statement: statement, procedure: procedure);
}
// Encontra arquivos locais no diretório de diagnósticos
final Directory diagnosticsDir = Directory(p.join(dir.path, 'diagnostics'));
await for (FileSystemEntity entity in diagnosticsDir.list()) {
final FileStat stat = await entity.stat();
// Filtra apenas subpastas no diretório
if (stat.type != FileSystemEntityType.directory) continue;
final String path = entity.absolute.path;
// Busca o nome do paciente pelo nome da subpasta
final String patientId = p.basename(path);
// Lista os conteúdos da subpasta
final List<XFile> files = [];
final Directory directory = Directory(path);
await for (FileSystemEntity entity in diagnosticsDir.list()) {
final FileStat stat = await entity.stat();
// Filtra apenas arquivos na subpasta
if (stat.type != FileSystemEntityType.file) continue;
final String path = entity.absolute.path;
// Cria um `XFile` referenciando o arquivo na subpasta
final XFile file = XFile(path);
files.add(file);
}
if (files.isEmpty) continue;
final StorageStatement statement = UploadScreeningImageStatement(
patientId: patiendId,
files: files,
);
// Informa que a subpasta deve ser removida após o upload
final StorageProcedure procedure = RemoveDirectoryStorageProcedure(
file: path,
);
yield StorageCommand(statement: statement, procedure: procedure);
}
// TODO Implementar para a instrução de tratamento
}
// ...
}
Na prática
Já que temos as nossas implementações do StorageManager
criadas, como
utilizar essa interface na prática nas aplicações Dart/Flutter?
Suponha uma tela simples do Flutter para envio de arquivo da triagem com dois
botões: um botão A nomeado “fazer upload” para selecionar um arquivo local,
que chama o método
pickFiles
da biblioteca file_picker
; e um botão B nomeado “concluir envio” para
finalizar o envio do arquivo.
O botão B deve ser implementado para acessar
uma instância de um StorageManager
(utilizando um serviço como o
get_it
ou uma implementação de um
singleton) e chamar o método
execute
, passando como parâmetro uma instância do objeto
UploadScreeningImageStatement
, com o ID do paciente e o arquivo obtido pelo
botão A.
Uma possível implementação seria:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:file_picker/file_picker.dart';
import 'package:get_it/get_it.dart';
import '../services/storage.dart' show StorageManager;
class _ImageNotifier extends ValueNotifier<XFile?> {
_ImageNotifier() : super(null);
}
class ScreeningFormScreen extends StatelessWidget {
final String patientId;
const ScreeningFormScreen({
super.key,
required this.patientId,
});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider<_ImageNotifier>(
create: (_) => _ImageNotifier(),
),
],
builder: (context, _) {
return Scaffold(
body: Column(
children: [
// Botão A
TextButton(
onPressed: () async {
final _ImageNotifier notifier = context.read();
final FilePickerResult? result = await FilePicker.instance.pickFiles();
if (result == null) return;
final List<XFile> files = result.xFiles;
if (files.isEmpty) return;
final XFile file = files[0];
notifier.value = file;
},
child: Text('fazer upload'),
),
// Botão B
TextButton(
onPressed: () async {
final _ImageNotifier notifier = context.read();
final XFile? file = notifier.value;
if (file == null) return;
final StorageManager manager = GetIt.instance.get<StorageManager>();
await manager.execute(
UploadScreeningImageStatement(patientId: patientId, file: file),
);
},
child: Text('concluir envio'),
),
],
),
);
},
);
}
}
Agora suponha outra tela do Flutter para exibição dos arquivos da triagem já enviados pelo paciente com dois elementos: uma imagem A, que exibe a foto enviada pelo paciente (estando local ou em nuvem); e um botão B nomeado “sincronizar agora”, exibido apenas se a foto não estiver sincronizada com a nuvem.
A tela deve ter uma lógica para
- obter uma instância de um
StorageManager
- listar seus
StorageCommand
s pendentes, pelo métodoevaluatePendingCommands
- verificar se pelo menos um dos comandos retornados possui como
statement
umUploadScreeningImageStatement
, e se seupatientId
for igual ao ID do paciente cuja tela está sendo exibida- caso haja um comando com esse predicado, significa que o arquivo ainda não
foi sincronizado com nuvem: deve-se acessar o campo
file
do comando encontrado e exibir a imagem A com seu conteúdo (usandoImage.memory
, por exemplo), além de exibir o botão B para permitir a sincronização manual com o usuário utilizando o métodocommit
noStorageManager
obtido no passo 1 - caso não haja um comando com esse predicado, significa que o arquivo já foi
sincronizado com a nuvem: deve-se acessar a endpoint respectiva para
obter a imagem dessa parte da aplicação e exibir a imagem A com seu
conteúdo (usando
Image.network
, por exemplo), além de ocultar o botão B
- caso haja um comando com esse predicado, significa que o arquivo ainda não
foi sincronizado com nuvem: deve-se acessar o campo
- após a sincronização manual, caso ocorra no passo 3.1, deve-se repetir o passo 2 para atualizar o estado da sincronização na tela
Uma possível implementação seria:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:get_it/get_it.dart';
// Objeto para armazenar o arquivo a ser salvo em nuvem e seu respectivo comando.
class _NotifierValue {
final StorageCommand command;
final XFile file;
const _NotifierValue({
required this.command,
required this.file,
});
}
class _Notifier extends ChangeNotifier {
final String patientId;
AsyncSnapshot<_NotifierValue?> _snapshot = const AsyncSnapshot.waiting();
static Future<_NotifierValue?> _checkForPendingFiles() async {
// Passo 1
final StorageManager manager = GetIt.instance.get<StorageManager>();
// Passo 2
final Stream<StorageCommand> commands = manager.evaluatePendingCommands();
// Passo 3
await for (StorageCommand command in commands) {
switch (command.statement) {
case UploadScreeningImageStatement(:String patientId, :XFile file):
if (this.patientId == patientId) {
// Passo 3.1
return _NotifierValue(command: command, file: file);
}
}
}
// Passo 3.2
return null;
}
_Notifier({required this.patientId}) {
_checkForPendingFiles().then((command) {
_snapshot = AsyncSnapshot.withData(ConnectionState.done, command);
notifyListeners();
});
}
AsyncSnapshot<_NotifierValue?> get snapshot => _snapshot;
void synchronize(_NotifierValue value) async {
// Passo 3.1
final StorageManager manager = await GetIt.instance.get<StorageManager>();
await manager.commit(value.command);
// Passo 4
final _NotifierValue? value = await _checkForPendingFiles();
_snapshot = AsyncSnapshot.withData(ConnectionState.done, value);
notifyListeners();
}
}
class ScreeningScreen extends StatelessWidget {
final String patientId;
const ScreeningScreen({
super.key,
required this.patientId,
});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider<_Notifier>(
create: (_) => _Notifier(patientId: patientId),
),
],
builder: (context, _) {
return Scaffold(
body: Selector<_Notifier, AsyncSnapshot<_NotifierValue?>(
selector: (context, notifier) => notifier.snapshot,
builder: (context, snapshot, _) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final _NotifierValue? value = snapshot.data;
return Column(
children: [
// Imagem A
if (value == null)
// Passo 3.1: sem arquivos locais, consultar em nuvem
Image.network(
'https://example.com/api/patients/$patientId/screening'
),
else
// Passo 3.2: com arquivos locais, exibir em cache
FutureBuilder<Uint8List>(
future: value.file.readAsBytes(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final Uint8List bytes = snapshot.data;
return Image.memory(bytes);
},
),
// Botão B
if (value != null)
// Passo 3.1
TextButton(
onPressed: () {
final _Notifier notifier = context.read();
notifier.synchronize(value);
},
child: const Text('sincronizar'),
),
],
);
},
),
);
},
);
}
}
Conclusão
O padrão de design sugerido nessa postagem é apenas uma sugestão de arquitetura limpa e multiplataforma para gerenciar o armazenamento em nuvem em Dart. Para uma melhor experiência do usuário, seria interessante adicionar a este padrão uma execução em segundo plano do envio para a nuvem e de forma automática, mas por motivos de brevidade, essa parte pode ser deixada para uma outra postagem (ou a exercício do leitor).
Como mencionado anteriormente, utilizei este padrão em um projeto há uns meses e funcionou consideravelmente bem, então resolvi publicar esta explicação para auxiliar outros colegas desenvolvedores que possam se deparar com o mesmo problema. Espero que o intuito tenha sido alcançado!