Les WebSockets permettent une communication en temps réel entre l’application mobile et le serveur. Vous apprendrez les principes à travers un exemple concret de jeu multijoueurs en temps réel.

Difficulté: Intermédiaire

Introduction

A moins que vous écriviez une application qui n’a pas besoin d’échanger des informations avec un serveur, la communication entre une application mobile et un serveur est quelque chose d’indispensable.

HTTP Client Server

La communication entre une application mobile et un serveur est généralement réalisée via le protocole HTTP, où le client (= application mobile) envoie une requête HTTP à un serveur via Internet. Une fois que le serveur a traité la demande, il renvoie la réponse au client et ferme la connexion.

Http_request

C’est une communication à sens unique, où la communication doit toujours être initiée par le client et consiste en un seul échange (envoi -> réception -> réponse -> fermeture). Le serveur n’a aucune possibilité d’envoyer quoi que ce soit au client sans que le client ne lui ait demandé de le faire.

La plupart du temps, ce moyen de communication est suffisant et même recommandé. Cependant, si vous avez besoin d’interroger le serveur très fréquemment, de déplacer de gros volumes de données, de réagir en fonction d’événements pouvant survenir du côté serveur, ce type de communication peut devenir un goulot d’étranglement.

WebSockets

Certains autres types d’applications telles que le chat, les jeux en temps réel, les enchères, les actions, … peuvent nécessiter:

  • d’avoir un canal de communication qui reste ouvert entre le client et le serveur pendant une période plus longue qu’un schéma de demande/réponse unique
  • d’avoir une transmission de données bidirectionnelle, où le serveur pourrait envoyer des données au client sans avoir été demandé/interrogé par le client
  • de supporter les flux de données

Les WebSockets permettent au client d’ouvrir et de maintenir une connexion avec serveur.

websockets

Un Web Socket est une connexion socket TCP entre le client et le serveur, au travers d’un réseau, ce qui permet une communication full duplex, autrement dit: les données peuvent être transmises dans les deux sens et en même temps.

Un socket TCP est une instance d’un point de terminaison sur le serveur, définie par une adresse IP et un port.

Pour une documentation technique détaillée sur les WebSockets, veuillez vous référer au standard défini par le RFC6455.

WebSockets en Flutter

Le package web_socket_channel fournit les utilitaires pour utiliser aisément les WebSockets en Flutter.

Pour installer ce package, ajoutez la ligne suivante à votre fichier pubspec.yaml:

dependencies:
  web_socket_channel: "^1.0.8"

Pour importer le package, ajoutez les 2 importations suivantes à vos fichiers .dart:

import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/status.dart' as status;

Comment se connecter au Serveur?

Pour pouvoir vous connecter au serveur, vous devez au moins connaître:

  • son adresse de socket TCP (ex. 192.168.1.25:1234)
  • le nom de son handler de requêtes de type WebSocket (ex. “/svc/websockets”)
  • le schéma d’URL à utiliser (=”URL Scheme”) (‘ws://’ pour une communication en texte clair or ‘wss://’ pour un canal encrypté)
IOWebSocketChannel channel = new IOWebSocketChannel.connect("ws://192.168.1.25:1234/svc/websockets");

Que se passe-t-il derrière?

Cette simple ligne envoie une requête HTTP régulière au serveur, qui contient également un en-tête “Upgrade” pour informer le serveur que le client souhaite établir une connexion WebSocket. Cette requête initie le processus “handshake”.

Si le serveur prend en charge le protocole WebSocket, il accepte de le mettre à niveau et de le communiquer via un en-tête “Upgrade” dans la réponse. Une fois la négociation terminée, la connexion HTTP initiale est remplacée par une connexion WebSocket qui utilise la même connexion TCP / IP sous-jacente.

Le canal de communication est maintenant ouvert et prêt à être utilisé.

Est-ce sûr?

Si vous ne comptez que sur le protocole de base “ws://“, ce dernier est aussi sûr que le protocole “http://” normal.

Par conséquent, il est fortement conseillé d’utiliser le même cryptage que pour HTTPS (TLS / SSL).

Pour établir une telle communication sécurisée, vous devez:

  • avoir un certificat SSL, installé sur votre serveur
  • utiliser “wss://” en place de ‘ws://
  • pointer vers votre port SSL

Étendre la sécurité en authentifiant le client

Comme décrit dans un de mes articles précédent, article, vous pouvez également utiliser la notion de “tokens” (jetons), et ne permettre la connexion qu’à des utilisateurs authentifiés.

Par conséquent, vous pouvez également transmettre des données supplémentaires au header (= “_en-tête”) pendant la connexion.

L’exemple suivant illustre comment transmettre des données supplémentaires dans l’en-tête de la requête.

  ///
  /// Ouverture d'un canal de communication sécurisé
  ///
  IOWebSocketChannel channel;
	
    try {
      channel = new IOWebSocketChannel.connect(
        "wss://192.168.1.25:443/svc/websockets",
        headers: {
          'X-DEVICE-ID': deviceIdentity,
          'X-TOKEN': token,
          'X-APP-ID': applicationId,
      });
	  ...
	  
    } catch(e){
      ///
      /// Une erreur est survenue
      ///
    }

Comment fermer une communication?

Le canal de communication peut être fermé par le client, en utilisant la commande suivante:

channel.sink.close();

Comment envoyer un message au serveur ?

channel.sink.add('les données à envoyer au serveur');

Comment gérer la communication à partir du serveur ?

Pour accepter les messages entrants, émis par le serveur, vous devez vous abonner (listen) à des events (= “événements”) à partir du Stream.

La signature de cet “abonnement” est:

StreamSubscription<T> channel.stream.listen(
	void onData(T event),
	{
		Function onError,
		void onDone(),
		bool cancelOnError
	}
);

où:

  • onData: méthode qui est invoquée lorsque certaines données sont reçues du serveur
  • onError: méthode pour gérer les erreurs recontrées
  • onDone: méthode à mettre en œuvre pour gérer une fermeture de communication (à partir du serveur, par exemple)
  • cancelOnError: (par défault: false). Si la valeur est true, StreamSubscription est automatiquement fermé à la première erreur rencontrée

Donc, une implémentation typique serait:

channel.stream.listen(
	(message){
		// gestion des messages entrants
	},
	onError: function(error, StackTrace stackTrace){
		// gestion des erreurs
	},
	onDone: function(){
		// la communication a été fermée par le serveur ou via une rupture de réseau
	}
);

Mettons tout cela en pratique

Contrairement à la plupart des exemples que vous pouvez trouver sur Internet, je ne réécris pas l’application Chat habituelle pour expliquer ce sujet. Au lieu de cela, nous allons construire le squelette d’un jeu multijoueurs en temps réel. Quelque chose comme un jeu Tic-Tac-Toe.

L’exemple consistera en:

  • Un serveur Websockets, écrit en NodeJS
  • Une Application Mobile:
    • Les utilisateurs fourniront leur nom pour rejoindre le jeu;
    • La liste de tous les joueurs sera actualisée en temps réel;
    • Un utilisateur choisira un autre joueur pour commencer une nouvelle partie;
    • Les deux joueurs seront avertis de manière simultanée et envoyés vers le plateau de jeu;
    • Les joueurs auront la possibilité de:
      • abondonner une partie;
      • jouer et leurs movements seront rendus directement visibles sur le plateau de jeu des 2 joueurs.

Avertissement

Cet exemple vise uniquement à illustrer le sujet. Le squelette du jeu que nous allons écrire est très basique, pas complet et sujet à d’énormes améliorations, validations …

Description

Pour commencer, nous devons décrire le jeu à la fois du côté du client et du côté du serveur.

Côté Client

Le côté client (l’application mobile elle-même), consistera en 2 écrans.

  • Ecran 1:

    Cet écran permettra à l’utilisateur de:

    • Entrer un nom de joueur et de rejoindre le jeu (= action: “join”)
    • Voir la liste de tous les joueurs qui ont rejoint le jeu (= action: “players_list”)
    • Sélectionner un joueur et lancer une nouvelle partie (= action: “new_game”)   Les deux joueurs seront automatiquement amenés au second écran.
  • Ecran 2:

    Cet écran affichera:

    • Le nom du joueur adverse
    • Un bouton de démission. Si l’utilisateur appuie sur ce bouton, le joueur renonce à la partie (= action: “resign”). Les deux joueurs sont ensuite ramenés à l’écran 1
    • Une grille Tic-Tac-Toe, composée de 9 cellules.
    • Quand un joueur clique sur une cellule de la grille, le symbole du joueur (“X” ou “O”) sera affiché sur l’application mobile des deux joueurs

Côté Serveur

Le serveur ne fera que:

  • Enregistrer la liste de tous les joueurs et donner un identifiant unique à chaque joueur
  • Diffuser cette liste à tous les joueurs quand un nouveau joueur rejoint
  • Enregistrer les joueurs d’une nouvelle partie
  • Transmettre les actions d’un joueur à l’autre joueur

Protocole de Communication

Afin d’avoir une communication entre les joueurs et le serveur, nous devons définir une sorte de langage. Nous appelons cela “protocol”.

Tous les messages qui seront envoyés au serveur ou du serveur à l’application mobile suivront ce protocole, qui consiste en:

  • une action
  • certaines données

Pour plus de commodité, j’utiliserai l’objet JSON suivant (= Map):

{
  "action": "nom de l'action",
  "data": "données à transmettre"
}

Le diagramme suivant montre le protocole: websocket game protocol

Server-side: WebSocket server

Pour écrire ce serveur WebSocket très basique, j’ai opté pour une implémentation dans NodeJS, en utilisant le paquet “WebSocket”.

Conditions préalables

Afin que cela puisse fonctionner, vous avez besoin:

  • d’avoir intallé NodeJS (version > 6) (référez-vous à NodeJS Website sur des explications relatives à l’installation de NodeJS).
  • d’installer le package “websocket

    utilisez “npm install websocket –save” pour installer ce package

Le Code Source

Le code source est ce serveur WebSocket est très basique. Voyons ce code. Les explications vont de pair avec le code.

/**
* Paramètres
*/
var webSocketsServerPort = 34263; // Adaptez le numéro de port à utiliser
/**
* Variables globales
*/
// websocket et http servers
var webSocketServer = require('websocket').server;
var http = require('http');
/**
* HTTP server pour implémenter les WebSockets
*/
var server = http.createServer(function(request, response) {
  // Non important pour nous car nous écrivons à WebSocket server
  // et non un HTTP server
});
server.listen(webSocketsServerPort, function() {
  console.log((new Date()) + " Serveur à l'écoute du port "
      + webSocketsServerPort);
});

/**
* WebSocket server
*/
var wsServer = new webSocketServer({
  // WebSocket server est lié à un HTTP server.
  // Un requête WebSocket n'est qu'une extension d'une requête HTTP.
  // Plus d'informations: http://tools.ietf.org/html/rfc6455#page-6
  httpServer: server
});

// Cette fonction est appelée à chaque fois d'un client 
// tente de se connecter au WebSocket server
wsServer.on('request', function(request) {
    
    var connection = request.accept(null, request.origin); 
	
  //
  // A nouveau joueur s'est connecté.  Mémorisons son socket
  //
    var player = new Player(request.key, connection);

  //
  // Ajoutons ce joueur à la liste de tous les joueurs
  //
    Players.push(player);

  //
  // Nous devons retourner l'identifiant unique de ce joueur au joueur lui-même
  //
    connection.sendUTF(JSON.stringify({action: 'connect', data: player.id}));

  //
  // Ecoutons tous les messages émis par ce joueur
  //
    connection.on('message', function(data) {

    //
    // Gestion des actions
    //
      var message = JSON.parse(data.utf8Data);
      switch(message.action){
        //
        // Lorsqu'un joueur envoie une action "join", il fournit un nom.
        // Mémorisons ce nom et maintenant qu'il en a un, 
        // envoyons une liste mise-à-jour de tous les joueurs à 
        // tous les joueurs
        //
          case 'join':
            player.name = message.data;
            BroadcastPlayersList();
            break;

        //
        // Quand un joueur se "couche", nous devons cassez la relation
        // entre les 2 joueurs de la partie et notifier l'autre joueur
        // que le premier s'est couché
        //
          case 'resign':
          console.log('resigned');
            Players[player.opponentIndex]
              .connection
              .sendUTF(JSON.stringify({'action':'resigned'}));

            setTimeout(function(){
              Players[player.opponentIndex].opponentIndex = player.opponentIndex = null;
            }, 0);
            break;

        //
        // Un joueur initie une nouvelle partie.
        // Créons la relation entre les deux joueurs de la partie
        // et notifions l'autre joueur que la partie commence
        // 
          case 'new_game':
            player.setOpponent(message.data);
            Players[player.opponentIndex]
              .connection
              .sendUTF(JSON.stringify({'action':'new_game', 'data': player.name}));
            break;

        //
        // Un joueur joue un mouvement.  Envoyons ce mouvement à l'autre joueur
        //
          case 'play':
            Players[player.opponentIndex]
              .connection
              .sendUTF(JSON.stringify({'action':'play', 'data': message.data}));
            break;
      }
    });

  // L'utilisateur se déconnecte
  connection.on('close', function(connection) {
    // Nous devons supprimer ce joueur de la liste
    // TODO
  });
});

// -----------------------------------------------------------
// Liste des joueurs
// -----------------------------------------------------------
var Players = [];

function Player(id, connection){
    this.id = id;
    this.connection = connection;
    this.name = "";
    this.opponentIndex = null;
    this.index = Players.length;
}

Player.prototype = {
    getId: function(){
        return {name: this.name, id: this.id};
    },
    setOpponent: function(id){
        var self = this;
        Players.forEach(function(player, index){
            if (player.id == id){
                self.opponentIndex = index;
                Players[index].opponentIndex = self.index;
                return false;
            }
        });
    }
};

// ---------------------------------------------------------
// Routine qui envoie la liste de tous les joueurs à tout
// le monde
// ---------------------------------------------------------
function BroadcastPlayersList(){
    var playersList = [];
    Players.forEach(function(player){
        if (player.name !== ''){
            playersList.push(player.getId());
        }
    });

    var message = JSON.stringify({
        'action': 'players_list',
        'data': playersList
    });

    Players.forEach(function(player){
        player.connection.sendUTF(message);
    });
}

Client-Side: Mobile App

Maintenant que nous avons le serveur, considérons l’application Flutter.

Le Websocket Helper

Commençons par implémenter la classe WebSocket Helper.

import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/io.dart';

///
/// Variable globale d'accès aux WebSockets
///
WebSocketsNotifications sockets = new WebSocketsNotifications();

///
/// Remplacez la ligne suivante par l'adresse IP et le numéro de port de votre serveur
///
const String _SERVER_ADDRESS = "ws://192.168.1.45:34263";

class WebSocketsNotifications {
  static final WebSocketsNotifications _sockets = new WebSocketsNotifications._internal();

  factory WebSocketsNotifications(){
    return _sockets;
  }

  WebSocketsNotifications._internal();

  ///
  /// Le canal de communication WebSocket
  ///
  IOWebSocketChannel _channel;

  ///
  /// La connexion est-elle établie ?
  ///
  bool _isOn = false;
  
  ///
  /// Listeners
  /// Liste des méthodes à appeler à chaque fois d'un message est reçu
  ///
  ObserverList<Function> _listeners = new ObserverList<Function>();

  /// ----------------------------------------------------------
  /// Initialisation de la connexion WebSockets avec le serveur
  /// ----------------------------------------------------------
  initCommunication() async {
    ///
    /// Juste au cas..., ferture d'un autre connexion
    ///
    reset();

    ///
    /// Ouvrir une nouvelle communication WebSockets
    ///
    try {
      _channel = new IOWebSocketChannel.connect(_SERVER_ADDRESS);

      ///
      /// Démarrage de l'écoute des nouveaux messages
      ///
      _channel.stream.listen(_onReceptionOfMessageFromServer);
    } catch(e){
      ///
      /// Gestion des erreurs globales
      /// TODO
      ///
    }
  }

  /// ----------------------------------------------------------
  /// Fermer la communication WebSockets
  /// ----------------------------------------------------------
  reset(){
    if (_channel != null){
      if (_channel.sink != null){
        _channel.sink.close();
        _isOn = false;
      }
    }
  }

  /// ---------------------------------------------------------
  /// Envoie un message au serveur
  /// ---------------------------------------------------------
  send(String message){
    if (_channel != null){
      if (_channel.sink != null && _isOn){
        _channel.sink.add(message);
      }
    }
  }

  /// ---------------------------------------------------------
  /// Gestion des routines à appeler lors de la réception
  /// des messages issus du serveur
  /// ---------------------------------------------------------
  addListener(Function callback){
    _listeners.add(callback);
  }
  removeListener(Function callback){
    _listeners.remove(callback);
  }

  /// ----------------------------------------------------------
  /// Appel de toutes les méthodes à l'écoute des messages entrants
  /// ----------------------------------------------------------
  _onReceptionOfMessageFromServer(message){
    _isOn = true;
    _listeners.forEach((Function callback){
      callback(message);
    });
  }
}

Cette classe est un Singleton qui gère la communication basée sur WebSockets.

Je l’ai implémentée en tant que Singleton afin de permettre sa réutilisation sur l’ensemble de l’application sans avoir à se préoccuper de la connexion.

Le simple fait d’importer ce fichier .dart est suffisant pour utiliser socket (variable globale au niveau de l’application).

La classe de communication du Jeu

Cette classe est responsable de la gestion des communications websockets liées au jeu. Cette classe est également implémentée en tant que Singleton car elle sera utilisée par les 2 écrans.

import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'websockets.dart';

///
/// A nouveau, variable globale
///
GameCommunication game = new GameCommunication();

class GameCommunication {
  static final GameCommunication _game = new GameCommunication._internal();

  ///
  /// A la première initialisation, le joueur n'a pas encore de nom
  ///
  String _playerName = "";

  ///
  /// Avant d'émettre l'action "join", le joueur n'a pas encore d'identifiant unique
  ///
  String _playerID = "";

  factory GameCommunication(){
    return _game;
  }

  GameCommunication._internal(){
    ///
    /// Initialisons la communication WebSockets
    ///
    sockets.initCommunication();

    ///
    /// et demandons d'être notifié à chaque fois qu'un message est envoyé par le serveur
    /// 
    sockets.addListener(_onMessageReceived);
  }

  ///
  /// Retourne le nom du joueur
  ///
  String get playerName => _playerName;

  /// ----------------------------------------------------------
  /// Gestion de tous les messages en provenance du serveur
  /// ----------------------------------------------------------
  _onMessageReceived(serverMessage){
    ///
    /// Comme les messages sont envoyés sous forme de chaîne de caractères
    /// récupérons l'objet JSON correspondant
    ///
    Map message = json.decode(serverMessage);

    switch(message["action"]){
      ///
      /// Quand la communication est établie, le serveur
      /// renvoie l'identifiant du joueur.
      /// Mémorisons-le
      ///
      case 'connect':
        _playerID = message["data"];
        break;

      ///
      /// Pour tout autre message entrant,
      /// envoyons-le à tous les "listeners".
      ///
      default:
        _listeners.forEach((Function callback){
          callback(message);
        });
        break;
    }
  }

  /// ----------------------------------------------------------
  /// Méthode d'envoi des messages au serveur
  /// ----------------------------------------------------------
  send(String action, String data){
    ///
    /// Quand un joueur rejoint, nous devons mémoriser son nom
    ///
    if (action == 'join'){
      _playerName = data;
    }

    ///
    /// Envoi d'une action au serveur
    /// Pour envoyer le message, nous transformons l'objet
    /// JSON en chaîne de caractères 
    ///
    sockets.send(json.encode({
      "action": action,
      "data": data
    }));
  }

  /// ==========================================================
  ///
  /// Listeners pour permettre aux différentes pages/écrans 
  /// d'être notifiés à chaque réception d'un message du serveur
  ///
  ObserverList<Function> _listeners = new ObserverList<Function>();

  addListener(Function callback){
    _listeners.add(callback);
  }
  removeListener(Function callback){
    _listeners.remove(callback);
  }
}

Pourquoi ai-je choisi d’implémenter cette classe, basée sur le WebSockets Helper?

Tout simplement parce que toute la logique liée au Jeu est centralisée. En outre, si nous souhaitons étendre le jeu en ajoutant une fonctionnalité Chat, par exemple, nous n’aurons qu’à créer une classe spécifique qui dépendrait également du même assistant WebSockets.


Ecran 1: Où l’utiliseur rejoint et lance une nouvelle partie

Cet écran est responsable de:

  • Laisser l’utilisateur rejoindre le jeu, en fournissant un nom
  • Maintenir une liste en temps réel de tous les joueurs
  • Laisser l’utilisateur commencer une nouvelle partie avec un autre joueur
import 'package:flutter/material.dart';
import 'game_communication.dart';
import 'game_page.dart';

class StartPage extends StatefulWidget {
  @override
  _StartPageState createState() => _StartPageState();
}

class _StartPageState extends State<StartPage> {
  static final TextEditingController _name = new TextEditingController();
  String playerName;
  List<dynamic> playersList = <dynamic>[];

  @override
  void initState() {
    super.initState();
    ///
    /// Demandons d'être notifié pour chaque message
    /// envoyé par le serveur
    ///
    game.addListener(_onGameDataReceived);
  }

  @override
  void dispose() {
    game.removeListener(_onGameDataReceived);
    super.dispose();
  }

  /// -------------------------------------------------------------------
  /// Cette routine gère tous les messages envoyés par le serveur.
  /// Seules les 2 actions suivantes, sont d'utilité dans cette page
  ///  - players_list
  ///  - new_game
  /// -------------------------------------------------------------------
  _onGameDataReceived(message) {
    switch (message["action"]) {
      ///
      /// A chaque fois qu'un utilisateur rejoint, nous devons
      ///   * enregistrer la nouvelle liste de joueurs
      ///   * rafraîchir la liste des joueurs
      ///
      case "players_list":
        playersList = message["data"];
        
        // force rafraîchissement
        setState(() {});
        break;

      ///
      /// Quand une partie est démarrée par un autre joueur,
      /// nous acceptons la partie et nous dirigeons vers
      /// l'écran 2 (jeu)
      /// Comme nous ne sommes pas l'initiateur du jeu,
      /// nous utiliserons les "O" pour jouer
      ///
      case 'new_game':
        Navigator.push(context, new MaterialPageRoute(
          builder: (BuildContext context)
                      => new GamePage(
                            opponentName: message["data"], // Nom de l'adversaire
                            character: 'O',
                        ),
        ));
        break;
    }
  }

  /// -----------------------------------------------------------
  /// Lorsque l'utilisateur n'a pas encore rejoint, nous le 
  /// laissons introduire son nom et rejoindre la liste des
  /// joueurs
  /// -----------------------------------------------------------
  Widget _buildJoin() {
    if (game.playerName != "") {
      return new Container();
    }
    return new Container(
      padding: const EdgeInsets.all(16.0),
      child: new Column(
        children: <Widget>[
          new TextField(
            controller: _name,
            keyboardType: TextInputType.text,
            decoration: new InputDecoration(
              hintText: 'Enter your name',
              contentPadding: const EdgeInsets.fromLTRB(20.0, 10.0, 20.0, 10.0),
              border: new OutlineInputBorder(
                borderRadius: new BorderRadius.circular(32.0),
              ),
              icon: const Icon(Icons.person),
            ),
          ),
          new Padding(
            padding: const EdgeInsets.all(8.0),
            child: new RaisedButton(
              onPressed: _onGameJoin,
              child: new Text('Join...'),
            ),
          ),
        ],
      ),
    );
  }

  /// ------------------------------------------------------
  /// L'utilisateur veut rejoindre.  Envoyons son nom.
  /// Maintenant que nous avons le nom du joueur, nous 
  /// pouvons afficher la liste des tous les joueurs.
  /// ------------------------------------------------------
  _onGameJoin() {
    game.send('join', _name.text);
	
    /// Forcer un rafraîchissement
    setState(() {});
  }

  /// ------------------------------------------------------
  /// Construction de la liste des joueurs
  /// ------------------------------------------------------
  Widget _playersList() {
    ///
    /// Si l'utilisateur n'a pas encore rejoint,
    /// nous n'affichons pas la liste des tous les joueurs
    ///
    if (game.playerName == "") {
      return new Container();
    }

    ///
    /// Affichage de la liste des joueurs...
    /// Pour chaque joueur, un bouton permet de lancer
    /// une nouvelle partie
    ///
    List<Widget> children = playersList.map((playerInfo) {
        return new ListTile(
          title: new Text(playerInfo["name"]),
          trailing: new RaisedButton(
            onPressed: (){
              _onPlayGame(playerInfo["name"], playerInfo["id"]);
            },
            child: new Text('Play'),
          ),
        );
      }).toList();

    return new Column(
      children: children,
    );
  }

  /// --------------------------------------------------------------
  /// Nous lançons une nouvelle partie.  Nous devons:
  ///    * envoyer l'action "new_game", en même temps que 
  ///      l'identifiant unique de l'adversaire choisi
  ///    * rediriger vers le plateau de jeu
  ///      Comme nous sommes l'initiateur du jeu, nous jouerons
  //       avec les "X"
  /// --------------------------------------------------------------
  _onPlayGame(String opponentName, String opponentId){
    // Nous devons envoyer l'identité de l'adversaire
    game.send('new_game', opponentId);
	
    Navigator.push(context, new MaterialPageRoute(
      builder: (BuildContext context) 
                  => new GamePage(
                      opponentName: opponentName, 
                      character: 'X',
                    ),
    ));
  }

  @override
  Widget build(BuildContext context) {
    return new SafeArea(
      bottom: false,
      top: false,
      child: Scaffold(
        appBar: new AppBar(
          title: new Text('TicTacToe'),
        ),
        body: SingleChildScrollView(
          child: new Column(
            mainAxisAlignment: MainAxisAlignment.start,
            children: <Widget>[
              _buildJoin(),
              new Text('List of players:'),
              _playersList(),
            ],
          ),
        ),
      ),
    );
  }
}

Ecran 2: le plateau de jeu

Ce deuxième écran est responsable de:

  • afficher le nom de l’adversaire dans l’AppBar
  • permettre à l’utilisateur de démissionner, via un bouton ‘Resign
  • afficher le jeu, en temps réel, avec tous les mouvements
  • permettre à l’utilisateur de jouer un coup et d’envoyer le coup à l’adversaire.

import 'package:flutter/material.dart';
import 'game_communication.dart';

class GamePage extends StatefulWidget {
  GamePage({
    Key key,
    this.opponentName,
    this.character,
  }): super(key: key);

  ///
  /// Nom de l'adversaire
  ///
  final String opponentName;
  
  ///
  /// Caratère utilisé pour les jetons du jeu ("X" ou "O")
  ///
  final String character;

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

class _GamePageState extends State<GamePage> {

  ///
  /// Un jeu consiste en une grille de 3 cases sur 3 cases.
  /// Quand un joueur joue, une de ces cases est remplie avec un "X" ou "O"
  ///
  List<String> grid = <String>["","","","","","","","",""];

  @override
  void initState(){
    super.initState();
    ///
    /// On écoute tous les messages relatifs au jeu+
    ///
    game.addListener(_onAction);
  }

  @override
  void dispose(){
    game.removeListener(_onAction);
    super.dispose();
  }

  /// ---------------------------------------------------------
  /// L'adversaire a émis une action.
  /// Gestion de ces actions.
  /// ---------------------------------------------------------
  _onAction(message){
    switch(message["action"]){
      ///
      /// L'adversaire a démissionné, alors laissons cet écran
      ///
      case 'resigned':
          Navigator.of(context).pop();
        break;

      ///
      /// L'adversaire à joué un coup.
      /// Mémorisons-le et affichons-le sur le plateau
      ///
      case 'play':
          var data = (message["data"] as String).split(';');
          grid[int.parse(data[0])] = data[1];

          // Forcer rafraîchissement
          setState((){});
        break;
    }
  }

  /// ---------------------------------------------------------
  /// Ce joueur rémissionne
  /// Nous devons envoyer une notification à l'adversaire.
  /// Ensuite, nous quittons cet écran.
  /// ---------------------------------------------------------
  _doResign(){
    game.send('resign', '');
    Navigator.of(context).pop();
  }

  @override
  Widget build(BuildContext context) {
    return new SafeArea(
      top: false,
      bottom: false,
      child: new Scaffold(
        appBar: new AppBar(
          title: new Text('Game against: ${widget.opponentName}', style: new TextStyle(fontSize: 16.0)),
          actions: <Widget>[
            new RaisedButton(
              onPressed: _doResign,
              child: new Text('Resign'),
            ),
          ]
        ),
        body: _buildBoard(),
      ),
    );
  }

  /// --------------------------------------------------------
  /// Construction du plateau de jeu
  /// --------------------------------------------------------
  Widget _buildBoard(){
    return new SafeArea(
      top: false,
      bottom: false,
      child: new GridView.builder(
        gridDelegate: new SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3,
        ),
        itemCount: 9,
        itemBuilder: (BuildContext context, int index){
          return _gridItem(index);
        },
      ),
    );
  }

  Widget _gridItem(int index){
    Color color = grid[index] == "X" ? Colors.blue : Colors.red;

    return new InkWell(
      onTap: () {
        ///
        /// Le joueur touche une case.
        /// Si cette dernière est vide, nous la remplissons
        /// avec le caractère du joueur et notifions son adversaire.
        /// Nous rafraîchissons le plateau de jeu
        ///
        if (grid[index] == ""){
          grid[index] = widget.character;

          ///
          /// Pour envoyer un mouvement, nous fournissons le numéro
          /// de la case et le caractère des jetons du joueur
          ///
          game.send('play', '$index;${widget.character}');

          /// Forcer le rafraîchissement du plateau
          setState((){});
        }
      },
      child: new GridTile(
        child: new Card(
          child: new FittedBox(
            fit: BoxFit.contain,
            child: new Text(grid[index], style: new TextStyle(fontSize: 50.0, color: color,))
          ),
        ),
      ),
    );
  }
}


L’entrée principale du programme

Enfin, nous avons la routine principale qui lance simplement le premier écran.

import 'package:flutter/material.dart';
import 'start_page.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'WebSockets Demo',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new StartPage(),
    );
  }
}


Résultat

La vidéo suivante montre 2 appareils mobiles exécutant cet exemple d’application. Comme vous pouvez le voir, les interactions sont en temps réel.

résultat résultat

Conclusions

Les WebSockets sont faciles à mettre en œuvre et sont essentiels lorsqu’une application mobile doit gérer des communications en temps réel et en duplex intégral.

J’espère que cet article a réussi à démystifier le concept des WebSockets à travers cet exemple pratique qui, une fois de plus, ne vise qu’à démontrer la communication en utilisant WebSockets.

Restez à l’écoute pour de nouveaux articles et bon codage.