# Sircle — construire un réseau social géolocalisé en Swift 3 avec RxSwift et Realm > Retour technique sur Sircle, une app iOS de networking professionnel pour les agences marketing. Architecture RxSwift-first, persistance Realm, API typesafe avec Moya, UI à bulles physiques avec UIDynamicAnimator, et chat temps réel via SendBird. Date : 20/03/2017 Auteur : Aurélien N. Tags : Swift, iOS, RxSwift, Realm, Architecture --- Sircle est un réseau social professionnel pensé pour les gens qui bossent en agence de marketing et de communication. L'idée : tu arrives dans un event, une conf, un afterwork, et l'app te montre qui est autour de toi dans le milieu. Tu vois leurs profils, leurs posts sur les différents réseaux sociaux, et tu peux les ajouter à ton cercle ou leur envoyer un message directement. On a commencé le dev en septembre 2016, avec une première beta en décembre. On est maintenant sur la phase de finalisation avant la sortie sur l'App Store. C'est un projet 100% natif iOS, en Swift 3, avec une architecture qu'on a voulue réactive de bout en bout. ## L'architecture en un coup d'oeil ## Le stack technique On est sur iOS 10, Swift 3 (sorti en septembre dernier avec Xcode 8), et un stack de dépendances gérées par CocoaPods. Le choix Swift 3 était obligé : Xcode 8.2 est la dernière version à supporter Swift 2.3, et la migration était déjà en cours sur tous nos projets. Le "Grand Renaming" d'Apple nous a coûté quelques jours, mais au moins c'est censé être la dernière migration source-breaking. Les dépendances principales : ```ruby pod "RxSwift" # reactive programming pod "RxCocoa" # bindings UIKit pod "RealmSwift" # base de données locale pod "RxRealm" # extensions Rx pour Realm pod "Moya" # couche réseau type-safe pod "Moya/RxSwift" # extensions Rx pour Moya pod "SendBirdSDK" # chat en temps réel pod "SnapKit" # Auto Layout programmatique pod "Kingfisher" # chargement/cache d'images pod "SwiftLocation" # abstraction CoreLocation ``` ## L'API type-safe avec Moya Moya est une couche d'abstraction au-dessus d'Alamofire. Le principe : on modélise l'API comme un `enum` Swift, et chaque case est un endpoint. Le compilateur vérifie qu'on appelle des routes valides avec les bons paramètres. ```swift enum SircleAPI ``` L'enum implémente le protocole `TargetType` de Moya, qui déclare le path, la méthode HTTP, les paramètres et l'encoding pour chaque case : ```swift extension SircleAPI: TargetType { var baseURL: URL var path: String { switch self } var method: Moya.Method { switch self } } ``` Le provider réseau est un singleton `RxMoyaProvider` qui chaîne des plugins : injection automatique du token d'authentification, logout sur 401, indicateur réseau, et logs en debug : ```swift let SircleProvider: RxMoyaProvider = { var plugins: [PluginType] = [ AuthPlugin(), SignOutOnUnauthorized(), NetworkActivityPlugin(networkActivityClosure: ) ] return RxMoyaProvider(plugins: plugins) }() ``` L'intérêt par rapport à Alamofire brut : un appel API retourne un `Observable`, ce qui s'intègre directement dans les chaînes RxSwift. Et les stubs de test sont des citoyens de première classe dans Moya, on peut mocker une réponse réseau en trois lignes. ## Le modèle de données : Entity vs Model On a séparé les objets en deux couches. Les **Entities** sont des structs Gloss (un mapper JSON, c'est ce qu'on utilisait avant Codable) qui décodent les réponses de l'API. Les **Models** sont des objets Realm qui persistent en local. ```swift // Entity — décodage JSON via Gloss class UserEntity: BaseUserEntity ``` ```swift // Model — objet Realm persisté en local class User: BaseUser ``` La conversion passe par des méthodes `update(from:)` sur les Models. Un `User` Realm est mis à jour depuis un `UserEntity` quand l'API répond. Ça évite de coupler le format JSON au schéma de la base locale. Le friendship entre utilisateurs est modélisé comme une machine à états. Cinq états, et les transitions dépendent de qui initie l'action : ```swift enum FriendshipState: String ``` ## L'interface à bulles : UIDynamicAnimator et RxSwift La feature la plus visible de Sircle, c'est l'interface à bulles. L'écran d'accueil affiche le profil de l'utilisateur au centre, entouré de bulles d'action ("Who's Around Me", "All My Friends", "Invite Friends") et de bulles représentant les contacts. Quand on passe en mode "Who's Around Me", les bulles se réorganisent pour montrer les utilisateurs à proximité. Tout ça repose sur `UIDynamicAnimator`, le moteur physique d'UIKit. Chaque bulle est une `UIView` attachée à des comportements dynamiques : ```swift func createUserEntryWam(for user: User, scaleBubbles: Bool, scaleFactors: [Int:CGFloat]) -> UserEntryWam ``` Chaque bulle a un `UISnapBehavior` qui l'attire vers le centre, un `UICollisionBehavior` partagé qui empêche les bulles de se chevaucher, et un `UIDynamicItemBehavior` pour les propriétés physiques (élasticité, friction). Le résultat : les bulles rebondissent les unes contre les autres et trouvent leur position d'équilibre naturellement. La taille des bulles dépend de la proximité. Les utilisateurs proches ont des bulles plus grosses, les éloignés plus petites, avec un scaling dynamique quand il y a beaucoup de monde : ```swift let maxScale: CGFloat = 1.5 let minScale: CGFloat = 0.5 let numScale = 5 let usersSorted = users.sorted(by: ) for (i, user) in usersSorted.enumerated() ``` ### Le flux réactif : de l'API aux bulles En résumé, la donnée traverse cinq couches avant d'arriver à l'écran. Chaque flèche est un `Observable` ou un `Driver` RxSwift. Aucun callback, aucun delegate. ### Le ViewModel réactif Le `BubblesView.ViewModel` concentre la logique d'affichage. 415 lignes, c'est le plus gros fichier du projet après `Actions.swift`. Il expose des `Driver` RxSwift pour chaque aspect de l'UI : ```swift class ViewModel { enum State let state: Variable let users: Driver<[User]> let favoriteUsers: Driver<[User]> let pendingUsers: Driver<[User]> let userScaleFactors: Driver<[Int:CGFloat]> let showTags: Driver let isLoading: Driver let showActionButtons: Driver let conversations: Driver<[Conversation]> // ... } ``` Les `Driver` sont des `Observable` garantis de n'émettre que sur le main thread et de ne jamais errorer. Parfait pour du binding UI. Quand l'état change de `.home` à `.whosAroundMe`, le ViewModel recalcule les prédicats Realm, recharge les utilisateurs filtrés par distance, recalcule les facteurs de scale, et le tout se propage jusqu'à la vue. Pour ceux qui ne connaissent pas RxSwift, voici ce que fait `combineLatest` concrètement. Trois streams entrent, un stream sort. À chaque émission sur l'un des trois, la fonction de combinaison est rappelée avec les dernières valeurs des deux autres : Le filtrage des utilisateurs par état et par distance se fait avec des prédicats Realm combinés réactivement : ```swift cursorPredicate = Driver.combineLatest(state$, wamDistance$, hasPinnedVenue) { (state, wamDistance, hasPinnedVenue) in switch state { case .home, .loading: return NSPredicate(format: "isBlocked = NO AND (isFavorite = YES OR isPending = YES " + "OR privateFriendshipState = 'requested')") case .whosAroundMe: if hasPinnedVenue else } } ``` Quand le prédicat change, la requête Realm est réexécutée et les résultats sont diffés pour animer les transitions (ajout, suppression, déplacement de bulles). ## La géolocalisation : un service réactif La géolocalisation est au coeur de l'app. Le `GeolocationService` est un singleton qui gère tout : autorisation CoreLocation, tracking en arrière-plan, synchronisation avec le serveur, et intégration Foursquare pour le "venue pinning" (l'utilisateur peut se localiser dans un lieu précis plutôt que par GPS). ```swift class GeolocationService ``` Le pipeline réactif du `GeolocationService` est un bon exemple de ce que RxSwift permet. Plusieurs sources d'événements (GPS, autorisation, venue Foursquare) sont combinées en un seul stream de coordonnées qui alimente le reste de l'app : Le service observe les changements d'autorisation CoreLocation de manière réactive : ```swift let authorizationStatus$: Observable = Observable.deferred ``` On a écrit une extension Rx pour `CLLocationManager` qui transforme les callbacks delegate en `Observable`. Une dizaine de lignes. Mais au lieu de gérer un delegate avec trois méthodes et un état mutable, on a un stream de localisations qu'on peut combiner, filtrer et transformer comme n'importe quel autre `Observable`. Le venue pinning ajoute une couche intéressante. Quand l'utilisateur se "pin" sur un lieu Foursquare, sa position est celle du lieu jusqu'à ce qu'il s'en éloigne de plus de 100 mètres : ```swift let distanceFromPinnedVenue$: Observable = detectedLocation$ .withLatestFrom(pinnedVenue$) { (location, venue) in guard let venue = venue else return CLLocation(latitude: location.latitude, longitude: location.longitude) .distance(from: CLLocation( latitude: venue.coordinate.latitude, longitude: venue.coordinate.longitude)) } distanceFromPinnedVenue$ .filter() .map() .bindTo(pinnedVenue) .disposed(by: disposeBag) ``` Le service s'occupe aussi d'envoyer la position au backend en arrière-plan. Dès que la position change et que l'utilisateur est connecté, on POST les nouvelles coordonnées. iOS 10 rend ça possible avec `allowsBackgroundLocationUpdates = true` et le "significant location change monitoring", qui réveille l'app quand l'utilisateur se déplace d'une antenne cellulaire à une autre. ## Le chat avec SendBird Pour la messagerie, on utilise SendBird. C'est un SDK de chat as-a-service qui gère les connexions temps réel, la persistance des messages, les push notifications et la montée en charge. On a évalué Layer (qui commençait à pivoter vers le support client) et Firebase (qui aurait demandé de tout construire à la main). SendBird fournit l'infrastructure et on gère l'UI. Côté UI, on utilise `JSQMessagesViewController`, la librairie de référence pour les interfaces de chat style iMessage. Le `ChatViewController` hérite de `JSQMessagesViewController` et gère la synchronisation avec SendBird : L'astuce, c'est le `SendBirdSyncer` qui synchronise les channels SendBird avec des objets `Conversation` dans Realm. L'écran de conversations observe la collection Realm, et les changements (nouveau message, lu/non-lu) se propagent automatiquement via RxRealm. ```swift class Conversation: Object ``` ## L'authentification multi-réseau L'inscription passe exclusivement par les réseaux sociaux : Facebook, Twitter ou LinkedIn. On utilise les SDK natifs de chaque plateforme pour l'OAuth, puis on envoie le token au backend qui crée ou retrouve le compte Sircle. L'écran de login affiche une vidéo en fond (ambiance agence, open space) via un `VideoPlayerView` custom. Un bug rigolo qu'on a fixé dans la beta 2 : quand l'app passait en arrière-plan, la vidéo se mettait en pause et ne reprenait pas au retour. Il a fallu observer les notifications `UIApplication.willEnterForeground` pour relancer la lecture. L'agrégation des posts sociaux est l'autre versant de l'intégration réseaux. Chaque utilisateur a ses comptes connectés (Facebook, Twitter, Instagram, LinkedIn), et l'app affiche un feed unifié de ses publications récentes. Les posts sont stockés comme des `SocialPost` Realm avec le réseau d'origine, le texte, l'image, le nombre de likes, et un lien vers le post original. ## Ce que ça donne en production 126 fichiers Swift, environ 16 000 lignes de code. Le plus gros fichier est `Actions.swift` (800 lignes) qui centralise toutes les actions asynchrones sous forme de méthodes statiques retournant des `Observable`. C'est le "service layer" de l'app, l'équivalent d'un gros service Angular. L'archi RxSwift a un coût d'entrée. Un développeur iOS classique qui débarque sur le projet va mettre un moment à comprendre les chaînes de `flatMapLatest`, `combineLatest` et `distinctUntilChanged`. Mais une fois qu'on est dedans, la gestion du temps réel (position qui change, messages qui arrivent, bulles qui se réorganisent) est beaucoup plus lisible qu'avec des delegates imbriqués. Realm avec RxRealm fonctionne bien comme source de vérité locale. On écrit dans Realm, les `Observable` émettent, l'UI se met à jour. Pas de `NotificationCenter`, pas de delegate, pas de KVO. Quand un utilisateur passe de "requesting" à "accepted" dans la base, la bulle correspondante change de couleur et de taille automatiquement. Le point faible, c'est la testabilité. On n'a pas assez de tests unitaires. Moya rend le mocking réseau facile, mais tester les chaînes Rx complexes du ViewModel demande un setup spécifique (`TestScheduler`, `TestableObserver`) qu'on n'a pas eu le temps de mettre en place sur ce projet. C'est un regret. Le projet continue, on vise une sortie App Store dans les prochaines semaines. La cible est niche, les gens en agence de com', et on verra si ça prend. Il n'y a rien sur le marché pour ce use case, mais ça ne veut pas dire que le marché existe.