Flutter Challenge: La mejor arquitectura | Flutter Taiwan
En este artículo vamos a resolver el reto propuesto en la página de Facebook Flutter Taiwán que básicamente dice:
El objetivo de este reto es reescribir el proyecto en la que tu creas es la mejor arquitectura (bloc, mvvm, mvc, mvp, etc.). Puedes usar cualquier paquete, debes incluir pruebas unitarias (unit test) y pruebas de widgets (widget tests). El proyecto se puede descargar de GitHub
Quiero aclarar que no existe una arquitectura que sea mejor que otra, en programación podemos llegar al mismo resultado de diferentes formas.
Analizando el proyecto actual (Sin arquitectura)
Primero vamos a ver la app corriendo:
Podemos ver que tenemos un app bar con el texto FlutterTaipei:)
, también hay un Listview
con una serie de ítems
que vienen de JsonPlaceHolder, el app bar también tiene unas opciones
para ordenar la lista por id
o por el titulo
del artículo.
La primera alerta que veo es que todo lo anterior está en el archivo main.dart
del proyecto, esto quiere decir que
la interfaz de usuario está mezclada con llamadas a la Rest Api, etc. En pocas palabras no hay una separación
de responsabilidades:
Veamos el código dentro de main.dart
, en la primera parte tenemos la función void main()
y un StatelessWidget
llamado MyApp
donde creamos el MaterialApp
:
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
title: 'MyApp',
theme: ThemeData(
primarySwatch: Colors.deepPurple,
),
home: PostPage(title: 'FlutterTaipei :)'),
);
}
}
Ahora vemos el código del widget PostPage
:
class PostPage extends StatefulWidget {
PostPage({Key? key, required this.title}) : super(key: key);
final String title;
_PostPageState createState() => _PostPageState();
}
class _PostPageState extends State<PostPage> {
static const int _sortWithId = 1;
static const int _sortWithTitle = 2;
List<dynamic> _posts = [];
void initState() {
super.initState();
_fetchData(_sortWithId);
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
actions: <Widget>[
PopupMenuButton(
icon: Icon(Icons.more_vert),
itemBuilder: (context) => [
PopupMenuItem(
child: Text('使用id排序'),
value: _sortWithId,
),
PopupMenuItem(
child: Text('使用title排序'),
value: _sortWithTitle,
)
],
onSelected: (int value) {
_fetchData(value);
})
],
),
body: ListView.separated(
itemCount: _posts.length,
itemBuilder: (context, index) {
String id = _posts[index]['id'].toString();
String title = _posts[index]['title'].toString();
String body = _posts[index]['body'].toString();
return Container(
padding: EdgeInsets.all(8),
child: RichText(
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: <TextSpan>[
TextSpan(
text: "$id. $title",
style: TextStyle(fontSize: 18, color: Colors.red),
),
TextSpan(
text: '\n' + body,
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
));
},
separatorBuilder: (context, index) {
return Divider();
},
));
}
}
Primero podemos ver que es un StatefulWidget
, dentro del estado el primer problema que veo es la variable:
List<dynamic> _posts = [];
Ya sé que aquí tenemos una lista de posts pero el tipo dynamic
es un gran problema. También podemos ver que llamamos
a una función _fetchData()
que por su nombre estoy seguro de que es la que llama a la Rest Api para traer los datos:
void initState() {
super.initState();
_fetchData(_sortWithId);
}
El problema es que traer los datos es asíncrono por lo que lo mejor sería usar await
y mientras se traen los datos
mostrar al usuario una animación de cargando. Otro problema está en el siguiente onSelected
del PopupMenuButton
:
onSelected: (int value) {
_fetchData(value);
},
Otra vez llamamos la función _fetchData()
pero ¿es realmente necesario? Si ya tengo los datos localmente tal vez no
sea necesario volver a llamar la API. Otro bloque de código que no me gusta está dentro del ListView
:
itemBuilder: (context, index) {
String id = _posts[index]['id'].toString();
String title = _posts[index]['title'].toString();
String body = _posts[index]['body'].toString();
//…más código
Recuerdan que la variable _posts
es de tipo dynamic
, bueno acceder así a las propiedades no es una buena idea. Lo
mejor sería tener una clase modelo para representar los posts.
Por último veamos la función _fetchData()
:
void _fetchData(int sort) async {
var url = Uri.https('jsonplaceholder.typicode.com', '/posts');
var response = await http.get(url);
print("response=${response.body}");
List<dynamic> result = jsonDecode(response.body);
if (sort == _sortWithId) {
result.sort((a, b) {
return int.parse(a['id'].toString())
.compareTo(int.parse(b['id'].toString()));
});
} else if (sort == _sortWithTitle) {
result.sort((a, b) {
return a['title'].toString().compareTo(b['title'].toString());
});
}
setState(() {
_posts = result;
});
}
El primer punto que no me gusta es que esta función regresa void
en lugar de Future<void>
. Otra cosa que no me
gusta es que para ordenar los posts tenemos que llamar esta función y cada vez hacemos una llamada a la Rest Api:
if (sort == _sortWithId) {
result.sort((a, b) {
return int.parse(a['id'].toString())
.compareTo(int.parse(b['id'].toString()));
});
} else if (sort == _sortWithTitle) {
result.sort((a, b) {
return a['title'].toString().compareTo(b['title'].toString());
});
}
Flutter Bloc
Para mejorar el código anterior vamos a utilizar Bloc utilizando el paquete flutter_bloc y la arquitectura propuesta en su página web
Básicamente, tenemos que separar nuestro código en 3 capas que son:
- Interfaz de usuario (UI)
- Lógica de negocios (Blocs)
- Datos:
- Repository (Aquí podemos reorganizar, mezclar, modificar los datos)
- Data Provider (Aquí llamamos a las Api)
Paquetes (Dependencias)
Vamos a comenzar agregando los paquetes que vamos a utilizar el archivo pubspec.yaml
comenzamos con la
sección de dependencies
:
equatable: ^2.0.3
flutter_bloc: ^7.0.1
http: ^0.13.3
http: Sirve para hacer peticiones a la Rest Api. flutter_bloc: Es el administrador de estado y nos va a facilitar implementar la arquitectura Bloc. equatable: Simplifica las comparaciones de objetos. Nos va a ayudar a comparar si 2 post son iguales o si dos estados son iguales.
Ahora vamos a agregar dev_dependencies
:
bloc_test: ^8.0.2
build_runner: ^2.0.5
json_serializable: ^4.1.3
bloc_test: Nos va a ayudar a crear pruebas unitarias a nuestro blog build_runner: Es un paquete para generar código y lo vamos a utilizar en conjunto con el paquete json_serializable. json_serializable: Permite serializar y deserializar JSON de forma sencilla.
La clase Post
Esta clase sirve para representar los datos obtenidos de la Rest Api en un objeto de Dart
()
class Post extends Equatable {
late final int id;
late final String title;
late final String body;
Post(this.id, this.title, this.body);
List<Object?> get props => [id, title, body];
factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
Map<String, dynamic> toJson() => _$PostToJson(this);
}
Utilizamos la etiqueta @JsonSerializable()
porque vamos a convertir el Json que regresa la Rest Api a objetos de
esta clase. También utilizamos extends Equatable
para comparar igualdad entre los objetos de tipo Post
.
Capa de Datos: La clase RestProvider
Desde esta clase vamos a hacer las peticiones al Backend. El código completo es:
const timeout = Duration(seconds: 10);
class RestProvider {
final http.Client _httpClient;
RestProvider({http.Client? httpClient}) : _httpClient = httpClient ?? http.Client();
Future<List<Post>> getPostList() async {
var uri = Uri.https('jsonplaceholder.typicode.com', '/posts');
final response = await _httpClient.get(uri).timeout(timeout);
print(response.body);
return List<Post>.from(json.decode(response.body).map((c) => Post.fromJson(c)).toList());
}
}
En el constructor RestProvider({http.Client? httpClient})
recibimos una instancia de http
lo que nos va a
permitir más adelante hacer mocks de esta clase y poder hacer unit test.
En la función Future<List<Post>> getPostList()
llamamos a la API y convertimos la respuesta a una lista de
objetos tipo Post
.
Es buena práctica verificar que la respuesta regrese un statusCode == 200 y si no es así lanzar una excepción. Pero para este ejemplo voy a suponer que la Rest Api siempre va a responder exitosamente.
Capa de datos: La clase Repository
Esta clase tiene muchas responsabilidades, por ejemplo si estuviéramos usando una base de datos esta clase tendría que verificar si los datos existen localmente y mandarlos al Bloc antes de hacer una llamada a la Rest Api.
En este proyecto la clase repositorio es muy sencilla, ya que solo tenemos una Api y no hay base de datos. El código sería:
abstract class Repository {
Future<List<Post>> getPostList();
}
class RepositoryImp extends Repository {
final RestProvider _provider;
RepositoryImp(this._provider);
Future<List<Post>> getPostList() => _provider.getPostList();
}
Como pueden ver tenemos una clase abstract class Repository
¿Por qué? Bueno esta abstracción nos permite hacer más
"testable" nuestro código. ¿A qué me refiero? Más adelante cuando tengamos que hacer pruebas unitarias vamos a
crear una clase class MockRepoImp extends Repository
y vamos a poder regresar lo que queramos de la función
getPostList()
de esta forma no vamos a depender de RestProvider
para "testear".
Lógica de Negocios: La PostCubit y sus estados
Primero tenemos que definir los estados que queremos tener en nuestra aplicación. Yo voy a crear 3 clases para definir estos estados:
abstract class PostState extends Equatable {
List<Object> get props => [];
}
class PostLoading extends PostState {}
class PostReady extends PostState {
final SortOptions _sortBy;
final List<Post> postList;
PostReady(this._sortBy, this.postList);
List<Object> get props => [_sortBy, postList];
}
PostState: Esta es una clase abstracta que será la clase padre de todos los estados soportados por la
clase PostCubit
.
PostLoading: Este estado estará activo mientras se esté llamando a la RestApi. Durante este estado en la UI
podemos mostrar una animación de cargando.
PostReady: Este estado estará activo cuando la Rest Api haya respondido y tenga los datos listos para ser
mostrados en la UI. Siempre que se tengan que ordenar de manera diferente los posts se crea un nuevo estado PostReady
.
Ahora que conocemos los estados, veamos el código de la clase PostCubit
:
class PostCubit extends Cubit<PostState> {
Repository _repository;
SortOptions _sortBy = SortOptions.id;
List<Post> postList = [];
PostCubit(this._repository) : super(PostLoading()) {
_fetchData();
}
Future<void> _fetchData() async {
emit(PostLoading());
postList = await _repository.getPostList();
sort(_sortBy);
}
void sort(SortOptions sortBy) {
_sortBy = sortBy;
switch (_sortBy) {
case SortOptions.title:
postList.sort((a, b) => a.title.compareTo(b.title));
break;
case SortOptions.id:
postList.sort((a, b) => a.id.compareTo(b.id));
break;
}
emit(PostReady(_sortBy, postList));
}
}
En el constructor recibimos una instancia de la clase Repository
y también mandamos llamar la
función _fetchData()
. Dentro de _fetchData()
emitimos el estado PostLoading()
y mandamos llamar la Rest Api.
Cuando ya tenemos los datos inmediatamente llamamos la función sort()
para ordenarlos y una vez ordenados
emitimos el estado PostReady
.
La función main()
Ahora que la capa de datos y la logística de negocios están listas, tenemos que "inyectarlas" al árbol de widgets "Widget Tree" para poder usarlas en nuestra capa de presentación (UI):
void main() {
Repository repository = RepositoryImp(RestProvider());
PostCubit postCubit = PostCubit(repository);
runApp(
BlocProvider<PostCubit>(
create: (_) => postCubit,
child: MyApp(),
),
);
}
Podemos ver que en la función main()
como inicializamos todas las clases y las pasamos al "Widget tree" por
medio de un BlocProvider
.
Hay que recordar que justo en el momento que inicializamos la clase PostCubit
estamos llamando a la Rest Api
para obtener la lista de posts.
La capa de presentación: Interfaz de usuario (UI)
Veamos el código final de la UI y después vamos a explicar las secciones más importantes:
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
title: 'MyApp',
theme: ThemeData(
primarySwatch: Colors.deepPurple,
),
home: PostPage(title: 'FlutterTaipei :)'),
);
}
}
class PostPage extends StatelessWidget {
final String title;
const PostPage({Key? key, required this.title}) : super(key: key);
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
actions: <Widget>[
PopupMenuButton<SortOptions>(
icon: Icon(Icons.more_vert),
itemBuilder: (context) => [
PopupMenuItem(
child: Text('使用id排序'),
value: SortOptions.id,
),
PopupMenuItem(
child: Text('使用title排序'),
value: SortOptions.title,
),
],
onSelected: context.read<PostCubit>().sort,
)
],
),
body: BlocBuilder<PostCubit, PostState>(
builder: (_, state) {
if (state is PostReady) {
return ListView.separated(
itemCount: state.postList.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
Post item = state.postList[index];
return Container(
padding: EdgeInsets.all(8),
child: RichText(
key: Key(item.id.toString()),
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: <TextSpan>[
TextSpan(
text: "${item.id}. ${item.title}",
style: TextStyle(fontSize: 18, color: Colors.red),
),
TextSpan(
text: '\n ${item.body}',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
));
},
);
}
return Center(child: CircularProgressIndicator());
},
),
);
}
}
Ya no tenemos ningún StatefulWidget
ahora todos son widgets son StatelessWidget
.
En la función onSelected
del PopupMenuButton
ya no ordenamos los post en la capa de presentación y mucho
menos llamamos la Rest API como antes. Ahora la clase PostCubit
se encarga de toda esa lógica.
onSelected: context.read<PostCubit>().sort
También podemos ver que ahora tenemos un BlocBuilder
, cuando el estado es PostReady
vamos a dibujar
un ListView
de lo contrario vamos a dibujar un CircularProgressIndicator
.
BlocBuilder<PostCubit, PostState>(
builder: (_, state) {
if (state is PostReady) {
// Dibujar ListView
}
return Center(child: CircularProgressIndicator());
},
)
Básicamente con estos cambios la UI quedó más limpia y le quitamos responsabilidades que no debería tener como lo es hacer llamadas a las Rest Api.
Pruebas unitarias: RestProvider
Ahora vamos a hacer pruebas unitarias al código de la clase RestProvider
. Vamos a comenzar con dos funciones que
nos van a ayudar a obtener un objeto RestProvider
al cual le pasamos un MockClient
en el constructor.
RestProvider _getProvider(String filePath) => RestProvider(httpClient: _getMockProvider(filePath));
MockClient _getMockProvider(String filePath) =>
MockClient((_) async => Response(await File(filePath).readAsString(), 200, headers: headers));
¿Por qué la función _getProvider
recibe la ruta de un archivo?. Porque vamos a crear 2 archivos para simular las
respuestas de la Rest Api:
mock_response.json:
[
{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum"
},
{
"userId": 1,
"id": 2,
"title": "qui est esse",
"body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque"
}
]
mock_response_error.json:
{
"error": 1
}
Ahora sí podemos crear las pruebas unitarias:
void main() {
test('Response is correct ', () async {
final provider = _getProvider('test/provider_test/mock_response.json');
final articles = await provider.getPostList();
expect(articles.length, 2);
expect(articles[0].id, 1);
expect(articles[1].id, 2);
});
test('Exception will be thrown', () async {
final provider = _getProvider('test/provider_test/mock_response_error.json');
expect(provider.getPostList(), throwsA(predicate((exception) => exception is TypeError)));
});
}
Pruebas Unitarias: PostCubit
Para hacer pruebas a la clase PostCubit
primero vamos a crear una clase simulada del Repository
a la que vamos a
llamar MockRepoImp
. El código final es:
class MockRepoImp extends Repository {
Future<List<Post>> getPostList() async => [
Post(1, 'Yayo title', 'This is the body'),
Post(2, 'Jonh title', 'This is the body 2'),
];
}
void main() {
group('Post Cubit Test', () {
blocTest<PostCubit, PostState>(
'News are loaded correctly',
build: () => PostCubit(MockRepoImp()),
expect: () => [
// isA<PostLoading>(),// Initial state is not emitted. Check documentation
isA<PostReady>(),
],
);
blocTest<PostCubit, PostState>(
'Sorted by id',
build: () => PostCubit(MockRepoImp()),
act: (cubit) => cubit.sort(SortOptions.id),
expect: () => [
// isA<PostLoading>(),// Initial state is not emitted. Check documentation
isA<PostReady>(),
isA<PostReady>(),
],
verify: (cubit) {
final readyState = cubit.state as PostReady;
expect(readyState.postList.length, 2);
expect(readyState.postList[0].id, 1);
expect(readyState.postList[1].id, 2);
},
);
blocTest<PostCubit, PostState>(
'Sorted by title',
build: () => PostCubit(MockRepoImp()),
act: (cubit) => cubit.sort(SortOptions.title),
expect: () => [
// isA<PostLoading>(),// Initial state is not emitted. Check documentation
isA<PostReady>(),
isA<PostReady>(),
],
verify: (cubit) {
final readyState = cubit.state as PostReady;
expect(readyState.postList.length, 2);
expect(readyState.postList[0].id, 2);
expect(readyState.postList[1].id, 1);
},
);
});
}
Pruebas Unitarias : UI
Para testear la UI vamos a utilizar la clase MockRepoImp
que creamos para probar él PostCubit
. Vamos a inyectarla
al árbol de widgets y vamos a verificar que los widgets sean encontrados:
void main() {
testWidgets('Post screen shows 2 elements', (WidgetTester tester) async {
await tester.pumpWidget(
BlocProvider<PostCubit>(
create: (context) => PostCubit(MockRepoImp()),
child: MaterialApp(
home: PostPage(title: 'FlutterTaipei :) Test'),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(Key('1')), findsOneWidget);
expect(find.byKey(Key('2')), findsOneWidget);
});
}
Conclusión
En este artículo aprendimos a separar en diferentes capas el código de una aplicación y vimos cómo podemos crear pruebas unitarias de cada una de las capas. Recuerden que es importante tener una buena organización en nuestro código y siempre es buena práctica agregar pruebas unitarias por si un día hacemos cambios poder verificar que todo funcione correctamente.
Código fuente
Pueden descargar el resultado de reescribir el proyecto utilizando Bloc en GitHub.