Monday, August 29, 2022

Socket Programming in Dart and Flutter

Much of the utility of mobile device apps comes from their ability to connect to other devices over a computer network. All modern operating systems for all network-enabled computing devices provide an important abstraction for making these connections: the TCP Socket. Each language, in turn, provides its own mechanisms for creating and using sockets to communicate.

Future, Async, Await

One of the trickiest issues involving sockets is timing. That is, the app probably has other things it can be doing while waiting for a message over a socket. In Flutter, there are three interrelated language constructs we use to manage timing:

  • Future objects
  • The async keyword
  • The await keyword
Any function in Dart can be labeled async. Every function labeled async returns a Future object immediately. The function then executes asynchronously with respect to the calling code. 

There are several ways in which the caller can interact with the returned Future object. The .then() method sets up a callback function to execute once the async function has completed successfully. The .catchError() method sets up a callback function to execute in the event that the async function throws an exception. And the .whenComplete() method's callback executes whenever the async function completes, error or not. These three methods can be thought of as analogous to try, catch, and finally, respectively.

Within an async function itself, it is pretty common to call another async function. In that case, the most common way to interact with the Future returned by the called function is to use the await keyword to pause the caller until the callee's Future completes. 

What works really well with this arrangement is that we can handle blocking communication without any explicit multithreading. Any networking code that blocks goes into an async function. 

Listening for Incoming Connections

The following examples come from a peer-to-peer text messenger app I wrote. Here is a code segment for setting up a server socket:
const int ourPort = 8888;

Future<void> _setupServer() async {
  try {
    ServerSocket server =
      await ServerSocket.bind(InternetAddress.anyIPv4, ourPort);
    server.listen(_listenToSocket); // StreamSubscription<Socket>
  } on SocketException catch (e) {
    _sendController.text = e.message;
  }
}
As _setupServer() is an async method, it needs to return a Future. Since it does not return any concrete values, it returns a Future<void>. It uses await to pause until it has acquired a ServerSocket instance. It then instructs the ServerSocket object to use _listenToSocket() as its callback by using the .listen() method. That method makes _listenToSocket()a subscriber to the ServerSocket
void _listenToSocket(Socket socket) {
  socket.listen((data) {
    setState(() {
      _handleIncomingMessage(socket.remoteAddress.address, data);
    });
  });
}
Within _listenToSocket(), there is no need for asynchronous code. Every time the ServerSocket receives an incoming connection, it executes _listenToSocket() and passes in the Socket it creates to communicate with the incoming connection. The _listenToSocket() method, in turn, listens for an incoming message from that socket. Once it arrives, it places the message on the user interface for the user to read. 

Because async is implemented behind-the-scenes with concurrent multithreading, data race conditions are a risk. In this program, the data that is shared between the implicit threads is all part of the user interface, represented by the _MyHomePageState class, of which _listenToSocket() is a method. As _MyHomePageState inherits from the State class, it inherits the .setState() method for updating the user interface. Each call to .setState() places the code passed to it in the UI event queue. The code passed through .setState() is thus effectively protected from data races on the UI state.

Sending Outgoing Messages

The app maintains a "friend" list of devices with whom the user can communicate. It retains the IP address, a nickname, and a message history with each friend. This data is encapsulated in the Friend class:
import 'package:mutex/mutex.dart';
final m = Mutex();

class Friend {
  String _ipAddr;
  String _name;
  List<Message> _messages;

  Friend(this._ipAddr, this._name) {
    _messages = [];
  }

  Future<void> send(String message) async {
    Socket socket = await Socket.connect(_ipAddr, ourPort);
    socket.write(message);
    socket.close();
    await _add_message("Me", message);
  }

  Future<void> receive(String message) async {
    return _add_message(_name, message);
  }

  Future<void> _add_message(String name, String message) async {
    await m.protect(() async => _messages.add(Message(name, message)));
  }
}
When the user sends a message to the specified Friend, the .send() method pauses via await while awaiting the acknowledgement of the connection and the creation of a Socket. The message history is stored in the _messages instance variable. Because messages can be appended to that list both when sent and received, concurrent data races are possible. To avert this issue, we use the mutex package. The .protect() method treats its code parameter as an atomic critical section. By using await, we can block until the mutex is free to allow the critical section to enter.

Review of Key Ideas

To write a TCP socket app using Flutter, it is important to understand the following key ideas:
  • Concurrency via Future, async, and await.
  • Subscribing callback code using .listen().

No comments:

Post a Comment