Dans l’article sur la version iOS, j’ai détaillé l’architecture RxSwift de Sircle : les bulles animées, le GeolocationService réactif, le ViewModel qui combine des streams Realm en temps réel. Maintenant qu’on attaque le portage Android, c’est l’occasion de comparer les deux approches.
Même app, même API backend, même spec fonctionnelle. Mais deux plateformes, deux langages, et deux façons assez différentes de faire du réactif. L’équipe Android (Bastien principalement) a fait des choix qui divergent de l’iOS sur plusieurs points, et c’est intéressant de comprendre pourquoi.
Le stack Android
On cible Android 4.4+ (API 19) en production, avec un fast-build flavor en API 21 pour le dev. Java 8 avec le compilateur Jack (pas de Kotlin sur ce projet, c’est encore tôt pour l’adopter en production). Le build Gradle est standard.
Les dépendances principales :
// Reactive
implementation 'io.reactivex.rxjava2:rxjava:2.0.1'
implementation 'io.reactivex.rxjava2:rxandroid:2.0.1'
// Networking
implementation 'com.squareup.retrofit2:retrofit:2.2.0'
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.2.0'
implementation 'com.squareup.retrofit2:converter-gson:2.2.0'
implementation 'com.squareup.okhttp3:logging-interceptor:3.6.0'
// UI
implementation 'com.github.Jawnnypoo:PhysicsLayout:2.1.0'
implementation 'com.jakewharton:butterknife:8.8.1'
implementation 'com.squareup.picasso:picasso:2.5.2'
// Social & Chat
implementation 'com.facebook.android:facebook-android-sdk:4.+'
implementation 'com.twitter.sdk.android:twitter:2.3.2'
implementation 'com.sendbird.sdk:sendbird-android-sdk:3.0.18'
implementation 'com.google.firebase:firebase-messaging:10.2.0'
Le réseau : Retrofit vs Moya
Côté iOS, on utilise Moya, une abstraction au-dessus d’Alamofire qui modélise l’API comme un enum Swift. Côté Android, c’est Retrofit 2, qui fait la même chose avec une interface Java et des annotations :
public interface SircleApiService {
@POST("/api/v1/auth/facebook/login")
Observable<SircleUser> loginFacebook(@Body FacebookLogin facebookLogin);
@GET("api/v1/users/me")
Observable<SircleUser> getMe(@Query("is_sign_up") String is_sign_up);
@PATCH("api/v1/users/me")
Observable<SircleUser> patchMyLocation(@Body UserLocation user);
@GET("api/v1/users/me/whos_around_me")
Observable<List<SircleUser>> getWhosAroundMe();
@GET("api/v1/users/{otheruserid}/posts")
Observable<List<Post>> getUserPosts(@Path("otheruserid") String userId);
@POST("api/v1/posts/{postId}/like")
Observable<String> likePost(@Path("postId") String postId);
}
Le RxJava2CallAdapterFactory fait que chaque endpoint retourne un Observable, exactement comme Moya/RxSwift côté iOS. Le SircleClient configure le tout : OkHttp avec un intercepteur pour le token Bearer, GSON pour la sérialisation, et des timeouts de 40 secondes :
public static SircleClient getInstance() {
if (SIRCLE_REST_CLIENT == null) {
synchronized (SircleClient.class) {
if (SIRCLE_REST_CLIENT == null) {
SIRCLE_REST_CLIENT = new SircleClient();
setupSircleRestClient();
}
}
}
return SIRCLE_REST_CLIENT;
}
Le double-checked locking pour le singleton, c’est du Java classique. En Swift on a un let qui fait le boulot en une ligne. C’est un des points où la verbosité de Java se fait sentir.
En iOS, Moya impose un enum par API avec un switch pour chaque propriété (path, method, parameters). C’est type-safe au compilateur : impossible d’oublier un case. Retrofit prend l’approche inverse : une interface avec des annotations. C’est moins strict (une typo dans le path compile sans erreur) mais c’est plus lisible quand l’API a beaucoup d’endpoints.
Le vrai sujet : le threading
C’est là que les deux mondes divergent le plus. En RxSwift, le Driver garantit qu’on émet sur le main thread. On ne se pose plus la question. En RxJava, il faut être explicite à chaque chaîne :
// Chaque appel réseau suit ce pattern
mCompositeDisposable.add(
SircleClient.getInstance().getApiService().getMe("")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::setupUser, this::handleRetrofitError)
);
subscribeOn(Schedulers.io()) exécute la requête sur un thread du pool IO. observeOn(AndroidSchedulers.mainThread()) switch sur le main thread pour le callback. Si tu oublies le observeOn, tu te retrouves à manipuler des vues depuis un thread background, et ça crash. Android est strict là-dessus : toucher une View hors du main thread, c’est une CalledFromWrongThreadException immédiate.
En iOS, c’est plus permissif. UIKit ne crash pas si tu modifies une vue depuis un thread secondaire, il affiche un warning (depuis Xcode 9). Du coup on peut être plus laxiste, et le Driver de RxSwift corrige ça de manière élégante : c’est un Observable qui garantit main thread + pas d’erreur + replay de la dernière valeur. RxJava n’a pas d’équivalent direct.
Le bus d’événements : RxManager vs Realm + RxRealm
C’est la divergence architecturale la plus nette entre les deux versions. En iOS, la source de vérité est Realm. On écrit dans la base, RxRealm émet les changements, le ViewModel combine et filtre. C’est unidirectionnel : API → Realm → Observable → Driver → UI.
En Android, pas de Realm. L’app utilise SharedPreferences pour persister l’utilisateur courant (un JSON sérialisé avec GSON), et un RxManager maison pour la communication entre composants :
public class RxManager {
private static RxManager instance;
private static UserLocationUpdate userLocationUpdate;
private static VenueUpdate venueSelected;
private static UserUpdate userUpdate;
private static EmptyToken emptyToken;
private static FirebaseTokenUpdate firebaseToken;
private static SendBirdConnect sendBirdConnect;
public static RxManager getInstance() {
if (instance == null) {
instance = new RxManager();
userLocationUpdate = new UserLocationUpdate();
venueSelected = new VenueUpdate();
userUpdate = new UserUpdate();
emptyToken = new EmptyToken();
firebaseToken = new FirebaseTokenUpdate();
sendBirdConnect = new SendBirdConnect();
}
return instance;
}
}
Chaque canal est un wrapper autour d’un PublishSubject :
public class UserLocationUpdate {
private PublishSubject<Location> subject = PublishSubject.create();
public void setLocation(Location latLng) {
subject.onNext(latLng);
}
public Observable<Location> getUserLocation() {
return subject;
}
}
C’est un bus d’événements typé. Le LocationService publie les positions, les fragments s’abonnent. Le SessionManager publie les changements de profil, les écrans se mettent à jour. Chaque PublishSubject a son type : Location, Boolean, SircleUser, String.
Comparé à la version iOS, c’est plus direct mais moins robuste. RxRealm nous donnait la persistance et la réactivité en un seul mécanisme. Ici, les données transitent en mémoire via les subjects et sont persistées séparément dans SharedPreferences. Si l’app est tuée par le système, les subjects sont perdus. C’est un compromis : plus simple à mettre en place, moins de dépendances, mais pas de source de vérité unique.
La géolocalisation : Bound Service vs singleton réactif
Sur iOS, le GeolocationService est un singleton avec des Observable qui combinent CoreLocation et le venue pinning Foursquare. Tout est réactif de bout en bout.
Sur Android, c’est un Service au sens Android du terme, bindé au cycle de vie de l’activity :
public class LocationService extends Service
implements GoogleApiClient.ConnectionCallbacks,
GoogleApiClient.OnConnectionFailedListener,
LocationListener {
private GoogleApiClient mGoogleApiClient;
private LocationRequest mLocationRequest;
private CompositeDisposable mCompositeDisposable;
private Boolean isVenueSelected = false;
@Override
public void onCreate() {
super.onCreate();
mCompositeDisposable = new CompositeDisposable();
buildGoogleApiClient();
listenVenueSelection();
}
@Override
public void onLocationChanged(Location location) {
if (previouslocation == null || !isVenueSelected
|| previouslocation.distanceTo(location) > 100) {
RxManager.getInstance().getRxLocationUpdate()
.setLocation(location);
patchNewLocation(location);
previouslocation = location;
isVenueSelected = false;
}
}
}
Plusieurs choses intéressantes ici. D’abord, le service implémente trois interfaces de callback (ConnectionCallbacks, OnConnectionFailedListener, LocationListener). C’est le pattern Google Play Services : on connecte un GoogleApiClient, on attend le callback onConnected, et là on démarre les updates. Trois interfaces, cinq callbacks, pour faire ce que CoreLocation fait avec un startUpdatingLocation().
Ensuite, le pattern hybride. Le service reçoit les locations via un callback classique (onLocationChanged), puis publie via RxJava (RxManager.getRxLocationUpdate().setLocation()). C’est le pont entre l’ancien monde (callbacks) et le nouveau (Rx). Sur iOS, on avait écrit une extension CLLocationManager.rx pour tout garder en Rx. Ici, la couche Google Play Services est trop complexe pour être wrappée proprement, et le Service Android a son propre cycle de vie qui ne se prête pas bien à du pur Rx.
Et le CompositeDisposable qu’il faut penser à dispose() dans onDestroy(). En iOS, le DisposeBag est libéré automatiquement par ARC quand l’objet qui le possède est désalloué. En Android, il faut le faire à la main. C’est une source de leaks si on oublie.
Les bulles : JBox2D vs UIDynamicAnimator
Sur iOS, les bulles utilisent UIDynamicAnimator, le moteur physique intégré à UIKit. Sur Android, pas d’équivalent natif. On a intégré PhysicsLayout, une librairie qui wrappe JBox2D (un portage Java de Box2D) pour faire de la physique dans un FrameLayout :
public class CustomPhysicsFrameLayout extends FrameLayout
implements Target {
private CustomPhysics physics;
private void init(AttributeSet attrs) {
setWillNotDraw(false);
physics = new CustomPhysics(this, attrs);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
physics.onDraw(canvas);
}
@Override
public boolean onTouchEvent(@NonNull MotionEvent event) {
return physics.onTouchEvent(event);
}
}
Le CustomPhysics encapsule le monde JBox2D. La simulation tourne de manière synchrone dans onDraw(), ce qui veut dire que chaque calcul physique bloque le main thread le temps du frame. Sur iOS, UIDynamicAnimator gère ça en interne avec CADisplayLink et c’est optimisé par Apple. Ici, quand il y a 30+ bulles avec des collisions, on sent la différence. On a dû limiter le nombre de bulles visibles plus agressivement que sur iOS.
L’autre différence : sur iOS, chaque bulle a un UISnapBehavior qui l’attire vers le centre avec du damping. JBox2D n’a pas de concept équivalent, il faut simuler ça avec des forces appliquées à chaque step de la simulation. C’est plus de code, et le comportement n’est pas tout à fait le même : les bulles Android sont un peu moins “organiques” dans leur mouvement.
Ce qui est pareil, ce qui ne l’est pas
Le tableau honnête :
| iOS (Swift / RxSwift) | Android (Java / RxJava 2) | |
|---|---|---|
| API réseau | Moya (enum typesafe) | Retrofit (interface annotée) |
| Appels → Observable | Moya/RxSwift | RxJava2CallAdapterFactory |
| Persistance locale | Realm + RxRealm | SharedPreferences + GSON |
| Bus événements | Realm notifications | RxManager (PublishSubject) |
| Thread safety | Driver (implicite) | observeOn/subscribeOn (explicite) |
| Cleanup mémoire | DisposeBag (ARC) | CompositeDisposable (manuel) |
| View binding | Code (SnapKit) | ButterKnife annotations |
| Moteur physique | UIDynamicAnimator (natif) | JBox2D via PhysicsLayout |
| Image loading | Kingfisher | Picasso |
| Social login | SDK natifs | SDK natifs (identique) |
| Chat | SendBird SDK | SendBird SDK (identique) |
Le réseau et le chat sont quasi identiques. Les SDK SendBird et les SDK sociaux (Facebook, Twitter, LinkedIn) ont les mêmes APIs sur les deux plateformes, ce qui facilite le portage. L’écart se creuse sur la couche données (Realm vs SharedPrefs), le threading (implicite vs explicite), et le moteur physique (natif vs tiers).
Les leçons du portage
La plus grosse surprise, c’est la gestion du cycle de vie. Sur iOS, un ViewController est créé et détruit, point. Sur Android, un Fragment peut être créé, détruit, recréé par le système (rotation d’écran, mémoire faible), et il faut gérer le SavedInstanceState pour ne pas perdre l’état. Combiné avec RxJava, ça oblige à re-souscrire aux observables à chaque recréation, et à bien penser au moment où on dispose les subscriptions.
Le pattern RxManager fonctionne, mais il montre ses limites quand les fragments se multiplient. Chaque fragment qui s’abonne doit penser à se désabonner dans onDestroy(). Sur iOS, le DisposeBag attaché au ViewController gère ça automatiquement. Ici, un oubli et c’est un leak mémoire. On a eu quelques cas en test.
L’absence de Realm côté Android est un choix qu’on referait différemment. On l’a évité pour ne pas ajouter une dépendance lourde sur un projet qui devait aller vite. Mais sans source de vérité persistée, la gestion de l’état est fragile. Si l’app est tuée en arrière-plan et relancée, les listes d’utilisateurs WAM sont perdues et il faut refaire les appels API. Côté iOS, Realm conserve tout.
Le compilateur Jack, on attend de pouvoir s’en débarrasser. Il est lent, il ne supporte pas toutes les features de Java 8, et Google pousse déjà D8/R8 comme remplaçant. Sur le prochain projet, si on passe à Kotlin, le problème disparaît.
Malgré tout ça, RxJava reste le bon choix pour ce type d’app. Sans Rx, la coordination entre le service de localisation, les appels API, les mises à jour UI en temps réel et le chat SendBird serait un enfer de callbacks imbriqués. Le coût d’entrée est réel (le subscribeOn/observeOn à écrire partout, le CompositeDisposable à gérer), mais c’est le prix d’une base de code qui reste lisible quand on a six sources d’événements asynchrones.