Flutter e BLoC - Implementação básica
O Flutter BLoC é um padrão muito usado no desenvolvimento mobile e que causa bastante confusão em relação ao seu conceito. Neste artigo, você vai ver uma implementação básica, sem pacotes extras, para entender a ideia antes de partir para soluções mais robustas.
BLoC é a sigla para Business Logic Component, ou seja, componente de lógica de negócio. Na verdade, ele nada mais é do que a separação das regras de negócio da sua interface.
Essa separação é recomendada em diversas tecnologias. Por exemplo, no curso Modelando Domínios Ricos implementamos a mesma ideia, com outro nome, em .NET.
Eu já falei de forma mais conceitual sobre o tema no artigo Flutter: setState, BLoC, MobX e state management. Então, se você está caindo de paraquedas aqui, sugiro ler esse conteúdo antes.
O que é o padrão Flutter BLoC
O padrão BLoC foi apresentado publicamente na DartConf de 2018. A proposta era permitir que a mesma lógica de negócio fosse compartilhada entre plataformas, separando o que a tela exibe do que a aplicação decide.
Em sua forma mais conhecida, o BLoC se apoia em Streams do Dart. A interface envia eventos, o componente processa a regra e devolve novos estados para a tela.
Hoje existe inclusive uma biblioteca dedicada a isso, documentada em bloclibrary.dev. Segundo a documentação, ela oferece o Cubit, baseado em funções, e o Bloc, baseado em eventos.
No entanto, a essência do padrão é mais simples do que parece. Antes de pensar em Streams, eventos ou pacotes, você precisa entender o objetivo central: tirar a regra de negócio da UI.
Por isso, este artigo mostra uma implementação deliberadamente básica. Neste exemplo, o Flutter BLoC é uma classe Dart comum, consumida por duas telas diferentes.
Quando utilizar o Flutter BLoC
O mais importante sobre conhecer padrões é saber quando utilizá-los, e com o Flutter BLoC não é diferente. O BLoC serve para separar a regra de negócio da interface, mas o que isso quer dizer na prática?
Imagine que você está construindo uma aplicação com duas interfaces, uma específica para Android e outra específica para iOS.
Embora as telas sejam diferentes, a regra de negócio por trás delas é a mesma. Ou seja, a forma como o usuário faz login e os cálculos executados são comuns às duas interfaces.
É aqui que entra a real necessidade do BLoC. Se você não tem duas interfaces distintas nem precisa reusar suas regras em lugares diferentes, então provavelmente não precisa dele.
Usar padrões sem necessidade causa o que chamamos de overengineering, ou engenharia desnecessária. Nesse caso, o padrão traz mais complexidade do que resolve e, em vez de ajudar, atrapalha.
Por outro lado, mesmo com uma única interface, o BLoC facilita os testes de unidade. Então, avalie também o quanto a sua regra de negócio precisa ser testada de forma isolada.
Setup do projeto com Flutter BLoC
Para executar este projeto de Flutter BLoC, vamos criar uma aplicação normalmente. A diferença está na estrutura de pastas, que fica assim:
lib/
android/
pages/
app.dart
ios/
pages/
app.dart
blocs/
main.dart
Na pasta android, teremos o app.dart com o MaterialApp, seguido das páginas que ficarão na pasta pages. Da mesma forma, a pasta ios segue a mesma lógica, mas com o CupertinoApp.
Já na pasta blocs fica a regra de negócio compartilhada entre as duas interfaces. Assim, a estrutura de pastas deixa claro o que é tela e o que é lógica.
Separando as interfaces no main.dart
A primeira coisa a fazer é separar completamente as interfaces. Para isso, removemos o MaterialApp do main.dart e o levamos para fora, afinal, não queremos nada do Cupertino no Material e vice-versa.
Dessa forma, o main.dart apenas decide se chama o AndroidApp ou o IOSApp.
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:bmi/android/app.dart';
import 'package:bmi/ios/app.dart';
void main() => Platform.isIOS ? runApp(IOSApp()) : runApp(AndroidApp());
A propriedade Platform.isIOS vem da biblioteca dart:io e, segundo a documentação do Dart, indica se o sistema operacional é uma versão do iOS. Como dart:io não está disponível na web, essa abordagem vale para aplicações mobile e desktop.
Criando o MaterialApp
Em seguida, criamos o MaterialApp no arquivo lib/android/app.dart.
import 'package:flutter/material.dart';
import 'package:bmi/android/pages/home.page.dart';
class AndroidApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: "BMI",
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.pink,
),
home: HomePage(),
);
}
}
Criando o CupertinoApp
Depois, repetimos o processo para o CupertinoApp, que fica no caminho lib/ios/app.dart.
import 'package:flutter/cupertino.dart';
import 'package:bmi/ios/pages/home.page.dart';
class IOSApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return CupertinoApp(
title: "BMI",
debugShowCheckedModeBanner: false,
home: HomePage(),
);
}
}
Pronto, quando o app iniciar em um dispositivo Android, ele carrega o AndroidApp com Material. Por outro lado, quando iniciar no iOS, carrega o IOSApp com Cupertino.
Separando as regras de negócio com Flutter BLoC
Nosso app vai realizar uma tarefa simples: calcular o IMC, que precisa do peso e da altura da pessoa.
O que não queremos é executar o cálculo do IMC na HomePage do Android e, depois, repetir tudo na do iOS. Pelo contrário, queremos reutilizar o mesmo cálculo nas duas telas.
Outra semelhança entre as telas são os campos e o resultado. Afinal, para calcular precisamos do peso e da altura, além de uma variável para exibir o resultado.
O BLoC, nesse caso, é uma classe que contém o peso, a altura, o resultado e uma função para calcular o IMC. Tudo o que fazemos é, literalmente, tirar o código da UI.
Então, criamos o arquivo lib/blocs/bmi.bloc.dart com o código abaixo.
import 'package:flutter_masked_text/flutter_masked_text.dart';
class BmiBloc {
var heightCtrl = new MaskedTextController(mask: '000');
var weightCtrl = new MaskedTextController(mask: '000');
var result = "Fill in the fields to calculate your BMI";
calculate() {
double weight = double.parse(weightCtrl.text);
double height = double.parse(heightCtrl.text) / 100;
double bmi = weight / (height * height);
var value = bmi.toStringAsPrecision(2);
// Faixas em ordem crescente, sem lacunas entre elas
if (bmi < 18.6) {
result = "Underweight ($value)";
} else if (bmi < 25) {
result = "Normal weight ($value)";
} else if (bmi < 30) {
result = "Slightly overweight ($value)";
} else if (bmi < 35) {
result = "Obesity class I ($value)";
} else if (bmi < 40) {
result = "Obesity class II ($value)";
} else {
result = "Obesity class III ($value)";
}
}
}
Repare que, neste Flutter BLoC, as faixas foram organizadas em ordem crescente, com uma comparação por vez. Dessa forma, nenhum valor intermediário, como 39,95, fica sem resultado.
Controladores dentro do BLoC
No BLoC, em vez de criar apenas as variáveis de peso e altura, já criei os controladores para facilitar ainda mais a interface. Lembre-se de que você não está restrito a recursos básicos aqui e pode usar um TextEditingController, por exemplo.
O cuidado que você deve ter no Flutter BLoC é não depender de coisas específicas do Material, como widgets ou temas. Caso contrário, o código não vai funcionar no Cupertino.
Neste exemplo, usei o MaskedTextController, do pacote flutter_masked_text. Caso queira usá-lo, adicione o pacote ao seu pubspec.yaml.
Na máscara desse pacote, o caractere 0 aceita apenas números. Portanto, a máscara '000' limita cada campo a três dígitos numéricos.
Vale lembrar também que, desde o Dart 2, a palavra new é opcional. O exemplo mantém o estilo do código original, mas você pode removê-la sem problemas.
Utilizando o BLoC nas telas do Flutter
Para usar o Flutter BLoC nas páginas, tudo o que precisamos é de uma instância dele, criada dentro do State de cada tela.
class _HomePageState extends State<HomePage> {
var bloc = new BmiBloc();
@override
Widget build(BuildContext context) {
// ...
}
}
Com a instância do BLoC em mãos, usamos os seus controladores nos campos de texto.
Padding(
padding: EdgeInsets.all(20),
child: TextFormField(
decoration: InputDecoration(
labelText: "Height (cm)",
),
controller: bloc.heightCtrl,
keyboardType: TextInputType.number,
),
),
Também podemos chamar o método calculate() em qualquer botão da página.
Padding(
padding: EdgeInsets.all(20),
child: FlatButton(
color: Theme.of(context).primaryColor,
child: Text(
"Calculate",
style: TextStyle(
color: Colors.white,
),
),
onPressed: () {
setState(() {
bloc.calculate();
});
},
),
),
O setState avisa ao Flutter que o estado mudou e que a tela precisa ser reconstruída. Assim, o novo valor de bloc.result aparece na interface logo após o cálculo.
O FlatButton era o botão padrão da época em que este exemplo foi escrito. Segundo o guia de migração dos botões do Flutter, ele foi substituído pelo TextButton a partir do Flutter 2.0.
Página do Android com Material
Como resultado, o arquivo final lib/android/pages/home.page.dart fica assim.
import 'package:flutter/material.dart';
import 'package:bmi/blocs/bmi.bloc.dart';
class HomePage extends StatefulWidget {
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
var bloc = new BmiBloc();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("BMI calculator"),
),
body: ListView(
children: <Widget>[
Padding(
padding: EdgeInsets.all(20),
child: TextFormField(
decoration: InputDecoration(
labelText: "Height (cm)",
),
controller: bloc.heightCtrl,
keyboardType: TextInputType.number,
),
),
Padding(
padding: EdgeInsets.all(20),
child: TextFormField(
decoration: InputDecoration(
labelText: "Weight (kg)",
),
controller: bloc.weightCtrl,
keyboardType: TextInputType.number,
),
),
Padding(
padding: EdgeInsets.all(20),
child: Text(
bloc.result,
textAlign: TextAlign.center,
),
),
Padding(
padding: EdgeInsets.all(20),
child: FlatButton(
color: Theme.of(context).primaryColor,
child: Text(
"Calculate",
style: TextStyle(
color: Colors.white,
),
),
onPressed: () {
setState(() {
bloc.calculate();
});
},
),
),
],
),
);
}
}
Página do iOS com Cupertino
Já o arquivo lib/ios/pages/home.page.dart fica assim.
import 'package:flutter/cupertino.dart';
import 'package:bmi/blocs/bmi.bloc.dart';
class HomePage extends StatefulWidget {
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
var bloc = new BmiBloc();
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text("BMI calculator"),
),
child: ListView(
children: <Widget>[
Padding(
padding: EdgeInsets.all(20),
child: CupertinoTextField(
placeholder: "Height (cm)",
controller: bloc.heightCtrl,
keyboardType: TextInputType.number,
),
),
Padding(
padding: EdgeInsets.all(20),
child: CupertinoTextField(
placeholder: "Weight (kg)",
controller: bloc.weightCtrl,
keyboardType: TextInputType.number,
),
),
Padding(
padding: EdgeInsets.all(20),
child: Text(
bloc.result,
textAlign: TextAlign.center,
),
),
Padding(
padding: EdgeInsets.all(20),
child: CupertinoButton.filled(
child: Text(
"Calculate",
),
onPressed: () {
setState(() {
bloc.calculate();
});
},
),
),
],
),
);
}
}
O que ganhamos com o Flutter BLoC
Note que as páginas ficam muito mais limpas, pois o código se concentra no BLoC. Além disso, qualquer alteração nas regras fica centralizada e é feita uma única vez.
As páginas ficaram parecidas em estrutura, mas repare que nunca usamos Cupertino no Material ou vice-versa. Em outras palavras, as duas interfaces estão completamente isoladas.
Se amanhã a faixa de algum resultado mudar, você altera apenas o arquivo bmi.bloc.dart. Então, as duas telas passam a exibir o novo comportamento sem nenhuma edição.
Essa é a principal vantagem do Flutter BLoC nesta implementação básica. A regra mora em um único lugar, e cada interface cuida apenas da sua aparência.
Limitações desta implementação
Esta versão simples ainda depende do setState para atualizar a tela. Ou seja, a página precisa saber que deve se reconstruir depois de chamar o BLoC.
Em aplicações maiores, isso tende a espalhar chamadas de setState por vários widgets. Por isso, implementações mais completas usam Streams, ou bibliotecas de gerenciamento de estado, para notificar a interface automaticamente.
Além disso, o método calculate() usa double.parse, que lança uma exceção se algum campo estiver vazio. Portanto, em um app real, vale validar a entrada antes de calcular.
Se quiser comparar com outra abordagem de estado reativo, veja o artigo sobre MobX no Flutter. Ele mostra como observar mudanças sem chamar o setState manualmente.
Boas práticas para o seu BLoC
Mesmo em uma implementação simples, alguns cuidados evitam problemas no futuro. Primeiro, confira os imports do arquivo do BLoC e garanta que ele não importe material.dart nem cupertino.dart.
Se um desses imports aparecer, é sinal de que algo da interface vazou para a regra de negócio. Nesse caso, mova esse trecho de volta para a tela correspondente.
Em seguida, pense no ciclo de vida dos controladores. Os controladores de texto mantêm listeners e recursos internos, então o ideal é liberá-los quando a tela sair de uso.
Uma forma simples de fazer isso é criar um método dispose() no próprio BLoC. Depois, chame esse método dentro do dispose() do State de cada página.
Também vale manter um BLoC por funcionalidade, e não um único BLoC para o app inteiro. Assim, cada classe continua pequena, fácil de ler e fácil de testar.
Por fim, prefira nomes que descrevam a regra, como BmiBloc, em vez de nomes genéricos. Na prática, isso ajuda qualquer pessoa da equipe a encontrar rapidamente onde está cada cálculo.
Seguindo esses pontos, o seu Flutter BLoC continua simples mesmo quando o app ganha novas telas. Além disso, a migração para Streams ou para a biblioteca bloc fica bem menos trabalhosa.
Testes de unidade no Flutter BLoC
Outro ponto que o Flutter BLoC facilita muito são os testes de unidade. Quanto mais perto da interface, mais difícil fica testar, e com as regras isoladas os testes ficam bem mais simples.
Por exemplo, você pode instanciar o BmiBloc, preencher os controladores e verificar o valor de result, tudo isso sem montar nenhum widget. Dessa forma, cada faixa do cálculo ganha seu próprio teste, rápido e independente da plataforma.
Um teste simples, usando o pacote flutter_test, poderia ficar assim.
import 'package:flutter_test/flutter_test.dart';
import 'package:bmi/blocs/bmi.bloc.dart';
void main() {
test('should return normal weight for 70 kg and 175 cm', () {
var bloc = BmiBloc();
bloc.weightCtrl.text = '70';
bloc.heightCtrl.text = '175';
bloc.calculate();
// 70 / (1,75 * 1,75) = 22,8
expect(bloc.result, startsWith('Normal weight'));
});
}
Perceba que o teste não cria nenhum MaterialApp nem CupertinoApp. Assim, a mesma verificação cobre as duas plataformas de uma vez.
Os testes merecem um artigo separado, mas a base já está pronta. Afinal, a regra de negócio não depende mais de nenhuma tela.
Evoluindo o Flutter BLoC para Streams
Quando a aplicação cresce, o próximo passo do Flutter BLoC costuma ser expor o resultado por meio de uma Stream. Dessa forma, a tela escuta as mudanças e se reconstrói sozinha, sem chamar o setState.
O esboço abaixo mostra a ideia usando apenas recursos nativos do Dart, com um StreamController.
import 'dart:async';
class BmiStreamBloc {
final _resultController = StreamController<String>();
// A tela apenas escuta esta Stream
Stream<String> get result => _resultController.stream;
void calculate(double weight, double heightInCm) {
var height = heightInCm / 100;
var bmi = weight / (height * height);
_resultController.add(bmi.toStringAsPrecision(2));
}
// Libera os recursos quando a tela for descartada
void dispose() => _resultController.close();
}
Na interface, um StreamBuilder recebe a Stream e reconstrói apenas o trecho que exibe o resultado. Com isso, a página deixa de controlar manualmente quando precisa ser redesenhada.
Essa é justamente a base que a biblioteca bloc formaliza com eventos e estados. Portanto, entender a versão básica primeiro torna a migração muito mais natural.
Código fonte
O código completo deste exemplo está disponível no repositório balta-io/7193 no GitHub.
Se você está começando com o framework, o artigo Flutter: por onde começar ajuda a montar o ambiente e entender os primeiros passos.
Conclusão
O Flutter BLoC é, antes de tudo, uma forma de separar a regra de negócio da interface. Nesta implementação básica, uma classe Dart comum concentrou os dados e o cálculo, enquanto as telas Material e Cupertino cuidaram apenas da apresentação.
Use o padrão quando houver reuso real de regras ou necessidade de testes isolados. Caso contrário, você corre o risco de adicionar complexidade sem retorno.
A partir daqui, o próximo passo natural é evoluir para Streams ou para a biblioteca bloc, que adicionam eventos, estados e atualização automática da interface.
FAQ
O que significa a sigla BLoC?
BLoC significa Business Logic Component, ou componente de lógica de negócio. O nome reflete o objetivo do padrão, que é isolar as regras da interface.
Preciso de um pacote para usar o padrão BLoC?
Não. Como mostra este artigo, uma classe Dart comum já separa a regra da tela, embora pacotes como o bloc adicionem eventos, estados e Streams.
Posso usar o mesmo BLoC no Material e no Cupertino?
Sim, desde que o BLoC não dependa de nada específico de um dos dois. Neste exemplo, ele usa apenas controladores de texto e tipos do Dart.
O FlatButton ainda funciona nas versões atuais do Flutter?
O FlatButton foi descontinuado e substituído pelo TextButton no Flutter 2.0. Por isso, use o TextButton em projetos novos.
Quando o BLoC é overengineering?
Quando a aplicação tem uma única interface simples e não precisa reusar nem testar regras isoladamente. Nesse caso, o padrão adiciona camadas sem resolver um problema real.