How to build real time apps using Flutter and gRPC

What you need to know to create robust applications using only Dart

In our last blog post, we talked about building Full Stack apps with Dart using Flutter and Dart Frog. This time, we are going to change the technology used in the backend and introduce a new one: gRPC. We will keep the same example and see that thanks to the architecture we design on the Flutter app, it won’t have a strong impact in our frontend.

Before we start, let’s talk a bit more about gRPC. As you might know, REST APIs are not the only technology that allow us to build a backend. In fact, there are plenty more, and we already introduced it in the past post such as gRPC, Web sockets or GraphQL.

As stated in their docs, “gRPC is a modern open source high performance Remote Procedure Call (RPC) framework that can run in any environment.” gRPC is nothing more than an implementation of Remote Procedure Call.

When we work with this protocol, we will be able to call a function that normally resides on the server, as if we were calling it on the client’s machine. This is a bit of the beauty of the protocol. We are not going to have to generate any kind of extra communication regarding on how to exchange information. We just call the function, almost as if it were a function we created on the client side.

gRPC and Protocol Buffers

Normally when we talked about gRPC we have to also talk about Protobuf because by default gRPC uses it. But wait… What is Protobuf? Protocol buffers, Protobuffers or simply Protobuf, is an open-source data serialization mechanism. Quoting the official docs: “Protobuf provide a language-neutral, platform-neutral, extensible mechanism for serializing structured data in a forward-compatible and backward-compatible way. It’s like JSON, except it’s smaller and faster, and it generates native language bindings.”

Now that we defined what gRPC and Protobuf are, we can start with the example. If we remember the previous example, we had two folders: client and server. So we are going to keep this same structure but in order not to get confused we are going to be creating a new repository which you can see here.

At the end of the article, we will have something like this:

All the code can be found on this GitHub repository!

We are going to start with the gRPC and Protobuf related code on the server side and then implement the client logic.

First, let’s download some dependencies necessaries to work with Protobuf. You can refer to the docs here, but we only have to execute this simple two commands in the terminal:

dart pub global activate protoc_plugin
export PATH="$PATH:$HOME/.pub-cache/bin"

Now we are able to create our project. Let’s create the console project in Dart using the command:

dart create users-api

This will create a Dart project using the default template (console).

Now let’s jump into the project and add the necessary dependencies to our pubspec.yaml:

dart pub add grpc protobuf

Then let’s create a protos folder where we would store all of our Protobuf definitions. As we are dealing with users, we will need to create a user.proto file.

syntax = "proto3";

message UsersRequest {
string id = 1;
}

message UserByIdRequest {
string id = 1;
}

message UsersReponse {
repeated User users = 1;
}

message User {
string id = 1;
string name = 2;
string email = 3;
}

service UserService {
rpc getUsers(UsersRequest) returns (UsersReponse);
rpc getUserById(UserByIdRequest) returns (User);
}

Let’s explain in deep what we just defined in our proto file. Well, as we are defining the model of a user, we need to create a message inside our proto file. Then we only need to specify each of the parameters needed to build a user. In this case, we can define three parameters of type string, and we need to assign them a unique value, so we just have to provide an integer that can auto increment as we put more parameters in.

On the other hand, we have defined a service. This will actually allow us to define the methods needed in the interaction from the client with the server. There are a few rules on how to define these methods. In general, the structure is the following:

We need to start the definition with the reserved word rpc, then provide the method name. Finally, we need to provide both a request and a response for our method. Moreover, both of them can have the reserved word stream(that in our example is marked optional with brackets) meaning that it can receive or return a stream. This is specially important because it allows us to do client-side, server-side or even bidirectional streaming.

In this way, we will be able to communicate, for instance, to the client, changes in real time, without them having to have a strategy (polling) to repeatedly request these changes that, if they happen, will not be handled even on time.

Now that we have seen the structure of a method in a service in gRPC if we go back to the method to get all the users:

rpc getUsers(UsersRequest) returns (UsersReponse);

We can see that we simply need a UsersRequest which will be empty, but in order to explain the request method we must construct this message while also returning a UsersReponse which basically contains a list of users. In order to return a list of users, note that we construct a message with the repeated keyword. In this case, we are only returning a list of users, and then we are going to try to return a stream of list of users. When we finished, your app can will be able to react to changes in real time thus taking more advantage of gRPC capabilities, that if you remember, we mentioned it also allows us to do bidirectional streaming.

Now that we have our proto, it's time to auto generate our code!

To do this, first create a destination folder where all the autogenerated files will be stored in src/generated. Then we simply have to run the command:

protoc --dart_out=grpc:lib/src/generated -Iprotos protos/

Once we run the command, this will be the result:

├── lib
| ├── src
│ │ ├── generated
│ │ | ├── user.pb.dart
│ │ | ├── user.pbenum.dart
│ │ | ├── user.pbgrpc.dart
│ │ | └── user.pbjson.dart

This is one of the biggest advantages of using gRPC. On the one hand, we make sure to have a single source of truth (our proto file) that will clearly contain all our definitions of models or services. On the other hand, we can auto-generate the code in any programming language (among those supported, which are many), which is Dart in this case. Therefore, it is language agnostic.

Finally, we are going to build our api_client.dart to provide our Flutter app with a new client that interacts this time with gRPC.

import '../../users_api.dart';

/// {@template api_client}
/// An http API client
/// {@endtemplate}
class ApiClient {
/// {@macro api_client}
ApiClient({
required String baseUrl,
required int port,
ClientChannel? channel,
}) {
_channel = ClientChannel(
baseUrl,
port: port,
options: const ChannelOptions(
credentials: ChannelCredentials.insecure(),
),
);
stub = UserServiceClient(_channel);
}

late final ClientChannel _channel;
late final UserServiceClient stub;

/// Get the list of users
Future<List<User>> getUsers() async {
final response = await stub.getUsers(UsersRequest());

return response.users;
}

/// Get a single user by id
Future<User> getUserById({
required String id,
}) async {
final user = await stub.getUserById(UserByIdRequest(id: id));

return user;
}
}

If we look closely, the code with the API client that we had previously generated in the last blog is very similar. The definitions of the methods are exactly the same, but we only have to change that in this case it does not use an HTTP client. We are going to be using a stub, in turn we will need a ClientChannel to establish the connection with the server using localhost as the base URL and the port that we use to run the server, in this case 8080.

Let’s note that for the moment, the getUsers method returns a Future<List<User>>, which we'll change later on.

Now that we are done with our API client, let’s export everything necessary for our client to use.

library users_api;

export 'src/generated/user.pb.dart';
export 'src/generated/user.pbenum.dart';
export 'src/generated/user.pbgrpc.dart';
export 'src/generated/user.pbjson.dart';

export 'src/api_client.dart/api_client.dart';

export 'package:grpc/grpc.dart';

Now, we can get to work on our Flutter app frontend. Positively, since we emphasized getting a good architecture even with only one feature in the previous example, now it will have little impact on it. Because we are on another project, we’ll copy and paste the client folder of our previous project, and then perform a few changes.

The first thing we have to change is the communication of the UsersRepository, because we no longer want it to use the API Client that we had previously made to communicate through HTTP with Dart Frog. Now, we want to inject the new API Client in order to use gRPC and the other exposed methods.

Here is our new UsersRepository:

import 'package:users_api/users_api.dart';

abstract class UsersRepositoryException implements Exception {
/// {@macro users_repository_exception}
const UsersRepositoryException(this.error, this.stackTrace);

/// The underlying error that occurred.
final Object error;

/// The relevant stack trace.
final StackTrace stackTrace;
}

/// {@template get_users}
/// Thrown during the get users if a failure occurs.
/// {@endtemplate}
class GetUsersFailure extends UsersRepositoryException {
/// {@macro get_users}
const GetUsersFailure(Object error, StackTrace stackTrace)
: super(error, stackTrace);
}

class UsersRepository {
const UsersRepository({
required ApiClient apiClient,
}) : _apiClient = apiClient;

final ApiClient _apiClient;

Future<List<User>> getUsers() async {
try {
final users = await _apiClient.getUsers();

return users;
} catch (error, stackTrace) {
throw GetUsersFailure(error, stackTrace);
}
}

Future<User> getUserById(String id) async {
try {
final user = await _apiClient.getUserById(id: id);

return user;
} catch (error, stackTrace) {
throw GetUsersFailure(error, stackTrace);
}
}
}

Lastly, we’ll need to inject the correct API Client into our UsersRepository. If we remember, we can do this directly in the main. We also need to provide the variables API_URL and API_PORT.

void main() {
bootstrap(() async {
const baseUrl = String.fromEnvironment('API_URL');
final port = int.tryParse(const String.fromEnvironment('API_PORT')) ?? 8080;

final apiClient = ApiClient(
baseUrl: baseUrl,
port: port,
);

final usersRepository = UsersRepository(apiClient: apiClient);

return App(
usersRepository: usersRepository,
);
});
}

And that’s all! If we look closely, we didn’t have to make many changes, which is one of the benefits of using a layered architecture. Everything that is our presentation layer (view and logic) was not affected.

Now, if we run the project, we will notice that we have the same example. We can run the project first by initializing the server from the terminal:

cd users-api && PORT=8080 dart bin/grpc_server.dart

And then the Flutter project with:

cd client && flutter run --dart-define=API_URL=localhost --dart-define=API_PORT=8080

Bonus: adding reactivity to our users

As we mentioned before, gRPC allows us not only to call a function to get a response, but also to do server side streaming. So now we are going to see how to be able to listen to the changes of the list of users in real time. The idea here is that if our database changes in some way, we can communicate to all clients (Flutter or any) that something has changed in the user list. To keep things neater, let’s create a new branch called feat/reactive-users where we'll be adding all our changes.

For this, first we have to return to our proto file (our source of truth), and change our definition of service:

service UserService {
rpc getUsers(UsersRequest) returns (stream UsersReponse);
rpc getUserById(UserByIdRequest) returns (User);
rpc addUser(User) returns (User);
}

Here we see two significant changes:

  1. We added the parameter streaminto our UsersResponsein the getUsersdefinition.
  2. A new addUsermethod was added into our service in a way that we can add users into our list.

Then we need to re-run our command to re-auto generate our files.

protoc --dart_out=grpc:lib/src/generated -Iprotos protos/*

Now, as our getUsers definition has changed, that means we also need to change our ApiClient getUsers method. We need to provide a stream of data instead, so whenever we receive any changes we will be using yield to emit new values of our user list.

/// Get the list of users
Stream<List<User>> getUsers() async* {
final response = stub.getUsers(UsersRequest());
await for (var users in response) {
yield users.users;
}
}

This change will also spread to our UserRepository almost in the same way:

Stream<List<User>> getUsers() async* {
try {
await for (final users in _apiClient.getUsers()) {
yield users;
}
} catch (error, stackTrace) {
throw GetUsersFailure(error, stackTrace);
}
}

Now, our BLoC will need to change as well, as it now has to listen for changes in the UserRepository. The way to do that is by having a StreamSubscription and initialize it whenever the BLoC is created. Remember to also close it, as we don't want to keep an unnecessary stream open, because it will affect the performance of our app.

import 'dart:async';

import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:users_api/users_api.dart';
import 'package:users_repository/users_repository.dart';

part 'users_event.dart';
part 'users_state.dart';

class UsersBloc extends Bloc<UsersEvent, UsersState> {
UsersBloc({required UsersRepository usersRepository})
: _usersRepository = usersRepository,
super(const UsersState.initial()) {
on<UsersFetched>(_onUsersFetched);

_streamSubscription = _usersRepository.getUsers().listen(
(users) {
add(UsersFetched(users: users));
},
);
}

final UsersRepository _usersRepository;
late final StreamSubscription _streamSubscription;

Future<void> _onUsersFetched(
UsersFetched event,
Emitter<UsersState> emit,
) async {
try {
emit(state.copyWith(status: UsersStatus.loading));
await Future.delayed(const Duration(seconds: 1));

emit(
state.copyWith(
status: UsersStatus.loaded,
users: event.users,
),
);
} on UsersRepositoryException {
emit(state.copyWith(status: UsersStatus.error));
}
}

@override
Future<void> close() {
_streamSubscription.cancel();
return super.close();
}
}

Our users_event.dart class also changed, so now we don't have to ask for the list, rather we only need to initialize the subscription in the BLoC.

class UsersFetched extends UsersEvent {
const UsersFetched({required this.users});

final List<User> users;

@override
List<Object?> get props => [users];
}

Now in our UserService inside grpc_server.dart we need to do a couple of updates as well. But first, we are going to add a new dependency to better deal with streams called RxDart:

dart pub add rxdart

Then change our UserService implementation for:

/// In memory [User] list
final List<User> users = [
User(id: '1', name: 'Gianfranco', email: 'gianfranco@email.com'),
User(id: '2', name: 'Gianfranco2', email: 'gianfranco@email.com'),
User(id: '3', name: 'Gianfranco3', email: 'gianfranco@email.com'),
User(id: '4', name: 'Gianfranco4', email: 'gianfranco@email.com'),
User(id: '5', name: 'Gianfranco5', email: 'gianfranco@email.com'),
];

late final BehaviorSubject<List<User>> usersStream;

UserService() {
usersStream = BehaviorSubject.seeded(users);
}

@override
Future<User> getUserById(ServiceCall call, UserByIdRequest request) async {
final userId = request.id;

return users.firstWhere(
(user) => user.id == userId,
orElse: () => User(),
);
}

@override
Stream<UsersReponse> getUsers(ServiceCall call, UsersRequest request) async* {
await for (final users in usersStream) {
yield UsersReponse(users: users);
}
}

@override
Future<User> addUser(ServiceCall call, User request) async {
final user = request;
users.add(user);
usersStream.sink.add(users);
return user;
}

Noticed how we change our getUsersimplementation, so that we only need to look it up in the usersStream and await for any result, then yield that to the listeners. Moreover, we add the addUser method, so we can also add users to our list and to the stream. We are going to test this behavior with a small Dart client to keep everything simple.

So now we need to create this client in Dart, let’s call it grpc_client.dart:

import 'dart:math';

import 'package:users_api/users_api.dart';

void main(List<String> args) async {
final channel = ClientChannel(
'localhost',
port: 8080,
options: const ChannelOptions(credentials: ChannelCredentials.insecure()),
);

final stub = UserServiceClient(channel);

final random = Random();
final id = random.nextInt(100) + 5;

await stub.addUser(User(
id: id.toString(),
name: 'Gianfranco${id.toString()}',
email: 'gianfranco@email.com',
));

await channel.shutdown();
}

We can see that the only thing this client does is connect to gRPC server and create a user with a random ID and name.

We can execute this client with:

dart bin/grpc_client.dart

That’s it! Now, if we see the full example in action, we should be able to see our list of users in our Flutter app by running our server first and our app second, and for the last step running our script grpc_client.dart to create random users and see the changes in the list.

Conclusions

We can see how gRPC can be a technology with many advantages when making applications in Flutter since it allows us to use Protobuf, another type of data information exchange that is more efficient than JSON. In addition, it allows us to auto-generate code so that we can be more efficient by speeding up the time that we are programming the communication with our backend, as well as the construction of models.

gRPC allows us to interoperate with other languages, although in this example we are dealing exclusively with Dart, it is easy to incorporate other languages ​​in the backend and still have a single source of truth thanks to Protobuf.

I hope you liked the article and let us know in the comments what other technologies you would like to see integrated with Flutter!

Gianfranco Papa is our CTO and is in charge of all the technical issues and challenges. Besides being a great reference for the team he is an amazing football player.

Originally published at https://somniosoftware.com.

--

--

Gianfranco Papa | Flutter & Dart
Somnio Software — Flutter Agency

CTO & Co-Founder at Somnio Software. Flutter & Dart Senior Developer and Enthusiast!