DEV Community

loading...
Cover image for Uma Idéia para Arquitetura de Aplicativos Flutter

Uma Idéia para Arquitetura de Aplicativos Flutter

ricardoogliari profile image Ricardo da Silva Ogliari ・11 min read

Como este texto tem como ponto central uma idéia de arquitetura para aplicações Flutter, deixe-me explicar o que uma imagem de uma espécie de casa está fazendo aqui. Ela é uma das igrejas de madeiras encontradas na Noruega. Para ser mais específico, na foto estamos vendo a Borgund Stave church. Para saber mais sobre estas igrejas impressionantes visite esta página. Devido a sua arquitetura ímpar, ela estampou a capa deste texto :).

Arquitetura base

Estudando possíveis arquiteturas já existentes para desenvolvimento Flutter, encontrei o artigo "A importância da Clean Architecture no Flutter", escrito por Fabio Maia e disponível aqui. O principal motivo por esta arquitetura chamar a minha atenção foi a semelhança com o JetPack Architecture Componentes, sendo assim, seria de fácil entendimento pelos desenvolvedores oriundos do nativo, principalmente do Android nativo. Abaixo, a imagem encontrada no artigo do Maia:

E abaixo você encontra um resumo alto nível das pastas que criei no projeto Flutter seguindo a estrutura proposta por Maia. Porém, não utilizei exatamente o mesmo fluxo de dados. Usei um pacote gerenciador de estados para não precisar ter o fluxo partindo dos dados até os Widgets.

Para ficar mais claro, veja como fica a estrutura de pastas do projeto base:

Alt Text

Aplicativo Base

O aplicativo aqui proposto é algo bem simples. Uma tela de login que leva para uma lista dos filmes preferidos, lendo os dados de um serviço web.

Alt Text

Alt Text

i18n no projeto

Começamos explicando o padrão usado para internacionalização. Seguimos a documentação entitulado como "Internationalizing Flutter apps", disponível no site de desenvolvedores Flutter. Link aqui.

Devido ao uso deste guia, temos uma pasta no diretório lib chamada l10n. Dentro desta, podemos encontrar 3 arquivos extensão .arb, definindo o conteúdo dos textos internacionalizados na aplicação. Sendo eles: app_en.arb, app_es.arb e app_pt.arb:

Alt Text

Abaixo, veja o conteúdo do app_en.arb:

{
  "login": "Log in",
  "@login": {},

  "user": "User",
  "@user": {},
  "enterUser": "Enter your user",
  "@enterUser": {},
  "pleaseEnterUser": "Please, enter your user",
  "@pleaseEnterUser": {},

  "password": "Password",
  "@password": {},
  "enterPassword": "Enter your password",
  "@enterPassword": {},
  "pleaseEnterPassword": "Please, enter your password",
  "@pleaseEnterPassword": {}
}
Enter fullscreen mode Exit fullscreen mode

O uso do arroba no nome do recurso de texto serve para adição de uma possível descrição do que é este recurso de texto. Aqui não foi usado, porém, é perfeitamente possível sua utilização. Outro ponto. Estas descrições só são exigidas no template do arquivo de internacionalização (vamos falar sobre isso na sequência). Ou seja, o conteúdo do arquivo app_es.arb não contém os recursos precedidos pelo arroba:

{
  "login": "Iniciar Sessión",

  "user": "Usuario",
  "enterUser": "Ingrese su  usuario",
  "pleaseEnterUser": "Por favor, ingrese su usuario!",

  "password": "Contraseña",
  "enterPassword": "Ingressa tu contraseña",
  "pleaseEnterPassword": "Por favor, introduzca su contraseña!"
}
Enter fullscreen mode Exit fullscreen mode

Na pasta raiz do projeto, também temos um arquivo l10n.yaml, crucial para a internacionalização. Seu conteúdo clarifica algumas coisas que mostramos anteriormente:

arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
Enter fullscreen mode Exit fullscreen mode

Falta pouco. Outra alteração é necessária no pubspec.yaml. Na sub-seção de dependencies adicionamos o flutter_localizations. Na sub-seção flutter, colocamos a propriedade generate com valor true.

...

dependencies:
  flutter_localizations: # Add this line
    sdk: flutter

...

# The following section is specific to Flutter.
flutter:
  generate: true
Enter fullscreen mode Exit fullscreen mode

E para completar a configuração de i18n é necessário a configuração de alguns delegates na MaterialApp. Os três pontos indicam parte do código que será visto na sequência e não importância neste momento.

...

class MyApp extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      ...
    );
  }

}
Enter fullscreen mode Exit fullscreen mode

Configurações Globais

Neste projeto também tomei o cuidado de encontrar um pacote que facilitasse a criação de configurações globais, como definição de url´s, pensando da divisão do projeto em flavors, ou ainda, alguma chave de api usada na aplicação, como Google Maps ou alguma chave de web service, por exemplo.

O primeiro passo é criar uma pasta assets->cgf com um arquivo app_settings.json.

Alt Text

O conteúdo deste arquivo pode ser visto aqui:

{
  "api_key": "ae5319efd54af97f99f70e5******"
}
Enter fullscreen mode Exit fullscreen mode

No momento só temos a chave de api para consumir um serviço web, que veremos na sequência. Porém, qualquer outra configuração global pode ser inserida aqui respeitando a estrutura do arquivo json.

Algumas alterações também são necessárias no pubspec.yaml. Na parte de dependências, adicione o pacote global_configuration seguido pela sua última versão. Na sub-seção flutter -> assets adicione o caminho do arquivo de configurações json.

dependencies:
  ...
  global_configuration: ^1.6.0

flutter:
  ...
  assets:
    - assets/cfg/
Enter fullscreen mode Exit fullscreen mode

Por fim, precisamos apenas carregar essas configurações em algum momento na nossa árvore de widgets. Eu preferi fazer isso logo na inicialização da aplicação:

void main() async{
  WidgetsFlutterBinding.ensureInitialized();
  await GlobalConfiguration().loadFromAsset("app_settings");
  runApp(MyApp());
}

class MyApp extends StatelessWidget {

  ...

}
Enter fullscreen mode Exit fullscreen mode

Link para o pacote global_configuration aqui.

Posteriormente, vamos usar os valores destas configurações na aplicação.

Menção honrosa: Get

Usamos diversos pacotes nesse modelo de arquitetura, porém, um deles merece destaque, porque foi usado em diversos momentos da aplicação. Estamos falando do Get. Sua documentação pode ser encontrada aqui. Usamos este pacote para gerenciamento de estados, controle de rotas e controle de dependências.

Rotas e Injeção de Dependência com Get

No arquivo main.dart, veremos o uso do Widget GetMaterialApp, um pouco incomum, visto que, o “normal” seria o MaterialApp. Isso foi necessário para a configuração de rotas com o Get. Perceba as propriedades initialRoute e getPages. Este último, por sua vez, recebeu o valor routes, que está definido no arquivo routesDefinitions, na pasta lib -> core.

class MyApp extends StatelessWidget {

 @override
 Widget build(BuildContext context) {

   return GetMaterialApp(
     ...
     initialRoute: loginPageName,
     getPages: routes,
   );
 }

}
Enter fullscreen mode Exit fullscreen mode

O routesDefinitions.dart possui a definição de rotas, que no pacote Get é chamado de page. Perceba dois pontos importantes aqui. Um deles é a definição de constantes para o nome das páginas. No momento temos apenas duas. O segundo ponto é a propriedade binding na primeira página. Desta forma, criamos uma vinculação e uma dependência automática entre esta página e uma instância de CoreBinding.

const String loginPageName = "/login";
const String listPageName = "/list";

final routes = [
 GetPage(
     name: loginPageName,
     page: () => LoginScreen(),
     binding: CoreBinding()
 ),
 GetPage(
   name: listPageName,
   page: () => ListScreen.initialize(),
 )
];
Enter fullscreen mode Exit fullscreen mode

Finalmente, o CoreBinding tem um código um pouco mais simples. Mas, perceba a herança com Bindings. E também, a sobrescrita do dependencies. Desta forma, vinculamos este método como dependência neste binding. Dentro do método estamos usando o lazyPut do Get, para que, quando necessário, uma instância de RestClient seja fornecida, já usando o design pattern Singleton. Assim, na primeira tela da aplicação já teremos criado uma instância da classe que é o ponto de conexão da aplicação com o mundo do webservice.

class CoreBinding extends Bindings{

 @override
 void dependencies() {
   Get.lazyPut<RestClient>(() => RestClient(Dio()));
 }

}
Enter fullscreen mode Exit fullscreen mode

Camada de Dados

Agora que falamos sobre alguns tópicos mais gerais, podemos entrar nas camadas que fazem parte da espinha dorsal da arquitetura, e, que foi mostrada nas figuras iniciais deste texto. Na pasta lib temos uma pasta filha, chamada dataSources. Dentro desta, por sua vez, temos uma pasta database para dados locais, salvos no banco de dados NoSQL com um ORM. E, uma pasta webServices para acesso a dados de um serviço REST.

No webServices, usei o pacote retrofit, documentação aqui. A principal razão foi pela similaridade com uma biblioteca homônima, disponível para desenvolvimento nativo Android.

Veja o conteúdo do arquivo restClient.dart, que está na pasta lib -> dataSources -> webServices.

part 'restClient.g.dart';

@RestApi(baseUrl: "https://api.themoviedb.org/3")
abstract class RestClient {
 factory RestClient(Dio dio, {String baseUrl}) = _RestClient;

 @GET("/movie/top_rated")
 Future<ResultGroup> getTopRated();

}
Enter fullscreen mode Exit fullscreen mode

O pacote retrofit usa o build_runner para geração de código automático. No restClient.g.dart tem um detalhe muito importante. Estou colocando a apikey do webservice themoviedb, lendo esta informação do pacote de configurações globais. Veja o trecho do qual estou me referindo.

@override
Future<ResultGroup> getTopRated() async {
 const _extra = <String, dynamic>{};
 final queryParameters = <String, dynamic>{};
 final _data = <String, dynamic>{};
 final _result = await _dio.request<Map<String, dynamic>>(
     '/movie/top_rated?api_key=${GlobalConfiguration().getValue("api_key")}',
     queryParameters: queryParameters,
     options: RequestOptions(
         method: 'GET',
         headers: <String, dynamic>{},
         extra: _extra,
         baseUrl: baseUrl),
     data: _data);
 final value = ResultGroup.fromJson(_result.data);
 return value;
}
Enter fullscreen mode Exit fullscreen mode

Já na camada de dados local, estamos usando o Floor. Sendo assim, precisamos criar uma entidade, o objeto de acesso aos dados (DAO) e o banco de dados propriamente dito.

A entidade está na pasta useCases -> models, no arquivo topRatedResponse.dart, na classe Result. Veja o uso da anotação @entity. Também, a anotação @JsonSerializable, porque estamos usando o pacote json_serializable documentação está disponível aqui.

part 'topRatedResponse.g.dart';

@entity
@JsonSerializable()
class Result {
 bool adult;
 String backdrop_path;

 @primaryKey
 int id;

 String original_language;
 String original_title;
 String overview;
 double popularity;
 String poster_path;
 String release_date;
 String title;
 bool video;
 double vote_average;
 int vote_count;

 Result({
   this.adult,
   this.backdrop_path,
   this.id,
   this.original_language,
   this.original_title,
   this.overview,
   this.popularity,
   this.poster_path,
   this.release_date,
   this.title,
   this.video,
   this.vote_average,
   this.vote_count
 });

 factory Result.fromJson(Map<String, dynamic> json) => _$ResultFromJson(json);
 Map<String, dynamic> toJson() => _$ResultToJson(this);
}

@JsonSerializable()
class ResultGroup {
 int page;
 int total_pages;
 int total_results;
 List<Result> results;

 ResultGroup({this.page, this.total_pages, this.total_results, this.results});

 factory ResultGroup.fromJson(Map<String, dynamic> json) =>
     _$ResultGroupFromJson(json);
 Map<String, dynamic> toJson() => _$ResultGroupToJson(this);
}
Enter fullscreen mode Exit fullscreen mode

O DAO (Data Access Object) está na pasta lib -> dataSources, no arquivo topRatedDAO.dart. No Floor usamos exaustivamente as anotações, prova disso é o uso do @dao , @Query e @insert. Além disso, todas são auto-expicativas.

@dao
abstract class TopRatedDao {
 @Query('SELECT * FROM Result')
 Future<List<Result>> findAllResults();

 @insert
 Future<List<int>> insertResults(List<Result> results);
}
Enter fullscreen mode Exit fullscreen mode

Por fim, na mesma pasta referida anteriormente, encontramos o appDatabase.dart.

part 'appDatabase.g.dart'; 

@Database(version: 1, entities: [Result])
abstract class AppDatabase extends FloorDatabase {
 TopRatedDao get topRatedDAO;
}
Enter fullscreen mode Exit fullscreen mode

Repositórios

Acima da camada de dados, temos os repositórios. Como nosso aplicativo ainda é muito simples, temos apenas o arquivo moviesRepository.dart, na pasta repositories. Nesta classes temos dois métodos, uma para chamadas remotas, ou seja, para o web service, nominado como getRemoteTopRated. E, um método que busca as informações salvas no banco local, no método nomeado como getLocalTopRated.

class MoviesRepository {

 void getRemoteTopRated() {
   Get.find<RestClient>().getTopRated().then((response) {
     ListScreenController controller = Get.find();
     Get.find<AppDatabase>().topRatedDAO.insertResults(response.results);
     controller.setResultGroup(response.results);
   });
 }

 void getLocalTopRated() {
   Get.find<AppDatabase>().topRatedDAO.findAllResults().then((response) {
       ListScreenController controller = Get.find();
       controller.setResultGroup(response);
     }
   );
 }

}
Enter fullscreen mode Exit fullscreen mode

Aqui usamos o pacote Get para injeção de dependências. Sendo assim, apenas usamos o método estático find da classe Get para requisitar as instâncias das classes desejadas. Em ambos os casos teremos um mesmo retorno de dados, que é passado para o controller da aplicação, que veremos logo na sequência.

Mas e aonde essas dependências são configuradas? Já vimos isso parcialmente, mas é um conceito importante que vale a pena ser revisto. Quanto ao RestClient, isso foi feito no CoreBinding.

class CoreBinding extends Bindings{

 @override
 void dependencies() {
   Get.lazyPut<RestClient>(() => RestClient(Dio()));
 }

}
Enter fullscreen mode Exit fullscreen mode

Quem assumir a dependência de binding com esta classe recebe o método dependencies por herança. Sendo assim, quando a página ligada ao CoreBinding for chamada, teremos o lazyPut do Get chamado. E isso podemos ver no routesDefinitions:

final routes = [
 GetPage(
     name: loginPageName,
     page: () => LoginScreen(),
     binding: CoreBinding()
 ),
 GetPage(
   name: listPageName,
   page: () => ListScreen.initialize(),
 )
];
Enter fullscreen mode Exit fullscreen mode

Em relação ao AppDatabase, sua instância foi criada no main.dart:

...

class MyApp extends StatelessWidget {

 @override
 Widget build(BuildContext context) {
   $FloorAppDatabase.databaseBuilder('app_database.db').build().then((appDb) => Get.lazyPut<AppDatabase>(() => appDb));

   ...
 }


}
Enter fullscreen mode Exit fullscreen mode

Casos de Uso

Neste aplicativo de exemplo, temos apenas um caso de uso. Ele está no arquivo moviesUseCase.dart, dentro da pasta useCases. Devido ao projeto ainda ser simples, o use cases também está simples. Ele é apenas uma camada de ligação para o repositório e a chamada ao método local ou remoto de busca de dados.

class MoviesUseCase {

 final MoviesRepository repository = MoviesRepository();

 void getRemoteTopRated() {
   return repository.getRemoteTopRated();
 }

 void getLocalTopRated() {
   return repository.getLocalTopRated();
 }

}
Enter fullscreen mode Exit fullscreen mode

Camada LogicHolders

Acima da camada de casos de uso, temos a logicHolders. Dentro dela temos os bindings, já discutidos anteriormente e, os controllers. Dentro do mundo Flutter, os controllers geralmente tem uma relação forte com pacotes de gerenciamento de estados, como Provider, BLoC ou MobX, por exemplo. Aqui, vamos usar o pacote Get também para esta função.

Alt Text

Dentro da pasta logicHolders -> controllers, encontramos o arquivo ListScreenControllers.dart. O principal ponto aqui é a variável resultGroup, que é apenas um observável de uma lista comum. Desta forma, ao atualizar essa lista, como acontece no método setResultGroup, chamamos o update do GetxController para avisar os observadores que a lista foi alterada. Por fim, o método getTopRated faz uma chamada direta ao repositório. Perceba que no momento não estamos tratando a conectividade para chamar o remote ou o local.

class ListScreenController extends GetxController{

 final moviesRepositories = MoviesRepository();

 var resultGroup = List<Result>().obs;  //observável.. programação reativa

 setResultGroup(List<Result> results) {
   resultGroup.assignAll(results);
   update();
 }

 getTopRated() => moviesRepositories.getRemoteTopRated();

}
Enter fullscreen mode Exit fullscreen mode

Camada de Widgets

E a última camada na nossa arquitetura é a de widgets. Na pasta screens temos uma sub-pasta customWidgets, onde ficam os widgets customizados no projeto. No nosso caso temos um TextFormField próprio.

No listScreen temos a definição do widget que mostra o formulário de login. Alguns detalhes merecem destaque:

A aquisição da instância de AppLocalizations logo no início do método build. A variável é usada em diversos momentos onde precisamos dos textos internacionalizados.
O uso do componente customizado em diversos momentos: customTextFormField.
A mudança para uma nova tela (na verdade outro Widget) foi feita também fazendo uso do Get. Veja o na linha Get.offNamed(listPageName). O nome da rota está no arquivo de definição de rotas: routesDefinitions.dart.

class LoginScreen extends StatelessWidget {

 TextEditingController _userController = TextEditingController();
 TextEditingController _passwordController = TextEditingController();

 final _formKey = GlobalKey<FormState>();

 @override
 Widget build(BuildContext context) {
   AppLocalizations appLocalizations = AppLocalizations.of(context);

   return Scaffold(
     appBar: AppBar(
       title: Text(appLocalizations.login),
     ),
     body: Padding(
       padding: EdgeInsets.all(12),
       child: Form(
         key: _formKey,
         child: Column(
           children: <Widget>[
             customTextFormField(
               appLocalizations.enterUser,
               appLocalizations.user,
               appLocalizations.pleaseEnterUser,
               _userController
             ),
             customTextFormField(
                 appLocalizations.enterPassword,
                 appLocalizations.password,
                 appLocalizations.pleaseEnterPassword,
                 _passwordController
             ),
             Padding(
               padding: EdgeInsets.symmetric(vertical: 16.0),
               child: ElevatedButton(
                 onPressed: () {
                   if (_formKey.currentState.validate()) {
                     Get.offNamed(listPageName);
                   }
                 },
                 child: Text(appLocalizations.login),
               ),
             ),
           ],
         ),
       ),
     ),
   );
 }
}
Enter fullscreen mode Exit fullscreen mode

Já no listScreen usamos o Get de forma mais ampla. Temos um método de inicialização, apenas para inserir a instância do controlador nesta tela. Já no build, esta mesma instância singleton é recuperada.

No corpo deste Widget usamos o GetBuilder. Passamos a chamada ao getTopRated no initState. No dispose também chamamos o dispose do controller, para não usar recursos de forma desnecessária. E, por fim, no builder chamamos o método buildBodyWithController. Este método vai mostrar um loading circular ou, uma lista com os filmes mais bem ranqueados, dependendo do retorno das camadas inferiores ao widget dentro da nossa arquitetura.

class ListScreen extends StatelessWidget{

 ListScreen.initialize() {
   Get.put(ListScreenController(), permanent: true);
 }

 @override
 Widget build(BuildContext context) {
   ListScreenController controller = Get.find<ListScreenController>();

   return Scaffold(
     appBar: AppBar(
       title: Text("Top Rated"),
     ),
     body: GetBuilder<ListScreenController>(
       initState: (_) => controller.getTopRated(),
       dispose: (_) => controller.dispose(),
       builder: (controller) => buildBodyWithController(controller),
     ),
     //Obx(() => buildBody())
   );
 }

 Widget buildBodyWithController(ListScreenController controller){
   return controller.resultGroup.value == null ?
       CircularProgressIndicator() :
       ListView.builder(
           itemCount: controller.resultGroup.value.length,
           itemBuilder: (context, index) => buildItemList(controller.resultGroup.value[index])
       );
 }

 Widget buildItemList(Result result) => Card(
     child: ListTile(
       title: Text(result.title),
       subtitle: Text(result.overview),
     ),
 );

}
Enter fullscreen mode Exit fullscreen mode

Discussion (0)

pic
Editor guide