D’année en année, les exigences des consommateurs d’applications mobiles ont drastiquement changé. Dorénavant, les utilisateurs souhaitent voir les données de leurs applications évoluer en temps réel. Que les applications soient des applications de finance, de collaboration, des réseaux sociaux ou encore des jeux, il est impératif que les données soient « poussées » directement sur l’appareil, et cela avec une très bonne performance.
Quelles solutions existent pour créer une application mobile avec des données en temps réel ? Quels sont les pièges à éviter et les règles à suivre ?
APNS, GCM et WebSockets
Aussi bien sur iOS que sur Android, il existe des solutions natives pour recevoir des données directement poussées par des serveurs : il s’agit d’APNS (iOS) et de GCM (Android).
Bien que ces solutions soient natives, elles ne sont pas toujours à préconiser car elles impliquent de fortes contraintes. Par exemple sur iOS, les push notifications envoyées ont un format imposé et ne doivent pas dépasser 256 octets. Aussi, les push notifications peuvent très bien ne jamais être reçues par un utilisateur si ce dernier ne souhaite pas les recevoir.
De ce fait, il est intéressant de se pencher sur les WebSockets : c’est un protocole de communication relativement nouveau (2011) qui se veut bidirectionnel et qui est supporté de plus en plus par diverses plateformes (dont les navigateurs). Ce protocole a l’avantage d’être très peu couteux et facile à mettre en place. Cet article présente un exemple d’implémentation de cette technologie, côté serveur et mobile (iOS).
Le serveur : Node.js et SockJS
Pour notre exemple, l’implémentation côté serveur se fera en Node.js. Pour la WebSocket, nous utiliserons une bibliothèque JavaScript appelée SockJS.
La création de la WebSocket est très simple et quasi-immédiate :
var connectedDevices = []; var echo = sockjs.createServer(options); echo.on('connection', function(conn) { connectedDevices.push(conn); conn.on('data', function(message) { }); conn.on('close', function() { connectedDevices.splice(connectedDevices.indexOf(conn), 1); }); });
Il s’agit juste ici de garder dans un tableau (connectedDevices) l’ensemble des connexions ouvertes. Ultérieurement, cela nous permettra d’envoyer des données via ces connexions.
Pour tester facilement, nous ajoutons la route "/dispatchMessage" sur le serveur qui permettra de déclencher l’envoi d’un message à toutes les connexions ouvertes de la WebSocket :
app.post('/dispatchMessage', function(req, res) { var messageToDispatch = req.body["message"]; var i = 0; if (messageToDispatch && messageToDispatch.length) { for (; i < connectedDevices.length; i++) { connectedDevices[i].write(messageToDispatch); } } res.type('application/json; charset=utf-8'); res.send('{ "result" : "message dispatched to ' + i + ' devices" }'); });
L’ensemble du code de la partie serveur est accessible ici : https://github.com/MartinMoizard/Realtime-samples/blob/master/server/main.js.
iOS et WebSocket
L’implémentation côté iOS se fera grâce à la bibliothèque SocketRocket. Bien que l’implémentation puisse paraitre futile, comme nous sommes en situation de mobilité, il y a tout de même certaines règles à respecter afin d’offrir des conditions optimales aux utilisateurs de notre application.
La WebSocket doit être connectée à tout moment, et le plus rapidement possible
Afin d’optimiser nos chances de recevoir les données en temps réel et le plus vite possible, il faut ouvrir la websocket… Le plus rapidement possible. Un bon moyen est de faire cela dans l’AppDelegate :
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { self.realTimeObject = [[XBRealtimeObject alloc] init]; return YES; }
La websocket est ici encapsulée dans l’objet XBRealtimeObject. À l’instanciation, la websocket est directement ouverte de cette manière :
- (id)init { self = [super init]; if (self) { [self connect:YES]; [self registerForReachabilityChanges]; } return self; } - (void)connect:(BOOL)force { [self open]; } - (void)open { if (self.webSocket && self.webSocket.readyState == SR_OPEN) { [self.webSocket close]; } self.webSocket = [[SRWebSocket alloc] initWithURL:[NSURL URLWithString:kXBWebSocketRawUrl]]; self.webSocket.delegate = self; [self.webSocket open]; }
Le paramètre "force" de la fonction "connect:" permet de choisir si l’on veut forcer une connexion immédiatement ou avec un délai. Nous verrons plus tard comment ce paramètre est utilisé.
L’application étant embarquée dans un appareil mobile, des déconnexions peuvent intervenir à n’importe quel moment. Dans ce cas là, il faut retenter d’ouvrir la websocket. Afin d’optimiser les chances de l’ouvrir le plus rapidement possible, il est conseillé de tenter une reconnexion lorsque la websocket est fermée et aussi quand le téléphone a de nouveau accès à Internet :
- (void)webSocket:(SRWebSocket *)webSocket didFailWithError:(NSError *)error { // If the websocket failed to connect, let's try again [self connect:NO]; } - (void)webSocket:(SRWebSocket *)webSocket didCloseWithCode:(NSInteger)code reason:(NSString *)reason wasClean:(BOOL)wasClean { [self connect:NO]; } - (void)registerForReachabilityChanges { __weak XBRealtimeObject *weakSelf = self; self.reachability = [Reachability reachabilityWithHostname:kXBWebSocketRawUrl]; // In case the connection was lost and established again, we need to try to reopen the websocket ASAP self.reachability.reachableBlock = ^(Reachability *r) { [weakSelf connect:YES]; }; [self.reachability startNotifier]; }
Il faut éviter de tenter des reconnexions dans un intervalle de temps réduit
Étant donné que l’application peut subir des déconnexions/reconnexions dans un intervalle de temps très petit, et cela avec une fréquence importante, si nous essayons d’ouvrir la websocket automatiquement dans "webSocket:didFailWithError:" ou "webSocket:didCloseWithCode:reason:wasClean:", l’application va boucler très rapidement et consommer des ressources pour rien. De ce fait, il est conseillé de mettre en place un garde-fou afin de retarder les essais d’ouverture de la websocket. C’est ici que le paramètre "force" de la méthode "connect:" va intervenir :
- (void)open { BOOL socketAlreadyOpened = (self.webSocket != nil) && ((self.webSocket.readyState == SR_OPEN) || (SR_CONNECTING == self.webSocket.readyState)); // We should not try to open a socket if one is already opened or connecting if (!socketAlreadyOpened) { if (self.webSocket && self.webSocket.readyState == SR_OPEN) { [self.webSocket close]; } self.webSocket = [[SRWebSocket alloc] initWithURL:[NSURL URLWithString:kXBWebSocketRawUrl]]; self.webSocket.delegate = self; [self.webSocket open]; } } - (void)connect:(BOOL)force { // If we want to force a connection, let's try to connect without any delay if (force) { self.retryTimeInterval = 0; } else { // Otherwise, let's increase the retry time interval self.retryTimeInterval = (self.retryTimeInterval >= 0.1 ? self.retryTimeInterval * 2 : 0.1); self.retryTimeInterval = MIN(XBWebSocketMaximumReconnectionDelay, self.retryTimeInterval); } dispatch_after(dispatch_time(DISPATCH_TIME_NOW, self.retryTimeInterval * NSEC_PER_SEC), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0) , ^{ [self open]; }); }
Avec cette implémentation, on est assuré de ne pas encombrer le système avec des tentatives d’ouvertures trop régulières.
Design pattern possible à la réception de données
Lorsque l’application réceptionne des données via la websocket, elle peut se trouver dans n’importe quel état. De ce fait, il n’est pas systématique que l’écran affiché ait besoin des données fraichement reçues.
Un bon design pattern à adopter afin de gérer les nouvelles données est celui des notifications : à la réception de données, il suffit de lancer une notification (via le NSNotificationCenter) et de laisser les controlleurs gérer ces notifications si nécessaires. L’implémentation côté websocket est très simple :
- (void)webSocket:(SRWebSocket *)webSocket didReceiveMessage:(id)message { NSString *aMessage = message; // In this example, message is a string [[NSNotificationCenter defaultCenter] postNotificationName:kXBWebSocketDidReceiveMessageNotification object:self userInfo:@{kXBWebSocketMessageKey : aMessage}]; }
Du côté des controlleurs, il suffit de s’abonner à la notification "kXBWebSocketDidReceiveMessageNotification" si le controlleur en question a besoin de données en temps réel :
- (void)addRealtimeObserver { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(realtimeMessageReceived:) name:kXBWebSocketDidReceiveMessageNotification object:nil]; } - (void)removeRealtimeObserver { [[NSNotificationCenter defaultCenter] removeObserver:self name:kXBWebSocketDidReceiveMessageNotification object:nil]; } - (void)realtimeMessageReceived:(NSNotification *)notification { __block NSString *aMessage = notification.userInfo[kXBWebSocketMessageKey]; dispatch_async(dispatch_get_main_queue(), ^{ self.messageLabel.text = aMessage; }); }
De telle façon, la méthode "realtimeMessageReceveid:" sera automatiquement appelée lorsque l’application recevra des données provenant de la websocket.
Les limites en mobilité
L’utilisation des websocket a tout de même des limites. Les configurations des opérateurs téléphoniques ne sont pas identiques et certains d’entre eux bloquent le protocole des WebSockets. De ce fait, lorsque l’utilisateur sera en 4G/3G ou en Edge, la websocket ne pourra plus être ouverte et les données temps réel ne seront plus reçues.
Une astuce pour contourner ce problème est de mettre en place une connexion sécurisée afin d’utiliser le port 443 – SSL (ce port étant utilisable chez l’ensemble des opérateurs).
Conclusion
Cet article montre brièvement et de manière illustrée comment faire du temps réel dans une application iOS. N’hésitez pas à vous inspirer de cette implémentation afin d’ajouter du temps réel dans vos applications si le sujet s’y prête. La même approche est bien sûr valable sur d’autres plateformes telle qu’Android (voir android-websockets). Le code source final de la partie serveur de cet article est disponible ici et celui de l’application iOS sur ce lien.