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 :
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.
enum SircleAPI {
case loginFacebook(String)
case loginTwitter(Parameters)
case loginLinkedin(String, Bool)
case loadCurrentUser(Parameters?)
case loadUser(Int)
case loadUserPosts(Int)
case whosAroundMe
case searchUserByName(String)
case uploadImage(Data, ImageType)
case updatePostLike(Int, Bool)
case reports(Int, ReportCause)
}
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 :
extension SircleAPI: TargetType {
var baseURL: URL { return Settings.apiURL }
var path: String {
switch self {
case .whosAroundMe:
return "users/me/whos_around_me"
case .loadUser(let userId):
return "users/\(userId)"
case .loadUserPosts(let userId):
return "users/\(userId)/posts"
case .searchUserByName:
return "users/search"
// ...
}
}
var method: Moya.Method {
switch self {
case .loginFacebook, .loginTwitter, .loginLinkedin,
.updatePostLike, .reports:
return .post
case .currentUserUpdate, .uploadImage:
return .patch
default:
return .get
}
}
}
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 :
let SircleProvider: RxMoyaProvider<SircleAPI> = {
var plugins: [PluginType] = [
AuthPlugin(),
SignOutOnUnauthorized(),
NetworkActivityPlugin(networkActivityClosure: { (state) in
UIApplication.shared.isNetworkActivityIndicatorVisible = state == .began
})
]
return RxMoyaProvider<SircleAPI>(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.
// Entity — décodage JSON via Gloss
class UserEntity: BaseUserEntity {
let isPending: Bool?
let isFavorite: Bool?
let isBlocked: Bool?
let distanceMeters: CGFloat?
let friendshipState: String?
// ...
}
// Model — objet Realm persisté en local
class User: BaseUser {
dynamic var isPending = false
dynamic var isFavorite = false
dynamic var isBlocked = false
dynamic var distanceMeters: CGFloat = CGFloat.greatestFiniteMagnitude
dynamic var privateFriendshipState = "unrelated"
}
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 :
enum FriendshipState: String {
case unrelated // pas de lien
case requested // j'ai demandé
case requesting // il/elle a demandé
case accepted // on est amis
case denied // refusé
}
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 :
func createUserEntryWam(for user: User, scaleBubbles: Bool,
scaleFactors: [Int:CGFloat]) -> UserEntryWam {
let frame = randomFrameForUserBubble(user,
scaleBubbles: scaleBubbles, scaleFactors: scaleFactors)
let view = UserBubbleView(frame: frame)
view.user = user
let snapBehavior = UISnapBehavior(item: view, snapTo: center)
snapBehavior.damping = snapDamping
let userEntry = UserEntryWam(user: user, view: view,
snapBehavior: snapBehavior)
addSubview(view)
collisionBehavior.addItem(view)
dynamicItemBehavior.addItem(view)
animator.addBehavior(snapBehavior)
animateAppearance(ofView: view)
return userEntry
}
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 :
let maxScale: CGFloat = 1.5
let minScale: CGFloat = 0.5
let numScale = 5
let usersSorted = users.sorted(by: { $0.distanceMeters < $1.distanceMeters })
for (i, user) in usersSorted.enumerated() {
let t = i / numScale
let scaleIncrement = (maxScale - minScale) / CGFloat(numScale)
let scaleForIndex = maxScale - (CGFloat(t) * scaleIncrement)
scaleFactors[user.id] = max(minScale, scaleForIndex)
}
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 :
class ViewModel {
enum State {
case home
case loading
case whosAroundMe
}
let state: Variable<State>
let users: Driver<[User]>
let favoriteUsers: Driver<[User]>
let pendingUsers: Driver<[User]>
let userScaleFactors: Driver<[Int:CGFloat]>
let showTags: Driver<Bool>
let isLoading: Driver<Bool>
let showActionButtons: Driver<Bool>
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 :
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 {
return NSPredicate(format: "isBlocked = NO AND distanceMeters = 0")
} else {
return NSPredicate(format:
"isBlocked = NO AND distanceMeters < %f", wamDistance)
}
}
}
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).
class GeolocationService {
static let shared = GeolocationService()
let authorized: Driver<Bool>
let currentLocation: Driver<CLLocationCoordinate2D>
let pinnedVenue: Variable<VenueEntity?> = Variable(nil)
let savedUserLocation: BehaviorSubject<CLLocationCoordinate2D?> =
BehaviorSubject(value: nil)
}
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 :
let authorizationStatus$: Observable<CLAuthorizationStatus> =
Observable.deferred {
let initialStatus = CLLocationManager.authorizationStatus()
return locationManager.rx.didChangeAuthorizationStatus
.startWith(initialStatus)
}
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 :
let distanceFromPinnedVenue$: Observable<CLLocationDistance?> =
detectedLocation$
.withLatestFrom(pinnedVenue$) { (location, venue) in
guard let venue = venue else { return nil }
return CLLocation(latitude: location.latitude,
longitude: location.longitude)
.distance(from: CLLocation(
latitude: venue.coordinate.latitude,
longitude: venue.coordinate.longitude))
}
distanceFromPinnedVenue$
.filter({ ($0 ?? -1) > resetDistanceMeters })
.map({ _ -> VenueEntity? in nil })
.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.
class Conversation: Object {
dynamic var userId = ""
dynamic var userName = ""
dynamic var profilePictureURL = ""
dynamic var hasUnreadMessages = false
dynamic var lastMessageDate: Date?
dynamic var lastMessageTitle = ""
}
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.