Flutter Challenge: La mejor arquitectura | Flutter Taiwan

Que tal en esta entrada 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

Nota

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 tambien 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 {
  @override
  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;

  @override
  _PostPageState createState() => _PostPageState();
}

class _PostPageState extends State<PostPage> {
  static const int _sortWithId = 1;
  static const int _sortWithTitle = 2;

  List<dynamic> _posts = [];

  @override
  void initState() {
    super.initState();
    _fetchData(_sortWithId);
  }

  @override
  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 se que aquí tenemos una lista de posts pero el tipo dynamic es un gran problema. También podemos ver que llamamos a una funcion _fetchData() que por su nombre estoy seguro que es la que llama a la Rest Api para traer los datos:

@override
  void initState() {
    super.initState();
    _fetchData(_sortWithId);
  }

El problema es que traer los datos es asíncrono por lo que lo mejor seria usar await y mientras se traen los datos mostrar al usuario una animación de cargando. Otro problema esta en el siguiente onSelected del PopupMenuButton:

onSelected: (int value) {
 _fetchData(value);
},

Otra vez llamamos la funcion _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 esta 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 arquitecutra propuesta en su pagina 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 manejador de estado y nos va facilitar implementar la arquitectura Bloc. equatable: Simplifica las comparaciones de objetos. Nos va 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 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

@JsonSerializable()
class Post extends Equatable {
  late final int id;
  late final String title;
  late final String body;

  Post(this.id, this.title, this.body);

  @override
  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 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.

Nota

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 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);

  @override
  Future<List<Post>> getPostList() => _provider.getPostList();
}

Como pueden ver tenemos una clase abstract class Repository ¿Porque? Bueno esta abstracción nos permite hacer más "testable" nuestro código. ¿A qué me refiero? Mas 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 {
  @override
  List<Object> get props => [];
}

class PostLoading extends PostState {}

class PostReady extends PostState {
  final SortOptions _sortBy;
  final List<Post> postList;

  PostReady(this._sortBy, this.postList);

  @override
  List<Object> get props => [_sortBy, postList];
}

PostState: Esta es una clase abstracta que será la 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 {
  @override
  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);

  @override
  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 si 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 {
  @override
  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 el 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.

Videotutorial en YouTube

Pueden descargar el resultado de reescribir el proyecto utilizando Bloc en mi GitHub. Y también pueden ver la solución del proyecto paso a paso en el siguiente video:

Comparte este artículo