all_box 0.2.0
all_box: ^0.2.0 copied to clipboard
Synchronous, lightweight key-value storage for Flutter with crash-safe writes (write-ahead + atomic rename) and a pure-Flutter reactive layer.
All Box
💡 Armazenamento chave-valor síncrono, leve e rápido para Flutter — com escrita crash-safe e camada reativa 100% Flutter.
🚀 Descrição do Projeto #
AllBox é um armazenamento chave-valor para Flutter, construído em torno de quatro pilares:
- Camada reativa 100% Flutter.
AllBoxListenableeAllBoxBuildersão construídos diretamente sobreChangeNotifiereValueListenable— sem nenhuma dependência externa de gerenciamento de estado. - Leituras síncronas. Depois do
init(), todoread<T>()é síncrono — semFuture, semFutureBuilder, sem espera de I/O no caminho de leitura. - Crash-safety de verdade. Toda escrita passa por um arquivo
.tmpe só então um rename atômico substitui o arquivo principal (.db); um.bakdo último estado bom é mantido à parte, com fallback automático em dois estágios (erro de decodificação UTF-8 e erro dejsonDecode). pathexplícito, nunca resolvido internamente.AllBoxnunca importapath_providernem resolve diretório algum — quem chamainit()decide onde o container vive. Isso evita, por construção, os bugs de resolução de plugin/Activity que afetam bibliotecas que resolvem o path por padrão.
Parte da família de pacotes open-source all_* ao lado
de all_validations_br
(validações brasileiras, utilitários e criptografia) e all_image_compress
(compressão de imagem).
📦 Instalação #
Adicione ao seu pubspec.yaml:
dependencies:
all_box: ^0.1.0
Em seguida:
flutter pub get
E importe no seu código:
import 'package:all_box/all_box.dart';
📱 App de Exemplo #
O diretório example/ contém um app Flutter interativo (CounterPage) que
demonstra toda a superfície pública usada no dia a dia: write() otimista
vs. writeAndFlush(), AllBoxBuilder<T> reativo, listenAll para efeitos
colaterais globais (um SnackBar) e flushNow() disparado em
AppLifecycleState.paused.
Para rodar:
cd example
flutter pub get
flutter run
⚙️ Funcionalidades #
Inicialização #
import 'package:all_box/all_box.dart';
import 'package:path_provider/path_provider.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// AllBox nunca resolve o próprio diretório — quem resolve é você, depois
// que o binding estiver pronto. Qualquer estratégia de path funciona.
final dir = await getApplicationDocumentsDirectory();
await AllBox.init('my_container', path: dir.path);
runApp(const MyApp());
}
Seed de dados no primeiro run (initialData) #
await AllBox.init(
'settings',
path: dir.path,
initialData: const {
'darkMode': false,
'onboarded': false,
},
);
initialData só é aplicado em um first-run de verdade — quando o container
ainda não tem <container>.db/<container>.bak no disco. É persistido
imediatamente (não espera o debounce), então sobrevive a um crash logo após
o primeiro lançamento do app. Se o container já existia antes — mesmo que
como um {} vazio deixado por um erase() anterior — initialData é
ignorado e o que está em disco prevalece.
Leitura e escrita (toda leitura é síncrona) #
final box = AllBox('my_container');
box.write('name', 'Carlos'); // otimista: memória + listeners
// atualizam na hora, o disco segue
// ~100ms depois (debounced)
String? name = box.read<String>('name');
String safeName = box.readOrDefault<String>('name', 'anonymous');
await box.writeAndFlush('name', 'Carlos'); // espera o disco confirmar
box.remove('name');
box.erase(); // limpa tudo e notifica todos os listeners que existiam
await box.flushNow(); // força um flush agora, ex.: em AppLifecycleState.paused
Escutando mudanças #
box.listenKey('name', () => print('name mudou'));
box.removeListenKey('name', callback);
final dispose = box.listenAll(() => print('container mudou'));
// depois
dispose();
Widgets reativos, sem dependências externas de gerenciamento de estado #
AllBoxBuilder<int>(
keyName: 'counter',
builder: (context, value) => Text('${value ?? 0}'),
)
Ou construa seu próprio ValueListenable com AllBoxListenable<T>:
final counter = AllBoxListenable<int>('counter');
ValueListenableBuilder<int?>(
valueListenable: counter,
builder: (context, value, _) => Text('${value ?? 0}'),
);
Helper .val() sem DI (opcional) #
Um mini state-manager opt-in, sem qualquer acoplamento de injeção de dependência:
final darkMode = 'darkMode'.val(false);
print(darkMode.value);
darkMode.value = true;
🧪 Exemplos de Uso #
Valor com fallback seguro #
final box = AllBox('settings');
final theme = box.readOrDefault<String>('theme', 'light');
// Retorna 'light' se a chave 'theme' ainda não existir
Escrita otimista vs. escrita confirmada #
box.write('score', 100); // memória atualizada na hora
await box.writeAndFlush('score', 100); // só retorna após confirmar no disco
Reagindo a uma única chave dentro de um widget #
class DarkModeSwitch extends StatelessWidget {
const DarkModeSwitch({super.key});
@override
Widget build(BuildContext context) {
return AllBoxBuilder<bool>(
keyName: 'darkMode',
builder: (context, value) => Switch(
value: value ?? false,
onChanged: (v) => AllBox().write('darkMode', v),
),
);
}
}
Limpando um container e reagindo globalmente #
final dispose = box.listenAll(() => print('algo mudou em "settings"'));
box.erase(); // dispara o listener acima uma única vez
dispose();
Introspecção do container #
box.hasData('theme'); // true / false
box.getKeys(); // todas as chaves gravadas
box.getValues(); // todos os valores gravados
Persistindo o estado do app ao ser pausado #
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
AllBox('my_container').flushNow();
}
}
}
📚 API #
| Member | Descrição |
|---|---|
AllBox([container]) |
Factory constructor; retorna um singleton por nome de container. |
static AllBox.init(container, {required path, flushDelay, initialData}) |
Carrega o container do disco para a memória. path é obrigatório — veja abaixo. initialData semeia valores default, mas só num first-run de verdade. |
T? read<T>(key) / T readOrDefault<T>(key, fallback) |
Leituras síncronas. |
void write(key, value) |
Escrita otimista + debounced. Em debug, avisa (via debugPrint em vermelho) se value não for JSON-encodável, mas nunca lança exceção. |
Future<void> writeAndFlush(key, value) |
Escreve e espera a confirmação em disco. Mesmo aviso de serialização de write(). |
void remove(key) / void erase() |
Remove uma chave / limpa tudo (erase() notifica os listeners de todas as chaves que existiam). |
Future<void> flushNow() |
Força um flush imediato, ignorando a janela de debounce. |
listenKey(key, cb) / removeListenKey(key, cb) |
Listeners por chave. |
VoidCallback listenAll(cb) |
Listener global; retorna uma função de dispose. |
hasData(key), getKeys(), getValues() |
Introspecção. |
AllBoxListenable<T> |
ChangeNotifier + ValueListenable<T?> para uma chave. |
AllBoxBuilder<T> |
Widget que reconstrói quando keyName muda. |
'key'.val<T>(default) |
Handle opcional de mini state-manager sem DI. |
Por que path é um parâmetro obrigatório de init()? #
AllBox nunca importa path_provider (nem resolve diretório algum)
internamente. Quem chama sempre decide onde o container vive. É uma escolha
de design deliberada, não um descuido — veja a seção abaixo.
🛠️ Decisões de Design #
pathexplícito e obrigatório eminit(). Oall_boxnunca resolve diretório algum internamente — quem chamainit()sempre informa opath, evitando qualquer resolução de plugin dentro da lib.initialDatasó se aplica em first-run de verdade. A checagem é feita pela existência de<container>.db/<container>.bakem disco, não pelo conteúdo em memória — um container esvaziado porerase()ainda tem um{}persistido, então não é considerado "primeiro run" e o seed não é reaplicado por cima dele.- Crash-safety com write-ahead + rename atômico. Toda escrita em disco
passa por um arquivo
.tmpe só então um rename atômico substitui o arquivo principal (.db); um.bakdo último estado bom é mantido à parte. - Tratamento de leitura em dois estágios. Erros de decodificação UTF-8 e
erros de
jsonDecodesão tratados como estágios/pontos de falha distintos, cada um com fallback para o.bakantes de desistir e começar vazio. - Fila de flush serializada. Nunca há duas escritas concorrentes no
mesmo arquivo, mesmo se
flushNow()/writeAndFlush()for chamado com um flush debounced ainda em andamento. - Benchmark próprio. Números de performance medidos e mantidos neste
repositório; veja
benchmark/. - Aviso de serialização em debug, não exceção.
write()/writeAndFlush()chamamjsonEncodeno valor na hora, só em debug, e emitem umdebugPrintem vermelho se ele não for serializável — mas nunca lançam exceção nem bloqueiam a escrita (mesmo comportamento permissivo doGetStorage). O valor segue gravado em memória normalmente; se realmente não puder ser codificado, a falha só volta a aparecer, calada, lá dentro do flush. - Sem suporte a Web nesta v1 (ver limitações abaixo).
⚠️ Limitações conhecidas (documentadas, não escondidas) #
- Sem suporte a Web nesta v1. Se um dia for adicionado, deve usar
package:webvia conditional imports — nuncadart:html, já quedart:htmlimpede a compilação para WASM (dart2wasm). - Não é isolate-safe. Cada
AllBoxmantém seu estado em memória no isolate onde foi inicializado; não há sincronização entre isolates. Se você usa múltiplos isolates (ex.:compute(), isolates de background), cada um precisa do seu próprioinit()e eles não verão as escritas uns dos outros até reler do disco. File.renamepara o swap atômico depende do sistema operacional. Em POSIX (Linux/macOS/Android/iOS) o rename sobre um arquivo existente é atômico. Em Windows o comportamento pode variar entre versões do SDK do Dart; teste esse cenário especificamente se seu app roda em Windows desktop.
📊 Resultado do Benchmark (execução local) #
Números de uma execução real em benchmark/benchmark.dart (Dart 3.9.2 stable,
Windows 11 Pro), confirmando o custo de cada caminho descrito acima —
leitura/escrita em memória são ordens de magnitude mais rápidas que qualquer
caminho que toque disco, e o debounce reduz drasticamente o custo de bursts
de escrita comparado a confirmar cada uma no disco individualmente:
[Gráfico de benchmark do AllBox: tempo médio de read/write em memória, write debounced e writeAndFlush durável]
µs × ms: o gráfico usa a unidade mais legível para cada barra — µs (microssegundo, 1 milionésimo de segundo) para as três primeiras operações, que são só memória, e ms (milissegundo, 1 milésimo de segundo = 1.000 µs) só para
writeAndFlush(), que realmente toca o disco e por isso é ordens de magnitude mais lenta. Não é erro de unidade — é zoom automático para cada barra continuar legível.
| Operação | Throughput | Latência média |
|---|---|---|
read<int>() em memória |
1.495.886 ops/s | 0,67 µs/op |
write() em memória (otimista) |
92.674 ops/s | 10,79 µs/op |
200× write() debounced + 1 flushNow() |
36.403 ops/s | 27,47 µs/op |
writeAndFlush() (tmp + backup + rename, por chamada) |
187 ops/s | 5,34 ms/op (= 5.340,29 µs/op) |
Como o próprio benchmark/benchmark.dart documenta, esses números só valem para o terminal onde eu testei rode flutter test benchmark\benchmark.dart corretamente no seu ambiente para medir na sua.
🧪 Testes #
flutter test
Os testes cobrem especificamente os cenários de bug mapeados acima: arquivo
corrompido com bytes binários aleatórios, JSON inválido, fallback para
.bak, múltiplos write() gerando um único flush, isolamento entre
containers, notificação correta de listeners em erase(), e
listenKey/listenAll sendo corretamente removidos.
Testando código que consome o all_box #
Se você está testando seu próprio app/pacote (não o all_box em si), não
precisa de um diretório real em disco — use o backend em memória:
await AllBox.initWithMemoryBackendForTesting(
'my_container',
initialValues: {'darkMode': true},
);
Isso não faz I/O real e não agenda nenhum Timer real (todo write()
"flusha" de forma síncrona) — é especialmente importante dentro de
testWidgets: sua zona FakeAsync espera que todo Timer seja resolvido
antes do teste terminar, e um container disk-backed real deixaria um
Timer de debounce pendente ali.
👥 Contribuidores #
Made with contrib.rocks.
Contribuições são bem-vindas! Leia o CONTRIBUTING.md para começar.
📄 Licença #
Distribuído sob a licença MIT. Veja LICENSE para mais detalhes.
💻 Desenvolvido com ❤️ para facilitar o desenvolvimento no Flutter.