Szerző: HWSW

2017. november 14. 15:27

RIBS: architektúra az Uber app alatt

Vadonatúj - és nyílt forráskódú - architektúrára vált az Uber. Miért van erre szükség, mik a tanulságok - és ha már bárki használhatja, kinek lehet érdemes bevetnie az új architektúrát?

Másfél éve az első magyar fejlesztőként az Uber-nél kezdtem dolgozni. A Core csapat tagjaként az első projektem az Uber app újraírása volt. Ez nem csak komplett design-váltást, hanem teljes architektúra-cserét is jelentett. Az újszerű architektúránk, a RIBs forráskódját a közelmúltban nyitottuk ki, ennek egyik közreműködője voltam. Ebben a cikkben bemutatom, hogy miért volt szükségünk egy újabb mobil architektúrára, mi is az a RIBs és hogyan különbözik más, népszerű architektúráktól. Végül pedig másfél év "hands on" RIBs használat és az open source-olás tapasztalata után elmondom, hogy mik a jó és a rossz tapasztalataim az architektúra használatával és kinek javasolnám ezt a RIBs bevetését éles környezetben.

Miért kezdjünk mindent elölről?

Az Uber appot eleinte MVC alapokon kezdtük el fejleszteni. Az első pár évben, amíg az app viszonylag egyszerű volt, illetve a csapatunk elég kicsi, ez jól működött. Minden fejlesztő átlátta az appot, új feature-öket könnyű volt írni és minden módosítás után elég egyszerű volt tesztelni.

Égbe révedő informatikusok: az Időkép-sztori

Mi fán terem az előrejelzés, hogy milyen infrastruktúra dolgozik az Időkép alatt, mi várható a deep learning modellek térnyerésével?

Égbe révedő informatikusok: az Időkép-sztori Mi fán terem az előrejelzés, hogy milyen infrastruktúra dolgozik az Időkép alatt, mi várható a deep learning modellek térnyerésével?

Ahogy az app nőtt komplexitásban, úgy nőtt a csapat is. Egyre több feature-t fejlesztettünk ugyanarra a képernyőre és mindezt egyre több fejlesztő csinálta, párhuzamosan. Az MVC architektúra még úgy-ahogy működött, de a ViewController hízott és egyre több üzleti logikát és állapotot tárolt. Egyre több olyan bug került az appba, aminek hibás állapotkezelés volt az oka, gyakran azért, mert a fejlesztő nem gondolt arra, hogy az app bizonyos állapotokba is kerülhet, amit szintén kezelnie kell(ene). Emellett egyre több vizuális bug is került az appba amiatt, mert nem minden fejlesztő tudta, hogy vele párhuzamosan mások milyen feature-eket adnak hozná ugyanahhoz a view-hez.

Mielőtt az újraírás mellett döntöttünk, többször nekifutottunk az app refaktorálásának. Az egyik legnagyobb gondunk az volt, hogy túl sok globális állapotot (state-et) tároltunk. Ennek kiküszöbölésére így néhány lokálisabb scope-ot definiáltunk, hogy az appot state kezelés szintjén kisebb darabokra szedjük. A nagyobb osztályokat szintén kisebb darabokra szedtük szét, ahol tudtuk. Ugyanakkor így is egyre lassabb lett egy-egy feature fejlesztése és mellette hétről hétre több fejlesztő dolgozott a kódbázison. A végső lökést az újraíráshoz végül a design csapat adta. 2015 végére az app helyenként így nézett ki: Screen Shot 2017-11-14 at 14.59.01

Az Uber app 2015 végén. A UI egyre sűrűbb lett és ideje érkezett a design váltásnak.

Az eredeti Uber app tervezésekor nem számítottunk rá, hogy ennyi különböző szállítási módot fog támogatni az app. A design csapat ekkor egy új, UX szempontból sokkal barátságosabb felületet tervezett, amit a mobilos fejlesztőcsapatnak implementálnia kellett. Ennek a megvalósítása a view hierarchia teljes változtatását jelentette - vagyis minden egyes view-t, illetve minden viewController-t is át kellett volna írnunk. Ha már ekkor változtatás állt előttünk, akkor már a teljes újraírás mellett döntöttünk.

Architektúra tervezés előtt: miért ilyen bonyolult az appunk? Avagy: az állapotgráf-vizsgálat

Az újraírást egy féléves kísérletezés-kutatás előzte meg. Egyértelmű volt, hogy az MVC, mint tervezési minta (design pattern) nem működött jól egy olyan appnál, aminek nagy számú state-je van, gyakran egyetlen view-en belül. Az újraírás előtt ezért alaposan feltérképeztük a lehetséges állapot átmeneteket az appban, ami így nézett ki:

Screen Shot 2017-11-14 at 15.02.37

Az Uber app állapotai és állapotok közötti az átmenetek 2015 végén. Néhány nézetben - mint az utazás rendelése, vagy utazás közbeni nézet - zajlott az átmenetek többsége.

Mielőtt bármit fejlesztettünk volna, ezt az állapot-gráfot kellett rendbe raknunk. Több ötlet után a legígéretesebb - és legkarbantarthatóbb - megoldás az állapotgráf egyszerűsítése egy fa struktúrára lett. Az előző állapot gráfot egy ilyen, egyszerűbb logikai fává változtattuk:

Screen Shot 2017-11-14 at 15.06.56

Az Uber app állapot gráfjának az átalakítása egy állapot fává. A fa struktúrával az app összes állapotát le tudtuk képezni.

Ebben a fában minden komponens felelőssége elég egyszerű. A Root egyetlen feladata, hogy tudja, hogy a felhasználó belépett-e. Ha igen, akkor megjeleníti a LoggedIn komponenst. Ha nem, akkor a LoggedOut komponenst jeleníti meg. Ugyanígy a LoggedIn komponens mindig megjeleníti a menüt. Emellett pedig a Request és az OnTrip komponens közül az egyiket jeleníti meg, attól függően, hogy a user kért-e egy Ubert, vagy nem. Ez a fajta fa megközelítés logikailag remekül működött és minden lehetőséget lefedett az appon belül. Úgyhogy ideje volt nekiállni a megvalósításnak.

Két hónapig minden létező app architektúrával kísérleteztünk, az Uber leggyakrabban használt funkcionalitását - egy Uber hívását - MVC, MVVM, MVP és VIPER alapokon is implementáltuk. Az állapotfa kezelését ugyanígy az összes népszerű state kezelő könyvtárral - Flow és társai - kipróbáltuk. Egyik megoldással sem voltunk elégedettek. Az összes népszerű architektúra kimondottam view alapon működött és a view nélküli komponenseket nem kezelték jól. Emellett pedig minden architektúra inkább optimalizált az iOS-re vagy Androidra és a másik platformon nehézkesebb használni. Ideje volt saját architektúrát terveznünk a gyűjtött tapasztalatok alapján.

A RIBs Architektúra

Mielőtt belevágtunk a saját architektúra írásába, lefektettük a fő céljainkat, amit ezzel az új architektúrával szerettünk volna elérni:

  • App hierarchia és route-olás az üzleti logika mentén, nem pedig a vizuális felépítés alapján. Az Uber app állapot átmeneteinek az egyszerűsítéséhez az kellett, hogy az app logikai felépítése és a vizuális felépítés különböző lehessen. Az appnak több olyan állapota és állapotátmenete van, amihez vizuális változás nem tartozik.
  • Platformokon átívelő architektúra iOS-re és Androidra. Korábban az iOS és Android csapatunk külön dolgozott azonos feature-ök fejlesztésén a két platformon. Lehetőség szerint közös architekturális alapokat akartunk teremteni az iOS és Android appokban.
  • Komponensek, mint építőelemek. Bármilyen felépítést is választunk a komponenseknek, egy fa struktúrában könnyen kell, hogy egymással működjenek és kommunikáljanak.
  • Felelősségek szétválasztása és tesztelhetőség. Az Uber appban a legtöbb bugot a túl sok állapot, és a köztük levő nem determinisztikus állapotátmenetek okozták. Ennek a kiküszöbölésére remek megoldás, ha a komponenseknek kevés állapotuk van. A kevés állapothoz pedig jól definiált felelősségek szükségesek. Az előző két feltétel teljesülése esetén pedig a komponensek osztályai könnyen tesztelhetőek.

A komponenst, amiről eddig beszéltünk, RIB-nek hívjuk. Ez a Router, Interactor, Builder rövidítése. Egy RIB ebből a három kötelező építőelemből áll, illetve kettő másik, opcionális elemből.

Screen Shot 2017-11-14 at 15.10.19

Az Interactor a komponens központja. Ide kerül a legtöbb üzleti logika és itt zajlanak a hálózati lekérések. Az interactor dönti el, hogy mikor kell navigálni, amit a Router-nek jelez. A Router egyetlen feladata magának a navigációnak a megvalósítása, gyakran a gyerek RIB-ek között. A Router hozza létre, illeszti a view fához, vagy veszi el onnan a gyerek RIB-eket és felelős azért, hogy a view hierarchia a megfelelő állapotban legyen a routing után. A Builder pedig a RIB létrehozásáért felel, biztosítja a megfelelő dependency-k injektálását. A Builder jelenléte sokat segít mind a tesztelhetőségben, mind pedig a RIB-ek létrehozásában.

A View nem minden RIB-ben kell, hogy jelen legyen. Azon RIB-ek, amelyeknek van vizuális megjelenésük, természetesen, kell, hogy rendelkezzenek vele. A View egy "buta" osztály, csak UI és animációs kód kerül bele. A Presenter pedig egy - legalábbis az Uber appban - ritkán használt és erősen opcionális osztály. Azon ritka esetekben, amikor az Interactorból érkező modellek túl komplexek ahhoz, hogy a View direktben használja ezeket, átfordítja egyszerűbb, a View által is érthető objektumokra. Illetve a másik irányból, a View irányából érkező eseményeket az Interactor számára érthetőbben fordíthatja. És hogy mennyire jellemző View nélküli RIB-ek használata? Az Uber appnál ez nem ritka, a RIB-einkek körülbelül negyede ilyen. A korábbi példánál maradva például a Request (utazás indítása) vagy Favorites (kedvencek) RIB-ek egyikének sincs vizuális megjelenése.

Screen Shot 2017-11-14 at 15.11.45

Zölddel a View-vel, narancssárgával a View nélküli RIB-ek az Uber appban. Nem minden RIB-nek van vizuális megjelenése, így View komponense.

Az architektúra a gyakorlatban

Lássunk egy gyakorlatibb példát, hogy hogyan működnek a RIB-ek. A fenti példánál maradva, belépés után, az Uber app "LoggedIn" nézetén találjuk magunkat. Ha nem vagyunk éppen úton, akkor az alábbi nézetet látjuk az aktív RIB-ekkel:

Screen Shot 2017-11-14 at 15.13.23

Belépés után az Uber app. A "LoggedIn" RIB a "Request" és a "Menu" RIB-et jeleníti meg. A "Request" RIB-nek nincsen view-je, hanem a "LocationEditor" és "Shortcuts" RIB-eket jeleníti meg.

Mi történik, ha a user kiválasztja a címet és megerősíti, hogy oda akar utazni? A Request RIB interactorja elküldi a szervernek ezt a requestet. A szerver válaszát viszont az Uber appon belül a RIB-ek nem kapják vissza direktben, hanem egy Rx model stream-en keresztül iratkozhatnak fel, amit a "LoggedIn" RIB meg is tesz. Amikor az Rx model jelez, hogy a user már úton van, akkor a "LoggedIn" RIB a Request RIB-et elrejti, az OnTrip RIB-et pedig megjeleníti.

Screen Shot 2017-11-14 at 15.16.07

A RIB-ek használata az Uber appon belül. Az Interactorok egy Service rétegen keresztül indítanak network requestet és egy Rx Model stream-re iratkoznak fel. A Service réteg a szerver válasza alapján pedig updateli az Rx Model stream-et.

Egy új architektúra bevezetése több száz fejlesztővel: tooling és tooling

Az első pár hónap után az RIBs architektúra prototípusa elkészült és az Uber app legfontosabb részét - egy utazás indítása - prototípusként lekódoltuk. Ezután bevontuk a Core Flow csapatot a csiszolásba és velük együtt elkészítettük az utazással kapcsolatos legfontosabb funkcionalitást. Ekkor mintegy 30 fejlesztő használta a RIBs architektúrát. Hátra volt a maradék, közel 200 fejlesztő, akiket rekord gyorsasággal akartunk rávenni, hogy ezt az új, és nem teljesen triviális architektúrát használják.

Screen Shot 2017-11-14 at 15.17.35

Az Uber app újraírásának a fázisai. A Core architektúra csapat 4-4 iOS és Android fejlesztőből, a Core Flow 10-10 iOS és Android fejlesztőből és a többi csapat durván 100-100 fejlesztőből állt platformonként. Hogy megkönnyítsük az átállást, a Core csapat toolingot kezdett fejleszteni.

Hogyan gyorsítottuk fel ezt a folyamatot? Először toolinggal, elsősorban kódgenerálással, később pedig tanulási anyagok készítésével. Az első tapasztalatok alapján a RIBs architektúra "nehézsúlyúnak" bizonyult: sok osztályt kellett létrehozni, amit könnyen el lehetett rontani. Hogy ezt megkönnyítsük, mind az XCode-hoz, mind pedig az Android Studióhoz kódgenerátort írtunk, ami RIBs vázakat generált. Ahogy pedig egyre több új fejlesztő kezdett RIBs-el fejleszteni, úgy a Core csapat egy rövid oktatási anyagot is összerakott egy kicsi példa alkalmazással, hogy a fejlesztők gyorsabban ráérezzenek a RIBs fejlesztés rejtelmeire. Mind a kódgenerátorunkat, mind pedig az oktatási anyagainkat szintén open source-oltuk.

MVC, MVP, MVVM, VIPER vs RIBs: összehasonlítás más, népszerű architektúrákkal

Az MVC, MVP és MVVM trió a legnépszerűbb a mobil app architektúrák között. Az MVC remekül működik egyszerűbb, unit tesztelést kevésbé igénylő appok építésénél. Az MVP és MVVM egy fokkal jobban szétválasztja a view és üzleti logikai feladatokat és jobban támogatja a unit tesztelést. Ahogy az alkalmazás egyre több mindent csinál, a VC, Presenter vagy ViewModel úgy lesz egyre nagyobb és egyre kevésbé karbantartható. A VIPER és a RIBs leginkább itt különbözik az előbbi architektúráktól: ahogy az app komplexebb lesz, a VIPER és RIBs további logikai szeparációt vezet be. Emellett a RIBs-el az app logikai hierarchiája nem kell, hogy kövesse a view-k hierarchiáját.

A VIPER architektúra hasonlít leginkább a RIBs-re az népszerűbb megközelítések közül. Ez nem véletlen, a legtöbb inspirációt ez az architektúra adta a tervezési döntéseink során, konkrétan a (B)VIPER pattern. A Builder, View, Interactor, Presenter és Router mind a VIPER-ben, mind pedig a RIBs-ben jelen van. Az Interactor mind a két architektúrában a komponens “agya”, ahol az üzleti logika lakik. Ugyanakkor a két architektúra között pár lényeges különbség van.

  • Üzleti logika vs view logika által vezérelt app struktúra. A RIBs használatakor az üzleti logika határozza meg az app struktúráját. A (B)VIPER esetén az app felépítése a view-k struktúráját követi. Azoknál az appoknál, ahol az alkalmazásnak vannak megjelenéshez nem köthető állapotai, a RIBs jobb választás lehet.
  • Kevésbé nehézsúlyú architektúra. A VIPER esetén minden osztály megírása és használata javasolt. A RIBs esetén a Presenter választható - az Ubernél a legtöbb RIBs nem használja. A View szintén választható és a nézettel rendelkező komponensek használják.
  • Cross-platform megközelítés az iOS-t preferáló architekturális megközelítés helyett. A RIBs írásakor iOS-en és Androidon azonos megközelítést használtunk. A VIPER iOS-re lett fejlesztve és olyan iOS specifikus döntéseket hozott, amik Androidon kevésbé értelmezhetők. Példának okáért a VIPER Androidon használatakor általában nincs szükség Routerre, mivel az Activity létrehozása máshogy működik, mint iOS-en a ViewController létrehozása.

 A RIBs és a többi, népszerű mobil architektúra közti különbségeket a lenti táblázat foglalja össze:

Screen Shot 2017-11-14 at 14.48.54

 * Dolgozunk rajta, hogy a RIBs következő változata ne legyen olyan nehézsúlyú, mint amilyen most.

Mielőtt a fentiek alapján az a konklúzió születne, hogy a RIBs sok esetben egy “jobb” architektúra az előzőeknél, azt leszögezném, hogy az appok 99 százaléka esetén bármelyik fenti, nem RIBs architektúra remekül működik. Az Uber esetén is kiválóan működött az MVC pár évig. A gondok egy olyan komplexitású appnál kezdődnek, amilyen nekünk volt négy év fejlesztés után. Vagyis amikor több párhuzamos funkciót fejleszt több csapat, ugyanazokon a nézeteken belül. Illetve amikor több tucat fejlesztő dolgozik ugyanazon a kódbázison, ráadásul a csapat folyamatosan és gyorsan nő.

A jó, a rossz és a ronda: másfél év tapasztalat a RIBs-el

Az Uber app újraírása óta használom a RIB-eket. Ezek alapján az alábbi, kifejezetten jó tapasztalataim voltak ezzel az architektúrával:

  • Azonos architektúra iOS-en és Androidon. Az iOS és Android csapat sokkal többet beszélnek egymással. A fejlesztők együtt terveznek és gyakran egymás kódját is review-olják, legalább az üzleti logika szintjén.
  • Egyszerű osztályok és könnyű tesztelhetőség. Az Uber app több, mint 600 RIB-ből áll. Ugyanakkor alig van osztályunk, aminek 300-nál több sora lenne. Az osztályokat egyszerű megérteni és egyszerű tesztelni is. Az előző Uber appot alig unit teszteltük, a mostaniban pedig az üzleti logikát nagyon alaposan teszteljük.
  • Izoláltan dolgozó csapatok. A jelenlegi kódbázisunkon több, mint 400 mobil fejlesztő dolgozik párhuzamosan. Az egyik csapat változtatása alig érinti egy másik, függetlenül dolgozó csapatot. A csapatok tipikusan saját RIB-eken dolgoznak, legfeljebb a szülő RIB-et módosítják mindketten.
  • Hatékonyabb fejlesztők (nagyon) nagy csapatokban. A kód generálásnak hála RIB skeletonokat könnyű kreálni. Emellett pedig a mobil appon dolgozó fejlesztők elégedettsége jelentősen nőtt az RIBs architektúra bevezetése óta. Mint olyan sok minden mást, ezt is rendszeresen mérjük az Uber-nél, fejlesztői NPS-ként.

Rossz dolgokból sem volt hiány. Először is szólnék azokról a tapasztalatokról, amik egy kis csapatnál hátrányként, egy nagynál viszont elfogadható és megszokható hátulütőként jelentkeznek, és amikhez én is hozzászoktam.

  • Egy kis módosítás sokszor sok osztályt/interface-t érint. A RIBs architektúrát szigorú scope-ok mellett használjuk az Uber-en belül. Ha egy RIB-nek egy új depdendency-re van szüksége - például egy újfajta network kliensre - akkor ezt a szülő RIB-jétől kell, hogy megkapja. Ha a szülő nem rendelkezik azzal a depdendencyvel, akkor az ő szülőjét is módosítani kell, és így tovább. Ez a gyakorlatban ugyan nehézkes, de egyben remekül megakadályozza a globális scope-ok létrejöttét, ami korábban annyi gondot okozott az Uber appban.
  • Hierarchikus, emiatt mélyebb és bonyolultabb app struktúra. Egy másik architektúra használatával szinte mindig egyszerűbb app struktúrát lehet írni. Ugyanakkor ez a struktúra kimondottan cél volt a RIBs-nél, hogy a kis, jól definiált és kevés állapotú komponensek használatát segítse.
  • Mély stack trace-ek a mély logikai alkalmazás fa miatt. Ez a fejlesztőknek, akiknek új a kódbázis, szinte mindig megnehezíti a debuggolást.

És végül pedig a ronda, vagyis olyan rossz tapasztalataimról, amit mindenképpen hátrányként említek:

  • Nehézsúlyú architektúra, sok boilerplate kóddal és protokollal/interfésszel. A legkisebb RIBs komponens is 3 osztályból áll, amiből sok a boilerplate. Ezt a kódot nehezebb olvasni, és sok az ismételt, “ragasztóként” működő kód minden RIBs komponensben.
  • Több kód és nagyobb bináris méret. Ha valahol, akkor az Uber-nél pontosan tudjuk, hogy mennyire fontos a bináris méret. Külön kísérleteket végzünk, hogy tudjuk, hogy minden MB-nyi app növekedés mennyivel kevesebb felhasználót eredményez. A RIBs pedig egy csomó boilerplate kódót generál, ami növeli az app méretet. Ugyanakkor, egyenlőre a fenti előnyök miatt ezt a hátulütőt elfogadtuk.
  • Nagyobb esély a memory leak-ekre. A RIBs alapokon épített app tipikusan sok RIB-ből áll. A Router-ek feladata a már nem használt RIBs-ek eltávolítása. Ezt azonban könnyű elfelejteni, ami memory leak-ekhez vezethet. Mivel a RIBs objektumok nagyobbak, a memory leak-ek is súlyosabbak. Ezt a problémát házon belül memory leak detektáló toolinggal orvosoljuk, amit reményeink szerint szintén open source-olunk.

Összefoglalva, a RIBs-et jó szívvel javasolnám komplex és dinamikus appok fejlesztéséhez, ahol az appnak sok állapota van, gyakran egy nézeten belül. Emellett nagy csapatoknál javasolnám, ahol a legtöbb ember már nem látja át az egész kódbázist, a csapat gyorsan nő és egy jobban struktúrált architektúra, izoláltabban dolgozó csapatok előnyei ellensúlyozzák a “nehezebb” architektúra, több boilerplate kód és több tanulási folyamat hátrányait. Olyan appoknál, ahol a nézetek nem bonyolultak vagy a csapat kicsi, a RIBs architektúra inkább teher, mint előny lenne.

Végezetül, ha további információ szükséges a RIBs-ekről az alábbiakat javasolnám: a nyílt forráskódú RIBs github repo, illetve dokumentáció és oktatóanyagok a projekt GitHub oldalán találhatóak. Egy bemutató videó a RIBs architektúráról: első, második és harmadik rész. És végül az angol nyelvű blog az Uber app újraírásáról, és az app állapotok és scope-ok fontosságáról.

A cikk szerzője Orosz Gergely, az Uber mobilos szoftvermérnöke és a Tech Inspiráció blog szerkesztője. Gergely kiemelt előadója az idei HWSW mobile! konferenciának, akit mélyebben is érdekel a téma, személyesen is konzultálhat vele november 29-én!

Nagyon széles az a skála, amin az állásinterjú visszajelzések tartalmi minősége mozog: túl rövid, túl hosszú, semmitmondó, értelmetlen vagy semmi. A friss heti kraftie hírlevélben ezt jártuk körül. Ha tetszett a cikk, iratkozz fel, és minden héten elküldjük emailben a legfrissebbet!

a címlapról